From 61c4b4cc264a2bdf8c2dbf6c2ef96753c8ae5b89 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:32:07 +0200 Subject: [PATCH 1/5] feat(core): add custom headers and model remap support - Add custom-headers module to apply configured headers to Claude Code requests - Add model-remap module for request model rewriting - Wire custom headers into applyClaudeCodeHeaders and export new modules - Update opencode transform and tests for proxy URL rewriting - Update pi stream tests Co-authored-by: randomvariable --- packages/core/src/claude-code.ts | 2 + packages/core/src/custom-headers.ts | 53 ++++++++ packages/core/src/index.ts | 2 + packages/core/src/model-remap.ts | 75 +++++++++++ packages/opencode/src/index.ts | 25 +++- .../opencode/src/tests/claude-code.test.ts | 56 ++++++++ packages/opencode/src/tests/setup.ts | 6 + packages/opencode/src/tests/transform.test.ts | 123 ++++++++++++++++++ packages/opencode/src/transform.ts | 28 +++- packages/pi/src/stream.ts | 2 + packages/pi/src/tests/stream.test.ts | 29 +++++ 11 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/custom-headers.ts create mode 100644 packages/core/src/model-remap.ts diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index ac093ebf..cc1076e0 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -7,6 +7,7 @@ import { FAST_MODE_BETA, USER_AGENT, } from './constants.ts' +import { applyCustomHeaders } from './custom-headers.ts' export type ClaudeCodeIdentity = { deviceId: string @@ -465,6 +466,7 @@ export function applyClaudeCodeHeaders( ) headers.set('x-stainless-timeout', '600') headers.delete('x-api-key') + applyCustomHeaders(headers) return headers } diff --git a/packages/core/src/custom-headers.ts b/packages/core/src/custom-headers.ts new file mode 100644 index 00000000..72ab735a --- /dev/null +++ b/packages/core/src/custom-headers.ts @@ -0,0 +1,53 @@ +export const ANTHROPIC_CUSTOM_HEADERS_ENV = 'ANTHROPIC_CUSTOM_HEADERS' + +export function parseCustomHeaders(raw: string | undefined): Headers { + const headers = new Headers() + if (!raw?.trim()) return 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(), + ) + } + return headers + } + + 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)) + } + } + + return 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 +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31207aed..2218782e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './claude-code.ts' export * from './claustrum.ts' export * from './commands/account.ts' export * from './constants.ts' +export * from './custom-headers.ts' export * from './dump.ts' export * from './fast.ts' export * from './json.ts' @@ -15,6 +16,7 @@ export * from './killswitch.ts' export * from './logger.ts' export * from './logging.ts' export * from './mid-conversation-output-config.ts' +export * from './model-remap.ts' export * from './models.ts' export * from './oauth-profile.ts' export * from './pkce.ts' diff --git a/packages/core/src/model-remap.ts b/packages/core/src/model-remap.ts new file mode 100644 index 00000000..c3d609f5 --- /dev/null +++ b/packages/core/src/model-remap.ts @@ -0,0 +1,75 @@ +/** + * 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 env vars follow the Claude Code convention: + * + * 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' + return null +} + +const TIER_ENV_MAP: Record = { + 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, +): boolean { + if (typeof parsed.model !== 'string') return false + const remapped = remapModelId(parsed.model) + if (remapped === parsed.model) return false + parsed.model = remapped + return true +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5822c771..93494311 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -4,6 +4,7 @@ import { type ApiKeyAccount, acquireRefreshFileLock, addAccountPersistent, + applyCustomHeaders, authorize, buildAccountList, buildClaudeQuotaSummary, @@ -150,6 +151,7 @@ import { quotaSnapshotPassesPolicy, refreshBackoffActive, refreshClaudeOAuthToken, + remapModelId, 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( @@ -6581,7 +6584,27 @@ 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 + if (typeof passthroughBody === 'string') { + try { + const parsed = JSON.parse(passthroughBody) + if (typeof parsed.model === 'string') { + const remapped = remapModelId(parsed.model) + if (remapped !== parsed.model) { + parsed.model = remapped + 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 } diff --git a/packages/opencode/src/tests/claude-code.test.ts b/packages/opencode/src/tests/claude-code.test.ts index f01ce4f0..69a45eac 100644 --- a/packages/opencode/src/tests/claude-code.test.ts +++ b/packages/opencode/src/tests/claude-code.test.ts @@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { applyClaudeCodeHeaders, applyClaudeCodeMetadata, + applyCustomHeaders, CLAUDE_CODE_FULL_AGENT_BETAS, type ClaudeCodeIdentity, getClaudeCodeIdentity, orderClaudeCodeBody, + parseCustomHeaders, REQUIRED_BETAS, resetClaudeCodeIdentityCachesForTest, resolveClaudeCodeIdentity, @@ -303,6 +305,60 @@ describe('Claude Code fingerprint helpers', () => { expect(compatibility.accountUuid).toBe('account-b') }) + test('applies ANTHROPIC_CUSTOM_HEADERS after generated Claude Code headers', () => { + const previous = process.env.ANTHROPIC_CUSTOM_HEADERS + process.env.ANTHROPIC_CUSTOM_HEADERS = JSON.stringify({ + 'x-provider-api-key': 'provider-key', + 'anthropic-version': '2024-01-01', + 'x-list': ['a', 'b'], + }) + try { + const headers = applyClaudeCodeHeaders(new Headers(), 'sk-ant-oat-test') + + expect(headers.get('x-provider-api-key')).toBe('provider-key') + expect(headers.get('anthropic-version')).toBe('2024-01-01') + expect(headers.get('x-list')).toBe('a, b') + } 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('rejects non-object custom headers configuration', () => { + expect(() => applyCustomHeaders(new Headers(), '[]')).toThrow( + 'ANTHROPIC_CUSTOM_HEADERS must be a JSON object', + ) + }) + test('orders serialized body fields like captured Claude Code requests', () => { const ordered = orderClaudeCodeBody({ stream: true, diff --git a/packages/opencode/src/tests/setup.ts b/packages/opencode/src/tests/setup.ts index 278c91f2..5d324c28 100644 --- a/packages/opencode/src/tests/setup.ts +++ b/packages/opencode/src/tests/setup.ts @@ -180,3 +180,9 @@ process.env.OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR = join( testDir, 'quota-header-feed', ) +// User-level Anthropic overrides are valid runtime configuration, but they make +// request-transform tests depend on the developer machine. Tests set these +// explicitly when they exercise override behavior. +for (const key of Object.keys(process.env)) { + if (key.startsWith('ANTHROPIC_')) delete process.env[key] +} diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index f4c89b3c..ab01cff8 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -915,6 +915,26 @@ describe('rewriteUrl', () => { expect(url.pathname).toBe('/v1/messages') }) + test('applies ANTHROPIC_BASE_URL path before /v1/messages', () => { + process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.test/anthropic' + const { input } = rewriteUrl('https://api.anthropic.com/v1/messages') + const url = new URL(input.toString()) + expect(url.origin).toBe('https://proxy.example.test') + expect(url.pathname).toBe('/anthropic/v1/messages') + expect(url.searchParams.get('beta')).toBe('true') + }) + + test('does not duplicate ANTHROPIC_BASE_URL path already present in request', () => { + process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.test/anthropic' + const { input } = rewriteUrl( + 'https://proxy.example.test/anthropic/v1/messages', + ) + const url = new URL(input.toString()) + expect(url.origin).toBe('https://proxy.example.test') + expect(url.pathname).toBe('/anthropic/v1/messages') + expect(url.searchParams.get('beta')).toBe('true') + }) + test('applies explicit fallback base URL path before /v1/messages', () => { const { input } = rewriteUrl('https://api.anthropic.com/v1/messages', { baseURL: 'https://api.kie.ai/claude', @@ -979,12 +999,14 @@ describe('rewriteUrl', () => { }) test('returns original input when no URL changes are needed', () => { + delete process.env.ANTHROPIC_BASE_URL const original = 'https://api.anthropic.com/v1/complete' const { input } = rewriteUrl(original) expect(input).toBe(original) }) test('returns original Request when no URL changes are needed', () => { + delete process.env.ANTHROPIC_BASE_URL const request = new Request('https://api.anthropic.com/v1/complete') const { input } = rewriteUrl(request) expect(input).toBe(request) @@ -3937,6 +3959,107 @@ describe('rewriteRequestBody', () => { expect(cachedBlocks).toHaveLength(1) expect(cachedBlocks[0].type).toBe('text') }) + + describe('model remapping via ANTHROPIC_DEFAULT_*_MODEL', () => { + const envBackup: Record = {} + + function setEnv(vars: Record) { + for (const [key, value] of Object.entries(vars)) { + envBackup[key] = process.env[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + + function restoreEnv() { + for (const [key, value] of Object.entries(envBackup)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + for (const key of Object.keys(envBackup)) delete envBackup[key] + } + + afterEach(() => restoreEnv()) + + test('remaps sonnet model using ANTHROPIC_DEFAULT_SONNET_MODEL', async () => { + setEnv({ ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6' }) + const body = JSON.stringify({ + model: 'claude-sonnet-4-20250514', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-sonnet-4-6') + }) + + test('remaps opus model using ANTHROPIC_DEFAULT_OPUS_MODEL', async () => { + setEnv({ ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8' }) + const body = JSON.stringify({ + model: 'claude-opus-4-20250514', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-opus-4-8') + }) + + test('remaps haiku model using ANTHROPIC_DEFAULT_HAIKU_MODEL', async () => { + setEnv({ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001' }) + const body = JSON.stringify({ + model: 'claude-haiku-4-5', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-haiku-4-5-20251001') + }) + + test('remaps fable/mythos model using ANTHROPIC_DEFAULT_FABLE_MODEL', async () => { + setEnv({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5' }) + const body = JSON.stringify({ + model: 'claude-mythos-5', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-fable-5') + }) + + test('tier-specific var takes precedence over ANTHROPIC_MODEL', async () => { + setEnv({ + ANTHROPIC_MODEL: 'claude-fallback', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + }) + const body = JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-sonnet-4-6') + }) + + test('falls back to ANTHROPIC_MODEL for unmatched claude model', async () => { + setEnv({ ANTHROPIC_MODEL: 'claude-default-proxy' }) + const body = JSON.stringify({ + model: 'claude-unknown-99', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-default-proxy') + }) + + test('leaves model unchanged when no env vars are set', async () => { + setEnv({ + ANTHROPIC_MODEL: undefined, + ANTHROPIC_DEFAULT_SONNET_MODEL: undefined, + ANTHROPIC_DEFAULT_OPUS_MODEL: undefined, + ANTHROPIC_DEFAULT_HAIKU_MODEL: undefined, + ANTHROPIC_DEFAULT_FABLE_MODEL: undefined, + }) + const body = JSON.stringify({ + model: 'claude-sonnet-4-20250514', + messages: [{ role: 'user', content: 'hi' }], + }) + const result = JSON.parse(await rewriteRequestBody(body)) + expect(result.model).toBe('claude-sonnet-4-20250514') + }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 16863813..8a05f2b9 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -27,6 +27,7 @@ import { orderClaudeCodeBody, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, + remapRequestBodyModel, selectClaudeCodeBetas, signRequestBody, TEXT_REPLACEMENTS, @@ -340,13 +341,32 @@ export function rewriteUrl( ? parseBaseUrl(options.baseURL) : resolveBaseUrl() if (baseUrl) { + const basePath = baseUrl.pathname.replace(/\/$/, '') requestUrl.protocol = baseUrl.protocol requestUrl.host = baseUrl.host - if (options.baseURL) { - requestUrl.pathname = `${baseUrl.pathname.replace(/\/$/, '')}${requestUrl.pathname}` + if ( + basePath && + requestUrl.pathname !== basePath && + !requestUrl.pathname.startsWith(`${basePath}/`) + ) { + requestUrl.pathname = `${basePath}${requestUrl.pathname}` } } + // Normalise path: when ANTHROPIC_BASE_URL replaces the full base (not just + // origin), the /v1 segment from the default api.anthropic.com/v1 path is lost. + // The @ai-sdk/anthropic provider sends to {baseURL}/messages, producing + // e.g. /proxy/messages instead of /proxy/v1/messages. Insert /v1 if missing. + if ( + requestUrl.pathname.endsWith('/messages') && + !requestUrl.pathname.endsWith('/v1/messages') + ) { + requestUrl.pathname = requestUrl.pathname.replace( + /\/messages$/, + '/v1/messages', + ) + } + if ( requestUrl.pathname.endsWith('/v1/messages') && !requestUrl.searchParams.has('beta') @@ -1249,6 +1269,9 @@ export async function rewriteRequestBody( ...countRewriteShape(parsed), }) + // Remap model ID for proxy backends (ANTHROPIC_DEFAULT_*_MODEL) + const modelRemapped = remapRequestBodyModel(parsed) + const trailingStart = rewriteNowMs() const messagesBeforeStrip = Array.isArray(parsed.messages) ? parsed.messages.length @@ -1281,6 +1304,7 @@ export async function rewriteRequestBody( options.perf?.('model_normalize', { ms: rewriteRoundMs(rewriteNowMs() - modelNormalizeStart), model: typeof parsed.model === 'string' ? parsed.model : undefined, + modelRemapped, fableMythosThinkingDisplay: fableMythosThinking ? 'summarized' : undefined, diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 2b6b9981..d9ad95d8 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -1,6 +1,7 @@ import { type ApiKeyAccount, applyClaudeCodeHeaders, + applyCustomHeaders, CACHE_KEEP_EXTENDED_TTL_BETA, CacheKeepManager, CacheKeepSessionRegistry, @@ -308,6 +309,7 @@ export function configureApiRouteHeaders( mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]), ) } + applyCustomHeaders(headers) return headers } diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index 3e3ada2d..a3714365 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -423,6 +423,35 @@ describe('Pi API fallback routing helpers', () => { expect(events.some((event) => event.type === 'error')).toBe(true) }) + test('applies ANTHROPIC_CUSTOM_HEADERS to API fallback routes', () => { + const previous = process.env.ANTHROPIC_CUSTOM_HEADERS + process.env.ANTHROPIC_CUSTOM_HEADERS = JSON.stringify({ + 'x-provider-api-key': 'provider-key', + 'anthropic-version': '2024-01-01', + }) + try { + const headers = configureApiRouteHeaders( + { + id: 'provider-route', + type: 'api', + apiKey: 'provider-key', + baseURL: 'https://provider.example/anthropic', + authHeader: 'x-api-key', + }, + false, + ) + + expect(headers.get('x-provider-api-key')).toBe('provider-key') + expect(headers.get('anthropic-version')).toBe('2024-01-01') + } finally { + if (previous === undefined) { + delete process.env.ANTHROPIC_CUSTOM_HEADERS + } else { + process.env.ANTHROPIC_CUSTOM_HEADERS = previous + } + } + }) + test('sticky-balanced keeps repeated Pi session requests on the quota-selected account', async () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-sticky-routing-')) const storagePath = join(tempDir, 'anthropic-auth.json') From c30ead66d989edef02d1b7d548ce27b5909b41e4 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:32:07 +0200 Subject: [PATCH 2/5] fix(core): scope custom headers to proxy routes Co-authored-by: randomvariable --- packages/core/src/claude-code.ts | 2 - packages/core/src/custom-headers.ts | 96 +++++++++++++------ .../opencode/src/tests/claude-code.test.ts | 76 ++++++++++++--- 3 files changed, 128 insertions(+), 46 deletions(-) diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index cc1076e0..ac093ebf 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -7,7 +7,6 @@ import { FAST_MODE_BETA, USER_AGENT, } from './constants.ts' -import { applyCustomHeaders } from './custom-headers.ts' export type ClaudeCodeIdentity = { deviceId: string @@ -466,7 +465,6 @@ export function applyClaudeCodeHeaders( ) headers.set('x-stainless-timeout', '600') headers.delete('x-api-key') - applyCustomHeaders(headers) return headers } diff --git a/packages/core/src/custom-headers.ts b/packages/core/src/custom-headers.ts index 72ab735a..936fae30 100644 --- a/packages/core/src/custom-headers.ts +++ b/packages/core/src/custom-headers.ts @@ -1,44 +1,78 @@ +import { logger } from './logger.ts' + export const ANTHROPIC_CUSTOM_HEADERS_ENV = 'ANTHROPIC_CUSTOM_HEADERS' +type HeaderEntries = Array<[string, string]> + +const parsedHeadersByRawValue = new Map() +const warnedMalformedRawValues = new Set() + export function parseCustomHeaders(raw: string | undefined): Headers { - const headers = new Headers() - if (!raw?.trim()) return 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) { + 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} entries must be "name: value"`, + `${ANTHROPIC_CUSTOM_HEADERS_ENV} must be a JSON object`, ) } - headers.set( - entry.slice(0, separator).trim(), - entry.slice(separator + 1).trim(), - ) - } - return headers - } - 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)) + } + } + } - 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() } - - return headers } export function applyCustomHeaders( diff --git a/packages/opencode/src/tests/claude-code.test.ts b/packages/opencode/src/tests/claude-code.test.ts index 69a45eac..9e43003c 100644 --- a/packages/opencode/src/tests/claude-code.test.ts +++ b/packages/opencode/src/tests/claude-code.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { + __setLogTestSink, applyClaudeCodeHeaders, applyClaudeCodeMetadata, applyCustomHeaders, @@ -305,19 +306,50 @@ describe('Claude Code fingerprint helpers', () => { expect(compatibility.accountUuid).toBe('account-b') }) - test('applies ANTHROPIC_CUSTOM_HEADERS after generated Claude Code headers', () => { + 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({ - 'x-provider-api-key': 'provider-key', - 'anthropic-version': '2024-01-01', - 'x-list': ['a', 'b'], + 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') - - expect(headers.get('x-provider-api-key')).toBe('provider-key') - expect(headers.get('anthropic-version')).toBe('2024-01-01') - expect(headers.get('x-list')).toBe('a, b') + 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 @@ -353,10 +385,28 @@ describe('Claude Code fingerprint helpers', () => { expect(headers.get('x-three')).toBe('value:with:colon') }) - test('rejects non-object custom headers configuration', () => { - expect(() => applyCustomHeaders(new Headers(), '[]')).toThrow( - 'ANTHROPIC_CUSTOM_HEADERS must be a JSON object', - ) + 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) + } finally { + __setLogTestSink(null) + } }) test('orders serialized body fields like captured Claude Code requests', () => { From 57ac6a5c74cd4c2fd31210ee55bf52c857b0a0b5 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:32:07 +0200 Subject: [PATCH 3/5] fix(core): limit model remaps to proxy routes Co-authored-by: randomvariable --- packages/core/src/model-remap.ts | 3 +- packages/opencode/src/index.ts | 11 ++-- packages/opencode/src/tests/transform.test.ts | 66 +++++++++++++++++-- packages/opencode/src/transform.ts | 6 +- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/core/src/model-remap.ts b/packages/core/src/model-remap.ts index c3d609f5..f9891f4b 100644 --- a/packages/core/src/model-remap.ts +++ b/packages/core/src/model-remap.ts @@ -4,7 +4,8 @@ * * LiteLLM/proxy backends often use shorter model aliases * (e.g. `claude-sonnet-4-6` instead of `claude-sonnet-4-20250514`). - * These env vars follow the Claude Code convention: + * 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-* diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 93494311..c025debe 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -151,7 +151,7 @@ import { quotaSnapshotPassesPolicy, refreshBackoffActive, refreshClaudeOAuthToken, - remapModelId, + remapRequestBodyModel, removeAccountPersistent, reorderAccountsPersistent, resolveClaudeCodeIdentity, @@ -5230,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 }), }) @@ -6591,12 +6592,8 @@ const anthropicAuthPlugin = async ( if (typeof passthroughBody === 'string') { try { const parsed = JSON.parse(passthroughBody) - if (typeof parsed.model === 'string') { - const remapped = remapModelId(parsed.model) - if (remapped !== parsed.model) { - parsed.model = remapped - passthroughBody = JSON.stringify(parsed) - } + if (remapRequestBodyModel(parsed)) { + passthroughBody = JSON.stringify(parsed) } } catch {} } diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index ab01cff8..17efa1ac 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -3981,13 +3981,57 @@ describe('rewriteRequestBody', () => { afterEach(() => restoreEnv()) + test('keeps Fable OAuth requests on their original model and thinking shape', async () => { + setEnv({ ANTHROPIC_MODEL: 'claude-sonnet-4-6' }) + const result = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model: 'claude-fable-5-1', + thinking: { type: 'adaptive' }, + messages: [{ role: 'user', content: 'hi' }], + }), + ), + ) + const headers = new Headers() + setOAuthHeaders(headers, 'token', { body: result }) + + expect(result.model).toBe('claude-fable-5-1') + expect(result.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + }) + expect(headers.get('anthropic-beta')).toContain('oauth-2025-04-20') + }) + + test('remaps API-key Fable requests after source-model normalization', async () => { + setEnv({ ANTHROPIC_MODEL: 'claude-sonnet-4-6' }) + const result = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + model: 'claude-fable-5-1', + thinking: { type: 'adaptive' }, + messages: [{ role: 'user', content: 'hi' }], + }), + { modelRemapEnabled: true }, + ), + ) + + expect(result.model).toBe('claude-sonnet-4-6') + expect(result.thinking).toEqual({ + type: 'adaptive', + display: 'summarized', + }) + }) + test('remaps sonnet model using ANTHROPIC_DEFAULT_SONNET_MODEL', async () => { setEnv({ ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6' }) const body = JSON.stringify({ model: 'claude-sonnet-4-20250514', messages: [{ role: 'user', content: 'hi' }], }) - const result = JSON.parse(await rewriteRequestBody(body)) + const result = JSON.parse( + await rewriteRequestBody(body, { modelRemapEnabled: true }), + ) expect(result.model).toBe('claude-sonnet-4-6') }) @@ -3997,7 +4041,9 @@ describe('rewriteRequestBody', () => { model: 'claude-opus-4-20250514', messages: [{ role: 'user', content: 'hi' }], }) - const result = JSON.parse(await rewriteRequestBody(body)) + const result = JSON.parse( + await rewriteRequestBody(body, { modelRemapEnabled: true }), + ) expect(result.model).toBe('claude-opus-4-8') }) @@ -4007,7 +4053,9 @@ describe('rewriteRequestBody', () => { model: 'claude-haiku-4-5', messages: [{ role: 'user', content: 'hi' }], }) - const result = JSON.parse(await rewriteRequestBody(body)) + const result = JSON.parse( + await rewriteRequestBody(body, { modelRemapEnabled: true }), + ) expect(result.model).toBe('claude-haiku-4-5-20251001') }) @@ -4017,7 +4065,9 @@ describe('rewriteRequestBody', () => { model: 'claude-mythos-5', messages: [{ role: 'user', content: 'hi' }], }) - const result = JSON.parse(await rewriteRequestBody(body)) + const result = JSON.parse( + await rewriteRequestBody(body, { modelRemapEnabled: true }), + ) expect(result.model).toBe('claude-fable-5') }) @@ -4030,7 +4080,9 @@ describe('rewriteRequestBody', () => { model: 'claude-sonnet-4-5', messages: [{ role: 'user', content: 'hi' }], }) - const result = JSON.parse(await rewriteRequestBody(body)) + const result = JSON.parse( + await rewriteRequestBody(body, { modelRemapEnabled: true }), + ) expect(result.model).toBe('claude-sonnet-4-6') }) @@ -4040,7 +4092,9 @@ describe('rewriteRequestBody', () => { model: 'claude-unknown-99', messages: [{ role: 'user', content: 'hi' }], }) - const result = JSON.parse(await rewriteRequestBody(body)) + const result = JSON.parse( + await rewriteRequestBody(body, { modelRemapEnabled: true }), + ) expect(result.model).toBe('claude-default-proxy') }) diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 8a05f2b9..4393f11a 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -1256,6 +1256,7 @@ export async function rewriteRequestBody( perf?: RewritePerfCallback hybridStandbyAnchor?: HybridMessageCacheAnchor serverSideFallbackEnabled?: boolean + modelRemapEnabled?: boolean laneStart?: boolean cacheDiagnosticsPreviousMessageId?: string | null } = {}, @@ -1269,9 +1270,6 @@ export async function rewriteRequestBody( ...countRewriteShape(parsed), }) - // Remap model ID for proxy backends (ANTHROPIC_DEFAULT_*_MODEL) - const modelRemapped = remapRequestBodyModel(parsed) - const trailingStart = rewriteNowMs() const messagesBeforeStrip = Array.isArray(parsed.messages) ? parsed.messages.length @@ -1304,7 +1302,6 @@ export async function rewriteRequestBody( options.perf?.('model_normalize', { ms: rewriteRoundMs(rewriteNowMs() - modelNormalizeStart), model: typeof parsed.model === 'string' ? parsed.model : undefined, - modelRemapped, fableMythosThinkingDisplay: fableMythosThinking ? 'summarized' : undefined, @@ -1411,6 +1408,7 @@ export async function rewriteRequestBody( }) const prefixStart = rewriteNowMs() + if (options.modelRemapEnabled === true) remapRequestBodyModel(parsed) const prefixed = prefixToolNames(parsed) options.perf?.('prefix_tools_stringify', { ms: rewriteRoundMs(rewriteNowMs() - prefixStart), From 19ecd411a603253b79fc92c74ccdfd71d405bb1f Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:32:07 +0200 Subject: [PATCH 4/5] fix(opencode): scope proxy URL path rewrites Honours an ANTHROPIC_BASE_URL path for proxy requests, where prior behavior silently dropped that path. Co-authored-by: randomvariable --- packages/opencode/src/tests/transform.test.ts | 26 +++++++++++++++++++ packages/opencode/src/transform.ts | 24 ++++++++--------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 17efa1ac..cf869e44 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -907,6 +907,32 @@ describe('rewriteUrl', () => { expect(url.searchParams.has('beta')).toBe(false) }) + test('preserves a root /messages path without a base URL override', () => { + const { input } = rewriteUrl('https://api.anthropic.com/messages') + const url = new URL(input.toString()) + expect(url.pathname).toBe('/messages') + expect(url.searchParams.has('beta')).toBe(false) + }) + + test('normalizes a root /messages path under ANTHROPIC_BASE_URL', () => { + process.env.ANTHROPIC_BASE_URL = 'https://proxy.example.test/anthropic' + const { input } = rewriteUrl('https://api.anthropic.com/messages') + const url = new URL(input.toString()) + expect(url.origin).toBe('https://proxy.example.test') + expect(url.pathname).toBe('/anthropic/v1/messages') + expect(url.searchParams.get('beta')).toBe('true') + }) + + test('normalizes a root /messages path under a per-account base URL', () => { + const { input } = rewriteUrl('https://api.anthropic.com/messages', { + baseURL: 'https://api.kie.ai/claude', + }) + const url = new URL(input.toString()) + expect(url.origin).toBe('https://api.kie.ai') + expect(url.pathname).toBe('/claude/v1/messages') + expect(url.searchParams.get('beta')).toBe('true') + }) + test('overrides origin when ANTHROPIC_BASE_URL is set', () => { process.env.ANTHROPIC_BASE_URL = 'http://localhost:8080' const { input } = rewriteUrl('https://api.anthropic.com/v1/messages') diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 4393f11a..37c26bd3 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -351,20 +351,18 @@ export function rewriteUrl( ) { requestUrl.pathname = `${basePath}${requestUrl.pathname}` } - } - // Normalise path: when ANTHROPIC_BASE_URL replaces the full base (not just - // origin), the /v1 segment from the default api.anthropic.com/v1 path is lost. - // The @ai-sdk/anthropic provider sends to {baseURL}/messages, producing - // e.g. /proxy/messages instead of /proxy/v1/messages. Insert /v1 if missing. - if ( - requestUrl.pathname.endsWith('/messages') && - !requestUrl.pathname.endsWith('/v1/messages') - ) { - requestUrl.pathname = requestUrl.pathname.replace( - /\/messages$/, - '/v1/messages', - ) + // The SDK sends {baseURL}/messages, so proxy overrides need the missing + // version segment restored without rewriting an unconfigured endpoint. + if ( + requestUrl.pathname.endsWith('/messages') && + !requestUrl.pathname.endsWith('/v1/messages') + ) { + requestUrl.pathname = requestUrl.pathname.replace( + /\/messages$/, + '/v1/messages', + ) + } } if ( From fc4612710a1a0a6759020958c3b0664768b18bb2 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:32:07 +0200 Subject: [PATCH 5/5] docs: describe proxy header and model overrides Co-authored-by: randomvariable --- packages/opencode/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index d73aa8c0..10889cd0 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -676,6 +676,12 @@ Dump state is persisted in the active sidecar config as `dump.enabled` (`~/.conf | Variable | Description | | --- | --- | | `ANTHROPIC_BASE_URL` | Override the Anthropic API endpoint. Must be HTTP(S). | +| `ANTHROPIC_CUSTOM_HEADERS` | Add or override headers on API-key and proxy routes. Ignored for OAuth requests. | +| `ANTHROPIC_MODEL` | Default proxy alias for any `claude-*` model. Ignored for OAuth requests. | +| `ANTHROPIC_DEFAULT_SONNET_MODEL` | Proxy alias for `claude-sonnet-*` models. | +| `ANTHROPIC_DEFAULT_OPUS_MODEL` | Proxy alias for `claude-opus-*` models. | +| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Proxy alias for `claude-haiku-*` models. | +| `ANTHROPIC_DEFAULT_FABLE_MODEL` | Proxy alias for `claude-fable-*` and `claude-mythos-*` models. | | `ANTHROPIC_INSECURE` | Set to `1` or `true` to skip TLS verification when `ANTHROPIC_BASE_URL` is set. | | `OPENCODE_ANTHROPIC_AUTH_FILE` | Override the OpenCode sidecar config path. | | `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE` | Set to `legacy` to bypass Anthropic's server policy and use deterministic 10-response client recovery exclusively. The default tries server-side safety fallback first and uses client recovery as a backstop. | @@ -684,6 +690,10 @@ Dump state is persisted in the active sidecar config as `dump.enabled` (`~/.conf | `CLOUDFLARE_API_TOKEN` | Cloudflare token used by `bunx @cortexkit/opencode-anthropic-auth relay setup`. Not stored. | | `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID used by relay setup. | +`ANTHROPIC_CUSTOM_HEADERS` and the model-alias variables apply only to API-key and proxy routes. OAuth requests keep the Claude Code header and model identity unchanged. Custom headers accept either a JSON object or comma/newline-separated `name: value` entries. Invalid values are ignored with one warning. + +An `ANTHROPIC_BASE_URL` path is preserved. For example, `https://proxy.example/anthropic` sends requests to `/anthropic/v1/messages`. The `/v1` path repair applies only while a base-URL override is active. + ## Request rewriting For Claude Pro/Max OAuth requests, the plugin works at the final Anthropic wire-request layer: