-
- {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,