-
Notifications
You must be signed in to change notification settings - Fork 15
feat: custom headers and model aliases for API-key and proxy routes #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
61c4b4c
c30ead6
57ac6a5
19ecd41
fc46127
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { logger } from './logger.ts' | ||
|
|
||
| export const ANTHROPIC_CUSTOM_HEADERS_ENV = 'ANTHROPIC_CUSTOM_HEADERS' | ||
|
|
||
| type HeaderEntries = Array<[string, string]> | ||
|
|
||
| const parsedHeadersByRawValue = new Map<string, HeaderEntries | null>() | ||
| const warnedMalformedRawValues = new Set<string>() | ||
|
|
||
| export function parseCustomHeaders(raw: string | undefined): Headers { | ||
| if (!raw?.trim()) return new Headers() | ||
|
|
||
| const cached = parsedHeadersByRawValue.get(raw) | ||
| if (cached !== undefined || parsedHeadersByRawValue.has(raw)) { | ||
| return new Headers(cached ?? []) | ||
| } | ||
|
|
||
| try { | ||
| const headers = new Headers() | ||
| const trimmed = raw.trim() | ||
| if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { | ||
| for (const entry of trimmed | ||
| .split(/\r?\n|,(?=[^,\s:]+:)/) | ||
| .map((value) => value.trim()) | ||
| .filter(Boolean)) { | ||
| const separator = entry.indexOf(':') | ||
| if (separator <= 0) { | ||
| throw new TypeError( | ||
| `${ANTHROPIC_CUSTOM_HEADERS_ENV} entries must be "name: value"`, | ||
| ) | ||
| } | ||
| headers.set( | ||
| entry.slice(0, separator).trim(), | ||
| entry.slice(separator + 1).trim(), | ||
| ) | ||
| } | ||
| } else { | ||
| const parsed = JSON.parse(trimmed) as unknown | ||
| if ( | ||
| parsed == null || | ||
| typeof parsed !== 'object' || | ||
| Array.isArray(parsed) | ||
| ) { | ||
| throw new TypeError( | ||
| `${ANTHROPIC_CUSTOM_HEADERS_ENV} must be a JSON object`, | ||
| ) | ||
| } | ||
|
|
||
| for (const [key, value] of Object.entries(parsed)) { | ||
| if (value == null) continue | ||
| if (Array.isArray(value)) { | ||
| headers.set(key, value.map(String).join(', ')) | ||
| } else { | ||
| headers.set(key, String(value)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const entries = [...headers.entries()] as HeaderEntries | ||
| parsedHeadersByRawValue.set(raw, entries) | ||
| return new Headers(entries) | ||
| } catch (error) { | ||
| parsedHeadersByRawValue.set(raw, null) | ||
| if (!warnedMalformedRawValues.has(raw)) { | ||
| warnedMalformedRawValues.add(raw) | ||
| logger.warn( | ||
| 'custom-headers', | ||
| 'ignoring malformed ANTHROPIC_CUSTOM_HEADERS', | ||
| { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }, | ||
| ) | ||
| } | ||
| return new Headers() | ||
| } | ||
| } | ||
|
|
||
| export function applyCustomHeaders( | ||
| headers: Headers, | ||
| raw = process.env[ANTHROPIC_CUSTOM_HEADERS_ENV], | ||
| ): Headers { | ||
| const customHeaders = parseCustomHeaders(raw) | ||
| customHeaders.forEach((value, key) => { | ||
| headers.set(key, value) | ||
| }) | ||
| return headers | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,76 @@ | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Remap canonical Claude model IDs to proxy-compatible names | ||||||||||||||||||||||
| * using ANTHROPIC_DEFAULT_*_MODEL env vars. | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * LiteLLM/proxy backends often use shorter model aliases | ||||||||||||||||||||||
| * (e.g. `claude-sonnet-4-6` instead of `claude-sonnet-4-20250514`). | ||||||||||||||||||||||
| * These proxy-route variables follow the Claude Code convention, except | ||||||||||||||||||||||
| * ANTHROPIC_DEFAULT_FABLE_MODEL, which is plugin-specific: | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * ANTHROPIC_MODEL — default for any claude-* model | ||||||||||||||||||||||
| * ANTHROPIC_DEFAULT_SONNET_MODEL — models matching claude-sonnet-* | ||||||||||||||||||||||
| * ANTHROPIC_DEFAULT_OPUS_MODEL — models matching claude-opus-* | ||||||||||||||||||||||
| * ANTHROPIC_DEFAULT_HAIKU_MODEL — models matching claude-haiku-* | ||||||||||||||||||||||
| * ANTHROPIC_DEFAULT_FABLE_MODEL — models matching claude-fable / claude-mythos | ||||||||||||||||||||||
| * | ||||||||||||||||||||||
| * Tier-specific vars take precedence over the generic ANTHROPIC_MODEL. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function getEnv(name: string): string | undefined { | ||||||||||||||||||||||
| const value = process.env[name]?.trim() | ||||||||||||||||||||||
| return value || undefined | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type ModelTier = 'sonnet' | 'opus' | 'haiku' | 'fable' | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| function getModelTier(model: string): ModelTier | null { | ||||||||||||||||||||||
| if (model.startsWith('claude-sonnet')) return 'sonnet' | ||||||||||||||||||||||
| if (model.startsWith('claude-opus')) return 'opus' | ||||||||||||||||||||||
| if (model.startsWith('claude-haiku')) return 'haiku' | ||||||||||||||||||||||
| if (model.startsWith('claude-fable') || model.startsWith('claude-mythos')) | ||||||||||||||||||||||
| return 'fable' | ||||||||||||||||||||||
|
Comment on lines
+27
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a tier-specific variable is set, identifiers such as Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| return null | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const TIER_ENV_MAP: Record<ModelTier, string> = { | ||||||||||||||||||||||
| sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL', | ||||||||||||||||||||||
| opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL', | ||||||||||||||||||||||
| haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', | ||||||||||||||||||||||
| fable: 'ANTHROPIC_DEFAULT_FABLE_MODEL', | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Resolve a canonical model ID to its proxy-compatible alias. | ||||||||||||||||||||||
| * Returns the original model when no env override is configured. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function remapModelId(model: string): string { | ||||||||||||||||||||||
| if (typeof model !== 'string' || !model) return model | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const tier = getModelTier(model) | ||||||||||||||||||||||
| if (tier) { | ||||||||||||||||||||||
| const tierModel = getEnv(TIER_ENV_MAP[tier]) | ||||||||||||||||||||||
| if (tierModel) return tierModel | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Generic fallback for any claude-* model | ||||||||||||||||||||||
| if (model.startsWith('claude-')) { | ||||||||||||||||||||||
| const defaultModel = getEnv('ANTHROPIC_MODEL') | ||||||||||||||||||||||
| if (defaultModel) return defaultModel | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return model | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Remap the `model` field in a parsed request body in place. | ||||||||||||||||||||||
| * Returns true if the model was changed. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| export function remapRequestBodyModel( | ||||||||||||||||||||||
| parsed: Record<string, unknown>, | ||||||||||||||||||||||
| ): boolean { | ||||||||||||||||||||||
| if (typeof parsed.model !== 'string') return false | ||||||||||||||||||||||
| const remapped = remapModelId(parsed.model) | ||||||||||||||||||||||
| if (remapped === parsed.model) return false | ||||||||||||||||||||||
| parsed.model = remapped | ||||||||||||||||||||||
| return true | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { | |
| type ApiKeyAccount, | ||
| acquireRefreshFileLock, | ||
| addAccountPersistent, | ||
| applyCustomHeaders, | ||
| authorize, | ||
| buildAccountList, | ||
| buildClaudeQuotaSummary, | ||
|
|
@@ -150,6 +151,7 @@ import { | |
| quotaSnapshotPassesPolicy, | ||
| refreshBackoffActive, | ||
| refreshClaudeOAuthToken, | ||
| remapRequestBodyModel, | ||
| removeAccountPersistent, | ||
| reorderAccountsPersistent, | ||
| resolveClaudeCodeIdentity, | ||
|
|
@@ -5175,6 +5177,7 @@ const anthropicAuthPlugin = async ( | |
| headers.set('Authorization', `Bearer ${account.apiKey ?? ''}`) | ||
| } | ||
| headers.set('Content-Type', 'application/json') | ||
| applyCustomHeaders(headers) | ||
| } | ||
|
|
||
| async function sendWithApiAccount( | ||
|
|
@@ -5227,6 +5230,7 @@ const anthropicAuthPlugin = async ( | |
| sessionId: directAffinity || undefined, | ||
| midConversationEffortEnabled: false, | ||
| midConversationEffortPlan: effortPlanHeader, | ||
| modelRemapEnabled: true, | ||
| perf: (stage, data) => | ||
| trace?.mark(`rewrite_body_${stage}`, { route, ...data }), | ||
| }) | ||
|
|
@@ -6581,7 +6585,23 @@ const anthropicAuthPlugin = async ( | |
| hasAccess: Boolean(auth.access), | ||
| }) | ||
| if (auth.type !== 'oauth') { | ||
| const response = await fetch(input, init) | ||
| const rewritten = rewriteUrl(input) | ||
| const passthroughHeaders = mergeHeaders(input, init) | ||
| applyCustomHeaders(passthroughHeaders) | ||
| let passthroughBody = init?.body | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a non-OAuth caller supplies the payload on a Prompt for AI agents |
||
| if (typeof passthroughBody === 'string') { | ||
| try { | ||
| const parsed = JSON.parse(passthroughBody) | ||
| if (remapRequestBodyModel(parsed)) { | ||
| passthroughBody = JSON.stringify(parsed) | ||
| } | ||
| } catch {} | ||
| } | ||
| const response = await fetch(rewritten.input, { | ||
| ...init, | ||
| body: passthroughBody, | ||
| headers: passthroughHeaders, | ||
| }) | ||
| trace.done('non_oauth_passthrough', { status: response.status }) | ||
| return response | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,14 @@ | ||
| import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' | ||
| import { | ||
| __setLogTestSink, | ||
| applyClaudeCodeHeaders, | ||
| applyClaudeCodeMetadata, | ||
| applyCustomHeaders, | ||
| CLAUDE_CODE_FULL_AGENT_BETAS, | ||
| type ClaudeCodeIdentity, | ||
| getClaudeCodeIdentity, | ||
| orderClaudeCodeBody, | ||
| parseCustomHeaders, | ||
| REQUIRED_BETAS, | ||
| resetClaudeCodeIdentityCachesForTest, | ||
| resolveClaudeCodeIdentity, | ||
|
|
@@ -303,6 +306,109 @@ describe('Claude Code fingerprint helpers', () => { | |
| expect(compatibility.accountUuid).toBe('account-b') | ||
| }) | ||
|
|
||
| test('keeps Claude Code OAuth identity headers unchanged when custom headers are configured', () => { | ||
| const previous = process.env.ANTHROPIC_CUSTOM_HEADERS | ||
| const identity: ClaudeCodeIdentity = { | ||
| deviceId: 'a'.repeat(64), | ||
| accountUuid: '11111111-2222-4333-8444-555555555555', | ||
| sessionId: '66666666-7777-4888-9999-aaaaaaaaaaaa', | ||
| } | ||
| const body = { | ||
| model: 'claude-sonnet-4-6', | ||
| messages: [], | ||
| system: [], | ||
| tools: [], | ||
| } | ||
| const normalizedHeaders = (headers: Headers) => { | ||
| const entries = [...headers.entries()] | ||
| .filter(([key]) => key !== 'x-client-request-id') | ||
| .sort(([left], [right]) => left.localeCompare(right)) | ||
| return new Headers(entries) | ||
| } | ||
|
|
||
| delete process.env.ANTHROPIC_CUSTOM_HEADERS | ||
| const baseline = applyClaudeCodeHeaders(new Headers(), 'sk-ant-oat-test', { | ||
| body, | ||
| identity, | ||
| }) | ||
|
|
||
| process.env.ANTHROPIC_CUSTOM_HEADERS = JSON.stringify({ | ||
| authorization: 'Bearer user-controlled', | ||
| 'user-agent': 'Mozilla/5.0', | ||
| 'x-app': 'not-cli', | ||
| 'anthropic-beta': 'not-a-beta', | ||
| 'anthropic-version': '1999-01-01', | ||
| 'x-claude-code-session-id': '00000000-0000-4000-8000-000000000000', | ||
| 'x-api-key': 'user-controlled', | ||
| }) | ||
| try { | ||
| const headers = applyClaudeCodeHeaders(new Headers(), 'sk-ant-oat-test', { | ||
| body, | ||
| identity, | ||
| }) | ||
|
|
||
| expect([...normalizedHeaders(headers).entries()]).toEqual([ | ||
| ...normalizedHeaders(baseline).entries(), | ||
| ]) | ||
| } finally { | ||
| if (previous === undefined) { | ||
| delete process.env.ANTHROPIC_CUSTOM_HEADERS | ||
| } else { | ||
| process.env.ANTHROPIC_CUSTOM_HEADERS = previous | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| test('parses custom headers from JSON object values', () => { | ||
| const headers = parseCustomHeaders( | ||
| JSON.stringify({ | ||
| 'x-string': 'value', | ||
| 'x-number': 123, | ||
| 'x-bool': true, | ||
| 'x-skip': null, | ||
| }), | ||
| ) | ||
|
|
||
| expect(headers.get('x-string')).toBe('value') | ||
| expect(headers.get('x-number')).toBe('123') | ||
| expect(headers.get('x-bool')).toBe('true') | ||
| expect(headers.get('x-skip')).toBeNull() | ||
| }) | ||
|
|
||
| test('parses custom headers from colon-separated env values', () => { | ||
| const headers = parseCustomHeaders( | ||
| 'x-one: one,x-two: two\nx-three: value:with:colon', | ||
| ) | ||
|
|
||
| expect(headers.get('x-one')).toBe('one') | ||
| expect(headers.get('x-two')).toBe('two') | ||
| expect(headers.get('x-three')).toBe('value:with:colon') | ||
| }) | ||
|
|
||
| test('ignores malformed custom headers after one warning without changing headers', () => { | ||
| const records: Array<{ level: string; channel: string; message: string }> = | ||
| [] | ||
| const malformed = '{"x-a":' | ||
| __setLogTestSink((record) => records.push(record)) | ||
| try { | ||
| const headers = new Headers({ 'x-existing': 'unchanged' }) | ||
|
|
||
| expect(() => applyCustomHeaders(headers, malformed)).not.toThrow() | ||
| expect(() => applyCustomHeaders(headers, malformed)).not.toThrow() | ||
| expect(headers).toEqual(new Headers({ 'x-existing': 'unchanged' })) | ||
| expect( | ||
| records.filter( | ||
| (record) => | ||
| record.level === 'warn' && | ||
| record.channel === 'custom-headers' && | ||
| record.message === 'ignoring malformed ANTHROPIC_CUSTOM_HEADERS', | ||
| ), | ||
| ).toHaveLength(1) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The malformed-header test asserts the warning is logged exactly once, but the warn-once and parse caches ( Prompt for AI agents |
||
| } finally { | ||
| __setLogTestSink(null) | ||
| } | ||
| }) | ||
|
|
||
| test('orders serialized body fields like captured Claude Code requests', () => { | ||
| const ordered = orderClaudeCodeBody({ | ||
| stream: true, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When users separate header entries with a normal space after the comma, the parser sends the second entry as part of the first header value instead of creating a second header. Allow optional whitespace after comma delimiters, and consume it so comma/newline combinations do not leak the comma into the value.
Prompt for AI agents