diff --git a/packages/core/src/custom-headers.ts b/packages/core/src/custom-headers.ts new file mode 100644 index 00000000..936fae30 --- /dev/null +++ b/packages/core/src/custom-headers.ts @@ -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() +const warnedMalformedRawValues = new Set() + +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 +} 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..f9891f4b --- /dev/null +++ b/packages/core/src/model-remap.ts @@ -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' + 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/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: diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5822c771..c025debe 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, + 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 + 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 } diff --git a/packages/opencode/src/tests/claude-code.test.ts b/packages/opencode/src/tests/claude-code.test.ts index f01ce4f0..9e43003c 100644 --- a/packages/opencode/src/tests/claude-code.test.ts +++ b/packages/opencode/src/tests/claude-code.test.ts @@ -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) + } finally { + __setLogTestSink(null) + } + }) + 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..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') @@ -915,6 +941,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 +1025,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 +3985,161 @@ 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('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, { modelRemapEnabled: true }), + ) + 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, { modelRemapEnabled: true }), + ) + 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, { modelRemapEnabled: true }), + ) + 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, { modelRemapEnabled: true }), + ) + 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, { modelRemapEnabled: true }), + ) + 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, { modelRemapEnabled: true }), + ) + 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..37c26bd3 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,10 +341,27 @@ 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}` + } + + // 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', + ) } } @@ -1236,6 +1254,7 @@ export async function rewriteRequestBody( perf?: RewritePerfCallback hybridStandbyAnchor?: HybridMessageCacheAnchor serverSideFallbackEnabled?: boolean + modelRemapEnabled?: boolean laneStart?: boolean cacheDiagnosticsPreviousMessageId?: string | null } = {}, @@ -1387,6 +1406,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), 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')