From bcaf6db36a1695854545fbb5501b842a3d806e30 Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Thu, 24 Sep 2026 20:39:30 +0800 Subject: [PATCH] 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. --- CHANGELOG.md | 17 + README.md | 2 +- README_zh.md | 2 +- backend/app/gateway/routers/models.py | 64 ++- .../app/gateway/routers/user_preferences.py | 6 +- backend/docs/API.md | 36 +- .../deerflow/agents/assembly_descriptor.py | 4 + .../deerflow/agents/lead_agent/agent.py | 19 +- backend/packages/harness/deerflow/client.py | 3 + .../harness/deerflow/config/AGENTS.md | 4 +- .../harness/deerflow/config/model_config.py | 166 ++++++- .../harness/deerflow/models/AGENTS.md | 41 ++ .../harness/deerflow/models/factory.py | 310 +++++++++--- .../harness/deerflow/models/reasoning.py | 217 +++++++++ .../harness/deerflow/subagents/executor.py | 10 +- .../tests/test_agent_assembly_descriptor.py | 23 + backend/tests/test_client.py | 13 + .../tests/test_lead_agent_model_resolution.py | 43 ++ backend/tests/test_model_factory.py | 387 +++++++++++++++ backend/tests/test_models_router_reasoning.py | 93 ++++ backend/tests/test_reasoning_contract.py | 452 ++++++++++++++++++ backend/tests/test_setup_wizard.py | 63 ++- backend/tests/test_user_preferences.py | 2 +- config.example.yaml | 68 ++- ...026-09-23-reasoning-capability-contract.md | 241 ++++++++++ frontend/AGENTS.md | 17 + .../agents/agent-settings-dialog.tsx | 42 +- .../src/components/workspace/input-box.tsx | 330 +++++++------ .../workspace/sidecar/sidecar-panel.tsx | 131 +++-- .../content/en/application/configuration.mdx | 33 ++ .../content/zh/application/configuration.mdx | 33 ++ frontend/src/core/i18n/locales/en-US.ts | 5 + frontend/src/core/i18n/locales/types.ts | 4 + frontend/src/core/i18n/locales/zh-CN.ts | 4 + frontend/src/core/models/index.ts | 1 + frontend/src/core/models/reasoning.ts | 138 ++++++ frontend/src/core/models/types.ts | 26 + frontend/src/core/settings/local.ts | 4 +- .../src/core/settings/preferences-sync.ts | 8 +- frontend/src/core/threads/types.ts | 4 +- frontend/tests/e2e/sidecar-chat.spec.ts | 4 +- .../tests/unit/core/models/reasoning.test.ts | 160 +++++++ .../core/settings/preferences-sync.test.ts | 20 + scripts/wizard/providers.py | 37 +- 44 files changed, 2887 insertions(+), 400 deletions(-) create mode 100644 backend/packages/harness/deerflow/models/reasoning.py create mode 100644 backend/tests/test_models_router_reasoning.py create mode 100644 backend/tests/test_reasoning_contract.py create mode 100644 docs/plans/2026-09-23-reasoning-capability-contract.md create mode 100644 frontend/src/core/models/reasoning.ts create mode 100644 frontend/tests/unit/core/models/reasoning.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaa5bc49d..759180a4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 85658a23cc..de7e0a650f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_zh.md b/README_zh.md index dd565231eb..51447ebdbe 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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` 设为一个占位值。 diff --git a/backend/app/gateway/routers/models.py b/backend/app/gateway/routers/models.py index 8fec748c96..cc68cdc4b6 100644 --- a/backend/app/gateway/routers/models.py +++ b/backend/app/gateway/routers/models.py @@ -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) diff --git a/backend/app/gateway/routers/user_preferences.py b/backend/app/gateway/routers/user_preferences.py index a6ba7bce83..38ffb9ddea 100644 --- a/backend/app/gateway/routers/user_preferences.py +++ b/backend/app/gateway/routers/user_preferences.py @@ -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: diff --git a/backend/docs/API.md b/backend/docs/API.md index d1c034e553..fd28c52f41 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -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"} } ``` diff --git a/backend/packages/harness/deerflow/agents/assembly_descriptor.py b/backend/packages/harness/deerflow/agents/assembly_descriptor.py index df9063eed4..a3a1cde079 100644 --- a/backend/packages/harness/deerflow/agents/assembly_descriptor.py +++ b/backend/packages/harness/deerflow/agents/assembly_descriptor.py @@ -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 = ( diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index e8a2158663..f4a11b3bd5 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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", diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 3776707da0..38bce69db3 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -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)), } # ------------------------------------------------------------------ diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index dced05055d..7b3734fd99 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -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` diff --git a/backend/packages/harness/deerflow/config/model_config.py b/backend/packages/harness/deerflow/config/model_config.py index 590e809758..4db8594675 100644 --- a/backend/packages/harness/deerflow/config/model_config.py +++ b/backend/packages/harness/deerflow/config/model_config.py @@ -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 diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 4dfaf95bdc..c236eb209d 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -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) diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index 6cfc263697..14f8297262 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -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. diff --git a/backend/packages/harness/deerflow/models/reasoning.py b/backend/packages/harness/deerflow/models/reasoning.py new file mode 100644 index 0000000000..b752689ebf --- /dev/null +++ b/backend/packages/harness/deerflow/models/reasoning.py @@ -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", +] diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index 4d7651004a..2cd319f73f 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -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, diff --git a/backend/tests/test_agent_assembly_descriptor.py b/backend/tests/test_agent_assembly_descriptor.py index 22597eca44..66cedc39a1 100644 --- a/backend/tests/test_agent_assembly_descriptor.py +++ b/backend/tests/test_agent_assembly_descriptor.py @@ -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")) diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 62a409f1f4..f53d7c39d2 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -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): diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 64dfe21aef..a4e2a5c153 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -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" diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index 1f05aeb044..3f44fd9080 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -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" diff --git a/backend/tests/test_models_router_reasoning.py b/backend/tests/test_models_router_reasoning.py new file mode 100644 index 0000000000..dd16ae9111 --- /dev/null +++ b/backend/tests/test_models_router_reasoning.py @@ -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"] diff --git a/backend/tests/test_reasoning_contract.py b/backend/tests/test_reasoning_contract.py new file mode 100644 index 0000000000..ff304ecdd4 --- /dev/null +++ b/backend/tests/test_reasoning_contract.py @@ -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] diff --git a/backend/tests/test_setup_wizard.py b/backend/tests/test_setup_wizard.py index dc074d62cf..84f6b8a473 100644 --- a/backend/tests/test_setup_wizard.py +++ b/backend/tests/test_setup_wizard.py @@ -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. diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py index 5d0eebfdca..6402ab8b33 100644 --- a/backend/tests/test_user_preferences.py +++ b/backend/tests/test_user_preferences.py @@ -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 diff --git a/config.example.yaml b/config.example.yaml index 95585bf7f0..7008610f93 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/docs/plans/2026-09-23-reasoning-capability-contract.md b/docs/plans/2026-09-23-reasoning-capability-contract.md new file mode 100644 index 0000000000..c191cee4a9 --- /dev/null +++ b/docs/plans/2026-09-23-reasoning-capability-contract.md @@ -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. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index b159d78790..ca3f4869eb 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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`. diff --git a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx index 7e156feffc..002fca73ee 100644 --- a/frontend/src/components/workspace/agents/agent-settings-dialog.tsx +++ b/frontend/src/components/workspace/agents/agent-settings-dialog.tsx @@ -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} @@ -393,7 +415,7 @@ export function AgentSettingsDialog({ {t.agents.settingsInherit} - {REASONING_EFFORTS.map((effort) => ( + {reasoningEffortOptions.map((effort) => ( {effort} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index c44722eb86..6534c88746 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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, "&") @@ -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} - handleModeSelect("flash")} - > -
-
- - {t.inputBox.flashMode} + {!thinkingRequired && ( + handleModeSelect("flash")} + > +
+
+ + {t.inputBox.flashMode} +
+
+ {t.inputBox.flashModeDescription} +
-
- {t.inputBox.flashModeDescription} -
-
- {context.mode === "flash" ? ( - - ) : ( -
- )} - + {context.mode === "flash" ? ( + + ) : ( +
+ )} + + )} {supportThinking && (
- {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)}
@@ -2768,96 +2825,33 @@ export function InputBox({ {t.inputBox.reasoningEffort} - handleReasoningEffortSelect("minimal")} - > -
-
- {t.inputBox.reasoningEffortMinimal} + {reasoningEffortOptions.map((effort) => ( + handleReasoningEffortSelect(effort)} + > +
+
+ {reasoningEffortLabel(effort)} +
+ {reasoningEffortDescription(effort) && ( +
+ {reasoningEffortDescription(effort)} +
+ )}
-
- {t.inputBox.reasoningEffortMinimalDescription} -
-
- {context.reasoning_effort === "minimal" ? ( - - ) : ( -
- )} - - handleReasoningEffortSelect("low")} - > -
-
- {t.inputBox.reasoningEffortLow} -
-
- {t.inputBox.reasoningEffortLowDescription} -
-
- {context.reasoning_effort === "low" ? ( - - ) : ( -
- )} - - handleReasoningEffortSelect("medium")} - > -
-
- {t.inputBox.reasoningEffortMedium} -
-
- {t.inputBox.reasoningEffortMediumDescription} -
-
- {context.reasoning_effort === "medium" || - !context.reasoning_effort ? ( - - ) : ( -
- )} - - handleReasoningEffortSelect("high")} - > -
-
- {t.inputBox.reasoningEffortHigh} -
-
- {t.inputBox.reasoningEffortHighDescription} -
-
- {context.reasoning_effort === "high" ? ( - - ) : ( -
- )} - + {effectiveReasoningEffort === effort ? ( + + ) : ( +
+ )} + + ))} diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx index e52509b27f..6efac7e01f 100644 --- a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx +++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx @@ -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; -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 }) { @@ -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 ( @@ -789,34 +772,36 @@ function SidecarModeMenu({ {t.inputBox.mode} - onModeSelect("flash")} - > -
-
- - {t.inputBox.flashMode} + {!thinkingRequired && ( + onModeSelect("flash")} + > +
+
+ + {t.inputBox.flashMode} +
+
+ {t.inputBox.flashModeDescription} +
-
- {t.inputBox.flashModeDescription} -
-
- {mode === "flash" ? ( - - ) : ( -
- )} - + {mode === "flash" ? ( + + ) : ( +
+ )} + + )} {supportThinking && ( +#### 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: diff --git a/frontend/src/content/zh/application/configuration.mdx b/frontend/src/content/zh/application/configuration.mdx index e8df2f743a..cd2c42b2e5 100644 --- a/frontend/src/content/zh/application/configuration.mdx +++ b/frontend/src/content/zh/application/configuration.mdx @@ -134,6 +134,39 @@ Docker 部署时,`localhost` 指向 DeerFlow 容器本身。请改用容器能 +#### 推理能力契约 + +`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 diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 929e2d1f8a..376e275d3b 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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...", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 405feb5553..a8a67142e1 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index ec4ff9a10a..5b3b1b35b9 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -382,6 +382,10 @@ export const zhCN: Translations = { reasoningEffortMediumDescription: "多层逻辑分析 + 基础验证", reasoningEffortHigh: "高", reasoningEffortHighDescription: "全维度逻辑推演 + 多路径验证 + 反推校验", + reasoningEffortXhigh: "极高", + reasoningEffortXhighDescription: "在“高”之上继续推演,最慢但最充分", + reasoningEffortMax: "最大", + reasoningEffortMaxDescription: "使用提供商允许的最大推理预算", surpriseMe: "小惊喜", surpriseMePrompt: "给我一个小惊喜吧", followupLoading: "正在生成可能的后续问题...", diff --git a/frontend/src/core/models/index.ts b/frontend/src/core/models/index.ts index 1d58e1a79b..4d8ecdfcfd 100644 --- a/frontend/src/core/models/index.ts +++ b/frontend/src/core/models/index.ts @@ -1,2 +1,3 @@ export * from "./api"; +export * from "./reasoning"; export * from "./types"; diff --git a/frontend/src/core/models/reasoning.ts b/frontend/src/core/models/reasoning.ts new file mode 100644 index 0000000000..a69d1e0674 --- /dev/null +++ b/frontend/src/core/models/reasoning.ts @@ -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 = { + 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]); +} diff --git a/frontend/src/core/models/types.ts b/frontend/src/core/models/types.ts index 2c888ed8d9..168352fa2b 100644 --- a/frontend/src/core/models/types.ts +++ b/frontend/src/core/models/types.ts @@ -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; +} + +/** + * 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 { diff --git a/frontend/src/core/settings/local.ts b/frontend/src/core/settings/local.ts index f54e0f50c0..f36d9524e2 100644 --- a/frontend/src/core/settings/local.ts +++ b/frontend/src/core/settings/local.ts @@ -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; }; } diff --git a/frontend/src/core/settings/preferences-sync.ts b/frontend/src/core/settings/preferences-sync.ts index e3a41b9261..3bc597f53e 100644 --- a/frontend/src/core/settings/preferences-sync.ts +++ b/frontend/src/core/settings/preferences-sync.ts @@ -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; diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts index d7751955fe..6513519300 100644 --- a/frontend/src/core/threads/types.ts +++ b/frontend/src/core/threads/types.ts @@ -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 { thinking_enabled: boolean; is_plan_mode: boolean; subagent_enabled: boolean; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; + reasoning_effort?: ReasoningEffortValue; agent_name?: string; } diff --git a/frontend/tests/e2e/sidecar-chat.spec.ts b/frontend/tests/e2e/sidecar-chat.spec.ts index c12b9263ed..f494845aeb 100644 --- a/frontend/tests/e2e/sidecar-chat.spec.ts +++ b/frontend/tests/e2e/sidecar-chat.spec.ts @@ -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({ diff --git a/frontend/tests/unit/core/models/reasoning.test.ts b/frontend/tests/unit/core/models/reasoning.test.ts new file mode 100644 index 0000000000..84750a7afb --- /dev/null +++ b/frontend/tests/unit/core/models/reasoning.test.ts @@ -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 { + 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"); + }); +}); diff --git a/frontend/tests/unit/core/settings/preferences-sync.test.ts b/frontend/tests/unit/core/settings/preferences-sync.test.ts index d6e3158eff..dfd7fe8db0 100644 --- a/frontend/tests/unit/core/settings/preferences-sync.test.ts +++ b/frontend/tests/unit/core/settings/preferences-sync.test.ts @@ -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({}); + }); +}); diff --git a/scripts/wizard/providers.py b/scripts/wizard/providers.py index 0641e606cf..fd0abc75db 100644 --- a/scripts/wizard/providers.py +++ b/scripts/wizard/providers.py @@ -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,