Merge remote-tracking branch 'origin/farm/f9a71625/anthropic-headers-override'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -193,10 +193,18 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
const oauthToken = options.isOAuth ?? isAnthropicOAuthToken(options.apiKey);
|
||||
const extraBetas = options.extraBetas ?? [];
|
||||
const stream = options.stream ?? false;
|
||||
// `enforcedHeaderKeys` strips User-Agent out of modelHeaders so a spread can't
|
||||
// produce case-duplicate keys; re-add the caller's value explicitly per branch
|
||||
// (OAuth replaces non-claude-cli values, the other branches forward verbatim).
|
||||
// `enforcedHeaderKeys` strips User-Agent / X-Api-Key / Authorization out of
|
||||
// modelHeaders so a case-insensitive spread can't produce duplicate keys; each
|
||||
// branch re-adds the caller's value explicitly. User-Agent and X-Api-Key are
|
||||
// always honored (with branch-specific defaults filling in when absent), while
|
||||
// Authorization is honored for every non-OAuth, non-Cloudflare-gateway branch —
|
||||
// OAuth requests MUST carry `Authorization: Bearer <oauth-token>` (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<s
|
||||
extraBetas,
|
||||
);
|
||||
const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
|
||||
const isCloudflare = options.isCloudflareAiGateway ?? false;
|
||||
const honorAuthorization = !oauthToken && !isCloudflare;
|
||||
const honorApiKey = !isCloudflare;
|
||||
const modelHeaders: Record<string, string> = {};
|
||||
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<s
|
||||
});
|
||||
}
|
||||
|
||||
if (options.isCloudflareAiGateway) {
|
||||
if (isCloudflare) {
|
||||
return {
|
||||
...modelHeaders,
|
||||
Accept: acceptHeader,
|
||||
@@ -251,15 +266,17 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
...(options.claudeCodeSessionId ? { "X-Claude-Code-Session-Id": options.claudeCodeSessionId } : {}),
|
||||
"x-client-request-id": nodeCrypto.randomUUID(),
|
||||
"User-Agent": userAgent,
|
||||
...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
|
||||
};
|
||||
} else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
|
||||
return {
|
||||
...modelHeaders,
|
||||
Accept: acceptHeader,
|
||||
Authorization: `Bearer ${options.apiKey}`,
|
||||
Authorization: incomingAuthorization ?? `Bearer ${options.apiKey}`,
|
||||
...sharedHeaders,
|
||||
...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
|
||||
...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
|
||||
...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -268,7 +285,8 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
|
||||
...sharedHeaders,
|
||||
...(incomingUserAgent ? { "User-Agent": incomingUserAgent } : {}),
|
||||
...(betaHeader ? { "anthropic-beta": betaHeader } : {}),
|
||||
"X-Api-Key": options.apiKey,
|
||||
...(incomingAuthorization ? { Authorization: incomingAuthorization } : {}),
|
||||
"X-Api-Key": incomingApiKey ?? options.apiKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2547,12 +2565,15 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
||||
};
|
||||
}
|
||||
|
||||
// Suppress the client-level `X-Api-Key` whenever an `Authorization` header
|
||||
// already sits in `defaultHeaders` for a non-official, non-OAuth endpoint —
|
||||
// either our auto-built `Bearer <apiKey>` 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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user