diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ef5179e3c..31f8f28721f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/anthropic-transport-stream.test.ts b/src/agents/anthropic-transport-stream.test.ts index d6cc77f4b95..5fd7e259209 100644 --- a/src/agents/anthropic-transport-stream.test.ts +++ b/src/agents/anthropic-transport-stream.test.ts @@ -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; @@ -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 () => { diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index d07d8a1da27..115b6626a31 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -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) { + 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,