mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
fix(openrouter): strip Anthropic reasoning prefill
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user