mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(openrouter): preserve deepseek v4 reasoning replay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user