diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a66049186..ee102cb07 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,10 @@ - Fixed Anthropic rate-limit header usage cache entries retaining legacy missing account metadata after refresh. - Fixed Anthropic-compatible budget-effort models dropping the selected effort before request serialization, so `output_config.effort` is emitted alongside `thinking.budget_tokens` when model metadata declares `mode: "anthropic-budget-effort"`. +### Fixed + +- Fixed `anthropic-messages` silently dropping caller-supplied `Authorization` / `X-Api-Key` from `model.headers` and `ANTHROPIC_CUSTOM_HEADERS`, blocking custom proxy auth schemes. Non-OAuth requests now honor the caller's value (matching `openai-responses`); the lower-level client also suppresses its `X-Api-Key` add when a custom `Authorization` is supplied for a non-official endpoint so the proxy receives a single credential. OAuth bearer + Cloudflare AI Gateway keep their pre-existing enforced auth headers. ([#3391](https://github.com/can1357/oh-my-pi/issues/3391)) + ## [16.1.16] - 2026-06-23 ### Fixed diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ab5974f3a..5a77dace0 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -193,10 +193,18 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record` (the OAuth + // credential itself) and Cloudflare AI Gateway authenticates via + // `cf-aig-authorization`, so user-supplied auth there would just leak. Both of + // those cases drop + log the caller value (#3391). const incomingUserAgent = getHeaderCaseInsensitive(options.modelHeaders, "User-Agent"); + const incomingAuthorization = getHeaderCaseInsensitive(options.modelHeaders, "Authorization"); + const incomingApiKey = getHeaderCaseInsensitive(options.modelHeaders, "X-Api-Key"); // Claude Code betas (oauth-2025-04-20, claude-code-20250219, …) are part of // the OAuth fingerprint; API-key requests default to extras only, matching // the streaming path (buildAnthropicClientOptions passes [] for non-OAuth). @@ -205,14 +213,21 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record = {}; const filteredEnforcedKeys: string[] = []; for (const [key, value] of Object.entries(options.modelHeaders ?? {})) { const lowerKey = key.toLowerCase(); if (enforcedHeaderKeys.has(lowerKey)) { - // User-Agent is filtered only to dedup the spread; every branch re-adds - // the caller's value explicitly, so it is not "ignored". - if (lowerKey !== "user-agent") filteredEnforcedKeys.push(key); + // user-agent is always re-applied explicitly. authorization / x-api-key + // are silently re-applied in honoring branches and dropped + logged + // where the branch enforces its own credential. + if (lowerKey === "user-agent") continue; + if (lowerKey === "authorization" && honorAuthorization) continue; + if (lowerKey === "x-api-key" && honorApiKey) continue; + filteredEnforcedKeys.push(key); continue; } modelHeaders[key] = value; @@ -226,7 +241,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record` or a caller-supplied custom auth + // scheme via `model.headers` (#3391). Adding a bonus `X-Api-Key` would force + // the proxy to deal with two competing credentials when the user explicitly + // asked for one. const authorizationHeader = getHeaderCaseInsensitive(defaultHeaders, "Authorization"); const shouldSuppressClientApiKey = - !oauthToken && - !model.compat.officialEndpoint && - typeof authorizationHeader === "string" && - /^Bearer\s+/i.test(authorizationHeader); + !oauthToken && !model.compat.officialEndpoint && typeof authorizationHeader === "string"; return { isOAuthToken: oauthToken, diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 0b9080953..5986b24d6 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -513,6 +513,89 @@ describe("Anthropic request fingerprint alignment", () => { expect(headers["X-Api-Key"]).toBeUndefined(); }); + it("honors caller-supplied Authorization on non-official Anthropic endpoints (#3391)", () => { + const headers = buildAnthropicHeaders({ + apiKey: "sk-ant-api-test", + baseUrl: "https://proxy.example.com/anthropic", + stream: true, + modelHeaders: { Authorization: "secret-proxy-key" }, + }); + + expect(headers.Authorization).toBe("secret-proxy-key"); + expect(headers["X-Api-Key"]).toBeUndefined(); + }); + + it("honors lowercase `authorization` from caller without duplicating the header key", () => { + const headers = buildAnthropicHeaders({ + apiKey: "sk-ant-api-test", + baseUrl: "https://proxy.example.com/anthropic", + stream: true, + modelHeaders: { authorization: "secret-proxy-key" }, + }); + + const authKeys = Object.keys(headers).filter(key => key.toLowerCase() === "authorization"); + expect(authKeys).toHaveLength(1); + expect(headers[authKeys[0]]).toBe("secret-proxy-key"); + }); + + it("honors caller-supplied X-Api-Key on official Anthropic endpoints", () => { + const headers = buildAnthropicHeaders({ + apiKey: "sk-ant-api-test", + baseUrl: "https://api.anthropic.com", + stream: true, + modelHeaders: { "X-Api-Key": "custom-api-key" }, + }); + + expect(headers["X-Api-Key"]).toBe("custom-api-key"); + expect(headers.Authorization).toBeUndefined(); + }); + + it("honors caller-supplied Authorization alongside X-Api-Key on official endpoints", () => { + const headers = buildAnthropicHeaders({ + apiKey: "sk-ant-api-test", + baseUrl: "https://api.anthropic.com", + stream: true, + modelHeaders: { Authorization: "Token tok-abc" }, + }); + + // Official endpoint keeps X-Api-Key as the primary credential but also + // forwards the caller's custom Authorization so a fronting proxy/gateway + // can read it. + expect(headers.Authorization).toBe("Token tok-abc"); + expect(headers["X-Api-Key"]).toBe("sk-ant-api-test"); + }); + + it("forces OAuth bearer auth and ignores caller-supplied Authorization under OAuth", () => { + const headers = buildAnthropicHeaders({ + apiKey: "sk-ant-oat-test", + isOAuth: true, + stream: true, + modelHeaders: { Authorization: "should-not-leak" }, + }); + + expect(headers.Authorization).toBe("Bearer sk-ant-oat-test"); + }); + + it("suppresses the client-level X-Api-Key when model.headers carries a custom Authorization (#3391)", () => { + const options = buildAnthropicClientOptions({ + model: buildModel({ + ...ANTHROPIC_MODEL_SPEC, + id: "proxy-claude", + name: "Proxy Claude", + baseUrl: "https://proxy.example.com/anthropic", + headers: { Authorization: "secret-proxy-key" }, + }), + apiKey: "sk-ant-api-test", + stream: true, + }); + + // `apiKey: null` keeps the lower-level client from adding an `X-Api-Key` + // header alongside the caller-supplied custom Authorization. + expect(options.apiKey).toBeNull(); + expect(options.defaultHeaders.Authorization).toBe("secret-proxy-key"); + expect(options.defaultHeaders["X-Api-Key"]).toBeUndefined(); + }); + it("keeps Umans Anthropic-compatible requests on X-Api-Key auth", () => { const options = buildAnthropicClientOptions({ model: buildModel({