fix(openrouter): preserve deepseek v4 reasoning replay

This commit is contained in:
Peter Steinberger
2026-05-02 10:45:12 +01:00
parent f2df789ca5
commit e0221d37e5
4 changed files with 174 additions and 2 deletions

View File

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

View File

@@ -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.
</Accordion>
<Accordion title="DeepSeek V4 reasoning replay">
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.
</Accordion>
<Accordion title="OpenAI-only request shaping">
OpenRouter still runs through the proxy-style OpenAI-compatible path, so
native OpenAI-only request shaping such as `serviceTier`, Responses `store`,

View File

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

View File

@@ -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<StreamFn>[0]): boolean {
const provider = readString(model.provider)?.toLowerCase();
const baseUrl = readString(model.baseUrl);
@@ -44,6 +61,15 @@ function shouldPatchAnthropicOpenRouterPayload(model: Parameters<StreamFn>[0]):
);
}
function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters<StreamFn>[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,
};