fix(openrouter): strip Anthropic reasoning prefill

This commit is contained in:
Peter Steinberger
2026-05-02 06:46:37 +01:00
parent dc72a2aa42
commit 24d5649284
7 changed files with 235 additions and 9 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
- CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888.
- Web search: keep public provider requests on the strict SSRF guard and reserve private-network access for explicit self-hosted SearXNG/Firecrawl endpoints. Fixes #74357 and supersedes #74360. Thanks @fede-kamel.
- Web search/Firecrawl: allow self-hosted private/internal Firecrawl `baseUrl` endpoints, including HTTP for private targets, while keeping hosted Firecrawl on the strict official endpoint. Fixes #63877 and supersedes #59666, #63941, and #74013. Thanks @jhthompson12, @jzakirov, @Mlightsnow, and @shad0wca7.
- Providers/OpenRouter: strip trailing assistant prefill turns from verified OpenRouter Anthropic model requests when reasoning is enabled, so Claude 4.6 routes no longer fail with Anthropic's prefill rejection through the OpenAI-compatible adapter. Fixes #75395. Thanks @sbmilburn.
- Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk.
- Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner.
- Web search/Exa: accept `plugins.entries.exa.config.webSearch.baseUrl`, normalize it to the Exa `/search` endpoint, and partition cached results by endpoint. Fixes #54928 and supersedes #54939. Thanks @mrpl327 and @lyfuci.

View File

@@ -159,6 +159,13 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers
better prompt-cache reuse on system/developer prompt blocks.
</Accordion>
<Accordion title="Anthropic reasoning prefill">
On verified OpenRouter routes, Anthropic model refs with reasoning enabled
drop trailing assistant prefill turns before the request reaches OpenRouter,
matching Anthropic's requirement that reasoning conversations end with a user
turn.
</Accordion>
<Accordion title="Thinking / reasoning injection">
On supported non-`auto` routes, OpenClaw maps the selected thinking level to
OpenRouter proxy reasoning payloads. Unsupported model hints and

View File

@@ -177,6 +177,12 @@ inter-session user turns that only have provenance metadata.
- Thought signature cleanup: strip non-base64 `thought_signature` values (keep base64).
**OpenRouter Anthropic**
- Trailing assistant prefill turns are stripped from verified OpenRouter
OpenAI-compatible Anthropic model payloads when reasoning is enabled, matching
direct Anthropic and Cloudflare Anthropic replay behavior.
**Everything else**
- Image sanitization only.

View File

@@ -218,4 +218,111 @@ describe("openrouter provider hooks", () => {
expect(capturedPayload).toEqual({});
expect(baseStreamFn).toHaveBeenCalledOnce();
});
it("strips OpenRouter-routed Anthropic assistant prefill when reasoning is enabled", 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: "Return JSON." },
{ role: "assistant", content: "{" },
],
};
void args[2]?.onPayload?.(payload, args[0]);
capturedPayload = payload;
return { async *[Symbol.asyncIterator]() {} } as never;
},
);
const wrapped = provider.wrapStreamFn?.({
provider: "openrouter",
modelId: "anthropic/claude-opus-4.6",
streamFn: baseStreamFn,
thinkingLevel: "high",
} as never);
void wrapped?.(
{
provider: "openrouter",
api: "openai-completions",
id: "anthropic/claude-opus-4.6",
baseUrl: "https://openrouter.ai/api/v1",
compat: {},
} as never,
{ messages: [] } as never,
{},
);
expect(capturedPayload).toMatchObject({
messages: [{ role: "user", content: "Return JSON." }],
reasoning: { effort: "high" },
});
expect(baseStreamFn).toHaveBeenCalledOnce();
});
it("keeps OpenRouter Anthropic prefill when reasoning is disabled or the route is custom", 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: "user", content: "Return JSON." },
{ role: "assistant", content: "{" },
],
};
void args[2]?.onPayload?.(payload, args[0]);
payloads.push(payload);
return { async *[Symbol.asyncIterator]() {} } as never;
},
);
const disabled = provider.wrapStreamFn?.({
provider: "openrouter",
modelId: "anthropic/claude-opus-4.6",
streamFn: baseStreamFn,
thinkingLevel: "off",
} as never);
void disabled?.(
{
provider: "openrouter",
api: "openai-completions",
id: "anthropic/claude-opus-4.6",
baseUrl: "https://openrouter.ai/api/v1",
compat: {},
} as never,
{ messages: [] } as never,
{},
);
const customRoute = provider.wrapStreamFn?.({
provider: "openrouter",
modelId: "anthropic/claude-opus-4.6",
streamFn: baseStreamFn,
thinkingLevel: "high",
} as never);
void customRoute?.(
{
provider: "openrouter",
api: "openai-completions",
id: "anthropic/claude-opus-4.6",
baseUrl: "https://proxy.example.com/v1",
compat: {},
} as never,
{ messages: [] } as never,
{},
);
expect(payloads).toHaveLength(2);
expect(payloads[0]?.messages).toHaveLength(2);
expect(payloads[0]).not.toHaveProperty("reasoning");
expect(payloads[1]?.messages).toHaveLength(2);
expect(payloads[1]).toMatchObject({ reasoning: { effort: "high" } });
});
});

View File

@@ -1,7 +1,65 @@
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 { isOpenRouterProxyReasoningUnsupportedModel } from "./provider-catalog.js";
import {
createPayloadPatchStreamWrapper,
stripTrailingAssistantPrefillMessages,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
isOpenRouterProxyReasoningUnsupportedModel,
normalizeOpenRouterBaseUrl,
OPENROUTER_BASE_URL,
} from "./provider-catalog.js";
const log = createSubsystemLogger("openrouter-stream");
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() : undefined;
}
function isOpenRouterAnthropicModelId(modelId: unknown): boolean {
const normalized = readString(modelId)?.toLowerCase();
return (
normalized?.startsWith("anthropic/") === true ||
normalized?.startsWith("openrouter/anthropic/") === true
);
}
function isVerifiedOpenRouterRoute(model: Parameters<StreamFn>[0]): boolean {
const provider = readString(model.provider)?.toLowerCase();
const baseUrl = readString(model.baseUrl);
if (baseUrl) {
return normalizeOpenRouterBaseUrl(baseUrl) === OPENROUTER_BASE_URL;
}
return provider === "openrouter";
}
function shouldPatchAnthropicOpenRouterPayload(model: Parameters<StreamFn>[0]): boolean {
const api = readString(model.api);
return (
(api === undefined || api === "openai-completions") &&
isOpenRouterAnthropicModelId(model.id) &&
isVerifiedOpenRouterRoute(model)
);
}
function isEnabledReasoningValue(value: unknown): boolean {
if (value === undefined || value === null || value === false) {
return false;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized !== "" && normalized !== "off" && normalized !== "none";
}
return true;
}
function isOpenRouterReasoningPayloadEnabled(payload: Record<string, unknown>): boolean {
return (
isEnabledReasoningValue(payload.reasoning) || isEnabledReasoningValue(payload.reasoning_effort)
);
}
function injectOpenRouterRouting(
baseStreamFn: StreamFn | undefined,
@@ -28,6 +86,26 @@ function injectOpenRouterRouting(
);
}
function createOpenRouterAnthropicPrefillWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
return createPayloadPatchStreamWrapper(
baseStreamFn,
({ payload }) => {
if (!isOpenRouterReasoningPayloadEnabled(payload)) {
return;
}
const stripped = stripTrailingAssistantPrefillMessages(payload);
if (stripped > 0) {
log.warn(
`removed ${stripped} trailing assistant prefill message${stripped === 1 ? "" : "s"} because OpenRouter-routed Anthropic reasoning requires conversations to end with a user turn`,
);
}
},
{
shouldPatch: ({ model }) => shouldPatchAnthropicOpenRouterPayload(model),
},
);
}
export function wrapOpenRouterProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | null | undefined {
@@ -40,15 +118,22 @@ export function wrapOpenRouterProviderStream(
: ctx.streamFn;
const wrapStreamFn = OPENROUTER_THINKING_STREAM_HOOKS.wrapStreamFn ?? undefined;
if (!wrapStreamFn) {
return routedStreamFn;
return createOpenRouterAnthropicPrefillWrapper(routedStreamFn);
}
return (
const wrappedStreamFn =
wrapStreamFn({
...ctx,
streamFn: routedStreamFn,
thinkingLevel: isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId)
? undefined
: ctx.thinkingLevel,
}) ?? undefined
);
}) ?? undefined;
return createOpenRouterAnthropicPrefillWrapper(wrappedStreamFn);
}
export const __testing = {
isOpenRouterAnthropicModelId,
isOpenRouterReasoningPayloadEnabled,
isVerifiedOpenRouterRoute,
shouldPatchAnthropicOpenRouterPayload,
};

View File

@@ -10,6 +10,7 @@ import {
decodeHtmlEntitiesInObject,
hasCopilotVisionInput,
isOpenAICompatibleThinkingEnabled,
stripTrailingAssistantPrefillMessages,
stripTrailingAnthropicAssistantPrefillWhenThinking,
} from "./provider-stream-shared.js";
@@ -301,6 +302,18 @@ describe("createPayloadPatchStreamWrapper", () => {
});
describe("stripTrailingAnthropicAssistantPrefillWhenThinking", () => {
it("exposes unconditional assistant prefill stripping for proxy reasoning wrappers", () => {
const payload = {
messages: [
{ role: "user", content: "Return JSON." },
{ role: "assistant", content: "{" },
],
};
expect(stripTrailingAssistantPrefillMessages(payload)).toBe(1);
expect(payload.messages).toEqual([{ role: "user", content: "Return JSON." }]);
});
it("removes trailing assistant text turns when Anthropic thinking is enabled", () => {
const payload = {
thinking: { type: "enabled", budget_tokens: 1024 },

View File

@@ -179,10 +179,8 @@ function assistantMessageHasAnthropicToolUse(message: Record<string, unknown>):
);
}
export function stripTrailingAnthropicAssistantPrefillWhenThinking(
payload: Record<string, unknown>,
): number {
if (!isAnthropicThinkingEnabled(payload) || !Array.isArray(payload.messages)) {
export function stripTrailingAssistantPrefillMessages(payload: Record<string, unknown>): number {
if (!Array.isArray(payload.messages)) {
return 0;
}
@@ -204,6 +202,15 @@ export function stripTrailingAnthropicAssistantPrefillWhenThinking(
return stripped;
}
export function stripTrailingAnthropicAssistantPrefillWhenThinking(
payload: Record<string, unknown>,
): number {
if (!isAnthropicThinkingEnabled(payload)) {
return 0;
}
return stripTrailingAssistantPrefillMessages(payload);
}
export function createAnthropicThinkingPrefillPayloadWrapper(
baseStreamFn: StreamFn | undefined,
onStripped?: (stripped: number) => void,