From e0221d37e504157f42e5b637ec6c2fe4a58c4c58 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 10:45:12 +0100 Subject: [PATCH] fix(openrouter): preserve deepseek v4 reasoning replay --- CHANGELOG.md | 1 + docs/providers/openrouter.md | 7 ++ extensions/openrouter/index.test.ts | 121 ++++++++++++++++++++++++++++ extensions/openrouter/stream.ts | 47 ++++++++++- 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ed67e8fe3..3798bc2f372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Plugins/CLI: cache plugin CLI registration entries per command program so completion state generation does not repeat the full plugin sweep in one invocation. Thanks @ScientificProgrammer. - Plugins: reuse gateway-bindable plugin loader cache entries for later default-mode loads without serving default-built registries to gateway-bound requests, reducing repeated plugin registration during dispatch. Refs #61756. Thanks @DmitryPogodaev. - Gateway/secrets: include the caught error message in `secrets.reload` and `secrets.resolve` warning logs while keeping RPC errors generic, so operators can diagnose reload and permission failures. Thanks @davidangularme. +- Providers/OpenRouter: fill DeepSeek V4 `reasoning_content` replay placeholders for `openrouter/deepseek/deepseek-v4-flash` and `openrouter/deepseek/deepseek-v4-pro`, so thinking/tool follow-up turns do not fail with DeepSeek's replay-shape error. Fixes #76018. Thanks @cloph-dsp. - Anthropic-compatible streams: recover text deltas that arrive before their matching content block, so Kimi Code and similar providers do not finish as empty `incomplete_result` replies. Fixes #76007. Thanks @vliuyt. - fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987. - MCP/OpenAI: normalize parameter-free tool schemas whose top-level object `properties` is missing, null, or invalid before sending tools to OpenAI, so MCP tools without params stay usable. Fixes #75362. Thanks @tolkonepiu and @SymbolStar. diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index d8cc51bda28..1ef170b99ba 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -174,6 +174,13 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers return final answer text in reasoning fields for that retired route. + + 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. + + OpenRouter still runs through the proxy-style OpenAI-compatible path, so native OpenAI-only request shaping such as `serviceTier`, Responses `store`, diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 2bea1091f5f..b53d194d0e9 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -219,6 +219,127 @@ describe("openrouter provider hooks", () => { expect(baseStreamFn).toHaveBeenCalledOnce(); }); + it("fills DeepSeek V4 reasoning_content for OpenRouter replay turns", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + let capturedPayload: Record | undefined; + const baseStreamFn = vi.fn( + ( + ...args: Parameters + ): ReturnType => { + const payload = { + messages: [ + { role: "user", content: "read file" }, + { role: "assistant", tool_calls: [{ id: "call_1", type: "function" }] }, + { role: "tool", content: "ok" }, + { role: "assistant", content: "done" }, + ], + }; + void args[2]?.onPayload?.(payload, args[0]); + capturedPayload = payload; + return { async *[Symbol.asyncIterator]() {} } as never; + }, + ); + + const wrapped = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "deepseek/deepseek-v4-flash", + streamFn: baseStreamFn, + thinkingLevel: "xhigh", + } as never); + + void wrapped?.( + { + provider: "openrouter", + api: "openai-completions", + id: "deepseek/deepseek-v4-flash", + baseUrl: "https://openrouter.ai/api/v1", + compat: {}, + } as never, + { messages: [] } as never, + {}, + ); + + expect(capturedPayload).toMatchObject({ + thinking: { type: "enabled" }, + reasoning_effort: "max", + messages: [ + { role: "user", content: "read file" }, + { + role: "assistant", + tool_calls: [{ id: "call_1", type: "function" }], + reasoning_content: "", + }, + { role: "tool", content: "ok" }, + { role: "assistant", content: "done", reasoning_content: "" }, + ], + }); + expect(baseStreamFn).toHaveBeenCalledOnce(); + }); + + it("recognizes full OpenRouter DeepSeek V4 refs but skips custom proxy routes", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const payloads: Array> = []; + const baseStreamFn = vi.fn( + ( + ...args: Parameters + ): ReturnType => { + const payload = { + messages: [{ role: "assistant", tool_calls: [{ id: "call_1", type: "function" }] }], + }; + void args[2]?.onPayload?.(payload, args[0]); + payloads.push(payload); + return { async *[Symbol.asyncIterator]() {} } as never; + }, + ); + + const fullRef = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "openrouter/deepseek/deepseek-v4-pro", + streamFn: baseStreamFn, + thinkingLevel: "high", + } as never); + void fullRef?.( + { + provider: "openrouter", + api: "openai-completions", + id: "openrouter/deepseek/deepseek-v4-pro", + baseUrl: "https://openrouter.ai/api/v1", + compat: {}, + } as never, + { messages: [] } as never, + {}, + ); + + const customRoute = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "deepseek/deepseek-v4-pro", + streamFn: baseStreamFn, + thinkingLevel: "high", + } as never); + void customRoute?.( + { + provider: "openrouter", + api: "openai-completions", + id: "deepseek/deepseek-v4-pro", + baseUrl: "https://proxy.example.com/v1", + compat: {}, + } as never, + { messages: [] } as never, + {}, + ); + + expect(payloads[0]?.messages).toEqual([ + { + role: "assistant", + tool_calls: [{ id: "call_1", type: "function" }], + reasoning_content: "", + }, + ]); + expect(payloads[1]?.messages).toEqual([ + { role: "assistant", tool_calls: [{ id: "call_1", type: "function" }] }, + ]); + }); + it("strips OpenRouter-routed Anthropic assistant prefill when reasoning is enabled", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); let capturedPayload: Record | undefined; diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 48fa6d3c379..eac91b89610 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; import { + createDeepSeekV4OpenAICompatibleThinkingWrapper, createPayloadPatchStreamWrapper, stripTrailingAssistantPrefillMessages, } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -26,6 +27,22 @@ function isOpenRouterAnthropicModelId(modelId: unknown): boolean { ); } +function normalizeOpenRouterModelId(modelId: unknown): string | undefined { + const normalized = readString(modelId)?.toLowerCase(); + return normalized?.startsWith("openrouter/") + ? normalized.slice("openrouter/".length) + : normalized; +} + +function isOpenRouterDeepSeekV4ModelId(modelId: unknown): boolean { + const normalized = normalizeOpenRouterModelId(modelId); + if (!normalized?.startsWith("deepseek/")) { + return false; + } + const deepSeekModelId = normalized.slice("deepseek/".length).split(":", 1)[0]; + return deepSeekModelId === "deepseek-v4-flash" || deepSeekModelId === "deepseek-v4-pro"; +} + function isVerifiedOpenRouterRoute(model: Parameters[0]): boolean { const provider = readString(model.provider)?.toLowerCase(); const baseUrl = readString(model.baseUrl); @@ -44,6 +61,15 @@ function shouldPatchAnthropicOpenRouterPayload(model: Parameters[0]): ); } +function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): boolean { + const api = readString(model.api); + return ( + (api === undefined || api === "openai-completions") && + isOpenRouterDeepSeekV4ModelId(model.id) && + isVerifiedOpenRouterRoute(model) + ); +} + function isEnabledReasoningValue(value: unknown): boolean { if (value === undefined || value === null || value === false) { return false; @@ -106,6 +132,17 @@ function createOpenRouterAnthropicPrefillWrapper(baseStreamFn: StreamFn | undefi ); } +function createOpenRouterDeepSeekV4ThinkingWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"], +): StreamFn | undefined { + return createDeepSeekV4OpenAICompatibleThinkingWrapper({ + baseStreamFn, + thinkingLevel, + shouldPatchModel: shouldPatchDeepSeekV4OpenRouterPayload, + }); +} + export function wrapOpenRouterProviderStream( ctx: ProviderWrapStreamFnContext, ): StreamFn | null | undefined { @@ -118,7 +155,9 @@ export function wrapOpenRouterProviderStream( : ctx.streamFn; const wrapStreamFn = OPENROUTER_THINKING_STREAM_HOOKS.wrapStreamFn ?? undefined; if (!wrapStreamFn) { - return createOpenRouterAnthropicPrefillWrapper(routedStreamFn); + return createOpenRouterAnthropicPrefillWrapper( + createOpenRouterDeepSeekV4ThinkingWrapper(routedStreamFn, ctx.thinkingLevel), + ); } const wrappedStreamFn = wrapStreamFn({ @@ -128,12 +167,16 @@ export function wrapOpenRouterProviderStream( ? undefined : ctx.thinkingLevel, }) ?? undefined; - return createOpenRouterAnthropicPrefillWrapper(wrappedStreamFn); + return createOpenRouterAnthropicPrefillWrapper( + createOpenRouterDeepSeekV4ThinkingWrapper(wrappedStreamFn, ctx.thinkingLevel), + ); } export const __testing = { + isOpenRouterDeepSeekV4ModelId, isOpenRouterAnthropicModelId, isOpenRouterReasoningPayloadEnabled, isVerifiedOpenRouterRoute, + shouldPatchDeepSeekV4OpenRouterPayload, shouldPatchAnthropicOpenRouterPayload, };