From 02ac7dc5a62e1e7726a7436b3e0a79ecd65bd696 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Mon, 4 May 2026 17:05:05 -0400 Subject: [PATCH] fix(openrouter): keep DeepSeek V4 reasoning effort valid (#77423) Summary: - The PR removes `max` from OpenRouter DeepSeek V4 thinking profiles, maps stale OpenRouter `max` overrides to `xhigh`, preserves direct DeepSeek behavior, and updates docs, tests, and changelog. - Reproducibility: yes. Source inspection on current main shows OpenRouter DeepSeek V4 advertises `max` and se ... ffort: "max"`, matching the linked 400 logs; I did not need a live OpenRouter request for this assist pass. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Addressed earlier ClawSweeper review findings before merge. - Included post-review commit in the final squash: docs(changelog): credit OpenRouter duplicate fix - Included post-review commit in the final squash: fix(openrouter): keep DeepSeek V4 reasoning effort valid Validation: - ClawSweeper review passed for head becdea4223be0cbb6806d92d11ca4307d19bfc3f. - Required merge gates passed before the squash merge. Prepared head SHA: becdea4223be0cbb6806d92d11ca4307d19bfc3f Review: https://github.com/openclaw/openclaw/pull/77423#issuecomment-4372880583 Co-authored-by: sallyom Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/providers/openrouter.md | 4 +- docs/tools/thinking.md | 3 +- extensions/openrouter/index.test.ts | 48 +++++++++++++++++++++++- extensions/openrouter/stream.ts | 33 +++++++++++----- extensions/openrouter/thinking-policy.ts | 1 - src/plugin-sdk/provider-stream-shared.ts | 9 ++++- 7 files changed, 83 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e88dc969c0f..655136a6c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. - fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987. +- Providers/OpenRouter: keep DeepSeek V4 `reasoning_effort` on OpenRouter-supported values, mapping stale `max` thinking overrides to `xhigh` so `openrouter/deepseek/deepseek-v4-pro` no longer fails with OpenRouter's invalid-effort 400. Fixes #77350. (#77423) Thanks @krllagent, @mushuiyu886, and @sallyom. - fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. - Agents/subagents: refresh deferred final-delivery payloads when same-session completion output changes, so retried parent notifications use the final child summary instead of stale progress text. Thanks @vincentkoc. diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index cf1861f7f0f..f0064bc1c4b 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -211,7 +211,9 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers On verified OpenRouter routes, `openrouter/deepseek/deepseek-v4-flash` and `openrouter/deepseek/deepseek-v4-pro` fill missing `reasoning_content` on replayed assistant turns so thinking/tool conversations keep DeepSeek V4's - required follow-up shape. + required follow-up shape. OpenClaw sends OpenRouter-supported + `reasoning_effort` values for these routes; `xhigh` is the highest advertised + level, and stale `max` overrides are mapped to `xhigh`. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 659cd27ce43..f7e97be51f2 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -26,7 +26,8 @@ title: "Thinking levels" - Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level. - Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting. - Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path. - - DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`. + - Direct DeepSeek V4 models expose `/think xhigh|max`; both map to DeepSeek `reasoning_effort: "max"` while lower non-off levels map to `high`. + - OpenRouter-routed DeepSeek V4 models expose `/think xhigh` and send OpenRouter-supported `reasoning_effort` values. Stored `max` overrides fall back to `xhigh`. - Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings. - OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value. - Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers..models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior. diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 777abb8f55b..08925d7015f 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -73,7 +73,7 @@ describe("openrouter provider hooks", () => { it("advertises xhigh thinking for OpenRouter-routed DeepSeek V4 models", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); - const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + const expectedV4Levels = ["off", "minimal", "low", "medium", "high", "xhigh"]; expect( provider @@ -309,7 +309,7 @@ describe("openrouter provider hooks", () => { expect(capturedPayload).toMatchObject({ thinking: { type: "enabled" }, - reasoning_effort: "max", + reasoning_effort: "xhigh", messages: [ { role: "user", content: "read file" }, { @@ -324,6 +324,50 @@ describe("openrouter provider hooks", () => { expect(baseStreamFn).toHaveBeenCalledOnce(); }); + it("keeps OpenRouter DeepSeek V4 reasoning_effort within OpenRouter values", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const payloads: Array> = []; + const baseStreamFn = vi.fn( + ( + ...args: Parameters + ): ReturnType => { + const payload = { messages: [] }; + void args[2]?.onPayload?.(payload, args[0]); + payloads.push(payload); + return { async *[Symbol.asyncIterator]() {} } as never; + }, + ); + + for (const thinkingLevel of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) { + const wrapped = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "openrouter/deepseek/deepseek-v4-pro", + streamFn: baseStreamFn, + thinkingLevel, + } as never); + void wrapped?.( + { + provider: "openrouter", + api: "openai-completions", + id: "openrouter/deepseek/deepseek-v4-pro", + baseUrl: "https://openrouter.ai/api/v1", + compat: {}, + } as never, + { messages: [] } as never, + {}, + ); + } + + expect(payloads.map((payload) => payload.reasoning_effort)).toEqual([ + "minimal", + "low", + "medium", + "high", + "xhigh", + "xhigh", + ]); + }); + it("recognizes full OpenRouter DeepSeek V4 refs but skips custom proxy routes", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); const payloads: Array> = []; diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 9f2e733d3a8..83d68c8bbf7 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -3,6 +3,8 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-ent import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { createDeepSeekV4OpenAICompatibleThinkingWrapper, + type DeepSeekV4ReasoningEffort, + type DeepSeekV4ThinkingLevel, createPayloadPatchStreamWrapper, stripTrailingAssistantPrefillMessages, } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -55,6 +57,27 @@ function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): ); } +function resolveOpenRouterDeepSeekV4ReasoningEffort( + thinkingLevel: DeepSeekV4ThinkingLevel, +): DeepSeekV4ReasoningEffort { + switch (thinkingLevel) { + case "minimal": + case "low": + case "medium": + case "high": + case "xhigh": + return thinkingLevel; + case "max": + return "xhigh"; + case "adaptive": + return "medium"; + case "off": + case undefined: + return "high"; + } + return "high"; +} + function isEnabledReasoningValue(value: unknown): boolean { if (value === undefined || value === null || value === false) { return false; @@ -125,6 +148,7 @@ function createOpenRouterDeepSeekV4ThinkingWrapper( baseStreamFn, thinkingLevel, shouldPatchModel: shouldPatchDeepSeekV4OpenRouterPayload, + resolveReasoningEffort: resolveOpenRouterDeepSeekV4ReasoningEffort, }); } @@ -156,12 +180,3 @@ export function wrapOpenRouterProviderStream( createOpenRouterDeepSeekV4ThinkingWrapper(wrappedStreamFn, ctx.thinkingLevel), ); } - -export const __testing = { - isOpenRouterDeepSeekV4ModelId, - isOpenRouterAnthropicModelId, - isOpenRouterReasoningPayloadEnabled, - isVerifiedOpenRouterRoute, - shouldPatchDeepSeekV4OpenRouterPayload, - shouldPatchAnthropicOpenRouterPayload, -}; diff --git a/extensions/openrouter/thinking-policy.ts b/extensions/openrouter/thinking-policy.ts index 18695fa7428..6c16c77b726 100644 --- a/extensions/openrouter/thinking-policy.ts +++ b/extensions/openrouter/thinking-policy.ts @@ -8,7 +8,6 @@ const OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS = [ "medium", "high", "xhigh", - "max", ] as const; function buildOpenRouterDeepSeekV4ThinkingLevel( diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index a1ee1428bb7..962b38b0bc5 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -244,13 +244,16 @@ export function isOpenAICompatibleThinkingEnabled(params: { } export type DeepSeekV4ThinkingLevel = ProviderWrapStreamFnContext["thinkingLevel"]; +export type DeepSeekV4ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; function isDisabledDeepSeekV4ThinkingLevel(thinkingLevel: DeepSeekV4ThinkingLevel): boolean { const normalized = typeof thinkingLevel === "string" ? thinkingLevel.toLowerCase() : ""; return normalized === "off" || normalized === "none"; } -function resolveDeepSeekV4ReasoningEffort(thinkingLevel: DeepSeekV4ThinkingLevel): "high" | "max" { +function resolveDeepSeekV4ReasoningEffort( + thinkingLevel: DeepSeekV4ThinkingLevel, +): DeepSeekV4ReasoningEffort { return thinkingLevel === "xhigh" || thinkingLevel === "max" ? "max" : "high"; } @@ -288,11 +291,13 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { baseStreamFn: StreamFn | undefined; thinkingLevel: DeepSeekV4ThinkingLevel; shouldPatchModel: (model: Parameters[0]) => boolean; + resolveReasoningEffort?: (thinkingLevel: DeepSeekV4ThinkingLevel) => DeepSeekV4ReasoningEffort; }): StreamFn | undefined { if (!params.baseStreamFn) { return undefined; } const underlying = params.baseStreamFn; + const resolveReasoningEffort = params.resolveReasoningEffort ?? resolveDeepSeekV4ReasoningEffort; return (model, context, options) => { if (!params.shouldPatchModel(model)) { return underlying(model, context, options); @@ -308,7 +313,7 @@ export function createDeepSeekV4OpenAICompatibleThinkingWrapper(params: { } payload.thinking = { type: "enabled" }; - payload.reasoning_effort = resolveDeepSeekV4ReasoningEffort(params.thinkingLevel); + payload.reasoning_effort = resolveReasoningEffort(params.thinkingLevel); ensureDeepSeekV4AssistantReasoningContent(payload); }); };