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,
};