feat(models): add declarative reasoning capability contract (#5780)

* feat(models): add declarative reasoning capability contract (#5073)

Introduce an optional per-model `reasoning:` block beside the legacy
`supports_thinking` / `supports_reasoning_effort` booleans. It declares
thinking availability (unsupported | optional | required), the accepted
effort values with aliases, default, and serialization path, the payload
dialect, and the reasoning-history requirement. When present the booleans
are derived from it and contradictory profiles fail at config load.

`deerflow.models.reasoning` normalizes legacy and declared profiles into
one immutable contract and resolves every caller's generic request against
it. `create_chat_model` is the single enforcement point: required-thinking
models never receive a synthesized disable payload, unsupported effort
values never reach the provider, and the dialect synthesizes the on/off
payload when no template exists. Legacy profiles keep the historical path
byte-for-byte. The lead agent and subagent descriptor resolve the same
policy first so run metadata reports effective values.

`/api/models` and `DeerFlowClient` project the contract as `reasoning`;
the frontend derives mode clamping and effort choices from it through
`core/models/reasoning.ts`, and user preferences accept provider-specific
effort tokens. The setup wizard's Z.AI GLM-5.3-Flash profile migrates to
the contract, restoring its low/high/max effort control.

* docs(models): move the reasoning contract guidance into models/AGENTS.md

backend/AGENTS.md sits within 46 bytes of the 96 KiB effective-chain hard
limit for the middlewares, runtime, sandbox, and subagents guides, so the
new section pushed scripts/check_agent_guidance.py over it. The models
guide is the nearest file for the factory and the contract module, and
its chain has ample room.

* fix(models): address review on the reasoning contract

- vLLM dialect: the enable path now mirrors whichever chat_template_kwargs
  switch the template declares, like the disable path already did, so a
  template using the older `thinking` key no longer also grows
  `enable_thinking`.
- Validate operator-supplied effort values at `effort.path` inside
  `when_thinking_enabled`, `when_thinking_disabled`, and the `thinking`
  shortcut against the contract, not only the profile-level value; they are
  forwarded when the caller chooses nothing, so the "unknown values never
  reach the provider" guarantee now holds for them too.
- Constrain `effort.path` to dotted identifiers and reject single-segment
  paths that would replace a whole mapping (`extra_body`, `thinking`,
  `model_kwargs`, `default_headers`, `default_query`).
- Z.AI GLM-5.3-Flash profile: `default: high` instead of `max`, because the
  default also governs summarization, title generation, and subagent calls
  that never choose an effort; `max` stays selectable. Documented in the
  config example, the configuration manual, and the design note.

* fix(models): close reasoning contract review gaps

* fix(models): preserve native Ollama reasoning config

* fix(models): keep native reasoning strings and project reasoning into the descriptor

- `ModelConfig.reasoning` also accepts the level string ChatOllama takes
  for gpt-oss style models (`low` / `medium` / `high`); the factory forwards
  both the boolean and the string on the legacy path, so profiles that
  passed `reasoning: high` through before the contract field existed keep
  loading and keep sending it.
- The assembly descriptor no longer excludes `reasoning` from
  `model_parameters`: the native ChatOllama value and a declared contract's
  dialect/history/effort change the request payload, so they now move the
  assembly fingerprint.
- e2e: the sidecar spec pinned the pre-contract body where a model without
  effort control still received `reasoning_effort: minimal`; the preset is
  now dropped for such models (the Gateway discarded it anyway), so the
  assertion expects the key to be absent.
This commit is contained in:
Nan Gao 2026-09-24 20:39:30 +08:00 committed by GitHub
parent 7d9025c052
commit bcaf6db36a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2887 additions and 400 deletions

View File

@ -493,6 +493,22 @@ This release closes that milestone with **765 merged pull requests**.
#### Models & integrations
- **models:** Optional per-model `reasoning:` capability contract beside the
legacy `supports_thinking` / `supports_reasoning_effort` booleans: thinking
`unsupported | optional | required`, the accepted effort `values` with
`aliases`, `default`, and serialization `path`, the payload `dialect`, and the
reasoning `history` requirement. Every model-creation path enforces one
normalized policy, so required-thinking models never receive a synthesized
disable payload and unsupported effort values never reach the provider;
`/api/models` projects the contract as `reasoning`, the composer derives its
effort choices from it, and the Z.AI GLM-5.3-Flash wizard profile regains its
`low/high/max` effort control. Custom effort paths reject leftover generic
effort keys, and the chat UI drops remembered contract-only levels when
switching to legacy models. Existing Ollama `reasoning: true` / `false` and
`low|medium|high` settings remain native provider options and stay in the
assembly fingerprint. Profiles without a mapping contract keep their existing
provider behavior.
([#5073])
- **community:** New web search/fetch engines - GroundRoute, Crawl4AI
(`web_fetch`), and a fastCRW provider - plus a Browserless `web_capture`
screenshot tool and Brave `image_search`. ([#3675], [#3821], [#3585], [#3881],
@ -4182,6 +4198,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5066]: https://github.com/bytedance/deer-flow/pull/5066
[#5069]: https://github.com/bytedance/deer-flow/pull/5069
[#5071]: https://github.com/bytedance/deer-flow/pull/5071
[#5073]: https://github.com/bytedance/deer-flow/issues/5073
[#5074]: https://github.com/bytedance/deer-flow/pull/5074
[#5076]: https://github.com/bytedance/deer-flow/pull/5076
[#5077]: https://github.com/bytedance/deer-flow/pull/5077

View File

@ -255,7 +255,7 @@ It is disabled by default; see the linked guide to enable it.
To route OpenAI models through `/v1/responses`, keep using `langchain_openai:ChatOpenAI` and set `use_responses_api: true` with `output_version: responses/v1`.
The setup wizard includes a Z.AI GLM-5.3-Flash profile. Because that model requires thinking and only accepts its own restricted effort levels, the compatibility profile keeps thinking enabled for every foreground and background call and temporarily suppresses DeerFlow's generic effort selector. See `config.example.yaml` for the equivalent manual configuration.
Models whose provider contract differs from DeerFlow's generic thinking/effort assumptions can declare a per-model mapping-valued `reasoning:` block (thinking `unsupported`/`optional`/`required`, the accepted effort values with aliases and a default, the payload dialect, and the reasoning-history requirement). The setup wizard's Z.AI GLM-5.3-Flash profile uses it: thinking stays on for every foreground and background call, and the effort selector offers the model's own `low`/`high`/`max` levels. Ollama's existing boolean `reasoning: true` remains a native provider setting and is forwarded to ChatOllama. When migrating a profile to a custom effort `path`, remove any old `reasoning_effort` setting from the profile and thinking templates; configuration validation rejects the leftover key. The chat UI drops a remembered provider-specific effort when switching to a legacy model that does not advertise it. Profiles without the block keep their existing provider behavior. See `config.example.yaml` for the shape and the equivalent manual configuration.
For vLLM 0.19.0, use `deerflow.models.vllm_provider:VllmChatModel`. For Qwen-style reasoning models, DeerFlow toggles reasoning with `extra_body.chat_template_kwargs.enable_thinking` and preserves vLLM's non-standard `reasoning` field across multi-turn tool-call conversations. Legacy `thinking` configs are normalized automatically for backward compatibility. If the endpoint reports a cumulative usage snapshot on every streaming chunk, set `cumulative_stream_usage: true` so DeerFlow converts those snapshots into per-chunk deltas; the option is disabled by default and leaves usage unchanged when a stable completion id is unavailable. Reasoning models may also require the server to be started with `--reasoning-parser ...`. If your local vLLM deployment accepts any non-empty API key, you can still set `VLLM_API_KEY` to a placeholder value.

View File

@ -176,7 +176,7 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
如果要让 OpenAI 模型走 `/v1/responses`,继续使用 `langchain_openai:ChatOpenAI`,并设置 `use_responses_api: true` 和 `output_version: responses/v1`。
Setup Wizard 已内置 Z.AI GLM-5.3-Flash 配置。由于该模型强制开启 thinking,且只接受自身限定的 effort 档位,当前兼容配置会在前台和后台调用中始终保持 thinking 开启,并暂时屏蔽 DeerFlow 的通用 effort 选择器。等价的手动配置见 `config.example.yaml`。
如果某个模型的 provider 约定与 DeerFlow 通用的 thinking/effort 假设不同,可以为该模型声明 `reasoning:` 块(thinking 为 `unsupported`/`optional`/`required`、允许的 effort 取值及别名和默认值、payload 方言、推理历史要求)。Setup Wizard 内置的 Z.AI GLM-5.3-Flash 配置就使用了它:前台和后台调用都会保持 thinking 开启,effort 选择器只提供该模型自己的 `low`/`high`/`max` 档位。未声明该块的配置行为保持不变。具体格式与等价的手动配置见 `config.example.yaml`。
对于 vLLM 0.19.0,请使用 `deerflow.models.vllm_provider:VllmChatModel`。对于 Qwen 风格的推理模型,DeerFlow 通过 `extra_body.chat_template_kwargs.enable_thinking` 开关推理,并在多轮 tool-call 对话中保留 vLLM 非标准的 `reasoning` 字段。旧版 `thinking` 配置会自动规范化以保持向后兼容。推理模型可能还需要在启动 vLLM 服务时加上 `--reasoning-parser ...` 参数。如果你的本地 vLLM 部署接受任意非空 API key,可以把 `VLLM_API_KEY` 设为一个占位值。

View File

@ -1,4 +1,5 @@
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
@ -11,12 +12,31 @@ from app.gateway.authz import (
)
from app.gateway.deps import get_config, get_optional_user_from_request
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.models.reasoning import reasoning_capabilities_payload, resolve_reasoning_contract
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["models"])
class ReasoningEffortCapabilitiesResponse(BaseModel):
"""Effort values a model accepts, in display order."""
values: list[str] = Field(..., description="Accepted effort values (provider vocabulary)")
default: str | None = Field(default=None, description="Effort applied when the caller does not choose one")
aliases: dict[str, str] = Field(default_factory=dict, description="Generic DeerFlow value -> provider value")
class ReasoningCapabilitiesResponse(BaseModel):
"""Normalized reasoning contract (issue #5073), derived for legacy profiles."""
thinking: Literal["unsupported", "optional", "required"] = Field(..., description="Whether thinking can be toggled, is always on, or is unavailable")
effort: ReasoningEffortCapabilitiesResponse | None = Field(default=None, description="Effort control; null when the model exposes none")
history: Literal["preserve", "clear"] | None = Field(default=None, description="Reasoning-history requirement, when declared")
source: Literal["legacy", "contract"] = Field(..., description="Whether the profile declared a contract or the booleans were projected")
class ModelResponse(BaseModel):
"""Response model for model information."""
@ -24,8 +44,21 @@ class ModelResponse(BaseModel):
model: str = Field(..., description="Actual provider model identifier")
display_name: str | None = Field(None, description="Human-readable name")
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode (deprecated: derived from `reasoning`)")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort (deprecated: derived from `reasoning`)")
reasoning: ReasoningCapabilitiesResponse = Field(..., description="Normalized reasoning capability contract")
def _model_response(model: ModelConfig) -> ModelResponse:
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
reasoning=ReasoningCapabilitiesResponse(**reasoning_capabilities_payload(resolve_reasoning_contract(model))),
)
class TokenUsageResponse(BaseModel):
@ -73,7 +106,8 @@ async def list_models(
"display_name": "GPT-4",
"description": "OpenAI GPT-4 model",
"supports_thinking": false,
"supports_reasoning_effort": false
"supports_reasoning_effort": false,
"reasoning": {"thinking": "unsupported", "effort": null, "history": null, "source": "legacy"}
},
{
"name": "claude-3-opus",
@ -81,7 +115,8 @@ async def list_models(
"display_name": "Claude 3 Opus",
"description": "Anthropic Claude 3 Opus model",
"supports_thinking": true,
"supports_reasoning_effort": false
"supports_reasoning_effort": false,
"reasoning": {"thinking": "optional", "effort": null, "history": null, "source": "legacy"}
}
],
"token_usage": {
@ -112,17 +147,7 @@ async def list_models(
logger.warning("Authorization provider failed while filtering models", exc_info=True)
visible_models = [] if fail_closed else config.models
models = [
ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
for model in visible_models
]
models = [_model_response(model) for model in visible_models]
return ModelsListResponse(
models=models,
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
@ -174,11 +199,4 @@ async def get_model(
if user is not None:
authorize_model_use(user, model_name, is_internal=_is_internal_caller(request, user), app_config=config)
return ModelResponse(
name=model.name,
model=model.model,
display_name=model.display_name,
description=model.description,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
)
return _model_response(model)

View File

@ -18,7 +18,11 @@ class Preferences(BaseModel):
notification_enabled: bool | None = None
model_name: Annotated[str, StringConstraints(max_length=200)] | None = None
mode: Literal["flash", "thinking", "pro", "ultra"] | None = None
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
# Provider vocabularies differ (issue #5073): accept any effort token so a
# model-specific default such as ``max`` can be remembered. The UI checks
# remembered values against the selected model; the factory enforces
# declared contracts while preserving legacy direct-request behavior.
reasoning_effort: Annotated[str, StringConstraints(max_length=32, pattern=r"^[A-Za-z0-9_.-]+$")] | None = None
async def _owner(request: Request, expected_user: str) -> str:

View File

@ -544,24 +544,41 @@ GET /api/models
"name": "gpt-4",
"display_name": "GPT-4",
"supports_thinking": false,
"supports_vision": true
},
{
"name": "claude-3-opus",
"display_name": "Claude 3 Opus",
"supports_thinking": false,
"supports_vision": true
"supports_reasoning_effort": false,
"reasoning": {"thinking": "unsupported", "effort": null, "history": null, "source": "legacy"}
},
{
"name": "deepseek-v3",
"display_name": "DeepSeek V3",
"supports_thinking": true,
"supports_vision": false
"supports_reasoning_effort": true,
"reasoning": {
"thinking": "optional",
"effort": {"values": ["minimal", "low", "medium", "high"], "default": null, "aliases": {}},
"history": null,
"source": "legacy"
}
},
{
"name": "glm-5.3-flash",
"display_name": "GLM-5.3-Flash",
"supports_thinking": true,
"supports_reasoning_effort": true,
"reasoning": {
"thinking": "required",
"effort": {"values": ["low", "high", "max"], "default": "high", "aliases": {"minimal": "low", "medium": "high"}},
"history": "clear",
"source": "contract"
}
}
]
}
```
`supports_thinking` and `supports_reasoning_effort` are deprecated projections of
`reasoning`. `reasoning.source` is `legacy` when the profile declares no
`reasoning:` block (the booleans were normalized) and `contract` when it does.
#### Get Model Details
```http
@ -576,7 +593,8 @@ GET /api/models/{model_name}
"model": "gpt-4",
"max_tokens": 4096,
"supports_thinking": false,
"supports_vision": true
"supports_reasoning_effort": false,
"reasoning": {"thinking": "unsupported", "effort": null, "history": null, "source": "legacy"}
}
```

View File

@ -49,6 +49,10 @@ _MODEL_METADATA_FIELDS = frozenset(
"use",
"context_window",
"pricing",
# ``reasoning`` is deliberately *not* excluded: as a bool or level
# string it is ChatOllama's native provider kwarg, and as a declared
# contract its dialect/history change the request payload. Both must
# move the assembly fingerprint (issue #5073, PR #5780 review).
}
)
_MIDDLEWARE_PUBLIC_FIELDS = (

View File

@ -64,6 +64,7 @@ from deerflow.config.subagents_config import (
effective_subagent_concurrency,
)
from deerflow.models import create_chat_model
from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request
from deerflow.runtime.checkpoint_mode import (
INTERNAL_CHECKPOINT_MODE_KEY,
freeze_checkpoint_channel_mode,
@ -989,9 +990,23 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
if model_config is None:
raise ValueError("No chat model could be resolved. Please configure at least one model in config.yaml or provide a valid 'model_name'/'model' in the request.")
if thinking_enabled and not model_config.supports_thinking:
# Normalize the request against the model's reasoning contract (issue #5073)
# so the run metadata, the assembly descriptor and the factory agree on the
# effective policy: required-thinking models turn the flag back on, an
# unsupported model turns it off, and a restricted effort vocabulary maps
# the generic value onto the provider's own.
reasoning_contract = resolve_reasoning_contract(model_config)
resolved_reasoning = resolve_reasoning_request(reasoning_contract, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort)
if "thinking_unsupported" in resolved_reasoning.adjustments:
logger.warning(f"Thinking mode is enabled but model '{model_name}' does not support it; fallback to non-thinking mode.")
thinking_enabled = False
elif resolved_reasoning.adjustments:
logger.info("Model '%s': reasoning request adjusted by its capability contract (%s)", model_name, ", ".join(resolved_reasoning.adjustments))
thinking_enabled = resolved_reasoning.thinking_enabled
if reasoning_contract.source == "contract":
# Legacy profiles keep forwarding the raw request (the factory strips
# what the profile cannot honor, exactly as before); declared
# contracts hand the factory the provider value they resolved to.
reasoning_effort = resolved_reasoning.reasoning_effort
logger.info(
"Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s, max_total_subagents: %s",

View File

@ -54,6 +54,7 @@ from deerflow.config.paths import get_paths
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.mcp_scope import THREAD_INCARNATION_CONTEXT_KEY
from deerflow.models import create_chat_model
from deerflow.models.reasoning import reasoning_capabilities_payload, resolve_reasoning_contract
from deerflow.runtime import CheckpointStateAccessor
from deerflow.runtime.checkpoint_mode import (
ensure_checkpoint_mode_compatible,
@ -1262,6 +1263,7 @@ class DeerFlowClient:
"description": getattr(model, "description", None),
"supports_thinking": getattr(model, "supports_thinking", False),
"supports_reasoning_effort": getattr(model, "supports_reasoning_effort", False),
"reasoning": reasoning_capabilities_payload(resolve_reasoning_contract(model)),
}
for model in self._app_config.models
],
@ -1334,6 +1336,7 @@ class DeerFlowClient:
"description": getattr(model, "description", None),
"supports_thinking": getattr(model, "supports_thinking", False),
"supports_reasoning_effort": getattr(model, "supports_reasoning_effort", False),
"reasoning": reasoning_capabilities_payload(resolve_reasoning_contract(model)),
}
# ------------------------------------------------------------------

View File

@ -82,7 +82,9 @@ Extensions are optional only in the fallback *search* mode (priority 3-4 above):
### Config Schema
**`config.yaml`** key sections:
- `models[]` - LLM configs with `use` class path, `supports_thinking`, `supports_vision`, provider-specific fields
- `models[]` - LLM configs with `use` class path, `supports_thinking`, `supports_vision`, provider-specific fields. An optional `reasoning:` block (issue #5073) declares thinking availability (`unsupported|optional|required`), the accepted effort `values` with `aliases`/`default`/`path`, the payload `dialect`, and the reasoning `history` requirement; when present, `supports_thinking` / `supports_reasoning_effort` are derived from it and contradictory combinations fail validation in `ModelConfig`
A declared custom `reasoning.effort.path` is the only effort serialization path: `ModelConfig` rejects leftover `reasoning_effort` keys in the profile or thinking templates, even if their values are otherwise accepted.
A boolean or level-string `reasoning` (`true` / `false`, or `low|medium|high` for gpt-oss style models) remains the legacy native ChatOllama setting and is passed to the provider; only a mapping opts into the contract.
- `logging.enhance` - Log output only (`enabled`, `format`): whether log records carry a `trace_id` field, and in which format. Trace ids are issued unconditionally — the Gateway `X-Trace-Id` header and Langfuse `deerflow_trace_id` metadata are always present whatever this says (see the Request Trace Context section in `packages/harness/deerflow/AGENTS.md`); restart-required
- vLLM reasoning models should use `deerflow.models.vllm_provider:VllmChatModel`; for Qwen-style parsers prefer `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking`, and DeerFlow will also normalize the older `thinking` alias
- `tools[]` - Tool configs with `use` variable path and `group`

View File

@ -1,4 +1,6 @@
from pydantic import BaseModel, ConfigDict, Field
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class RequestAdmissionConfig(BaseModel):
@ -11,6 +13,101 @@ class RequestAdmissionConfig(BaseModel):
max_queue_size: int = Field(default=256, gt=0, strict=True)
_EFFORT_TOKEN_PATTERN = r"^[A-Za-z0-9_.-]{1,32}$"
# Dotted identifiers only: the factory writes the effort value at this path by
# creating nested dicts, so it must never be able to address arbitrary keys.
_EFFORT_PATH_PATTERN = r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$"
# Single-segment paths that would replace a whole dict-valued provider setting
# with the effort string (``path: extra_body`` would clobber ``extra_body``).
_EFFORT_PATH_RESERVED_ROOTS = frozenset({"extra_body", "thinking", "model_kwargs", "default_headers", "default_query"})
def _lookup_dotted(mapping: object, path: str) -> object | None:
"""Return the value at a dotted *path* inside nested dicts, or ``None``."""
cursor = mapping
for part in path.split("."):
if not isinstance(cursor, dict) or part not in cursor:
return None
cursor = cursor[part]
return cursor
class ReasoningEffortCapabilities(BaseModel):
"""Effort vocabulary one model accepts (issue #5073).
``values`` is the provider's own vocabulary in display order. ``aliases``
map DeerFlow's generic values (``minimal``/``low``/``medium``/``high``) onto
that vocabulary so a UI preset or a per-agent default never reaches the
provider unmapped. ``path`` is where the value is serialized: the default
``reasoning_effort`` constructor keyword, or a dotted path such as
``extra_body.thinking.effort`` for providers that nest it.
"""
model_config = ConfigDict(extra="forbid")
values: list[str] = Field(..., min_length=1, description="Accepted effort values, in display order")
default: str | None = Field(default=None, description="Effort used when the caller does not choose one")
aliases: dict[str, str] = Field(default_factory=dict, description="Generic DeerFlow value -> provider value")
path: str = Field(default="reasoning_effort", pattern=_EFFORT_PATH_PATTERN, description="Dotted model-settings path the value is written to")
@field_validator("path")
@classmethod
def _path_must_not_shadow_a_container(cls, path: str) -> str:
if path in _EFFORT_PATH_RESERVED_ROOTS:
raise ValueError(f"effort path {path!r} would replace the whole {path!r} mapping; address a key inside it instead (e.g. {path}.effort)")
return path
@field_validator("values")
@classmethod
def _values_are_unique_tokens(cls, values: list[str]) -> list[str]:
import re
for value in values:
if not re.fullmatch(_EFFORT_TOKEN_PATTERN, value):
raise ValueError(f"effort value {value!r} is not a valid token")
if len(set(values)) != len(values):
raise ValueError("effort values must be unique")
return values
@model_validator(mode="after")
def _default_and_aliases_point_at_values(self) -> "ReasoningEffortCapabilities":
if self.default is not None and self.default not in self.values:
raise ValueError(f"effort default {self.default!r} is not one of the declared values {self.values}")
for generic, provider in self.aliases.items():
if generic in self.values:
raise ValueError(f"effort alias {generic!r} is already a declared value; aliases may only map generic values onto the provider vocabulary")
if provider not in self.values:
raise ValueError(f"effort alias {generic!r} -> {provider!r} does not target a declared value")
return self
class ReasoningCapabilities(BaseModel):
"""Declarative reasoning contract for one model (issue #5073).
Declared beside the legacy ``supports_thinking`` / ``supports_reasoning_effort``
booleans; when present, those booleans are derived from it. See
``deerflow.models.reasoning`` for the normalized runtime view.
"""
model_config = ConfigDict(extra="forbid")
thinking: Literal["unsupported", "optional", "required"] = Field(default="unsupported", description="Whether thinking can be toggled, is always on, or is unavailable")
on_disable_request: Literal["keep_enabled", "reject"] = Field(
default="keep_enabled",
description="Required-thinking models only: silently keep thinking on when a caller asks for it to be off, or fail the request",
)
dialect: Literal["auto", "openai_extra_body", "anthropic", "vllm_chat_template", "ollama", "none"] = Field(
default="auto",
description="Payload dialect used to serialize thinking on/off; auto infers it from when_thinking_enabled",
)
history: Literal["preserve", "clear"] | None = Field(default=None, description="Whether reasoning history must be preserved or cleared across turns")
effort: ReasoningEffortCapabilities | None = Field(default=None, description="Effort control; omitted means the model exposes none")
@model_validator(mode="after")
def _reject_only_applies_to_required_thinking(self) -> "ReasoningCapabilities":
if self.on_disable_request == "reject" and self.thinking != "required":
raise ValueError("on_disable_request: reject is only meaningful when thinking is required")
return self
class ModelConfig(BaseModel):
"""Config section for a model"""
@ -34,6 +131,15 @@ class ModelConfig(BaseModel):
)
supports_thinking: bool = Field(default_factory=lambda: False, description="Whether the model supports thinking")
supports_reasoning_effort: bool = Field(default_factory=lambda: False, description="Whether the model supports reasoning effort")
reasoning: ReasoningCapabilities | bool | str | None = Field(
default=None,
description=(
"Declarative reasoning capability contract (thinking availability, accepted effort values, "
"payload dialect, reasoning-history requirement). A boolean or a string preserves the legacy native "
"provider setting (e.g. ChatOllama reasoning: true, or reasoning: high for gpt-oss levels); "
"only a contract derives supports_thinking / supports_reasoning_effort."
),
)
when_thinking_enabled: dict | None = Field(
default_factory=lambda: None,
description="Extra settings to be passed to the model when thinking is enabled",
@ -72,3 +178,61 @@ class ModelConfig(BaseModel):
"This is a shortcut for `when_thinking_enabled` and will be merged with `when_thinking_enabled` if both are provided."
),
)
@model_validator(mode="after")
def _validate_reasoning_contract(self) -> "ModelConfig":
"""Fail early on combinations the contract cannot honor, then project the legacy booleans."""
contract = self.reasoning
if not isinstance(contract, ReasoningCapabilities):
return self
if contract.thinking == "required" and self.when_thinking_disabled is not None:
raise ValueError("reasoning.thinking: required cannot be combined with when_thinking_disabled")
if contract.thinking == "unsupported":
if self.when_thinking_enabled is not None:
raise ValueError("reasoning.thinking: unsupported cannot be combined with when_thinking_enabled")
if self.thinking is not None:
raise ValueError("reasoning.thinking: unsupported cannot be combined with the thinking shortcut")
derived_thinking = contract.thinking != "unsupported"
derived_effort = contract.effort is not None
if "supports_thinking" in self.model_fields_set and self.supports_thinking != derived_thinking:
raise ValueError(f"supports_thinking={self.supports_thinking} contradicts reasoning.thinking: {contract.thinking}; drop the legacy flag or fix the contract")
if "supports_reasoning_effort" in self.model_fields_set and self.supports_reasoning_effort != derived_effort:
raise ValueError(f"supports_reasoning_effort={self.supports_reasoning_effort} contradicts the reasoning.effort contract; drop the legacy flag or fix the contract")
# Operator-supplied effort values — the profile itself and the thinking
# templates — are forwarded when the caller chooses nothing, so they
# must satisfy the contract too; otherwise an out-of-vocabulary value
# could still reach the provider despite the contract.
effort_path = contract.effort.path if contract.effort is not None else "reasoning_effort"
sources: list[tuple[str, object]] = [
("profile-level", self.model_extra or {}),
("when_thinking_enabled", self.when_thinking_enabled),
("when_thinking_disabled", self.when_thinking_disabled),
("thinking", {"thinking": self.thinking} if self.thinking is not None else None),
]
for source, mapping in sources:
if mapping is None:
continue
# The thinking shortcut becomes the nested ``thinking`` payload;
# the other sources are merged at the constructor-settings root.
generic_path = "thinking.reasoning_effort" if source == "thinking" else "reasoning_effort"
generic_mapping = self.thinking if source == "thinking" else mapping
if effort_path != generic_path and isinstance(generic_mapping, dict) and "reasoning_effort" in generic_mapping:
if contract.effort is None:
raise ValueError(f"{source} reasoning_effort is set, but the reasoning contract declares no effort control")
raise ValueError(f"{source} reasoning_effort conflicts with reasoning.effort.path={effort_path!r}; remove the legacy key")
value = _lookup_dotted(mapping, effort_path)
if value is None:
continue
if contract.effort is None:
raise ValueError(f"{source} {effort_path} is set, but the reasoning contract declares no effort control")
if value not in contract.effort.values:
raise ValueError(f"{source} {effort_path} {value!r} is not one of the contract's accepted values {contract.effort.values}")
# Deprecation-window projection: readers that still consult the booleans
# (wizard, older clients, the /api/models legacy fields) stay correct.
self.supports_thinking = derived_thinking
self.supports_reasoning_effort = derived_effort
return self

View File

@ -17,6 +17,47 @@ while handing off to the wait queue.
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
- Optional `models[].request_admission` attaches a process-shared `BaseRateLimiter` at the model factory. Identical explicit groups share one FIFO across model instances, threads and event loops; implicit groups use the configured model name. Policies are immutable once registered and conflicting settings fail construction. A monotonic minimum interval spaces requests without idle-time burst credit; bounded waiters poll without occupying executor threads and unregister in `finally`. The factory strips the policy from provider kwargs and sets exposed SDK `max_retries=0` so middleware retries re-enter admission. This limits model invocations, not tokens or a distributed provider account; custom providers bypassing BaseChatModel hooks are outside the contract. Tests: `test_model_request_admission.py` and `test_model_request_admission_fifo_atomic.py`.
### Reasoning Capability Contract (`packages/harness/deerflow/models/reasoning.py`)
Mapping-valued `ModelConfig.reasoning` (issue #5073) is an optional declarative contract beside
the legacy `supports_thinking` / `supports_reasoning_effort` booleans: thinking
`unsupported | optional | required`, `on_disable_request` (`keep_enabled` or
`reject`, required-thinking only), the payload `dialect` (`auto` infers it from
`when_thinking_enabled`), the reasoning `history` requirement, and an `effort`
vocabulary with `default`, generic-value `aliases`, and a serialization `path`.
A boolean or level-string `reasoning` (`true` / `false`, or `low|medium|high` for
gpt-oss style models) remains a native ChatOllama provider setting; the factory
forwards it on the legacy path, and the assembly descriptor keeps it — like a
declared contract's `dialect` / `history` — inside `model_parameters` so
request-affecting reasoning settings move the fingerprint. When the block is
present the booleans are derived from it and contradictory
profiles fail at config load (`required` + `when_thinking_disabled`, `unsupported`
+ an enable template, a `default` outside `values`, an explicit boolean that
disagrees, an effort value at `effort.path` in the profile or in the
`when_thinking_*` / `thinking` templates that the contract rejects, a `path` that
is not a dotted identifier or would overwrite a whole mapping, or a stale
`reasoning_effort` key beside a custom effort path). The factory strips a
generic effort key from runtime overrides for custom-path contracts. `default` also
governs callers that never choose an effort (summarization, title, subagents), so
shipped profiles keep it below the provider's deepest level.
`resolve_reasoning_contract` turns any profile (legacy or declared) into an
immutable `ReasoningContract`, `resolve_reasoning_request` applies a caller's
generic `thinking_enabled` / `reasoning_effort` to it, and
`reasoning_capabilities_payload` projects it for `/api/models` and
`DeerFlowClient` (`reasoning` object, `source: legacy|contract`).
`create_chat_model` is the single enforcement point for every caller (lead agent,
subagents, summarization, title, one-shot utilities): a required-thinking model
never enters the disable branch, effort is mapped through aliases or the default
and otherwise dropped, and `dialect` synthesizes the on/off payload when no
template exists. Legacy profiles (no mapping-valued `reasoning:` contract) keep the historical path
byte-for-byte, including the synthesized `reasoning_effort=minimal` on the
OpenAI-compatible disable path. The lead agent and the subagent descriptor resolve
the same policy first so run metadata reports the effective values. Design note:
`docs/plans/2026-09-23-reasoning-capability-contract.md`; tests:
`tests/test_reasoning_contract.py`, the contract section of
`tests/test_model_factory.py`, `tests/test_models_router_reasoning.py`.
### Claude Code Credentials (`packages/harness/deerflow/models/credential_loader.py`)
- `ClaudeChatModel.model_post_init` calls `load_claude_code_credential()` for every instance, and `create_chat_model` builds fresh instances per run (lead agent, title, summarization, subagents)

View File

@ -5,11 +5,40 @@ from langchain_openai.chat_models.base import BaseChatOpenAI
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.models.reasoning import ReasoningContract, ReasoningPolicyError, resolve_reasoning_contract, resolve_reasoning_request
from deerflow.reflection import resolve_class
from deerflow.tracing import build_tracing_callbacks
logger = logging.getLogger(__name__)
# ``ModelConfig`` fields that describe the profile rather than configure the
# provider client. They are stripped before the constructor sees the profile.
_MODEL_METADATA_FIELDS: frozenset[str] = frozenset(
{
"use",
"name",
"display_name",
"description",
"supports_thinking",
"supports_reasoning_effort",
# Declarative reasoning contract (issue #5073); consumed by the
# normalization layer, never a provider kwarg.
"reasoning",
"when_thinking_enabled",
"when_thinking_disabled",
"thinking",
"supports_vision",
# Runtime/UI metadata used to size the context indicator. Provider
# clients do not accept this as a model-constructor argument.
"context_window",
# Presentation-only metadata (consumed by the console's cost
# display) — must never reach the provider client, which would
# forward unknown kwargs into the completion request payload.
"pricing",
"request_admission",
}
)
def _deep_merge_dicts(base: dict | None, override: dict) -> dict:
"""Recursively merge two dictionaries without mutating the inputs."""
@ -32,6 +61,167 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
return disable_kwargs
def _merge_settings(settings: dict, payload: dict) -> None:
"""Deep-merge *payload* into the constructor settings in place.
Nested dicts (``extra_body``, ``thinking``, ``chat_template_kwargs``) merge
recursively so a synthesized thinking block never clobbers unrelated keys an
operator put beside it (e.g. GLM's ``extra_body.tool_stream``).
"""
for key, value in payload.items():
if isinstance(value, dict) and isinstance(settings.get(key), dict):
settings[key] = _deep_merge_dicts(settings[key], value)
else:
settings[key] = value
def _set_dotted_setting(settings: dict, path: str, value: object) -> None:
"""Write *value* at a dotted *path* (``reasoning_effort`` or ``extra_body.thinking.effort``)."""
head, _, rest = path.partition(".")
if not rest:
settings[head] = value
return
nested: dict = {}
cursor = nested
parts = rest.split(".")
for part in parts[:-1]:
cursor[part] = {}
cursor = cursor[part]
cursor[parts[-1]] = value
_merge_settings(settings, {head: nested})
def _infer_dialect(effective_wte: dict) -> str:
"""Infer the thinking payload dialect from a ``when_thinking_enabled`` template.
Mirrors the historical disable-path heuristics so ``dialect: auto`` behaves
like the legacy factory did for the same template shape.
"""
extra_body = effective_wte.get("extra_body") or {}
thinking = extra_body.get("thinking")
if isinstance(thinking, dict) and thinking.get("type"):
return "openai_extra_body"
chat_template_kwargs = extra_body.get("chat_template_kwargs") or {}
if isinstance(chat_template_kwargs, dict) and ("thinking" in chat_template_kwargs or "enable_thinking" in chat_template_kwargs):
return "vllm_chat_template"
native_thinking = effective_wte.get("thinking")
if isinstance(native_thinking, dict) and native_thinking.get("type"):
return "anthropic"
if "reasoning" in effective_wte:
return "ollama"
return "none"
def _vllm_toggle_chat_template_kwargs(chat_template_kwargs: dict, *, enabled: bool) -> dict:
"""Mirror whichever vLLM/Qwen switch the template declares; default to ``enable_thinking``."""
toggled = {key: enabled for key in ("thinking", "enable_thinking") if key in chat_template_kwargs}
return toggled or {"enable_thinking": enabled}
def _dialect_payload(dialect: str, *, enabled: bool, chat_template_kwargs: dict | None = None) -> dict:
"""Constructor settings that switch thinking on or off for one payload dialect."""
if dialect == "openai_extra_body":
return {"extra_body": {"thinking": {"type": "enabled" if enabled else "disabled"}}}
if dialect == "anthropic":
return {"thinking": {"type": "enabled" if enabled else "disabled"}}
if dialect == "vllm_chat_template":
return {"extra_body": {"chat_template_kwargs": _vllm_toggle_chat_template_kwargs(chat_template_kwargs or {}, enabled=enabled)}}
if dialect == "ollama":
return {"reasoning": enabled}
return {}
def _apply_legacy_thinking_settings(
settings: dict,
model_config,
*,
thinking_enabled: bool,
has_thinking_settings: bool,
effective_wte: dict,
requested_reasoning_effort: str | None,
is_codex_model: bool,
) -> None:
"""Historical thinking/effort payload path for profiles without a ``reasoning:`` contract.
Kept byte-for-byte so existing configurations retain their behavior (issue
#5073 acceptance criterion), including the synthesized ``reasoning_effort=minimal``
on the OpenAI-compatible disable path that the contract path drops.
"""
if requested_reasoning_effort is not None and not is_codex_model:
settings["reasoning_effort"] = requested_reasoning_effort
if thinking_enabled and has_thinking_settings and effective_wte:
settings.update(effective_wte)
if not thinking_enabled:
if model_config.when_thinking_disabled is not None:
# User-provided disable settings take full precedence
settings.update(model_config.when_thinking_disabled)
elif has_thinking_settings and effective_wte.get("extra_body", {}).get("thinking", {}).get("type"):
# OpenAI-compatible gateway: thinking is nested under extra_body
settings["extra_body"] = _deep_merge_dicts(
settings.get("extra_body"),
{"thinking": {"type": "disabled"}},
)
settings["reasoning_effort"] = "minimal"
elif has_thinking_settings and (disable_chat_template_kwargs := _vllm_disable_chat_template_kwargs(effective_wte.get("extra_body", {}).get("chat_template_kwargs") or {})):
# vLLM uses chat template kwargs to switch thinking on/off.
settings["extra_body"] = _deep_merge_dicts(
settings.get("extra_body"),
{"chat_template_kwargs": disable_chat_template_kwargs},
)
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
# Native langchain_anthropic: thinking is a direct constructor parameter
settings["thinking"] = {"type": "disabled"}
def _apply_contract_thinking_settings(
settings: dict,
contract: ReasoningContract,
model_config,
*,
thinking_enabled: bool,
effective_wte: dict,
requested_reasoning_effort: str | None,
codex_handles_generic_effort: bool,
) -> None:
"""Thinking/effort payload path for profiles that declare a ``reasoning:`` contract.
``thinking_enabled`` and ``requested_reasoning_effort`` are the *resolved*
values from :func:`resolve_reasoning_request`, so a required-thinking model
never enters the disable branch and the effort value is already one the
provider accepts.
* Enabled: the dialect's enable payload deep-merged under the operator's
``when_thinking_enabled`` / ``thinking`` template (template wins), plus
``clear_thinking`` when the contract states a history requirement and the
dialect nests thinking under ``extra_body``.
* Disabled: ``when_thinking_disabled`` when present, else the dialect's
disable payload. No effort value is ever synthesized here.
* Effort: written last at ``effort.path`` so a validated request wins over
a template value; ``None`` leaves any profile/template value in place. A
model without an effort contract never forwards ``reasoning_effort``.
"""
dialect = contract.dialect if contract.dialect != "auto" else _infer_dialect(effective_wte)
# Both directions mirror whichever vLLM switch the template declares, so a
# template using the older ``thinking`` key never also grows ``enable_thinking``.
chat_template_kwargs = (effective_wte.get("extra_body") or {}).get("chat_template_kwargs") or {}
if thinking_enabled:
payload = _deep_merge_dicts(_dialect_payload(dialect, enabled=True, chat_template_kwargs=chat_template_kwargs), effective_wte)
if contract.history is not None and dialect == "openai_extra_body":
payload = _deep_merge_dicts(payload, {"extra_body": {"thinking": {"clear_thinking": contract.history == "clear"}}})
_merge_settings(settings, payload)
elif model_config.when_thinking_disabled is not None:
_merge_settings(settings, model_config.when_thinking_disabled)
else:
_merge_settings(settings, _dialect_payload(dialect, enabled=False, chat_template_kwargs=chat_template_kwargs))
if contract.effort is None or contract.effort.path != "reasoning_effort":
# A custom path is the only effort wire format for this contract.
# Drop a generic key supplied by model_overrides as well as stale
# settings on a ModelConfig mutated after load-time validation.
settings.pop("reasoning_effort", None)
if contract.effort is not None and requested_reasoning_effort is not None and not codex_handles_generic_effort:
_set_dotted_setting(settings, contract.effort.path, requested_reasoning_effort)
def _declares_api_base(model_class: type) -> bool:
"""Whether *model_class* declares ``api_base`` as its own constructor field.
@ -206,29 +396,14 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
if model_config is None:
raise ValueError(f"Model {name} not found in config") from None
model_class = resolve_class(model_config.use, BaseChatModel)
model_settings_from_config = model_config.model_dump(
exclude_none=True,
exclude={
"use",
"name",
"display_name",
"description",
"supports_thinking",
"supports_reasoning_effort",
"when_thinking_enabled",
"when_thinking_disabled",
"thinking",
"supports_vision",
# Runtime/UI metadata used to size the context indicator. Provider
# clients do not accept this as a model-constructor argument.
"context_window",
# Presentation-only metadata (consumed by the console's cost
# display) — must never reach the provider client, which would
# forward unknown kwargs into the completion request payload.
"pricing",
"request_admission",
},
)
model_settings_from_config = model_config.model_dump(exclude_none=True, exclude=set(_MODEL_METADATA_FIELDS))
if isinstance(model_config.reasoning, bool | str):
# Before the capability contract, ``reasoning`` was an unrestricted
# provider kwarg. ChatOllama uses the boolean form and, for gpt-oss
# style models, the ``low|medium|high`` level string; keep forwarding
# both on the legacy path even though contract metadata is excluded
# above.
model_settings_from_config["reasoning"] = model_config.reasoning
# Layer per-caller sampling overrides (e.g. a custom agent's temperature /
# max_tokens) on top of the profile. Ignore None so an unset override never
# clobbers a configured profile value. Applied here — before the thinking
@ -246,8 +421,6 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
is_codex_model = issubclass(model_class, CodexChatModel)
requested_reasoning_effort = kwargs.pop("reasoning_effort", None)
if requested_reasoning_effort is not None and not is_codex_model:
model_settings_from_config["reasoning_effort"] = requested_reasoning_effort
# Compute effective when_thinking_enabled by merging in the `thinking` shortcut field.
# The `thinking` shortcut is equivalent to setting when_thinking_enabled["thinking"].
has_thinking_settings = (model_config.when_thinking_enabled is not None) or (model_config.thinking is not None)
@ -255,34 +428,50 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
if model_config.thinking is not None:
merged_thinking = {**(effective_wte.get("thinking") or {}), **model_config.thinking}
effective_wte = {**effective_wte, "thinking": merged_thinking}
if thinking_enabled and has_thinking_settings:
if not model_config.supports_thinking:
raise ValueError(f"Model {name} does not support thinking. Set `supports_thinking` to true in the `config.yaml` to enable thinking.") from None
if effective_wte:
model_settings_from_config.update(effective_wte)
if not thinking_enabled:
if model_config.when_thinking_disabled is not None:
# User-provided disable settings take full precedence
model_settings_from_config.update(model_config.when_thinking_disabled)
elif has_thinking_settings and effective_wte.get("extra_body", {}).get("thinking", {}).get("type"):
# OpenAI-compatible gateway: thinking is nested under extra_body
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
{"thinking": {"type": "disabled"}},
)
model_settings_from_config["reasoning_effort"] = "minimal"
elif has_thinking_settings and (disable_chat_template_kwargs := _vllm_disable_chat_template_kwargs(effective_wte.get("extra_body", {}).get("chat_template_kwargs") or {})):
# vLLM uses chat template kwargs to switch thinking on/off.
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
{"chat_template_kwargs": disable_chat_template_kwargs},
)
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
# Native langchain_anthropic: thinking is a direct constructor parameter
model_settings_from_config["thinking"] = {"type": "disabled"}
if not model_config.supports_reasoning_effort:
requested_reasoning_effort = None
model_settings_from_config.pop("reasoning_effort", None)
# One normalized reasoning policy for every caller (issue #5073): the lead
# agent, subagents, summarization, title generation and one-shot utilities
# all land here, so required thinking, unsupported thinking and restricted
# effort vocabularies are enforced in exactly one place.
contract = resolve_reasoning_contract(model_config)
# Codex's legacy/default-path adapter writes the generic constructor key.
# A declared custom path takes precedence even for a Codex subclass.
codex_handles_generic_effort = is_codex_model and (contract.source == "legacy" or contract.effort is None or contract.effort.path == "reasoning_effort")
if contract.source == "legacy" and thinking_enabled and has_thinking_settings and not contract.supports_thinking:
raise ValueError(f"Model {name} does not support thinking. Set `supports_thinking` to true in the `config.yaml` to enable thinking.") from None
try:
resolved_reasoning = resolve_reasoning_request(contract, thinking_enabled=thinking_enabled, reasoning_effort=requested_reasoning_effort)
except ReasoningPolicyError as exc:
raise ValueError(f"Model {name}: {exc}") from exc
if resolved_reasoning.adjustments:
logger.info("Model '%s': reasoning request adjusted by its capability contract (%s)", name, ", ".join(resolved_reasoning.adjustments))
requested_reasoning_effort = resolved_reasoning.reasoning_effort
if contract.source == "legacy":
# Profiles without a ``reasoning:`` block keep the historical payload
# path byte-for-byte, driven by the caller's raw thinking flag.
_apply_legacy_thinking_settings(
model_settings_from_config,
model_config,
thinking_enabled=thinking_enabled,
has_thinking_settings=has_thinking_settings,
effective_wte=effective_wte,
requested_reasoning_effort=requested_reasoning_effort,
is_codex_model=is_codex_model,
)
if not contract.supports_reasoning_effort:
requested_reasoning_effort = None
model_settings_from_config.pop("reasoning_effort", None)
else:
thinking_enabled = resolved_reasoning.thinking_enabled
_apply_contract_thinking_settings(
model_settings_from_config,
contract,
model_config,
thinking_enabled=thinking_enabled,
effective_wte=effective_wte,
requested_reasoning_effort=requested_reasoning_effort,
codex_handles_generic_effort=codex_handles_generic_effort,
)
# Normalize the api_base -> base_url alias FIRST, so the downstream OpenAI-compatible
# heuristics (stream_usage default below / stream_chunk_timeout) see the canonical endpoint key.
@ -294,13 +483,14 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
# The ChatGPT Codex endpoint currently rejects max_tokens/max_output_tokens.
model_settings_from_config.pop("max_tokens", None)
# Use explicit reasoning_effort from frontend if provided (low/medium/high)
if not thinking_enabled:
model_settings_from_config["reasoning_effort"] = "none"
elif requested_reasoning_effort in ("low", "medium", "high", "xhigh"):
model_settings_from_config["reasoning_effort"] = requested_reasoning_effort
elif "reasoning_effort" not in model_settings_from_config:
model_settings_from_config["reasoning_effort"] = "medium"
if codex_handles_generic_effort:
# Use explicit reasoning_effort from frontend if provided (low/medium/high)
if not thinking_enabled:
model_settings_from_config["reasoning_effort"] = "none"
elif requested_reasoning_effort in ("low", "medium", "high", "xhigh"):
model_settings_from_config["reasoning_effort"] = requested_reasoning_effort
elif "reasoning_effort" not in model_settings_from_config:
model_settings_from_config["reasoning_effort"] = "medium"
# For MindIE models: enforce conservative retry defaults.
# Timeout normalization is handled inside MindIEChatModel itself.

View File

@ -0,0 +1,217 @@
"""Normalized model reasoning capability contract (issue #5073).
Every model creation path — the lead agent, subagents, summarization, title
generation, one-shot utilities — used to interpret ``supports_thinking`` /
``supports_reasoning_effort`` on its own and then hand generic
``thinking_enabled`` / ``reasoning_effort`` values to the factory. That could
not describe models whose provider contract differs from the generic
assumptions (required thinking, restricted effort vocabularies, provider
specific payload dialects).
This module owns the single normalized view:
* :func:`resolve_reasoning_contract` turns a :class:`ModelConfig` (or any
duck-typed profile) into an immutable :class:`ReasoningContract`. Profiles
that declare ``reasoning:`` map 1:1; legacy profiles derive the contract from
the booleans and keep their historical, non-strict behavior.
* :func:`resolve_reasoning_request` applies a caller's generic request to the
contract and returns the effective ``thinking_enabled`` and provider effort
value. It is the one policy the factory and the callers share.
* :func:`reasoning_capabilities_payload` projects the contract for the
Gateway ``/api/models`` response and ``DeerFlowClient``.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Literal
from deerflow.config.model_config import ReasoningCapabilities
ThinkingMode = Literal["unsupported", "optional", "required"]
DisableRequestPolicy = Literal["keep_enabled", "reject"]
ReasoningDialect = Literal["auto", "openai_extra_body", "anthropic", "vllm_chat_template", "ollama", "none"]
ReasoningHistory = Literal["preserve", "clear"]
ContractSource = Literal["legacy", "contract"]
#: The vocabulary DeerFlow's generic UI and per-agent defaults emit. Legacy
#: profiles advertise exactly this set so the frontend keeps its old choices.
GENERIC_EFFORT_VALUES: tuple[str, ...] = ("minimal", "low", "medium", "high")
#: Constructor keyword the OpenAI-compatible clients accept for effort.
DEFAULT_EFFORT_PATH = "reasoning_effort"
class ReasoningPolicyError(ValueError):
"""A request contradicts the model's reasoning contract and must not reach the provider."""
@dataclass(frozen=True)
class EffortContract:
"""Allowed effort values for one model.
``strict`` distinguishes a declared contract (unknown values never reach the
provider) from the legacy projection (values are forwarded verbatim, as
they always were).
"""
values: tuple[str, ...]
default: str | None = None
aliases: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
path: str = DEFAULT_EFFORT_PATH
strict: bool = True
@dataclass(frozen=True)
class ReasoningContract:
"""Normalized reasoning capabilities of one model profile."""
thinking: ThinkingMode
effort: EffortContract | None
on_disable_request: DisableRequestPolicy = "keep_enabled"
dialect: ReasoningDialect = "auto"
history: ReasoningHistory | None = None
source: ContractSource = "legacy"
@property
def supports_thinking(self) -> bool:
return self.thinking != "unsupported"
@property
def thinking_required(self) -> bool:
return self.thinking == "required"
@property
def supports_reasoning_effort(self) -> bool:
return self.effort is not None
@dataclass(frozen=True)
class ResolvedReasoning:
"""The effective policy for one model call.
``adjustments`` lists what the contract changed about the request so
callers can log it: ``thinking_unsupported``, ``thinking_forced_on``,
``effort_unsupported``, ``effort_aliased``, ``effort_unsupported_value``.
"""
thinking_enabled: bool
reasoning_effort: str | None
adjustments: tuple[str, ...] = ()
def resolve_reasoning_contract(model_config: Any) -> ReasoningContract:
"""Normalize a model profile into a :class:`ReasoningContract`.
Accepts a real :class:`ModelConfig` as well as duck-typed stand-ins (the
subagent descriptor builder and several tests pass ``SimpleNamespace`` or
mocks). Only a genuine :class:`ReasoningCapabilities` instance counts as a
declared contract; anything else falls back to the legacy booleans.
"""
declared = getattr(model_config, "reasoning", None)
if isinstance(declared, ReasoningCapabilities):
effort = None
if declared.effort is not None:
effort = EffortContract(
values=tuple(declared.effort.values),
default=declared.effort.default,
aliases=MappingProxyType(dict(declared.effort.aliases)),
path=declared.effort.path,
strict=True,
)
return ReasoningContract(
thinking=declared.thinking,
effort=effort,
on_disable_request=declared.on_disable_request,
dialect=declared.dialect,
history=declared.history,
source="contract",
)
supports_thinking = bool(getattr(model_config, "supports_thinking", False))
supports_effort = bool(getattr(model_config, "supports_reasoning_effort", False))
return ReasoningContract(
thinking="optional" if supports_thinking else "unsupported",
effort=EffortContract(values=GENERIC_EFFORT_VALUES, strict=False) if supports_effort else None,
source="legacy",
)
def resolve_reasoning_request(contract: ReasoningContract, *, thinking_enabled: bool, reasoning_effort: str | None) -> ResolvedReasoning:
"""Apply a generic request to the contract.
Raises:
ReasoningPolicyError: the model requires thinking, the caller asked for
it to be off, and the profile chose ``on_disable_request: reject``.
"""
adjustments: list[str] = []
if contract.thinking == "unsupported":
if thinking_enabled:
adjustments.append("thinking_unsupported")
effective_thinking = False
elif contract.thinking == "required":
if not thinking_enabled:
if contract.on_disable_request == "reject":
raise ReasoningPolicyError("model requires thinking, but the request asked for it to be disabled")
adjustments.append("thinking_forced_on")
effective_thinking = True
else:
effective_thinking = bool(thinking_enabled)
effort = contract.effort
if effort is None:
if reasoning_effort is not None:
adjustments.append("effort_unsupported")
effective_effort: str | None = None
elif not effort.strict:
effective_effort = reasoning_effort
elif reasoning_effort is None:
effective_effort = effort.default
elif not isinstance(reasoning_effort, str):
# Only string tokens can be matched against a vocabulary; anything else
# is an invalid request and falls back like an unknown value.
adjustments.append("effort_unsupported_value")
effective_effort = effort.default
elif reasoning_effort in effort.values:
effective_effort = reasoning_effort
elif reasoning_effort in effort.aliases:
adjustments.append("effort_aliased")
effective_effort = effort.aliases[reasoning_effort]
else:
adjustments.append("effort_unsupported_value")
effective_effort = effort.default
return ResolvedReasoning(thinking_enabled=effective_thinking, reasoning_effort=effective_effort, adjustments=tuple(adjustments))
def reasoning_capabilities_payload(contract: ReasoningContract) -> dict[str, Any]:
"""JSON-shaped projection of the contract for the Gateway API and the client."""
effort: dict[str, Any] | None = None
if contract.effort is not None:
effort = {
"values": list(contract.effort.values),
"default": contract.effort.default,
"aliases": dict(contract.effort.aliases),
}
return {
"thinking": contract.thinking,
"effort": effort,
"history": contract.history,
"source": contract.source,
}
__all__ = [
"DEFAULT_EFFORT_PATH",
"GENERIC_EFFORT_VALUES",
"EffortContract",
"ReasoningContract",
"ReasoningPolicyError",
"ResolvedReasoning",
"reasoning_capabilities_payload",
"resolve_reasoning_contract",
"resolve_reasoning_request",
]

View File

@ -1116,14 +1116,20 @@ class SubagentExecutor:
supports_vision=False,
)
deferred_names = deferred_setup.deferred_names if deferred_setup is not None else frozenset()
# Subagents request thinking off; the model's reasoning contract
# decides what that means (a required-thinking model stays on), and
# the descriptor reports the effective policy the factory applied.
from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request
effective_reasoning = resolve_reasoning_request(resolve_reasoning_contract(model_config), thinking_enabled=False, reasoning_effort=None)
descriptor = build_assembly_descriptor(
namespace="deerflow",
agent_name=self.config.name,
requested_model=(self.config.model if self.config.model != "inherit" else self.parent_model),
effective_model=self.model_name,
model_config=model_config,
thinking_enabled=False,
reasoning_effort=None,
thinking_enabled=effective_reasoning.thinking_enabled,
reasoning_effort=effective_reasoning.reasoning_effort,
rendered_base_prompt=self._assembled_system_prompt,
prompt_template_id="deerflow-subagent-v1",
tools=tools,

View File

@ -604,6 +604,29 @@ class TestModelParametersProjectEffectiveSettings:
with_noop_override = self._build(model_config, model_overrides={"temperature": None})
assert profile_only.fingerprint == with_noop_override.fingerprint
def test_native_reasoning_setting_is_projected_and_moves_the_fingerprint(self):
"""``reasoning`` is a real provider kwarg for ChatOllama (bool or level string), so
flipping it changes requests and must change the fingerprint (PR #5780 review)."""
on = self._build(self._model_config(reasoning=True))
off = self._build(self._model_config(reasoning=False))
high = self._build(self._model_config(reasoning="high"))
assert on.fingerprint != off.fingerprint
assert on.fingerprint != high.fingerprint
assert on.model_parameters["reasoning"] is True
assert high.model_parameters["reasoning"] == "high"
def test_declared_reasoning_contract_is_projected_and_moves_the_fingerprint(self):
"""A contract's dialect/history change the request payload, so they belong in the fingerprint."""
def contract(history):
return {"thinking": "optional", "dialect": "openai_extra_body", "history": history}
clear = self._build(self._model_config(reasoning=contract("clear")))
preserve = self._build(self._model_config(reasoning=contract("preserve")))
assert clear.fingerprint != preserve.fingerprint
assert clear.model_parameters["reasoning"]["history"] == "clear"
assert clear.model_parameters["reasoning"]["dialect"] == "openai_extra_body"
def test_api_key_is_never_projected_and_never_moves_the_fingerprint(self):
quiet = self._build(self._model_config(api_key="sk-aaaaaaaaaaaa"))
loud = self._build(self._model_config(api_key="sk-bbbbbbbbbbbb"))

View File

@ -197,6 +197,13 @@ class TestConfigQueries:
assert "model" in result["models"][0]
assert "display_name" in result["models"][0]
assert "supports_thinking" in result["models"][0]
# The normalized reasoning contract is projected beside the legacy booleans.
assert result["models"][0]["reasoning"] == {
"thinking": "unsupported",
"effort": None,
"history": None,
"source": "legacy",
}
def test_list_skills(self, client):
skill = MagicMock()
@ -1993,6 +2000,12 @@ class TestGetModel:
"description": "A test model",
"supports_thinking": True,
"supports_reasoning_effort": True,
"reasoning": {
"thinking": "optional",
"effort": {"values": ["minimal", "low", "medium", "high"], "default": None, "aliases": {}},
"history": None,
"source": "legacy",
},
}
def test_not_found(self, client):

View File

@ -1564,3 +1564,46 @@ def test_make_lead_agent_no_agent_settings_passes_none_overrides(monkeypatch):
lead_agent_module._make_lead_agent({"context": {"model_name": "safe-model"}}, app_config=app_config)
assert captured["model_overrides"] is None
def test_internal_make_lead_agent_applies_the_required_thinking_contract(monkeypatch):
"""A required-thinking model (issue #5073) turns a ``thinking_enabled=False`` request
back on and maps the generic effort through the contract *before* the factory
runs, so the assembly metadata and the model see the same effective policy."""
model = ModelConfig(
name="glm-5.3-flash",
display_name="GLM-5.3-Flash",
description=None,
use="langchain_openai:ChatOpenAI",
model="glm-5.3-flash",
supports_vision=False,
reasoning={
"thinking": "required",
"dialect": "openai_extra_body",
"effort": {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}},
},
)
app_config = _make_app_config([model])
import deerflow.tools as tools_module
monkeypatch.setattr(lead_agent_module, "get_app_config", lambda: app_config)
monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: [])
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
captured: dict[str, object] = {}
def _fake_create_chat_model(*, name, thinking_enabled, reasoning_effort=None, app_config=None, attach_tracing=True, model_overrides=None):
captured["thinking_enabled"] = thinking_enabled
captured["reasoning_effort"] = reasoning_effort
return object()
monkeypatch.setattr(lead_agent_module, "create_chat_model", _fake_create_chat_model)
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
config: dict = {"configurable": {"model_name": "glm-5.3-flash", "thinking_enabled": False, "reasoning_effort": "minimal"}}
lead_agent_module._make_lead_agent(config, app_config=app_config)
assert captured == {"thinking_enabled": True, "reasoning_effort": "low"}
assert config["metadata"]["thinking_enabled"] is True
assert config["metadata"]["reasoning_effort"] == "low"

View File

@ -11,6 +11,7 @@ from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.models import factory as factory_module
from deerflow.models import openai_codex_provider as codex_provider_module
from deerflow.models.reasoning import resolve_reasoning_contract
from deerflow.reflection import resolve_class
# ---------------------------------------------------------------------------
@ -1838,3 +1839,389 @@ def test_codex_still_strips_overridden_max_tokens(monkeypatch):
factory_module.create_chat_model(name="codex", model_overrides={"max_tokens": 9999})
assert "max_tokens" not in captured
# ---------------------------------------------------------------------------
# Declarative reasoning capability contract (issue #5073)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("provider_name", ["ollama_qwen", "ollama_gemma"])
def test_ollama_wizard_native_reasoning_survives_config_load_and_model_build(monkeypatch, provider_name):
import yaml
from wizard.providers import LLM_PROVIDERS
from wizard.writer import build_minimal_config
provider = next(item for item in LLM_PROVIDERS if item.name == provider_name)
content = build_minimal_config(
provider_use=provider.use,
model_name=provider.default_model,
display_name=provider.display_name,
api_key_field=provider.api_key_field,
env_var=provider.env_var,
extra_model_config=provider.extra_config,
)
config = AppConfig.model_validate(yaml.safe_load(content))
model = config.models[0]
assert model.reasoning is True
assert model.supports_thinking is True
assert resolve_reasoning_contract(model).source == "legacy"
captured: dict = {}
_patch_factory(monkeypatch, config, model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name=model.name, thinking_enabled=True)
assert captured["reasoning"] is True
@pytest.mark.parametrize("level", ["low", "medium", "high"])
def test_native_provider_reasoning_level_string_is_forwarded(monkeypatch, level):
"""langchain-ollama accepts ``reasoning: low|medium|high`` (e.g. gpt-oss). Before the
contract field existed the string passed straight through; it still must."""
model = ModelConfig(name="ollama", use="langchain_ollama:ChatOllama", model="gpt-oss:20b", reasoning=level)
assert model.reasoning == level
assert model.supports_thinking is False
assert resolve_reasoning_contract(model).source == "legacy"
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="ollama", thinking_enabled=True)
assert captured["reasoning"] == level
def test_native_provider_reasoning_false_is_forwarded(monkeypatch):
model = ModelConfig(name="ollama", use="langchain_ollama:ChatOllama", model="ollama", reasoning=False)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="ollama", thinking_enabled=False)
assert captured["reasoning"] is False
def _glm_effort() -> dict:
return {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}}
def _contract_model(name: str = "contract-model", *, use: str = "langchain_openai:ChatOpenAI", reasoning: dict, **extras) -> ModelConfig:
return ModelConfig(
name=name,
display_name=name,
description=None,
use=use,
model=name,
api_key="test-key",
supports_vision=False,
reasoning=reasoning,
**extras,
)
def test_contract_required_thinking_never_receives_a_disable_payload():
"""A background caller asking for ``thinking_enabled=False`` on GLM-5.3-Flash
keeps thinking on and receives a mapped provider effort instead of the
synthesized ``thinking.type=disabled`` + ``reasoning_effort=minimal`` pair."""
model = _contract_model(
"glm-5.3-flash",
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
reasoning={"thinking": "required", "dialect": "openai_extra_body", "history": "clear", "effort": _glm_effort()},
api_base="https://api.z.ai/api/paas/v4",
stream_usage=False,
extra_body={"tool_stream": True},
)
chat_model = factory_module.create_chat_model(
name="glm-5.3-flash",
thinking_enabled=False,
reasoning_effort="medium",
app_config=_make_app_config([model]),
attach_tracing=False,
)
payload = chat_model._get_request_payload([HumanMessage(content="ping")])
assert payload["extra_body"] == {
"thinking": {"type": "enabled", "clear_thinking": True},
"tool_stream": True,
}
assert payload["reasoning_effort"] == "high"
assert chat_model.stream_usage is False
def test_contract_required_thinking_uses_the_default_effort_for_background_callers():
model = _contract_model(
"glm-5.3-flash",
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
reasoning={"thinking": "required", "dialect": "openai_extra_body", "effort": _glm_effort()},
api_base="https://api.z.ai/api/paas/v4",
)
chat_model = factory_module.create_chat_model(name="glm-5.3-flash", thinking_enabled=False, app_config=_make_app_config([model]), attach_tracing=False)
payload = chat_model._get_request_payload([HumanMessage(content="ping")])
assert payload["extra_body"]["thinking"] == {"type": "enabled"}
assert payload["reasoning_effort"] == "max"
def test_contract_optional_thinking_disable_path_maps_effort_instead_of_synthesizing_minimal(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "effort": {"values": ["low", "medium", "high"], "aliases": {"minimal": "low"}}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=False, reasoning_effort="minimal")
assert captured["extra_body"] == {"thinking": {"type": "disabled"}}
assert captured["reasoning_effort"] == "low"
def test_contract_unknown_effort_without_default_is_not_forwarded(monkeypatch):
model = _contract_model(reasoning={"thinking": "optional", "effort": {"values": ["low", "high"]}})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True, reasoning_effort="medium")
assert "reasoning_effort" not in captured
def test_contract_custom_effort_path_drops_generic_override(monkeypatch):
model = _contract_model(reasoning={"thinking": "optional", "effort": {"values": ["low", "high"], "default": "high", "path": "extra_body.thinking.effort"}})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True, model_overrides={"reasoning_effort": "minimal"})
assert "reasoning_effort" not in captured
assert captured["extra_body"]["thinking"]["effort"] == "high"
def test_contract_custom_effort_path_stays_canonical_for_codex(monkeypatch):
model = _contract_model(
"codex-model",
use="deerflow.models.openai_codex_provider:CodexChatModel",
reasoning={"thinking": "optional", "effort": {"values": ["low", "high"], "default": "high", "path": "extra_body.thinking.effort"}},
)
_patch_factory(monkeypatch, _make_app_config([model]), model_class=FakeCodexChatModel)
monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel)
FakeChatModel.captured_kwargs = {}
factory_module.create_chat_model(name="codex-model", thinking_enabled=True, model_overrides={"reasoning_effort": "minimal"})
assert "reasoning_effort" not in FakeChatModel.captured_kwargs
assert FakeChatModel.captured_kwargs["extra_body"]["thinking"]["effort"] == "high"
def test_contract_without_effort_never_forwards_reasoning_effort(monkeypatch):
model = _contract_model(reasoning={"thinking": "optional"})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True, reasoning_effort="high")
assert "reasoning_effort" not in captured
def test_contract_dialect_auto_infers_disable_payload_from_when_thinking_enabled(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional", "effort": {"values": ["low", "medium", "high"], "aliases": {"minimal": "low"}}},
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=False)
assert captured["extra_body"] == {"thinking": {"type": "disabled"}}
assert "reasoning_effort" not in captured
@pytest.mark.parametrize(("thinking_enabled", "expected"), [(True, True), (False, False)])
def test_contract_dialect_vllm_synthesizes_chat_template_kwargs(monkeypatch, thinking_enabled, expected):
model = _contract_model(reasoning={"thinking": "optional", "dialect": "vllm_chat_template"})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=thinking_enabled)
assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": expected}}
def test_contract_dialect_vllm_enable_mirrors_the_declared_template_keys(monkeypatch):
"""A template that declares the older ``thinking`` switch must not also grow
``enable_thinking`` on the enable path — the legacy path never produced that shape."""
model = _contract_model(
reasoning={"thinking": "optional", "dialect": "vllm_chat_template"},
when_thinking_enabled={"extra_body": {"chat_template_kwargs": {"thinking": True}}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True)
assert captured["extra_body"] == {"chat_template_kwargs": {"thinking": True}}
def test_contract_dialect_vllm_disable_mirrors_the_declared_template_keys(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional", "dialect": "vllm_chat_template"},
when_thinking_enabled={"extra_body": {"chat_template_kwargs": {"thinking": True}}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=False)
assert captured["extra_body"] == {"chat_template_kwargs": {"thinking": False}}
def test_contract_dialect_anthropic_merges_the_thinking_shortcut(monkeypatch):
model = _contract_model(reasoning={"thinking": "optional", "dialect": "anthropic"}, thinking={"budget_tokens": 2048})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True)
assert captured["thinking"] == {"type": "enabled", "budget_tokens": 2048}
factory_module.create_chat_model(name="contract-model", thinking_enabled=False)
assert captured["thinking"] == {"type": "disabled"}
@pytest.mark.parametrize(("thinking_enabled", "expected"), [(True, True), (False, False)])
def test_contract_dialect_ollama_sets_the_reasoning_flag(monkeypatch, thinking_enabled, expected):
model = _contract_model(reasoning={"thinking": "optional", "dialect": "ollama"})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=thinking_enabled)
assert captured["reasoning"] is expected
def test_contract_dialect_none_leaves_the_payload_alone(monkeypatch):
model = _contract_model(reasoning={"thinking": "optional", "dialect": "none", "effort": {"values": ["minimal", "low", "medium", "high"]}})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=False, reasoning_effort="minimal")
assert "extra_body" not in captured
assert "thinking" not in captured
assert captured["reasoning_effort"] == "minimal"
def test_contract_history_preserve_serializes_clear_thinking_false(monkeypatch):
model = _contract_model(reasoning={"thinking": "optional", "dialect": "openai_extra_body", "history": "preserve"})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True)
assert captured["extra_body"] == {"thinking": {"type": "enabled", "clear_thinking": False}}
def test_contract_history_is_inferred_for_auto_dialect(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional", "history": "clear"},
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True)
assert captured["extra_body"] == {"thinking": {"type": "enabled", "clear_thinking": True}}
def test_contract_effort_path_serializes_nested_values(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "effort": {"values": ["low", "high"], "path": "extra_body.thinking.effort"}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True, reasoning_effort="high")
assert captured["extra_body"] == {"thinking": {"type": "enabled", "effort": "high"}}
assert "reasoning_effort" not in captured
def test_contract_when_thinking_enabled_deep_merges_into_the_base_extra_body(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional"},
extra_body={"tool_stream": True},
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True)
assert captured["extra_body"] == {"tool_stream": True, "thinking": {"type": "enabled"}}
def test_contract_when_thinking_disabled_template_wins_over_dialect_synthesis(monkeypatch):
model = _contract_model(
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "effort": {"values": ["low", "high"]}},
when_thinking_disabled={"reasoning_effort": "low"},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=False)
assert "extra_body" not in captured
assert captured["reasoning_effort"] == "low"
@pytest.mark.parametrize(("requested", "expected"), [("high", "high"), (None, "medium")])
def test_contract_validated_request_wins_over_template_effort(monkeypatch, requested, expected):
model = _contract_model(
reasoning={"thinking": "optional", "effort": {"values": ["low", "medium", "high"]}},
when_thinking_enabled={"reasoning_effort": "medium"},
)
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="contract-model", thinking_enabled=True, reasoning_effort=requested)
assert captured["reasoning_effort"] == expected
def test_contract_reject_policy_fails_before_the_provider_is_built(monkeypatch):
model = _contract_model(reasoning={"thinking": "required", "on_disable_request": "reject", "dialect": "openai_extra_body"})
captured: dict = {}
_patch_factory(monkeypatch, _make_app_config([model]), model_class=_capturing_class(FakeChatModel, captured))
with pytest.raises(ValueError, match="requires thinking"):
factory_module.create_chat_model(name="contract-model", thinking_enabled=False)
assert captured == {}
def test_contract_codex_maps_aliases_before_codex_validation(monkeypatch):
model = _contract_model(
"codex-model",
use="deerflow.models.openai_codex_provider:CodexChatModel",
reasoning={"thinking": "optional", "dialect": "none", "effort": {"values": ["low", "medium", "high", "xhigh"], "aliases": {"minimal": "low"}}},
)
_patch_factory(monkeypatch, _make_app_config([model]), model_class=FakeCodexChatModel)
monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel)
FakeChatModel.captured_kwargs = {}
factory_module.create_chat_model(name="codex-model", thinking_enabled=True, reasoning_effort="minimal")
assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "low"
def test_legacy_profiles_are_untouched_by_the_contract_machinery(monkeypatch):
"""A profile without ``reasoning:`` still takes the historical disable path, including
the hard-coded ``minimal`` effort the contract path deliberately drops."""
wte = {"extra_body": {"thinking": {"type": "enabled"}}}
cfg = _make_app_config([_make_model("legacy", supports_thinking=True, supports_reasoning_effort=True, when_thinking_enabled=wte)])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(FakeChatModel, captured))
factory_module.create_chat_model(name="legacy", thinking_enabled=False, reasoning_effort="high")
assert captured["extra_body"] == {"thinking": {"type": "disabled"}}
assert captured["reasoning_effort"] == "minimal"

View File

@ -0,0 +1,93 @@
"""``/api/models`` projects the normalized reasoning contract beside the legacy booleans (issue #5073)."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import models as models_router
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.token_usage_config import TokenUsageConfig
def _app_config(models: list[ModelConfig]) -> AppConfig:
return AppConfig(
models=models,
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
token_usage=TokenUsageConfig(enabled=False),
authorization=AuthorizationConfig(),
)
def _legacy_model() -> ModelConfig:
return ModelConfig(name="legacy", model="legacy", use="langchain_openai:ChatOpenAI", supports_thinking=True, supports_reasoning_effort=True)
def _contract_model() -> ModelConfig:
return ModelConfig(
name="glm-5.3-flash",
model="glm-5.3-flash",
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
reasoning={
"thinking": "required",
"dialect": "openai_extra_body",
"history": "clear",
"effort": {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}},
},
)
@pytest.fixture
def client(monkeypatch):
config = AuthorizationConfig(enabled=False)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
monkeypatch.setattr("app.gateway.routers.models.get_optional_user_from_request", AsyncMock(return_value=None))
app = FastAPI()
app.include_router(models_router.router)
app.dependency_overrides[models_router.get_config] = lambda: _app_config([_legacy_model(), _contract_model()])
with TestClient(app) as test_client:
yield test_client
def test_list_models_projects_reasoning_for_every_model(client):
response = client.get("/api/models")
assert response.status_code == 200
by_name = {m["name"]: m for m in response.json()["models"]}
legacy = by_name["legacy"]
assert legacy["supports_thinking"] is True
assert legacy["supports_reasoning_effort"] is True
assert legacy["reasoning"] == {
"thinking": "optional",
"effort": {"values": ["minimal", "low", "medium", "high"], "default": None, "aliases": {}},
"history": None,
"source": "legacy",
}
contract = by_name["glm-5.3-flash"]
# Deprecation-window projection: the booleans are derived from the contract.
assert contract["supports_thinking"] is True
assert contract["supports_reasoning_effort"] is True
assert contract["reasoning"] == {
"thinking": "required",
"effort": {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}},
"history": "clear",
"source": "contract",
}
def test_get_model_projects_reasoning(client):
response = client.get("/api/models/glm-5.3-flash")
assert response.status_code == 200
body = response.json()
assert body["reasoning"]["thinking"] == "required"
assert body["reasoning"]["effort"]["values"] == ["low", "high", "max"]

View File

@ -0,0 +1,452 @@
"""Tests for the declarative model reasoning capability contract (issue #5073).
Covers three layers:
- ``ModelConfig.reasoning`` schema validation and legacy-boolean projection.
- ``deerflow.models.reasoning`` normalization and request resolution.
- The ``reasoning`` capabilities payload projected through ``/api/models``.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from deerflow.config.model_config import ModelConfig, ReasoningCapabilities, ReasoningEffortCapabilities
from deerflow.models.reasoning import (
GENERIC_EFFORT_VALUES,
ReasoningContract,
ReasoningPolicyError,
reasoning_capabilities_payload,
resolve_reasoning_contract,
resolve_reasoning_request,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _model(**overrides) -> ModelConfig:
values = {
"name": "model",
"display_name": "Model",
"description": None,
"use": "langchain_openai:ChatOpenAI",
"model": "model",
}
values.update(overrides)
return ModelConfig(**values)
def _glm_contract() -> dict:
return {
"thinking": "required",
"dialect": "openai_extra_body",
"history": "clear",
"effort": {
"values": ["low", "high", "max"],
"default": "max",
"aliases": {"minimal": "low", "medium": "high"},
},
}
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
def test_reasoning_field_is_declared_and_optional():
assert "reasoning" in ModelConfig.model_fields
assert _model().reasoning is None
def test_contract_round_trips_and_projects_legacy_booleans():
model = _model(reasoning=_glm_contract())
assert isinstance(model.reasoning, ReasoningCapabilities)
assert isinstance(model.reasoning.effort, ReasoningEffortCapabilities)
assert model.reasoning.thinking == "required"
assert model.reasoning.effort.values == ["low", "high", "max"]
# Legacy readers keep working: the booleans are derived from the contract.
assert model.supports_thinking is True
assert model.supports_reasoning_effort is True
def test_contract_without_effort_projects_effort_unsupported():
model = _model(reasoning={"thinking": "optional"})
assert model.supports_thinking is True
assert model.supports_reasoning_effort is False
def test_contract_unsupported_thinking_projects_false():
model = _model(reasoning={"thinking": "unsupported", "effort": {"values": ["low", "high"]}})
assert model.supports_thinking is False
assert model.supports_reasoning_effort is True
@pytest.mark.parametrize("native", [True, False, "low", "medium", "high"])
def test_native_provider_reasoning_values_load_as_legacy_profiles(native):
"""``reasoning`` was an unrestricted provider kwarg before the contract existed;
ChatOllama's boolean and level-string forms must keep loading and stay legacy."""
model = _model(use="langchain_ollama:ChatOllama", reasoning=native)
assert model.reasoning == native
assert model.supports_thinking is False
assert model.supports_reasoning_effort is False
contract = resolve_reasoning_contract(model)
assert contract.source == "legacy"
assert contract.thinking == "unsupported"
def test_contract_is_excluded_from_provider_kwargs_by_the_factory_exclusion_list():
"""The factory excludes ``reasoning`` explicitly; pin the field name so a rename cannot leak it."""
from deerflow.models import factory as factory_module
assert "reasoning" in factory_module._MODEL_METADATA_FIELDS
@pytest.mark.parametrize(
("contract", "match"),
[
pytest.param({"thinking": "optional", "effort": {"values": ["low"], "default": "high"}}, "default", id="default-not-in-values"),
pytest.param({"thinking": "optional", "effort": {"values": ["low", "low"]}}, "unique", id="duplicate-values"),
pytest.param({"thinking": "optional", "effort": {"values": []}}, "values", id="empty-values"),
pytest.param({"thinking": "optional", "effort": {"values": ["low"], "aliases": {"low": "low"}}}, "alias", id="alias-key-is-a-value"),
pytest.param({"thinking": "optional", "effort": {"values": ["low"], "aliases": {"medium": "high"}}}, "alias", id="alias-target-not-a-value"),
pytest.param({"thinking": "optional", "on_disable_request": "reject"}, "on_disable_request", id="reject-without-required"),
pytest.param({"thinking": "bogus"}, "thinking", id="unknown-thinking-mode"),
pytest.param({"thinking": "optional", "unknown": 1}, "unknown", id="unknown-key"),
],
)
def test_contract_rejects_impossible_shapes(contract, match):
with pytest.raises(ValidationError, match=match):
_model(reasoning=contract)
def test_required_thinking_rejects_when_thinking_disabled_template():
with pytest.raises(ValidationError, match="when_thinking_disabled"):
_model(reasoning={"thinking": "required"}, when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}})
@pytest.mark.parametrize("template", ["when_thinking_enabled", "thinking"])
def test_unsupported_thinking_rejects_enable_templates(template):
with pytest.raises(ValidationError, match=template):
_model(reasoning={"thinking": "unsupported"}, **{template: {"type": "enabled"}})
@pytest.mark.parametrize(
("legacy", "contract"),
[
pytest.param({"supports_thinking": False}, {"thinking": "required"}, id="supports_thinking-false-vs-required"),
pytest.param({"supports_thinking": True}, {"thinking": "unsupported"}, id="supports_thinking-true-vs-unsupported"),
pytest.param({"supports_reasoning_effort": True}, {"thinking": "optional"}, id="supports_reasoning_effort-true-vs-no-effort"),
pytest.param({"supports_reasoning_effort": False}, {"thinking": "optional", "effort": {"values": ["low"]}}, id="supports_reasoning_effort-false-vs-effort"),
],
)
def test_explicit_legacy_boolean_conflicting_with_contract_is_rejected(legacy, contract):
with pytest.raises(ValidationError, match="reasoning"):
_model(reasoning=contract, **legacy)
def test_matching_legacy_booleans_beside_contract_are_accepted():
model = _model(reasoning=_glm_contract(), supports_thinking=True, supports_reasoning_effort=True)
assert model.supports_thinking is True
def test_profile_reasoning_effort_must_be_accepted_by_the_contract():
with pytest.raises(ValidationError, match="reasoning_effort"):
_model(reasoning=_glm_contract(), reasoning_effort="medium")
def test_profile_reasoning_effort_rejected_when_contract_declares_no_effort():
with pytest.raises(ValidationError, match="reasoning_effort"):
_model(reasoning={"thinking": "optional"}, reasoning_effort="low")
def test_profile_reasoning_effort_in_values_is_accepted():
model = _model(reasoning=_glm_contract(), reasoning_effort="high")
assert model.model_extra["reasoning_effort"] == "high"
@pytest.mark.parametrize("template", ["when_thinking_enabled", "when_thinking_disabled"])
def test_template_reasoning_effort_must_be_accepted_by_the_contract(template):
"""A template value the caller never chose still reaches the provider when no
request and no default override it, so it is validated like the profile value."""
with pytest.raises(ValidationError, match=template):
_model(reasoning={"thinking": "optional", "effort": {"values": ["low", "high"]}}, **{template: {"reasoning_effort": "minimal"}})
@pytest.mark.parametrize("template", ["when_thinking_enabled", "when_thinking_disabled"])
def test_template_reasoning_effort_in_values_is_accepted(template):
model = _model(reasoning={"thinking": "optional", "effort": {"values": ["low", "high"]}}, **{template: {"reasoning_effort": "low"}})
assert getattr(model, template) == {"reasoning_effort": "low"}
def test_template_reasoning_effort_rejected_when_contract_declares_no_effort():
with pytest.raises(ValidationError, match="when_thinking_disabled"):
_model(reasoning={"thinking": "optional"}, when_thinking_disabled={"reasoning_effort": "low"})
def test_template_effort_is_validated_at_the_declared_path():
contract = {"thinking": "optional", "dialect": "openai_extra_body", "effort": {"values": ["low", "high"], "path": "extra_body.thinking.effort"}}
with pytest.raises(ValidationError, match="when_thinking_enabled"):
_model(reasoning=contract, when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled", "effort": "max"}}})
model = _model(reasoning=contract, when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled", "effort": "high"}}})
assert model.when_thinking_enabled["extra_body"]["thinking"]["effort"] == "high"
def test_thinking_shortcut_effort_is_validated_at_the_declared_path():
contract = {"thinking": "optional", "dialect": "anthropic", "effort": {"values": ["low", "high"], "path": "thinking.effort"}}
with pytest.raises(ValidationError, match="thinking"):
_model(reasoning=contract, thinking={"budget_tokens": 1024, "effort": "max"})
@pytest.mark.parametrize("source", ["profile-level", "when_thinking_enabled", "when_thinking_disabled"])
@pytest.mark.parametrize("stale_effort", ["minimal", "low"])
def test_custom_effort_path_rejects_stale_generic_reasoning_effort(source, stale_effort):
contract = {"thinking": "optional", "effort": {"values": ["low", "high"], "path": "extra_body.thinking.effort"}}
extras = {"reasoning_effort": stale_effort} if source == "profile-level" else {source: {"reasoning_effort": stale_effort}}
with pytest.raises(ValidationError, match=f"{source} reasoning_effort"):
_model(reasoning=contract, **extras)
def test_custom_effort_path_rejects_stale_generic_key_in_thinking_shortcut():
contract = {"thinking": "optional", "effort": {"values": ["low", "high"], "path": "thinking.effort"}}
with pytest.raises(ValidationError, match="thinking reasoning_effort"):
_model(reasoning=contract, thinking={"reasoning_effort": "minimal"})
def test_thinking_shortcut_generic_effort_rejected_without_effort_control():
with pytest.raises(ValidationError, match="declares no effort control"):
_model(reasoning={"thinking": "optional"}, thinking={"reasoning_effort": "minimal"})
@pytest.mark.parametrize(
"path",
[
pytest.param("extra_body", id="shadows-extra_body"),
pytest.param("thinking", id="shadows-thinking"),
pytest.param("model_kwargs", id="shadows-model_kwargs"),
pytest.param("1bad", id="leading-digit"),
pytest.param("extra_body..effort", id="empty-segment"),
pytest.param("extra_body.thinking-effort", id="dash"),
pytest.param(".reasoning_effort", id="leading-dot"),
],
)
def test_effort_path_rejects_unsafe_values(path):
with pytest.raises(ValidationError, match="path"):
_model(reasoning={"thinking": "optional", "effort": {"values": ["low"], "path": path}})
@pytest.mark.parametrize("path", ["reasoning_effort", "extra_body.thinking.effort", "model_kwargs.reasoning_effort", "_private"])
def test_effort_path_accepts_dotted_identifiers(path):
model = _model(reasoning={"thinking": "optional", "effort": {"values": ["low"], "path": path}})
assert model.reasoning.effort.path == path
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
def test_legacy_booleans_normalize_to_optional_thinking_and_generic_effort():
contract = resolve_reasoning_contract(_model(supports_thinking=True, supports_reasoning_effort=True))
assert contract.source == "legacy"
assert contract.thinking == "optional"
assert contract.effort is not None
assert contract.effort.values == GENERIC_EFFORT_VALUES
assert contract.effort.default is None
assert contract.effort.strict is False
assert contract.dialect == "auto"
assert contract.history is None
def test_legacy_defaults_normalize_to_unsupported():
contract = resolve_reasoning_contract(_model())
assert contract.thinking == "unsupported"
assert contract.effort is None
assert contract.supports_thinking is False
assert contract.supports_reasoning_effort is False
def test_declared_contract_normalizes_strictly():
contract = resolve_reasoning_contract(_model(reasoning=_glm_contract()))
assert contract.source == "contract"
assert contract.thinking == "required"
assert contract.thinking_required is True
assert contract.effort is not None
assert contract.effort.values == ("low", "high", "max")
assert contract.effort.default == "max"
assert dict(contract.effort.aliases) == {"minimal": "low", "medium": "high"}
assert contract.effort.strict is True
assert contract.dialect == "openai_extra_body"
assert contract.history == "clear"
def test_normalization_accepts_duck_typed_profiles():
"""Descriptor builders and mocks pass plain objects without a ``reasoning`` attribute."""
from types import SimpleNamespace
contract = resolve_reasoning_contract(SimpleNamespace(supports_thinking=True, supports_reasoning_effort=False))
assert contract.source == "legacy"
assert contract.thinking == "optional"
assert contract.effort is None
def test_normalization_ignores_non_contract_reasoning_attribute():
from types import SimpleNamespace
contract = resolve_reasoning_contract(SimpleNamespace(supports_thinking=False, supports_reasoning_effort=False, reasoning=object()))
assert contract.source == "legacy"
# ---------------------------------------------------------------------------
# Request resolution — thinking
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("requested", [True, False])
def test_unsupported_thinking_always_resolves_off(requested):
contract = resolve_reasoning_contract(_model())
resolved = resolve_reasoning_request(contract, thinking_enabled=requested, reasoning_effort=None)
assert resolved.thinking_enabled is False
assert ("thinking_unsupported" in resolved.adjustments) is requested
@pytest.mark.parametrize("requested", [True, False])
def test_optional_thinking_honors_the_request(requested):
contract = resolve_reasoning_contract(_model(reasoning={"thinking": "optional"}))
resolved = resolve_reasoning_request(contract, thinking_enabled=requested, reasoning_effort=None)
assert resolved.thinking_enabled is requested
assert resolved.adjustments == ()
def test_required_thinking_forces_on_when_a_background_caller_requests_off():
contract = resolve_reasoning_contract(_model(reasoning={"thinking": "required"}))
resolved = resolve_reasoning_request(contract, thinking_enabled=False, reasoning_effort=None)
assert resolved.thinking_enabled is True
assert "thinking_forced_on" in resolved.adjustments
def test_required_thinking_with_reject_policy_raises_before_the_provider():
contract = resolve_reasoning_contract(_model(reasoning={"thinking": "required", "on_disable_request": "reject"}))
with pytest.raises(ReasoningPolicyError, match="requires thinking"):
resolve_reasoning_request(contract, thinking_enabled=False, reasoning_effort=None)
assert resolve_reasoning_request(contract, thinking_enabled=True, reasoning_effort=None).thinking_enabled is True
# ---------------------------------------------------------------------------
# Request resolution — effort
# ---------------------------------------------------------------------------
def test_effort_dropped_when_the_model_declares_no_effort_control():
contract = resolve_reasoning_contract(_model(reasoning={"thinking": "optional"}))
resolved = resolve_reasoning_request(contract, thinking_enabled=True, reasoning_effort="high")
assert resolved.reasoning_effort is None
assert "effort_unsupported" in resolved.adjustments
def test_legacy_effort_is_forwarded_verbatim():
contract = resolve_reasoning_contract(_model(supports_reasoning_effort=True))
assert resolve_reasoning_request(contract, thinking_enabled=True, reasoning_effort="xhigh").reasoning_effort == "xhigh"
assert resolve_reasoning_request(contract, thinking_enabled=True, reasoning_effort=None).reasoning_effort is None
@pytest.mark.parametrize(
("requested", "expected", "adjustment"),
[
pytest.param(None, "max", None, id="unset-uses-default"),
pytest.param("high", "high", None, id="declared-value-passes"),
pytest.param("minimal", "low", "effort_aliased", id="generic-minimal-maps-to-low"),
pytest.param("medium", "high", "effort_aliased", id="generic-medium-maps-to-high"),
pytest.param("xhigh", "max", "effort_unsupported_value", id="unknown-falls-back-to-default"),
],
)
def test_restricted_effort_resolution(requested, expected, adjustment):
contract = resolve_reasoning_contract(_model(reasoning=_glm_contract()))
resolved = resolve_reasoning_request(contract, thinking_enabled=True, reasoning_effort=requested)
assert resolved.reasoning_effort == expected
if adjustment is None:
assert resolved.adjustments == ()
else:
assert adjustment in resolved.adjustments
def test_unknown_effort_without_default_is_dropped():
contract = resolve_reasoning_contract(_model(reasoning={"thinking": "optional", "effort": {"values": ["low", "high"]}}))
resolved = resolve_reasoning_request(contract, thinking_enabled=True, reasoning_effort="minimal")
assert resolved.reasoning_effort is None
assert "effort_unsupported_value" in resolved.adjustments
def test_effort_resolution_is_idempotent():
"""Callers may resolve before the factory resolves again; a provider value must survive."""
contract = resolve_reasoning_contract(_model(reasoning=_glm_contract()))
first = resolve_reasoning_request(contract, thinking_enabled=False, reasoning_effort="minimal")
second = resolve_reasoning_request(contract, thinking_enabled=first.thinking_enabled, reasoning_effort=first.reasoning_effort)
assert second.thinking_enabled is True
assert second.reasoning_effort == "low"
assert second.adjustments == ()
# ---------------------------------------------------------------------------
# API projection
# ---------------------------------------------------------------------------
def test_payload_for_legacy_model_matches_previous_ui_assumptions():
payload = reasoning_capabilities_payload(resolve_reasoning_contract(_model(supports_thinking=True, supports_reasoning_effort=True)))
assert payload == {
"thinking": "optional",
"effort": {"values": list(GENERIC_EFFORT_VALUES), "default": None, "aliases": {}},
"history": None,
"source": "legacy",
}
def test_payload_for_contract_model_exposes_provider_vocabulary():
payload = reasoning_capabilities_payload(resolve_reasoning_contract(_model(reasoning=_glm_contract())))
assert payload == {
"thinking": "required",
"effort": {"values": ["low", "high", "max"], "default": "max", "aliases": {"minimal": "low", "medium": "high"}},
"history": "clear",
"source": "contract",
}
def test_payload_without_effort_reports_null():
payload = reasoning_capabilities_payload(resolve_reasoning_contract(_model()))
assert payload["thinking"] == "unsupported"
assert payload["effort"] is None
def test_contract_dataclass_is_immutable():
contract = resolve_reasoning_contract(_model())
assert isinstance(contract, ReasoningContract)
with pytest.raises((AttributeError, TypeError)):
contract.thinking = "optional" # type: ignore[misc]

View File

@ -57,23 +57,38 @@ class TestProviders:
assert providers["deepseek"].use == "deerflow.models.patched_deepseek:PatchedChatDeepSeek"
assert providers["volcengine"].extra_config["api_base"] == "https://ark.cn-beijing.volces.com/api/v3"
def test_zai_glm_flash_uses_required_thinking_workaround(self):
def test_zai_glm_flash_declares_the_reasoning_contract(self):
"""GLM-5.3-Flash cannot disable thinking and only accepts low/high/max (issue #5073).
The profile opts into the declarative contract instead of the former
base-``extra_body`` workaround, which restores its effort control.
"""
provider = next(p for p in LLM_PROVIDERS if p.name == "zai")
config = provider.extra_config_for("glm-5.3-flash")
assert provider.use == "deerflow.models.patched_deepseek:PatchedChatDeepSeek"
assert provider.env_var == "ZAI_API_KEY"
assert config["api_base"] == "https://api.z.ai/api/paas/v4"
assert config["supports_thinking"] is True
assert config["supports_reasoning_effort"] is False
assert config["reasoning"] == {
"thinking": "required",
"dialect": "openai_extra_body",
"history": "clear",
"effort": {
"values": ["low", "high", "max"],
# Background callers (summarization, title, subagents) never
# choose an effort, so the default must not be the provider's
# deepest and most expensive level; `max` stays selectable.
"default": "high",
"aliases": {"minimal": "low", "medium": "high"},
},
}
assert "supports_thinking" not in config
assert "supports_reasoning_effort" not in config
assert "when_thinking_enabled" not in config
assert "when_thinking_disabled" not in config
assert config["extra_body"] == {
"thinking": {"type": "enabled", "clear_thinking": True},
"tool_stream": True,
}
assert config["extra_body"] == {"tool_stream": True}
def test_zai_glm_flash_workaround_is_preserved_in_generated_config(self):
def test_zai_glm_flash_contract_is_preserved_in_generated_config(self):
provider = next(p for p in LLM_PROVIDERS if p.name == "zai")
content = build_minimal_config(
provider_use=provider.use,
@ -87,11 +102,33 @@ class TestProviders:
model = yaml.safe_load(content)["models"][0]
assert model["model"] == "glm-5.3-flash"
assert model["api_key"] == "$ZAI_API_KEY"
assert model["supports_reasoning_effort"] is False
assert model["extra_body"]["thinking"] == {
"type": "enabled",
"clear_thinking": True,
}
assert model["reasoning"]["thinking"] == "required"
assert model["reasoning"]["effort"]["values"] == ["low", "high", "max"]
assert model["extra_body"] == {"tool_stream": True}
def test_zai_glm_flash_generated_config_loads_as_a_required_thinking_model(self):
from deerflow.config.model_config import ModelConfig
from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request
provider = next(p for p in LLM_PROVIDERS if p.name == "zai")
content = build_minimal_config(
provider_use=provider.use,
model_name=provider.default_model,
display_name=provider.display_name,
api_key_field=provider.api_key_field,
env_var=provider.env_var,
extra_model_config=provider.extra_config,
)
model = ModelConfig(**yaml.safe_load(content)["models"][0])
contract = resolve_reasoning_contract(model)
assert model.supports_thinking is True
assert model.supports_reasoning_effort is True
resolved = resolve_reasoning_request(contract, thinking_enabled=False, reasoning_effort="minimal")
assert resolved.thinking_enabled is True
assert resolved.reasoning_effort == "low"
background = resolve_reasoning_request(contract, thinking_enabled=False, reasoning_effort=None)
assert background.reasoning_effort == "high"
def test_minimax_vision_is_per_model(self):
"""M3 supports vision; M2.7 variants are text-only.

View File

@ -93,7 +93,7 @@ async def test_preferences_api_partial_patch_reset_and_owner_isolation(api):
@pytest.mark.anyio
@pytest.mark.parametrize("body", [{"notification_enabled": "false"}, {"mode": "invalid"}, {"reasoning_effort": "max"}, {"model_name": "a" * 201}, {"context": {"github_token": "not-a-real-token"}}, {"user_id": "bob"}])
@pytest.mark.parametrize("body", [{"notification_enabled": "false"}, {"mode": "invalid"}, {"reasoning_effort": "Not Valid!"}, {"model_name": "a" * 201}, {"context": {"github_token": "not-a-real-token"}}, {"user_id": "bob"}])
async def test_preferences_api_rejects_invalid_and_unrelated_fields(api, body):
client, _ = api
assert (await client.patch("/api/v1/auth/preferences", json=body)).status_code == 422

View File

@ -214,16 +214,53 @@ models:
# thinking:
# type: disabled
# Example: Z.AI GLM-5.3-Flash (required-thinking workaround)
# Reasoning capability contract (optional, per model)
#
# A mapping-valued `reasoning:` block opts into this contract. The existing
# Ollama `reasoning: true` / `false` boolean, and the `low` / `medium` / `high`
# level string gpt-oss style models take, remain native provider options and
# are still forwarded to ChatOllama.
#
# `supports_thinking` / `supports_reasoning_effort` only say whether a knob
# exists. Models whose provider contract differs from DeerFlow's generic
# assumptions can declare a `reasoning:` block instead; the two booleans are
# then derived from it and every model-creation path (chat, summarization,
# title generation, subagents) enforces the same normalized policy:
#
# reasoning:
# thinking: optional # unsupported | optional | required
# on_disable_request: keep_enabled # required only: keep_enabled | reject
# dialect: auto # auto | openai_extra_body | anthropic |
# # vllm_chat_template | ollama | none
# history: clear # preserve | clear (omit for provider default)
# effort:
# values: [low, medium, high] # provider vocabulary, in display order
# default: medium # applied when the caller does not choose
# aliases: {minimal: low} # DeerFlow generic value -> provider value
# path: reasoning_effort # dotted identifier path (default)
#
# `required` thinking never receives a disable payload, unknown effort values
# never reach the provider (they map through `aliases` or fall back to
# `default`), and `dialect` lets the factory synthesize the on/off payload
# when `when_thinking_enabled` / `when_thinking_disabled` are absent
# (`auto` infers it from `when_thinking_enabled`). `default` also applies to
# callers that never choose an effort — summarization, title generation and
# subagents — so pick a level you are happy to pay for on every background
# call. Contradictory profiles fail at startup: `required` plus
# `when_thinking_disabled`, a `default` outside `values`, an effort value in
# the profile or in `when_thinking_*` templates that the contract rejects, a
# stale `reasoning_effort` beside a custom `effort.path`, or a `path` that
# would overwrite a whole mapping such as `extra_body`.
# Profiles without `reasoning:` behave exactly as before.
# Example: Z.AI GLM-5.3-Flash (required thinking, restricted effort)
#
# GLM-5.3-Flash cannot disable thinking and only accepts low/high/max effort.
# DeerFlow's current generic UI may request disabled thinking or emit
# minimal/medium, so this profile keeps thinking enabled unconditionally and
# suppresses generic effort forwarding. Keep the thinking block in the base
# extra_body: moving it to when_thinking_enabled would let background calls
# synthesize the invalid thinking.type=disabled + reasoning_effort=minimal pair.
# clear_thinking=true is intentional for this compatibility workaround: it
# avoids requiring exact historical reasoning replay after summarization.
# The contract keeps thinking on for every foreground and background call,
# maps DeerFlow's generic minimal/medium presets onto low/high, and
# serializes clear_thinking=true so the model does not require exact
# reasoning replay after summarization. `default: high` keeps background
# calls off the provider's deepest budget; `max` stays selectable in the UI.
#
# - name: glm-5.3-flash
# display_name: GLM-5.3-Flash
@ -237,14 +274,19 @@ models:
# top_p: 0.95
# max_tokens: 131072
# context_window: 1000000
# supports_thinking: true
# supports_reasoning_effort: false
# supports_vision: true
# stream_usage: false
# reasoning:
# thinking: required
# dialect: openai_extra_body
# history: clear
# effort:
# values: [low, high, max]
# default: high
# aliases:
# minimal: low
# medium: high
# extra_body:
# thinking:
# type: enabled
# clear_thinking: true
# tool_stream: true
# Example: OpenAI model

View File

@ -0,0 +1,241 @@
# Model Provider Reasoning Capability Contract
## Status
Implemented in the `feat-model-provider-reasoning-capability-contract` worktree
for issue #5073, following option 1 from the issue discussion: an optional
structured, mapping-valued `reasoning:` block lives beside the legacy `supports_thinking` /
`supports_reasoning_effort` booleans, both shapes normalize to one contract,
and the normalized result is projected through the existing `/api/models`
response during a deprecation window.
This is an implementation document. It describes the contract the code
delivers, the compatibility boundary, and the validation rules.
## Problem
DeerFlow modeled reasoning support with two independent booleans and then
applied generic runtime values (`thinking_enabled`, `reasoning_effort`)
uniformly across providers. Three things could not be expressed:
- **Required thinking.** GLM-5.3-Flash rejects `thinking.type=disabled`, yet
summarization, title generation, subagents, and the flash chat mode all
request `thinking_enabled=False`. PR #5074 worked around this at the profile
level by pinning the thinking block into the base `extra_body` and switching
effort forwarding off entirely.
- **Restricted effort vocabularies.** The UI emits `minimal/low/medium/high`;
GLM-5.3-Flash accepts `low/high/max`, DeepSeek accepts
`low/medium/high/xhigh/max` (issue #4514). The factory also hard-coded
`reasoning_effort=minimal` on the OpenAI-compatible disable path.
- **Payload dialects and reasoning history.** The factory guessed the disable
payload from the shape of `when_thinking_enabled`, and whether reasoning
history is preserved (`clear_thinking`) was buried in provider-specific
`extra_body` blocks.
## Contract
### Configuration shape
```yaml
models:
- name: glm-5.3-flash
use: deerflow.models.patched_deepseek:PatchedChatDeepSeek
model: glm-5.3-flash
api_base: https://api.z.ai/api/paas/v4
api_key: $ZAI_API_KEY
reasoning:
thinking: required # unsupported | optional | required
on_disable_request: keep_enabled # keep_enabled | reject (required only)
dialect: openai_extra_body # auto | openai_extra_body | anthropic | vllm_chat_template | ollama | none
history: clear # preserve | clear | omitted (provider default)
effort:
values: [low, high, max] # provider vocabulary, in display order
default: high # used when the caller does not choose (also background calls)
aliases: # DeerFlow generic value -> provider value
minimal: low
medium: high
path: reasoning_effort # where the value is serialized (dotted identifier path)
```
Every key under `reasoning` except `thinking` is optional. `effort` omitted
means the model exposes no effort control.
### Normalized contract
`deerflow.models.reasoning.resolve_reasoning_contract(model_config)` returns a
frozen `ReasoningContract`:
| Field | Legacy derivation | Contract |
| --- | --- | --- |
| `thinking` | `optional` if `supports_thinking` else `unsupported` | as declared |
| `effort` | generic `minimal/low/medium/high`, non-strict, when `supports_reasoning_effort` | declared values, strict |
| `on_disable_request` | `keep_enabled` | as declared |
| `dialect` | `auto` | as declared |
| `history` | `None` | as declared |
| `source` | `legacy` | `contract` |
`strict=False` on the legacy effort contract means values are forwarded
verbatim, exactly as before. `strict=True` means unknown values never reach the
provider. The chat UI still drops a remembered contract-only value when the
selected legacy model does not advertise it; direct legacy backend requests
retain their historical non-strict behavior.
### Request resolution
`resolve_reasoning_request(contract, thinking_enabled=..., reasoning_effort=...)`
is the single policy every model creation path goes through. It returns the
effective `thinking_enabled`, the effective provider effort value, and a tuple
of adjustment codes for logging.
| Thinking | Request | Result |
| --- | --- | --- |
| `unsupported` | any | `False` |
| `optional` | `x` | `x` |
| `required` | `True` | `True` |
| `required` | `False`, `keep_enabled` | `True` (adjustment `thinking_forced_on`) |
| `required` | `False`, `reject` | `ReasoningPolicyError` |
| Effort contract | Request | Result |
| --- | --- | --- |
| none | any | `None` (adjustment `effort_unsupported` when a value was requested) |
| legacy (non-strict) | `x` | `x` |
| strict | `None` | `default` |
| strict | value in `values` | value |
| strict | key in `aliases` | `aliases[key]` |
| strict | anything else | `default` (adjustment `effort_unsupported_value`) |
### Factory behavior
`create_chat_model` resolves the contract, resolves the request, and then
branches on `contract.source`:
- **legacy** keeps the pre-existing code path byte-for-byte: the
`when_thinking_enabled` guard, the inferred disable payloads, the hard-coded
`reasoning_effort=minimal` on the OpenAI-compatible disable path, and the
`supports_reasoning_effort=False` strip. Existing profiles therefore behave
exactly as before.
- **contract** applies `when_thinking_enabled` / `thinking` / `when_thinking_disabled`
when present and otherwise synthesizes the on/off payload from the dialect.
`dialect: auto` infers the dialect from `when_thinking_enabled` the same way
the legacy path does. `history` is serialized as
`extra_body.thinking.clear_thinking` for the `openai_extra_body` dialect and
is otherwise informational. The resolved effort is written to `effort.path`
after the templates, so a validated request always wins over a template
value; a `None` result leaves the profile value in place. A model without an
effort contract never forwards `reasoning_effort`.
Required thinking short-circuits the disable branch entirely, which is what
makes the synthesized `thinking.type=disabled` + `reasoning_effort=minimal`
pair impossible for those models.
### Validation
`ModelConfig` rejects impossible combinations at load time when `reasoning`
is present:
- `effort.default` not in `effort.values`; an alias key that is already a
value; an alias target outside `values`; empty or duplicate values.
- `effort.path` that is not a dotted identifier, or a single segment that would
replace a whole mapping (`extra_body`, `thinking`, `model_kwargs`,
`default_headers`, `default_query`).
- `thinking: required` together with `when_thinking_disabled`.
- `thinking: unsupported` together with `when_thinking_enabled` or `thinking`.
- `on_disable_request: reject` on a model whose thinking is not `required`.
- A legacy boolean explicitly set to a value that contradicts the contract.
- An effort value at `effort.path` in the profile, in `when_thinking_enabled`
/ `when_thinking_disabled`, or in the `thinking` shortcut that the effort
contract does not accept, or any such value on a model that declares no
effort control. These operator-supplied values are forwarded when the caller
chooses nothing, so they must satisfy the contract too.
- A stale `reasoning_effort` key in the profile or a thinking template when
`effort.path` points elsewhere. The factory also removes a generic key from
runtime overrides before forwarding a custom-path contract to the provider.
When the contract is present the legacy booleans are projected from it, so
`ModelConfig.supports_thinking` and `supports_reasoning_effort` stay correct
for every reader that has not migrated yet.
### API projection
`GET /api/models` and `DeerFlowClient.list_models()` / `get_model()` keep the
legacy booleans and add a `reasoning` object for every model:
```json
{
"reasoning": {
"thinking": "required",
"effort": {"values": ["low", "high", "max"], "default": "high", "aliases": {"minimal": "low", "medium": "high"}},
"history": "clear",
"source": "contract"
}
}
```
Legacy models report `source: "legacy"`, the generic effort vocabulary, and no
default, which is exactly what the UI used to assume.
### Frontend
`core/models/reasoning.ts` normalizes a `Model` (falling back to the booleans
when an older Gateway omits `reasoning`) and owns the mode/effort decisions:
- `getResolvedMode` never yields `flash` for a required-thinking model and
never yields a thinking mode for an unsupported one.
- The effort menu renders the contract's `values`; generic values keep their
localized labels and descriptions, provider-specific values (`max`,
`xhigh`) get their own labels.
- Mode presets (`thinking → low`, `pro → medium`, `ultra → high`) are mapped
through `resolveReasoningEffort`, so a preset that the model does not accept
becomes the alias or the default instead of an invalid request.
- When a remembered provider-specific effort is used with a legacy model,
`resolveReasoningEffort` keeps only the legacy model's advertised generic
values and drops the rest.
- The custom-agent dialog hides the "off" thinking option for required models
and offers only the effort values both the model and the per-agent schema
accept.
User preferences accept any lowercase effort token so a provider-specific
default such as `max` can be persisted.
## Compatibility
- No existing `config.yaml` changes meaning. A model without a mapping-valued
`reasoning:` contract is `source: legacy` and follows the pre-existing factory
path. In particular, a boolean or level-string `reasoning` (`true` /
`false`, or `low|medium|high` for gpt-oss style models) remains a native
ChatOllama constructor setting and is forwarded on that path. The assembly
descriptor keeps that native value — and a declared contract — in
`model_parameters`, so request-affecting reasoning settings move the
fingerprint.
- `supports_thinking` / `supports_reasoning_effort` remain in the config
schema, the API, and the frontend types. They are derived when a contract is
present and are the deprecation-window projection.
- Per-agent `reasoning_effort` keeps its `low/medium/high` schema; the factory
maps it through the model's aliases at run time.
- The wizard's Z.AI profile and `config.example.yaml` migrate GLM-5.3-Flash to
the contract, restoring its `low/high/max` effort control. Its `default` is
`high` rather than the provider-recommended `max` because the default also
governs summarization, title generation, and subagent calls, which never
choose an effort.
## Validation contract
- `backend/tests/test_reasoning_contract.py` — normalization, validation
failures, request resolution for optional / required / unsupported /
restricted-effort models, API projection.
- `backend/tests/test_model_factory.py` — required-thinking models never
receive a disable payload, restricted effort mapping, dialect synthesis,
history serialization, legacy behavior unchanged.
- `backend/tests/test_models_authorization.py` and `test_client.py` — API
projection shape.
- `backend/tests/test_setup_wizard.py` — Z.AI profile uses the contract.
- `frontend/tests/unit/core/models/reasoning.test.ts` — mode resolution,
effort options, alias mapping, legacy fallback.
## Follow-ups
- Widen `AgentConfig.reasoning_effort` once the UI can express per-agent
provider-specific values.
- Teach the summarization middleware to honor `history: preserve` by replaying
reasoning blocks; today the contract only serializes the provider flag.
- Retire the legacy booleans from the API after a deprecation window.

View File

@ -263,6 +263,23 @@ keeps the saved key, explicit removal sends an empty key. Saving invalidates bot
admin catalog and `MODELS_QUERY_KEY`. Editor unmount aborts probes and fences late
callbacks. Static demos and non-admin users must not query the management API.
### Model reasoning capabilities
`/api/models` projects a per-model `reasoning` contract (issue #5073) beside the
deprecated `supports_thinking` / `supports_reasoning_effort` booleans.
`core/models/reasoning.ts` is the only place that interprets it: it falls back to
the booleans for older Gateways, clamps the chat mode (`getResolvedMode` never
yields `flash` for a required-thinking model), lists the effort options the
composer and the sidecar render, and maps mode presets and remembered values
through the contract's aliases/default (`resolveReasoningEffort`). Effort values
are open strings (`ReasoningEffortValue`), so provider-specific tokens such as
`max` flow through local settings and account preferences unchanged; the custom
agent dialog offers only the intersection with the per-agent `low/medium/high`
schema and hides "off" for required-thinking models. Do not read the booleans in
components directly. On a legacy model, `resolveReasoningEffort` keeps only its
advertised generic values; this drops a remembered provider-specific token after
a model switch without changing the backend's direct legacy-request behavior.
## Full-stack plugin UI
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.

View File

@ -31,6 +31,11 @@ import {
type KnowledgeScopeSelection,
} from "@/core/knowledge";
import { useModels } from "@/core/models/hooks";
import {
getReasoningEffortOptions,
isThinkingRequired,
supportsThinking as modelSupportsThinking,
} from "@/core/models/reasoning";
import { useSubagents } from "@/core/subagents";
import { KnowledgeScopeSelector } from "../knowledge-scope-selector";
@ -131,9 +136,23 @@ export function AgentSettingsDialog({
() => resolveEffectiveModel(models, model),
[models, model],
);
const supportsThinking = selectedModel?.supports_thinking ?? false;
const supportsReasoningEffort =
selectedModel?.supports_reasoning_effort ?? false;
const supportsThinking = modelSupportsThinking(selectedModel);
const thinkingRequired = isThinkingRequired(selectedModel);
// Per-agent defaults keep the generic low/medium/high schema; offer only the
// values the selected model's reasoning contract also accepts (issue #5073).
const reasoningEffortOptions = useMemo(
() =>
getReasoningEffortOptions(selectedModel).filter(
(effort): effort is ReasoningEffort =>
(REASONING_EFFORTS as string[]).includes(effort),
),
[selectedModel],
);
const supportsReasoningEffort = reasoningEffortOptions.length > 0;
// A required-thinking model cannot be switched off: a stale "off" default
// collapses to "inherit" so the save never asks for the impossible.
const effectiveThinking =
thinkingRequired && thinking === "off" ? INHERIT_VALUE : thinking;
const selectableSubagents = useMemo(
() =>
Array.from(
@ -188,10 +207,11 @@ export function AgentSettingsDialog({
model: model === DEFAULT_MODEL_VALUE ? null : model,
model_settings: parsedSettings.modelSettings,
thinking_enabled: supportsThinking
? selectionToThinkingEnabled(thinking)
? selectionToThinkingEnabled(effectiveThinking)
: null,
reasoning_effort:
supportsReasoningEffort && reasoningEffort !== INHERIT_VALUE
supportsReasoningEffort &&
reasoningEffortOptions.includes(reasoningEffort as ReasoningEffort)
? (reasoningEffort as ReasoningEffort)
: null,
allowed_subagents: selectionToAllowedSubagents(
@ -355,7 +375,7 @@ export function AgentSettingsDialog({
{t.agents.settingsThinking}
</span>
<Select
value={thinking}
value={effectiveThinking}
onValueChange={(value) => setThinking(value as typeof thinking)}
>
<SelectTrigger className="w-full">
@ -368,9 +388,11 @@ export function AgentSettingsDialog({
<SelectItem value="on">
{t.agents.settingsThinkingOn}
</SelectItem>
<SelectItem value="off">
{t.agents.settingsThinkingOff}
</SelectItem>
{!thinkingRequired && (
<SelectItem value="off">
{t.agents.settingsThinkingOff}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
@ -393,7 +415,7 @@ export function AgentSettingsDialog({
<SelectItem value={INHERIT_VALUE}>
{t.agents.settingsInherit}
</SelectItem>
{REASONING_EFFORTS.map((effort) => (
{reasoningEffortOptions.map((effort) => (
<SelectItem key={effort} value={effort}>
{effort}
</SelectItem>

View File

@ -82,6 +82,16 @@ import {
type FileInMessage,
} from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import {
getReasoningEffortOptions,
getResolvedMode,
type InputMode,
isThinkingRequired,
type ReasoningEffortValue,
reasoningEffortForMode,
resolveReasoningEffort,
supportsThinking as modelSupportsThinking,
} from "@/core/models/reasoning";
import { useStagedProjectAttachments } from "@/core/projects/composer-attach";
import {
buildReferenceMessageMetadata,
@ -165,8 +175,6 @@ import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
import { SlashSkillChip } from "./slash-skill-chip";
import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
const COMPOSER_DRAFT_SAVE_DELAY_MS = 300;
function focusContentEditableEnd(element: HTMLElement | null) {
@ -209,19 +217,6 @@ function insertPlainTextAtSelection(container: HTMLElement, text: string) {
return true;
}
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
): InputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
function escapeXmlAttribute(value: string) {
return value
.replace(/&/g, "&amp;")
@ -324,7 +319,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: ReasoningEffortValue;
};
extraHeader?: React.ReactNode;
/**
@ -357,7 +352,7 @@ export function InputBox({
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: ReasoningEffortValue;
}
>,
options?: { automatic: boolean },
@ -629,11 +624,20 @@ export function InputBox({
? models.find((m) => m.name === defaultModelName)
: undefined;
const fallbackModel = currentModel ?? agentDefaultModel ?? models[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking);
const nextMode = getResolvedMode(context.mode, fallbackModel);
// A remembered effort may not exist in this model's contract (issue #5073);
// map it through the aliases / default instead of sending it verbatim.
const nextEffort = resolveReasoningEffort(
fallbackModel,
context.reasoning_effort,
);
if (context.model_name === nextModelName && context.mode === nextMode) {
if (
context.model_name === nextModelName &&
context.mode === nextMode &&
context.reasoning_effort === nextEffort
) {
return;
}
@ -642,6 +646,7 @@ export function InputBox({
...context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: nextEffort,
},
{ automatic: true },
);
@ -657,15 +662,78 @@ export function InputBox({
const resolvedModelName = selectedModel?.name;
const supportThinking = useMemo(
() => selectedModel?.supports_thinking ?? false,
() => modelSupportsThinking(selectedModel),
[selectedModel],
);
const supportReasoningEffort = useMemo(
() => selectedModel?.supports_reasoning_effort ?? false,
const thinkingRequired = useMemo(
() => isThinkingRequired(selectedModel),
[selectedModel],
);
// Effort choices come from the model's reasoning contract (issue #5073), so
// a provider that only accepts e.g. low/high/max never sees a generic value.
const reasoningEffortOptions = useMemo(
() => getReasoningEffortOptions(selectedModel),
[selectedModel],
);
const supportReasoningEffort = reasoningEffortOptions.length > 0;
const effectiveReasoningEffort = useMemo(
() =>
resolveReasoningEffort(
selectedModel,
context.reasoning_effort ??
reasoningEffortForMode(context.mode ?? "pro", selectedModel),
),
[context.mode, context.reasoning_effort, selectedModel],
);
const reasoningEffortLabel = useCallback(
(effort: string | undefined) => {
switch (effort) {
case "minimal":
return t.inputBox.reasoningEffortMinimal;
case "low":
return t.inputBox.reasoningEffortLow;
case "medium":
return t.inputBox.reasoningEffortMedium;
case "high":
return t.inputBox.reasoningEffortHigh;
case "xhigh":
return t.inputBox.reasoningEffortXhigh;
case "max":
return t.inputBox.reasoningEffortMax;
default:
return effort ?? "";
}
},
[t],
);
const reasoningEffortDescription = useCallback(
(effort: string) => {
switch (effort) {
case "minimal":
return t.inputBox.reasoningEffortMinimalDescription;
case "low":
return t.inputBox.reasoningEffortLowDescription;
case "medium":
return t.inputBox.reasoningEffortMediumDescription;
case "high":
return t.inputBox.reasoningEffortHighDescription;
case "xhigh":
return t.inputBox.reasoningEffortXhighDescription;
case "max":
return t.inputBox.reasoningEffortMaxDescription;
default:
return null;
}
},
[t],
);
const draftKey = useMemo(
() =>
buildComposerDraftKey({
@ -905,13 +973,17 @@ export function InputBox({
if (!model) {
return;
}
const mode = getResolvedMode(
context.mode,
model.supports_thinking ?? false,
const mode = getResolvedMode(context.mode, model);
const reasoning_effort = resolveReasoningEffort(
model,
context.reasoning_effort,
);
onContextChange?.({
model_name,
...(mode !== context.mode ? { mode } : {}),
...(reasoning_effort !== context.reasoning_effort
? { reasoning_effort }
: {}),
});
setModelDialogOpen(false);
},
@ -923,23 +995,17 @@ export function InputBox({
if (disabled || polishingInput) {
return;
}
const nextMode = getResolvedMode(mode, selectedModel);
onContextChange?.({
mode: getResolvedMode(mode, supportThinking),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal",
mode: nextMode,
reasoning_effort: reasoningEffortForMode(nextMode, selectedModel),
});
},
[disabled, onContextChange, polishingInput, supportThinking],
[disabled, onContextChange, polishingInput, selectedModel],
);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
(effort: ReasoningEffortValue) => {
if (disabled || polishingInput) {
return;
}
@ -1224,10 +1290,7 @@ export function InputBox({
{
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
mode: getResolvedMode(context.mode, selectedModel),
},
{ automatic: true },
);
@ -2615,35 +2678,37 @@ export function InputBox({
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
context.mode === "flash" &&
"text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
{!thinkingRequired && (
<PromptInputActionMenuItem
className={cn(
context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
context.mode === "flash" &&
"text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
{context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
@ -2750,16 +2815,8 @@ export function InputBox({
disabled={composerLocked}
>
<div className="text-xs font-normal">
{t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" &&
" " + t.inputBox.reasoningEffortMinimal}
{context.reasoning_effort === "low" &&
" " + t.inputBox.reasoningEffortLow}
{(context.reasoning_effort === "medium" ||
!context.reasoning_effort) &&
" " + t.inputBox.reasoningEffortMedium}
{context.reasoning_effort === "high" &&
" " + t.inputBox.reasoningEffortHigh}
{t.inputBox.reasoningEffort}:{" "}
{reasoningEffortLabel(effectiveReasoningEffort)}
</div>
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent className="w-70">
@ -2768,96 +2825,33 @@ export function InputBox({
{t.inputBox.reasoningEffort}
</DropdownMenuLabel>
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "minimal"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("minimal")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMinimal}
{reasoningEffortOptions.map((effort) => (
<PromptInputActionMenuItem
key={effort}
className={cn(
effectiveReasoningEffort === effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect(effort)}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{reasoningEffortLabel(effort)}
</div>
{reasoningEffortDescription(effort) && (
<div className="pl-2 text-xs">
{reasoningEffortDescription(effort)}
</div>
)}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMinimalDescription}
</div>
</div>
{context.reasoning_effort === "minimal" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "low"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("low")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortLow}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortLowDescription}
</div>
</div>
{context.reasoning_effort === "low" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "medium" ||
!context.reasoning_effort
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("medium")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortMedium}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortMediumDescription}
</div>
</div>
{context.reasoning_effort === "medium" ||
!context.reasoning_effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
context.reasoning_effort === "high"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => handleReasoningEffortSelect("high")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
{t.inputBox.reasoningEffortHigh}
</div>
<div className="pl-2 text-xs">
{t.inputBox.reasoningEffortHighDescription}
</div>
</div>
{context.reasoning_effort === "high" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{effectiveReasoningEffort === effort ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
))}
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>

View File

@ -57,6 +57,13 @@ import {
type HumanInputResponse,
} from "@/core/messages/human-input";
import { useModels } from "@/core/models/hooks";
import {
getResolvedMode,
isThinkingRequired,
reasoningEffortForMode,
resolveReasoningEffort,
supportsThinking as modelSupportsThinking,
} from "@/core/models/reasoning";
import type { Model } from "@/core/models/types";
import { useLocalSettings } from "@/core/settings";
import {
@ -113,29 +120,6 @@ function buildHiddenSidecarContextMessage({
type SidecarInputMode = NonNullable<ThreadStreamOptions["context"]["mode"]>;
function getResolvedMode(
mode: ThreadStreamOptions["context"]["mode"],
supportsThinking: boolean,
): SidecarInputMode {
if (!supportsThinking && mode !== "flash") {
return "flash";
}
if (mode) {
return mode;
}
return supportsThinking ? "pro" : "flash";
}
function reasoningEffortForMode(mode: SidecarInputMode) {
return mode === "ultra"
? "high"
: mode === "pro"
? "medium"
: mode === "thinking"
? "low"
: "minimal";
}
function promptMessageFiles(message: PromptInputMessage) {
return message.files.flatMap((file) =>
file.file instanceof File ? [file.file] : [],
@ -173,8 +157,6 @@ export function SidecarPanel({ className }: { className?: string }) {
);
}, [models, sidecar.context.model_name]);
const supportThinking = selectedModel?.supports_thinking ?? false;
const {
thread,
sendMessage,
@ -230,13 +212,17 @@ export function SidecarPanel({ className }: { className?: string }) {
);
const fallbackModel = currentModel ?? models[0]!;
const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(
sidecar.context.mode,
fallbackModel.supports_thinking ?? false,
);
const nextMode = getResolvedMode(sidecar.context.mode, fallbackModel);
const modeChanged = sidecar.context.mode !== nextMode;
const nextEffort = modeChanged
? reasoningEffortForMode(nextMode, fallbackModel)
: resolveReasoningEffort(fallbackModel, sidecar.context.reasoning_effort);
if (sidecar.context.model_name === nextModelName && !modeChanged) {
if (
sidecar.context.model_name === nextModelName &&
!modeChanged &&
nextEffort === sidecar.context.reasoning_effort
) {
return;
}
@ -244,9 +230,7 @@ export function SidecarPanel({ className }: { className?: string }) {
...sidecar.context,
model_name: nextModelName,
mode: nextMode,
reasoning_effort: modeChanged
? reasoningEffortForMode(nextMode)
: sidecar.context.reasoning_effort,
reasoning_effort: nextEffort,
});
}, [models, sidecar]);
@ -283,18 +267,15 @@ export function SidecarPanel({ className }: { className?: string }) {
if (!model) {
return;
}
const nextMode = getResolvedMode(
sidecar.context.mode,
model.supports_thinking ?? false,
);
const nextMode = getResolvedMode(sidecar.context.mode, model);
const modeChanged = sidecar.context.mode !== nextMode;
sidecar.setContext({
...sidecar.context,
model_name: modelName,
mode: nextMode,
reasoning_effort: modeChanged
? reasoningEffortForMode(nextMode)
: sidecar.context.reasoning_effort,
? reasoningEffortForMode(nextMode, model)
: resolveReasoningEffort(model, sidecar.context.reasoning_effort),
});
setModelDialogOpen(false);
},
@ -303,14 +284,14 @@ export function SidecarPanel({ className }: { className?: string }) {
const handleModeSelect = useCallback(
(mode: SidecarInputMode) => {
const nextMode = getResolvedMode(mode, supportThinking);
const nextMode = getResolvedMode(mode, selectedModel);
sidecar.setContext({
...sidecar.context,
mode: nextMode,
reasoning_effort: reasoningEffortForMode(nextMode),
reasoning_effort: reasoningEffortForMode(nextMode, selectedModel),
});
},
[sidecar, supportThinking],
[sidecar, selectedModel],
);
const ensureSidecarThread = useCallback(
@ -635,7 +616,7 @@ export function SidecarPanel({ className }: { className?: string }) {
<SidecarAddAttachmentsButton uploadLimits={uploadLimits} />
<SidecarModeMenu
context={sidecar.context}
supportThinking={supportThinking}
model={selectedModel}
onModeSelect={handleModeSelect}
/>
</PromptInputTools>
@ -749,15 +730,17 @@ function SidecarAddAttachmentsButton({
function SidecarModeMenu({
context,
supportThinking,
model,
onModeSelect,
}: {
context: ThreadStreamOptions["context"];
supportThinking: boolean;
model: Model | undefined;
onModeSelect: (mode: SidecarInputMode) => void;
}) {
const { t } = useI18n();
const mode = getResolvedMode(context.mode, supportThinking);
const supportThinking = modelSupportsThinking(model);
const thinkingRequired = isThinkingRequired(model);
const mode = getResolvedMode(context.mode, model);
return (
<PromptInputActionMenu>
@ -789,34 +772,36 @@ function SidecarModeMenu({
<DropdownMenuLabel className="text-muted-foreground text-xs">
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenuItem
className={cn(
mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => onModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
mode === "flash" && "text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
{!thinkingRequired && (
<PromptInputActionMenuItem
className={cn(
mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
onSelect={() => onModeSelect("flash")}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<ZapIcon
className={cn(
"mr-2 size-4",
mode === "flash" && "text-accent-foreground",
)}
/>
{t.inputBox.flashMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
<div className="pl-7 text-xs">
{t.inputBox.flashModeDescription}
</div>
</div>
{mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
{mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
{supportThinking && (
<PromptInputActionMenuItem
className={cn(

View File

@ -149,6 +149,39 @@ models:
</Tabs.Tab>
</Tabs>
#### Reasoning capabilities
`supports_thinking` and `supports_reasoning_effort` only say whether a knob exists. When a provider's contract differs from DeerFlow's generic assumptions — thinking that cannot be turned off, or an effort vocabulary other than `minimal/low/medium/high` — declare a mapping-valued `reasoning` block instead. The two booleans are then derived from it, the chat UI offers only the values the model accepts, and every model call (chat, summarization, title generation, subagents) follows the same policy. Existing Ollama `reasoning: true` / `false` values, and the `low` / `medium` / `high` level strings gpt-oss style models take, remain native ChatOllama settings and do not opt into this contract.
```yaml
models:
- name: glm-5.3-flash
use: deerflow.models.patched_deepseek:PatchedChatDeepSeek
model: glm-5.3-flash
api_base: https://api.z.ai/api/paas/v4
api_key: $ZAI_API_KEY
supports_vision: true
reasoning:
thinking: required # unsupported | optional | required
dialect: openai_extra_body # auto | openai_extra_body | anthropic | vllm_chat_template | ollama | none
history: clear # preserve | clear
effort:
values: [low, high, max] # the provider's own vocabulary
default: high # used when the caller does not choose
aliases: # DeerFlow generic value -> provider value
minimal: low
medium: high
extra_body:
tool_stream: true
```
- `thinking: required` keeps thinking on even when a background caller asks for it off; set `on_disable_request: reject` to fail such calls instead.
- Effort values outside `values` map through `aliases` or fall back to `default`; they never reach the provider. Effort values written into the profile or into `when_thinking_enabled` / `when_thinking_disabled` are validated against `values` at startup for the same reason.
- When changing `effort.path`, remove old `reasoning_effort` keys from the profile and thinking templates. A declared contract rejects these conflicting keys at startup. The chat UI also drops a remembered provider-specific effort when switching to a legacy model that does not advertise it.
- `default` also applies to callers that never choose an effort — summarization, title generation, and subagents — so pick a level you are happy to pay for on every background call (`high` above, with `max` left selectable in the composer).
- `dialect: auto` (the default) infers the on/off payload from `when_thinking_enabled`, so existing profiles can add `reasoning` without rewriting their templates. Profiles without a `reasoning` block behave exactly as before.
- Contradictory profiles (for example `required` together with `when_thinking_disabled`) fail at startup.
### Sandbox
Choose the execution environment for agent file and command operations:

View File

@ -134,6 +134,39 @@ Docker 部署时,`localhost` 指向 DeerFlow 容器本身。请改用容器能
</Tabs.Tab>
</Tabs>
#### 推理能力契约
`supports_thinking` 和 `supports_reasoning_effort` 只能表示"有没有这个开关"。当 provider 的约定与 DeerFlow 的通用假设不同——例如 thinking 无法关闭,或 effort 取值不是 `minimal/low/medium/high`——可以改为声明映射形式的 `reasoning` 块。此时这两个布尔值会由契约推导,聊天界面只提供模型接受的取值,并且所有模型调用(对话、总结、标题生成、子代理)都遵循同一套策略。已有的 Ollama `reasoning: true` / `false`,以及 gpt-oss 一类模型使用的 `low` / `medium` / `high` 档位字符串,仍是 ChatOllama 原生参数,不会启用此契约。
```yaml
models:
- name: glm-5.3-flash
use: deerflow.models.patched_deepseek:PatchedChatDeepSeek
model: glm-5.3-flash
api_base: https://api.z.ai/api/paas/v4
api_key: $ZAI_API_KEY
supports_vision: true
reasoning:
thinking: required # unsupported | optional | required
dialect: openai_extra_body # auto | openai_extra_body | anthropic | vllm_chat_template | ollama | none
history: clear # preserve | clear
effort:
values: [low, high, max] # provider 自己的取值
default: high # 调用方未指定时使用
aliases: # DeerFlow 通用取值 -> provider 取值
minimal: low
medium: high
extra_body:
tool_stream: true
```
- `thinking: required` 会在后台调用请求关闭 thinking 时仍保持开启;设置 `on_disable_request: reject` 则改为让这类调用直接失败。
- 不在 `values` 中的 effort 取值会先经 `aliases` 映射,否则回退到 `default`,绝不会原样发给 provider。出于同样的原因,写在 profile 或 `when_thinking_enabled` / `when_thinking_disabled` 模板里的 effort 取值也会在启动时对照 `values` 校验。
- 修改 `effort.path` 时,请移除 profile 和 thinking 模板中旧的 `reasoning_effort` 字段;契约会在启动时拒绝这类冲突配置。切换到未声明该取值的旧模型时,聊天界面也会丢弃记住的 provider 专属 effort 档位。
- `default` 同样作用于从不指定 effort 的调用方——总结、标题生成和子代理——所以请选一个你愿意为每次后台调用付费的档位(上例用 `high`,`max` 仍可在输入框里手动选择)。
- `dialect: auto`(默认值)会根据 `when_thinking_enabled` 推断开关 payload,因此已有配置可以直接加上 `reasoning` 而无需改写模板。未声明 `reasoning` 块的配置行为完全不变。
- 自相矛盾的配置(例如 `required` 同时配置了 `when_thinking_disabled`)会在启动时报错。
### 沙箱
```yaml

View File

@ -408,6 +408,11 @@ export const enUS: Translations = {
reasoningEffortHigh: "High",
reasoningEffortHighDescription:
"Full-dimensional Logic Deduction + Multi-path Verification + Backward Check",
reasoningEffortXhigh: "Extra High",
reasoningEffortXhighDescription:
"Extended deduction beyond High; slowest, most thorough",
reasoningEffortMax: "Max",
reasoningEffortMaxDescription: "The provider's deepest reasoning budget",
surpriseMe: "Surprise",
surpriseMePrompt: "Surprise me",
followupLoading: "Generating follow-up questions...",

View File

@ -348,6 +348,10 @@ export interface Translations {
reasoningEffortMediumDescription: string;
reasoningEffortHigh: string;
reasoningEffortHighDescription: string;
reasoningEffortXhigh: string;
reasoningEffortXhighDescription: string;
reasoningEffortMax: string;
reasoningEffortMaxDescription: string;
surpriseMe: string;
surpriseMePrompt: string;
followupLoading: string;

View File

@ -382,6 +382,10 @@ export const zhCN: Translations = {
reasoningEffortMediumDescription: "多层逻辑分析 + 基础验证",
reasoningEffortHigh: "高",
reasoningEffortHighDescription: "全维度逻辑推演 + 多路径验证 + 反推校验",
reasoningEffortXhigh: "极高",
reasoningEffortXhighDescription: "在“高”之上继续推演,最慢但最充分",
reasoningEffortMax: "最大",
reasoningEffortMaxDescription: "使用提供商允许的最大推理预算",
surpriseMe: "小惊喜",
surpriseMePrompt: "给我一个小惊喜吧",
followupLoading: "正在生成可能的后续问题...",

View File

@ -1,2 +1,3 @@
export * from "./api";
export * from "./reasoning";
export * from "./types";

View File

@ -0,0 +1,138 @@
import type { Model, ModelReasoningCapabilities } from "./types";
/** The vocabulary DeerFlow's mode presets and legacy profiles use. */
export const GENERIC_REASONING_EFFORTS = [
"minimal",
"low",
"medium",
"high",
] as const;
export type GenericReasoningEffort = (typeof GENERIC_REASONING_EFFORTS)[number];
/**
* An effort value sent to the Gateway. Provider contracts may add values
* outside the generic set (for example `max` or `xhigh`), so this stays open
* while keeping autocomplete for the generic ones.
*/
export type ReasoningEffortValue = GenericReasoningEffort | (string & {});
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
const MODE_EFFORT_PRESETS: Record<InputMode, GenericReasoningEffort> = {
flash: "minimal",
thinking: "low",
pro: "medium",
ultra: "high",
};
const UNSUPPORTED: ModelReasoningCapabilities = {
thinking: "unsupported",
effort: null,
history: null,
source: "legacy",
};
/**
* Normalize a model's reasoning capabilities. Prefers the contract projected
* by the Gateway and falls back to the legacy booleans for older backends, so
* every caller reads one shape.
*/
export function getReasoningCapabilities(
model: Model | null | undefined,
): ModelReasoningCapabilities {
if (!model) {
return UNSUPPORTED;
}
if (model.reasoning) {
return model.reasoning;
}
return {
thinking: model.supports_thinking ? "optional" : "unsupported",
effort: model.supports_reasoning_effort
? { values: [...GENERIC_REASONING_EFFORTS], default: null, aliases: {} }
: null,
history: null,
source: "legacy",
};
}
export function supportsThinking(model: Model | null | undefined): boolean {
return getReasoningCapabilities(model).thinking !== "unsupported";
}
export function isThinkingRequired(model: Model | null | undefined): boolean {
return getReasoningCapabilities(model).thinking === "required";
}
/** Effort values the UI may offer for this model, in display order. */
export function getReasoningEffortOptions(
model: Model | null | undefined,
): string[] {
return getReasoningCapabilities(model).effort?.values ?? [];
}
/**
* Map a requested effort onto a value the model accepts.
*
* - `undefined` stays `undefined` (the Gateway applies the contract default).
* - Legacy profiles keep only their advertised generic values. A remembered
* contract-only value is dropped when the user switches models.
* - Declared contracts keep accepted values, map generic values through the
* contract's aliases, and otherwise fall back to the contract default.
*/
export function resolveReasoningEffort(
model: Model | null | undefined,
requested: ReasoningEffortValue | undefined,
): ReasoningEffortValue | undefined {
if (requested === undefined) {
return undefined;
}
const capabilities = getReasoningCapabilities(model);
if (capabilities.source === "legacy") {
return capabilities.effort?.values.includes(requested)
? requested
: undefined;
}
const effort = capabilities.effort;
if (!effort) {
return undefined;
}
if (effort.values.includes(requested)) {
return requested;
}
const alias = effort.aliases[requested];
if (alias !== undefined) {
return alias;
}
return effort.default ?? undefined;
}
/**
* Clamp a chat mode to what the model supports: no thinking mode on a model
* without thinking, and never `flash` on a model whose thinking is required.
*/
export function getResolvedMode(
mode: InputMode | undefined,
model: Model | null | undefined,
): InputMode {
const { thinking } = getReasoningCapabilities(model);
if (thinking === "unsupported") {
return "flash";
}
if (thinking === "required") {
if (mode === "flash") {
return "thinking";
}
return mode ?? "pro";
}
return mode ?? "pro";
}
/** The effort a mode preset implies, mapped onto the model's contract. */
export function reasoningEffortForMode(
mode: InputMode,
model: Model | null | undefined,
): ReasoningEffortValue | undefined {
return resolveReasoningEffort(model, MODE_EFFORT_PRESETS[mode]);
}

View File

@ -1,11 +1,37 @@
export type ModelThinkingMode = "unsupported" | "optional" | "required";
export interface ModelReasoningEffortCapabilities {
/** Accepted effort values in the provider's own vocabulary, in display order. */
values: string[];
/** Effort the Gateway applies when the caller does not choose one. */
default: string | null;
/** DeerFlow generic value (minimal/low/medium/high) -> provider value. */
aliases: Record<string, string>;
}
/**
* Normalized reasoning capability contract projected by `/api/models`
* (issue #5073). Legacy profiles report `source: "legacy"` with the generic
* effort vocabulary, which is what the UI used to assume from the booleans.
*/
export interface ModelReasoningCapabilities {
thinking: ModelThinkingMode;
effort: ModelReasoningEffortCapabilities | null;
history: "preserve" | "clear" | null;
source: "legacy" | "contract";
}
export interface Model {
id: string;
name: string;
model: string;
display_name: string;
description?: string | null;
/** @deprecated derived from `reasoning`; kept for older Gateways. */
supports_thinking?: boolean;
/** @deprecated derived from `reasoning`; kept for older Gateways. */
supports_reasoning_effort?: boolean;
reasoning?: ModelReasoningCapabilities | null;
}
export interface TokenUsageSettings {

View File

@ -1,3 +1,5 @@
import type { ReasoningEffortValue } from "@/core/models/reasoning";
import type { TokenUsageInlineMode } from "../messages/usage-model";
import type { AgentThreadContext } from "../threads";
@ -83,7 +85,7 @@ export interface LocalSettings {
> & {
model_name?: string | undefined;
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: ReasoningEffortValue;
};
}

View File

@ -4,7 +4,13 @@ const fields = {
notification_enabled: z.boolean().nullable(),
model_name: z.string().max(200).nullable(),
mode: z.enum(["flash", "thinking", "pro", "ultra"]).nullable(),
reasoning_effort: z.enum(["minimal", "low", "medium", "high"]).nullable(),
// Provider contracts may add values such as `max`; the Gateway validates
// the token against the selected model (issue #5073).
reasoning_effort: z
.string()
.max(32)
.regex(/^[A-Za-z0-9_.-]+$/)
.nullable(),
};
const schema = z.object(fields).partial();
export type Preferences = z.infer<typeof schema>;

View File

@ -1,5 +1,7 @@
import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { ReasoningEffortValue } from "@/core/models/reasoning";
import type { Todo } from "../todos";
export interface GoalState {
@ -43,7 +45,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
reasoning_effort?: ReasoningEffortValue;
agent_name?: string;
}

View File

@ -885,9 +885,11 @@ test.describe("Side chat", () => {
thinking_enabled: false,
is_plan_mode: false,
subagent_enabled: false,
reasoning_effort: "minimal",
thread_id: MOCK_SIDECAR_THREAD_ID,
});
// fast-model advertises no effort control, so the mode preset is dropped
// instead of being sent for the Gateway to discard (issue #5073).
expect(streamBody?.context).not.toHaveProperty("reasoning_effort");
const messages = streamBody?.input?.messages ?? [];
expect(messages[0]?.additional_kwargs).toMatchObject({

View File

@ -0,0 +1,160 @@
import { describe, expect, it } from "@rstest/core";
import {
getReasoningCapabilities,
getReasoningEffortOptions,
getResolvedMode,
isThinkingRequired,
reasoningEffortForMode,
resolveReasoningEffort,
supportsThinking,
} from "@/core/models/reasoning";
import type { Model } from "@/core/models/types";
function model(overrides: Partial<Model>): Model {
return {
id: "m",
name: "m",
model: "m",
display_name: "M",
...overrides,
};
}
const legacyThinking = model({
supports_thinking: true,
supports_reasoning_effort: true,
});
const legacyPlain = model({});
const glm = model({
supports_thinking: true,
supports_reasoning_effort: true,
reasoning: {
thinking: "required",
effort: {
values: ["low", "high", "max"],
default: "max",
aliases: { minimal: "low", medium: "high" },
},
history: "clear",
source: "contract",
},
});
const optionalNoEffort = model({
reasoning: {
thinking: "optional",
effort: null,
history: null,
source: "contract",
},
});
const restrictedNoDefault = model({
reasoning: {
thinking: "optional",
effort: { values: ["low", "high"], default: null, aliases: {} },
history: null,
source: "contract",
},
});
describe("getReasoningCapabilities", () => {
it("falls back to the legacy booleans when the Gateway omits the contract", () => {
expect(getReasoningCapabilities(legacyThinking)).toEqual({
thinking: "optional",
effort: {
values: ["minimal", "low", "medium", "high"],
default: null,
aliases: {},
},
history: null,
source: "legacy",
});
expect(getReasoningCapabilities(legacyPlain).thinking).toBe("unsupported");
expect(getReasoningCapabilities(legacyPlain).effort).toBeNull();
});
it("treats a missing model as unsupported", () => {
expect(getReasoningCapabilities(undefined).thinking).toBe("unsupported");
expect(supportsThinking(undefined)).toBe(false);
expect(getReasoningEffortOptions(null)).toEqual([]);
});
it("prefers the projected contract over the booleans", () => {
expect(getReasoningCapabilities(glm).source).toBe("contract");
expect(isThinkingRequired(glm)).toBe(true);
expect(getReasoningEffortOptions(glm)).toEqual(["low", "high", "max"]);
});
});
describe("resolveReasoningEffort", () => {
it("keeps an unset value unset so the Gateway applies the contract default", () => {
expect(resolveReasoningEffort(glm, undefined)).toBeUndefined();
});
it("keeps advertised legacy values and drops remembered contract-only values", () => {
expect(resolveReasoningEffort(legacyThinking, "minimal")).toBe("minimal");
expect(resolveReasoningEffort(legacyThinking, "xhigh")).toBeUndefined();
expect(
resolveReasoningEffort(
legacyThinking,
resolveReasoningEffort(glm, "max"),
),
).toBeUndefined();
expect(resolveReasoningEffort(legacyPlain, "high")).toBeUndefined();
});
it("maps generic presets through the contract aliases", () => {
expect(resolveReasoningEffort(glm, "minimal")).toBe("low");
expect(resolveReasoningEffort(glm, "medium")).toBe("high");
expect(resolveReasoningEffort(glm, "max")).toBe("max");
});
it("falls back to the contract default for unknown values", () => {
expect(resolveReasoningEffort(glm, "xhigh")).toBe("max");
expect(
resolveReasoningEffort(restrictedNoDefault, "medium"),
).toBeUndefined();
});
it("drops any value for a model without effort control", () => {
expect(resolveReasoningEffort(optionalNoEffort, "high")).toBeUndefined();
});
});
describe("getResolvedMode", () => {
it("clamps every mode to flash on a model without thinking", () => {
expect(getResolvedMode("pro", legacyPlain)).toBe("flash");
expect(getResolvedMode(undefined, legacyPlain)).toBe("flash");
});
it("keeps the requested mode on an optional-thinking model", () => {
expect(getResolvedMode("flash", legacyThinking)).toBe("flash");
expect(getResolvedMode("ultra", legacyThinking)).toBe("ultra");
expect(getResolvedMode(undefined, legacyThinking)).toBe("pro");
});
it("never yields flash on a required-thinking model", () => {
expect(getResolvedMode("flash", glm)).toBe("thinking");
expect(getResolvedMode(undefined, glm)).toBe("pro");
expect(getResolvedMode("ultra", glm)).toBe("ultra");
});
});
describe("reasoningEffortForMode", () => {
it("uses the generic presets for legacy models", () => {
expect(reasoningEffortForMode("flash", legacyThinking)).toBe("minimal");
expect(reasoningEffortForMode("thinking", legacyThinking)).toBe("low");
expect(reasoningEffortForMode("pro", legacyThinking)).toBe("medium");
expect(reasoningEffortForMode("ultra", legacyThinking)).toBe("high");
});
it("maps the presets onto a restricted contract", () => {
expect(reasoningEffortForMode("thinking", glm)).toBe("low");
expect(reasoningEffortForMode("pro", glm)).toBe("high");
expect(reasoningEffortForMode("ultra", glm)).toBe("high");
});
});

View File

@ -95,3 +95,23 @@ describe("account preference synchronization", () => {
expect(state.visible()).toEqual({});
});
});
describe("parsePreferences reasoning_effort", () => {
it("accepts provider-specific effort tokens such as max", async () => {
const { parsePreferences } =
await import("@/core/settings/preferences-sync");
expect(parsePreferences({ reasoning_effort: "max" })).toEqual({
reasoning_effort: "max",
});
expect(parsePreferences({ reasoning_effort: "xhigh" })).toEqual({
reasoning_effort: "xhigh",
});
});
it("drops effort values that are not plain tokens", async () => {
const { parsePreferences } =
await import("@/core/settings/preferences-sync");
expect(parsePreferences({ reasoning_effort: "Not Valid!" })).toEqual({});
expect(parsePreferences({ reasoning_effort: 42 })).toEqual({});
});
});

View File

@ -178,7 +178,7 @@ LLM_PROVIDERS: list[LLMProvider] = [
LLMProvider(
name="zai",
display_name="Z.AI GLM-5.3-Flash",
description="GLM-5.3-Flash with required thinking and native vision",
description="GLM-5.3-Flash with required thinking, low/high/max effort, and native vision",
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
models=["glm-5.3-flash"],
default_model="glm-5.3-flash",
@ -192,25 +192,28 @@ LLM_PROVIDERS: list[LLMProvider] = [
"top_p": 0.95,
"max_tokens": 131072,
"context_window": 1000000,
"supports_thinking": True,
# GLM-5.3-Flash only accepts low/high/max, while DeerFlow's current
# generic UI can emit minimal/medium. Keep provider effort control
# disabled until model-specific reasoning capabilities are exposed.
"supports_reasoning_effort": False,
"supports_vision": True,
# The model cannot disable thinking. This unconditional base payload
# deliberately avoids when_thinking_enabled/disabled so background
# callers that request thinking_enabled=False cannot synthesize an
# invalid disabled/minimal combination in the model factory.
"extra_body": {
"thinking": {
"type": "enabled",
# Avoid preserved-thinking history requirements until the
# reasoning-history abstraction handles summarization.
"clear_thinking": True,
# Declarative reasoning contract (issue #5073). GLM-5.3-Flash cannot
# disable thinking and only accepts low/high/max effort, so the
# contract keeps thinking on for every foreground and background
# call, maps DeerFlow's generic minimal/medium presets onto the
# provider vocabulary, and serializes clear_thinking=true so the
# model does not require exact reasoning replay after summarization.
# `default` also governs callers that never choose an effort
# (summarization, title generation, subagents), so it stays at
# `high` rather than the provider's deepest `max`; `max` remains
# selectable in the composer.
"reasoning": {
"thinking": "required",
"dialect": "openai_extra_body",
"history": "clear",
"effort": {
"values": ["low", "high", "max"],
"default": "high",
"aliases": {"minimal": "low", "medium": "high"},
},
"tool_stream": True,
},
"extra_body": {"tool_stream": True},
# Z.AI streams terminal usage without requiring OpenAI's undocumented
# stream_options.include_usage request field.
"stream_usage": False,