Merge PR #5890: fix(ai): allow custom OAuth fingerprint header overrides (@roboomp)

This commit is contained in:
can1357
2026-07-18 19:57:45 +02:00
9 changed files with 97 additions and 14 deletions
+4
View File
@@ -35,6 +35,10 @@
- Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
- Fixed Anthropic usage reports treating the organization response header as the account identity, which caused the 5h/7d status-line segment to disappear for OAuth credentials without stored organization metadata. ([#5698](https://github.com/can1357/oh-my-pi/issues/5698))
### Fixed
- Fixed custom OAuth Anthropic-compatible endpoints with explicit header overrides still receiving generated Claude Code fingerprint headers instead. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
## [17.0.2] - 2026-07-17
### Fixed
+35 -13
View File
@@ -100,6 +100,8 @@ export type AnthropicHeaderOptions = {
isCloudflareAiGateway?: boolean;
claudeCodeSessionId?: string;
claudeCodeBetas?: readonly string[];
/** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */
allowAnthropicHeaderOverrides?: boolean;
};
export function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined {
@@ -226,22 +228,36 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
const acceptHeader = oauthToken ? "application/json" : stream ? "text/event-stream" : "application/json";
const isCloudflare = options.isCloudflareAiGateway ?? false;
const honorAuthorization = !oauthToken && !isCloudflare;
const allowAnthropicHeaderOverrides =
oauthToken &&
options.allowAnthropicHeaderOverrides === true &&
!isCloudflare &&
!isOfficialAnthropicApiUrl(options.baseUrl);
const honorApiKey = !isCloudflare;
const modelHeaders: Record<string, string> = {};
const anthropicHeaderOverrides: 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 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;
const headerSource = options.modelHeaders;
if (headerSource) {
for (const key in headerSource) {
const value = headerSource[key];
const lowerKey = key.toLowerCase();
if (enforcedHeaderKeys.has(lowerKey)) {
if (allowAnthropicHeaderOverrides && overridableAnthropicHeaderKeys.has(lowerKey)) {
anthropicHeaderOverrides[key] = value;
continue;
}
// 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;
}
modelHeaders[key] = value;
}
if (filteredEnforcedKeys.length > 0) {
// Caller/env-supplied values (options.headers, ANTHROPIC_CUSTOM_HEADERS)
@@ -267,7 +283,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
const userAgent = isClaudeCodeClientUserAgent(incomingUserAgent)
? incomingUserAgent
: `claude-cli/${claudeCodeVersion} (external, local-agent, agent-sdk/${claudeAgentSdkVersion})`;
return {
const headers = {
...modelHeaders,
...claudeCodeHeaders,
Accept: acceptHeader,
@@ -279,6 +295,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
"User-Agent": userAgent,
...(incomingApiKey ? { "X-Api-Key": incomingApiKey } : {}),
};
return allowAnthropicHeaderOverrides ? mergeHeaders(headers, anthropicHeaderOverrides) : headers;
} else if (!isOfficialAnthropicApiUrl(options.baseUrl)) {
return {
...modelHeaders,
@@ -521,6 +538,10 @@ const enforcedHeaderKeys = new Set(
].map(key => key.toLowerCase()),
);
const overridableAnthropicHeaderKeys = new Set(
[...Object.keys(claudeCodeHeaders), "anthropic-beta", "User-Agent", "x-app"].map(key => key.toLowerCase()),
);
const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
function createClaudeBillingHeader(firstUserMessageText: string): string {
@@ -2811,6 +2832,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
dynamicHeaders,
),
isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
allowAnthropicHeaderOverrides: model.compat.allowAnthropicHeaderOverrides,
claudeCodeSessionId,
claudeCodeBetas: oauthToken
? buildClaudeCodeBetas(
@@ -677,6 +677,48 @@ describe("Anthropic request fingerprint alignment", () => {
expect(headers.Authorization).toBe("Bearer sk-ant-oat-test");
});
it("honors opted-in OAuth fingerprint headers on non-official endpoints (#5888)", () => {
const options = buildAnthropicClientOptions({
model: buildModel({
...ANTHROPIC_MODEL_SPEC,
provider: "custom-anthropic",
baseUrl: "https://proxy.example.com/anthropic",
headers: {
"anthropic-beta": "custom-beta-token",
"x-app": "custom-app-token",
"X-Stainless-Runtime-Version": "custom-runtime-token",
Authorization: "should-not-leak",
},
compat: { allowAnthropicHeaderOverrides: true },
}),
apiKey: "sk-ant-oat-test",
stream: true,
});
expect(options.defaultHeaders["anthropic-beta"]).toBe("custom-beta-token");
expect(options.defaultHeaders["x-app"]).toBe("custom-app-token");
expect(options.defaultHeaders["X-Stainless-Runtime-Version"]).toBe("custom-runtime-token");
expect(options.defaultHeaders.Authorization).toBe("Bearer sk-ant-oat-test");
});
it("keeps OAuth fingerprint defaults on official endpoints despite the compat opt-in", () => {
const headers = buildAnthropicHeaders({
apiKey: "sk-ant-oat-test",
baseUrl: "https://api.anthropic.com",
isOAuth: true,
allowAnthropicHeaderOverrides: true,
modelHeaders: {
"anthropic-beta": "custom-beta-token",
"x-app": "custom-app-token",
"X-Stainless-Runtime-Version": "custom-runtime-token",
},
});
expect(headers["anthropic-beta"]).not.toBe("custom-beta-token");
expect(headers["x-app"]).toBe("cli");
expect(headers["X-Stainless-Runtime-Version"]).toBe("v24.3.0");
});
it("suppresses the client-level X-Api-Key when model.headers carries a custom Authorization (#3391)", () => {
const options = buildAnthropicClientOptions({
model: buildModel({
+3
View File
@@ -19,6 +19,9 @@
- Logged LiteLLM rich-metadata endpoint failures once with their endpoint and status before falling back to incomplete `/v1/models` data ([#5801](https://github.com/can1357/oh-my-pi/issues/5801)).
- Fixed authenticated Kimi Code discovery to preserve live effort levels, default effort, mandatory-thinking state, and per-model protocol metadata ([#5893](https://github.com/can1357/oh-my-pi/issues/5893)).
- Fixed LiteLLM provider ignoring per-model pricing: `mapLiteLLMRichEntry` now reads `input_cost_per_token` / `output_cost_per_token` (plus cache costs) from LiteLLM rich metadata and maps them to `cost.input` / `cost.output`, falling back to the bundled reference only when LiteLLM omits cost, so proxied models no longer display as free ([#5818](https://github.com/can1357/oh-my-pi/issues/5818)).
### Added
- Added an Anthropic compatibility flag for opting non-official OAuth endpoints into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
## [17.0.2] - 2026-07-17
+1
View File
@@ -109,6 +109,7 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res
signingEndpoint,
disableStrictTools: isAzure,
disableAdaptiveThinking: false,
allowAnthropicHeaderOverrides: false,
supportsEagerToolInputStreaming: official,
// Long cache retention is only sent to the official API by default;
// proxies opt in explicitly via `compat.supportsLongCacheRetention: true`.
+5
View File
@@ -418,6 +418,11 @@ export interface AnthropicCompat {
* auto-detected (Z.AI hosts).
*/
requiresToolResultId?: boolean;
/**
* Allow configured Claude Code fingerprint headers to replace generated
* OAuth defaults on non-official Anthropic endpoints.
*/
allowAnthropicHeaderOverrides?: boolean;
/**
* Replay unsigned `thinking` blocks from prior assistant turns as native
* thinking instead of demoting them to text. Official Anthropic enforces
+1
View File
@@ -84,6 +84,7 @@
- Removed the unreliable Bing and Yahoo HTML-scraping web search providers
- Fixed the Cursor-backed advisor losing entire turns when it selected server-native tools (`bash`, `grep`, etc.) outside its grant: exec-resolved native blocks are already rejected in-band by the advisor-scoped bridge, so they no longer trip the unavailable-tool quarantine and discard the `advise` emitted in the same turn ([#5900](https://github.com/can1357/oh-my-pi/issues/5900)).
- Fixed custom `anthropic-messages` OAuth providers being unable to opt into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
## [17.0.2] - 2026-07-17
@@ -61,6 +61,7 @@ const OpenAICompatFields = {
"supportsImageDetailOriginal?": "boolean",
// anthropic-messages compat flags (same `compat` slot, per-api interpretation)
"supportsEagerToolInputStreaming?": "boolean",
"allowAnthropicHeaderOverrides?": "boolean",
"requiresToolResultId?": "boolean",
"replayUnsignedThinking?": "boolean",
} as const;
@@ -584,6 +584,7 @@ describe("ModelRegistry", () => {
api: "anthropic-messages",
compat: {
supportsEagerToolInputStreaming: true,
allowAnthropicHeaderOverrides: true,
},
models: [
{
@@ -685,7 +686,10 @@ describe("ModelRegistry", () => {
test("custom Anthropic providers can opt into eager tool input streaming", () => {
const model = customAnthropicCompat.find("anthropic-proxy", "claude-haiku-4.5");
expect(model?.compat).toMatchObject({ supportsEagerToolInputStreaming: true });
expect(model?.compat).toMatchObject({
supportsEagerToolInputStreaming: true,
allowAnthropicHeaderOverrides: true,
});
});
test("custom Responses providers can disable original image detail", () => {