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 becdea4223.
- Required merge gates passed before the squash merge.

Prepared head SHA: becdea4223
Review: https://github.com/openclaw/openclaw/pull/77423#issuecomment-4372880583

Co-authored-by: sallyom <somalley@redhat.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Sally O'Malley
2026-05-04 17:05:05 -04:00
committed by GitHub
parent a9817a5f97
commit 02ac7dc5a6
7 changed files with 83 additions and 16 deletions

View File

@@ -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.

View File

@@ -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`.
</Accordion>
<Accordion title="OpenAI-only request shaping">

View File

@@ -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.<provider>.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.

View File

@@ -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<Record<string, unknown>> = [];
const baseStreamFn = vi.fn(
(
...args: Parameters<import("@mariozechner/pi-agent-core").StreamFn>
): ReturnType<import("@mariozechner/pi-agent-core").StreamFn> => {
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<Record<string, unknown>> = [];

View File

@@ -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<StreamFn>[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,
};

View File

@@ -8,7 +8,6 @@ const OPENROUTER_DEEPSEEK_V4_THINKING_LEVEL_IDS = [
"medium",
"high",
"xhigh",
"max",
] as const;
function buildOpenRouterDeepSeekV4ThinkingLevel(

View File

@@ -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<StreamFn>[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);
});
};