fix(agents): suppress Anthropic beta headers for custom endpoints

This commit is contained in:
Peter Steinberger
2026-04-28 09:20:49 +01:00
parent 2a1e47ffcb
commit 09ec5d2c4d
3 changed files with 126 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Anthropic: send implicit Anthropic beta headers only to direct public Anthropic endpoints, including OAuth, so custom Anthropic-compatible providers no longer mis-handle unsupported beta flags unless explicitly configured. Refs #73346. Thanks @byBrodowski.
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock, including dist-runtime canonical roots, so Docker Desktop/WSL cold starts no longer hold `.openclaw-runtime-mirror.lock` while scanning slow persisted volumes. Fixes #73339. Thanks @1yihui.
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.

View File

@@ -62,10 +62,16 @@ function latestAnthropicRequest() {
};
}
function latestAnthropicRequestHeaders() {
return new Headers(latestAnthropicRequest().init?.headers);
}
function makeAnthropicTransportModel(
params: {
id?: string;
name?: string;
provider?: string;
baseUrl?: string;
reasoning?: boolean;
maxTokens?: number;
headers?: Record<string, string>;
@@ -77,8 +83,8 @@ function makeAnthropicTransportModel(
id: params.id ?? "claude-sonnet-4-6",
name: params.name ?? "Claude Sonnet 4.6",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
provider: params.provider ?? "anthropic",
baseUrl: params.baseUrl ?? "https://api.anthropic.com",
reasoning: params.reasoning ?? true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -159,6 +165,97 @@ describe("anthropic transport stream", () => {
model: "claude-sonnet-4-6",
stream: true,
});
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBe(
"fine-grained-tool-streaming-2025-05-14",
);
});
it("does not add implicit Anthropic beta headers for custom compatible API-key endpoints", async () => {
const model = makeAnthropicTransportModel({
provider: "anthropic",
baseUrl: "https://custom-proxy.example",
});
await runTransportStream(
model,
{
messages: [{ role: "user", content: "hello" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-api",
} as AnthropicStreamOptions,
);
expect(guardedFetchMock).toHaveBeenCalledWith(
"https://custom-proxy.example/v1/messages",
expect.objectContaining({ method: "POST" }),
);
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBeNull();
});
it("does not add implicit Anthropic beta headers for custom compatible OAuth endpoints", async () => {
await runTransportStream(
makeAnthropicTransportModel({
provider: "anthropic",
baseUrl: "https://custom-proxy.example",
}),
{
messages: [{ role: "user", content: "hello" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-oat-token",
} as AnthropicStreamOptions,
);
const headers = latestAnthropicRequestHeaders();
expect(headers.get("authorization")).toBe("Bearer sk-ant-oat-token");
expect(headers.get("anthropic-beta")).toBeNull();
});
it("keeps Anthropic beta headers for direct Anthropic OAuth endpoints", async () => {
await runTransportStream(
makeAnthropicTransportModel(),
{
messages: [{ role: "user", content: "hello" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-oat-token",
} as AnthropicStreamOptions,
);
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBe(
"claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
);
});
it("recognizes schemeless api.anthropic.com base URLs as direct Anthropic", async () => {
await runTransportStream(
makeAnthropicTransportModel({ baseUrl: "api.anthropic.com" }),
{
messages: [{ role: "user", content: "hello" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-api",
} as AnthropicStreamOptions,
);
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBe(
"fine-grained-tool-streaming-2025-05-14",
);
});
it("does not add implicit Anthropic beta headers for foreign hosts mentioning api.anthropic.com", async () => {
await runTransportStream(
makeAnthropicTransportModel({ baseUrl: "https://attacker.example/api.anthropic.com" }),
{
messages: [{ role: "user", content: "hello" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-api",
} as AnthropicStreamOptions,
);
expect(latestAnthropicRequestHeaders().get("anthropic-beta")).toBeNull();
});
it("ignores non-positive runtime maxTokens overrides and falls back to the model limit", async () => {

View File

@@ -15,6 +15,7 @@ import {
resolveAnthropicPayloadPolicy,
} from "./anthropic-payload-policy.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
import { transformTransportMessages } from "./transport-message-transform.js";
import {
@@ -191,6 +192,27 @@ function isAnthropicOAuthToken(apiKey: string): boolean {
return apiKey.includes("sk-ant-oat");
}
function isDirectAnthropicModel(model: Pick<AnthropicTransportModel, "provider" | "baseUrl">) {
if (normalizeLowercaseStringOrEmpty(model.provider) !== "anthropic") {
return false;
}
const endpointClass = resolveProviderEndpoint(model.baseUrl).endpointClass;
return endpointClass === "default" || endpointClass === "anthropic-public";
}
function buildAnthropicBetaHeader(
model: AnthropicTransportModel,
betaFeatures: readonly string[],
params: { oauth: boolean },
): string | undefined {
if (!isDirectAnthropicModel(model)) {
return undefined;
}
return params.oauth
? `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`
: betaFeatures.join(",");
}
function toClaudeCodeName(name: string): string {
return CLAUDE_CODE_TOOL_LOOKUP.get(normalizeLowercaseStringOrEmpty(name)) ?? name;
}
@@ -638,6 +660,7 @@ function createAnthropicTransportClient(params: {
betaFeatures.push("interleaved-thinking-2025-05-14");
}
if (isAnthropicOAuthToken(apiKey)) {
const betaHeader = buildAnthropicBetaHeader(model, betaFeatures, { oauth: true });
return {
client: createAnthropicMessagesClient({
apiKey: null,
@@ -647,7 +670,7 @@ function createAnthropicTransportClient(params: {
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`,
...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
"user-agent": `claude-cli/${CLAUDE_CODE_VERSION}`,
"x-app": "cli",
},
@@ -659,6 +682,7 @@ function createAnthropicTransportClient(params: {
isOAuthToken: true,
};
}
const betaHeader = buildAnthropicBetaHeader(model, betaFeatures, { oauth: false });
return {
client: createAnthropicMessagesClient({
apiKey,
@@ -667,7 +691,7 @@ function createAnthropicTransportClient(params: {
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"anthropic-beta": betaFeatures.join(","),
...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
},
model.headers,
options?.headers,