Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/core/src/custom-headers.ts
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:]+:)/)

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/custom-headers.ts, line 23:

<comment>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.</comment>

<file context>
@@ -0,0 +1,87 @@
+    const trimmed = raw.trim()
+    if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
+      for (const entry of trimmed
+        .split(/\r?\n|,(?=[^,\s:]+:)/)
+        .map((value) => value.trim())
+        .filter(Boolean)) {
</file context>
Suggested change
.split(/\r?\n|,(?=[^,\s:]+:)/)
.split(/\r?\n|,\s*(?=[^,\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
}
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ 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'
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'
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/model-remap.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a tier-specific variable is set, identifiers such as claude-sonnetx are incorrectly treated as Sonnet and remapped to that tier alias. Require the hyphen boundary for every family prefix so unrelated claude-* identifiers use only the generic mapping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/model-remap.ts, line 27:

<comment>When a tier-specific variable is set, identifiers such as `claude-sonnetx` are incorrectly treated as Sonnet and remapped to that tier alias. Require the hyphen boundary for every family prefix so unrelated `claude-*` identifiers use only the generic mapping.</comment>

<file context>
@@ -0,0 +1,76 @@
+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'
</file context>
Suggested change
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'
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<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
}
10 changes: 10 additions & 0 deletions packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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:
Expand Down
22 changes: 21 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type ApiKeyAccount,
acquireRefreshFileLock,
addAccountPersistent,
applyCustomHeaders,
authorize,
buildAccountList,
buildClaudeQuotaSummary,
Expand Down Expand Up @@ -150,6 +151,7 @@ import {
quotaSnapshotPassesPolicy,
refreshBackoffActive,
refreshClaudeOAuthToken,
remapRequestBodyModel,
removeAccountPersistent,
reorderAccountsPersistent,
resolveClaudeCodeIdentity,
Expand Down Expand Up @@ -5175,6 +5177,7 @@ const anthropicAuthPlugin = async (
headers.set('Authorization', `Bearer ${account.apiKey ?? ''}`)
}
headers.set('Content-Type', 'application/json')
applyCustomHeaders(headers)
}

async function sendWithApiAccount(
Expand Down Expand Up @@ -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 }),
})
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a non-OAuth caller supplies the payload on a Request input instead of init.body, this branch never remaps its model because passthroughBody is undefined. Read or clone the Request body before applying remapRequestBodyModel so all supported fetch input forms receive the proxy alias.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/index.ts, line 6591:

<comment>When a non-OAuth caller supplies the payload on a `Request` input instead of `init.body`, this branch never remaps its model because `passthroughBody` is undefined. Read or clone the `Request` body before applying `remapRequestBodyModel` so all supported fetch input forms receive the proxy alias.</comment>

<file context>
@@ -6581,7 +6585,23 @@ const anthropicAuthPlugin = async (
+                const rewritten = rewriteUrl(input)
+                const passthroughHeaders = mergeHeaders(input, init)
+                applyCustomHeaders(passthroughHeaders)
+                let passthroughBody = init?.body
+                if (typeof passthroughBody === 'string') {
+                  try {
</file context>

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
}
Expand Down
106 changes: 106 additions & 0 deletions packages/opencode/src/tests/claude-code.test.ts
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,
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 (warnedMalformedRawValues, parsedHeadersByRawValue) are module-level singletons in packages/core/src/custom-headers.ts that are never reset between tests or runs. If the suite is re-run in the same process (e.g. watch mode) or any other test first parses the same malformed raw string, the warning is already suppressed and toHaveLength(1) fails. Add a test-only resetter for these caches and call it in beforeEach, or make the assertion not depend on global process-lifetime state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tests/claude-code.test.ts, line 406:

<comment>The malformed-header test asserts the warning is logged exactly once, but the warn-once and parse caches (`warnedMalformedRawValues`, `parsedHeadersByRawValue`) are module-level singletons in `packages/core/src/custom-headers.ts` that are never reset between tests or runs. If the suite is re-run in the same process (e.g. watch mode) or any other test first parses the same malformed raw string, the warning is already suppressed and `toHaveLength(1)` fails. Add a test-only resetter for these caches and call it in `beforeEach`, or make the assertion not depend on global process-lifetime state.</comment>

<file context>
@@ -303,6 +306,109 @@ describe('Claude Code fingerprint helpers', () => {
+            record.channel === 'custom-headers' &&
+            record.message === 'ignoring malformed ANTHROPIC_CUSTOM_HEADERS',
+        ),
+      ).toHaveLength(1)
+    } finally {
+      __setLogTestSink(null)
</file context>

} finally {
__setLogTestSink(null)
}
})

test('orders serialized body fields like captured Claude Code requests', () => {
const ordered = orderClaudeCodeBody({
stream: true,
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Loading