From d2e06fc9d08eaa0b029b64ca93f6ae855d6bd5ac Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 24 Jun 2026 17:21:25 +0000 Subject: [PATCH] fix(ai/anthropic): honored caller-supplied Authorization/X-Api-Key for custom proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildAnthropicHeaders` filtered `Authorization` and `X-Api-Key` out of `model.headers` (and `ANTHROPIC_CUSTOM_HEADERS`) via `enforcedHeaderKeys`, then re-attached the auto-built `Bearer ` / `X-Api-Key: `, so custom-proxy users could never override anthropic-messages auth — unlike `openai-responses` (`headers.Authorization ??= Bearer apiKey`) and the GitHub Copilot anthropic branch (`mergeHeaders(..., model.headers, ...)`). Non-OAuth, non-Cloudflare branches now honor the caller's `Authorization` / `X-Api-Key`; case-insensitive capture keeps the spread free of duplicate keys. OAuth still forces `Authorization: Bearer ` (the OAuth credential itself) and Cloudflare AI Gateway still owns `cf-aig-authorization` — both drop + log caller overrides. `buildAnthropicClientOptions` no longer requires the surviving `Authorization` to start with `Bearer ` before suppressing the client-level `X-Api-Key`; any custom Authorization on a non-official endpoint suppresses it so the proxy gets a single credential. Fixes #3391 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/anthropic.ts | 47 ++++++++--- packages/ai/test/anthropic-alignment.test.ts | 83 ++++++++++++++++++++ 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 34c31852e..13e7c65eb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 f4efa80f7..a87fb0d9c 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -498,6 +498,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({