feat: custom headers and model aliases for API-key and proxy routes - #215
feat: custom headers and model aliases for API-key and proxy routes#215iceteaSA wants to merge 5 commits into
Conversation
- 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 <redacted@localhost>
Co-authored-by: randomvariable <redacted@localhost>
Co-authored-by: randomvariable <redacted@localhost>
Honours an ANTHROPIC_BASE_URL path for proxy requests, where prior behavior silently dropped that path. Co-authored-by: randomvariable <redacted@localhost>
Co-authored-by: randomvariable <redacted@localhost>
There was a problem hiding this comment.
6 issues found across 11 files
Confidence score: 2/5
packages/opencode/src/transform.ts: URL repair can both duplicate/v1and rewrite valid non-v1 or sibling/messagespaths, causing requests to miss or corrupt the configured proxy endpoint — constrain normalization to the intended/v1case and strip only a trailing/v1from the base path.packages/opencode/src/index.ts: Request-body payloads supplied through aRequestare skipped by model remapping, so non-OAuth callers can send the wrong model — read or clone the Request body before applying the remap.packages/core/src/model-remap.ts: Tier-specific remapping treats unrelated identifiers such asclaude-sonnetxas Sonnet, potentially routing them to the wrong alias — require a hyphen boundary for each family prefix.packages/core/src/custom-headers.tsandpackages/opencode/src/tests/claude-code.test.ts: Normal spaces after comma separators can merge headers, while module-level parse and warning caches make the malformed-header assertion order-dependent — accept optional whitespace and isolate or reset cache state in the test.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/core/src/model-remap.ts">
<violation number="1" location="packages/core/src/model-remap.ts:27">
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.</violation>
</file>
<file name="packages/core/src/custom-headers.ts">
<violation number="1" location="packages/core/src/custom-headers.ts:23">
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.</violation>
</file>
<file name="packages/opencode/src/tests/claude-code.test.ts">
<violation number="1" location="packages/opencode/src/tests/claude-code.test.ts:406">
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.</violation>
</file>
<file name="packages/opencode/src/index.ts">
<violation number="1" location="packages/opencode/src/index.ts:6591">
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.</violation>
</file>
<file name="packages/opencode/src/transform.ts">
<violation number="1" location="packages/opencode/src/transform.ts:344">
P1: When a proxy base URL contains a namespace plus `/v1`, the SDK’s `/v1/messages` path is appended as a second version segment, so API-key requests miss the configured endpoint. Strip a trailing `/v1` from `basePath` before prefixing, then let the existing repair add it exactly once.</violation>
<violation number="2" location="packages/opencode/src/transform.ts:358">
P2: The /v1 repair rewrites any path ending in `/messages` that isn't literally `/v1/messages`, so a proxy base with a non-v1 version segment (e.g. `/v2/messages` becomes `/v2/v1/messages`) or a sibling resource named `messages` is corrupted. Since the base-path prepend already ran, gate the repair on the exact `{basePath}/messages` form.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as Claude Code Client
participant Plugin as Anthropic Auth Plugin
participant Remap as Model Remap
participant Headers as Custom Headers
participant Proxy as LiteLLM/Proxy Backend
participant OAuth as Anthropic OAuth API
Note over Client,Proxy: API-Key Route Flow (NEW: Custom Headers + Model Remap)
Client->>Plugin: Request with canonical model ID
Plugin->>Plugin: Check auth type
alt API-Key Account
Plugin->>Plugin: Build API-key headers
Plugin->>Headers: NEW: Apply ANTHROPIC_CUSTOM_HEADERS
Headers-->>Plugin: Headers with custom values
Plugin->>Remap: NEW: remapModelId(canonical model)
Note over Remap: Check ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE}_MODEL<br/>then ANTHROPIC_MODEL fallback
alt Tier-specific env var found
Remap-->>Plugin: Proxy alias model ID
else No env override
Remap-->>Plugin: Original model ID
end
Plugin->>Proxy: Forward request (custom headers + remapped model)
Proxy-->>Plugin: Response with proxy alias
Plugin-->>Client: Stream response
end
Note over Client,OAuth: OAuth Route (Unchanged)
Client->>Plugin: OAuth request (claude-fable-5-1)
Plugin->>OAuth: Byte-identical Claude Code identity
OAuth-->>Plugin: Response
Plugin-->>Client: Stream response
Note over Plugin,Proxy: Non-OAuth Passthrough (NEW: Header + Remap)
Client->>Plugin: Passthrough request
Plugin->>Headers: NEW: applyCustomHeaders(passthrough headers)
Plugin->>Remap: NEW: remapRequestBodyModel(parsed body)
Plugin->>Proxy: Forward with custom headers + alias model
Note over Plugin,Proxy: Base URL Override (CHANGED: Path Preservation)
alt ANTHROPIC_BASE_URL set
Plugin->>Plugin: rewriteUrl() with base path
Plugin->>Plugin: Preserve base path (e.g., /anthropic)
Plugin->>Plugin: Repair /messages -> /v1/messages
Plugin->>Proxy: Request with full override path
else Default endpoint
Plugin->>OAuth: /v1/messages untouched
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| ? parseBaseUrl(options.baseURL) | ||
| : resolveBaseUrl() | ||
| if (baseUrl) { | ||
| const basePath = baseUrl.pathname.replace(/\/$/, '') |
There was a problem hiding this comment.
P1: When a proxy base URL contains a namespace plus /v1, the SDK’s /v1/messages path is appended as a second version segment, so API-key requests miss the configured endpoint. Strip a trailing /v1 from basePath before prefixing, then let the existing repair add it exactly once.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/transform.ts, line 344:
<comment>When a proxy base URL contains a namespace plus `/v1`, the SDK’s `/v1/messages` path is appended as a second version segment, so API-key requests miss the configured endpoint. Strip a trailing `/v1` from `basePath` before prefixing, then let the existing repair add it exactly once.</comment>
<file context>
@@ -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
</file context>
| const basePath = baseUrl.pathname.replace(/\/$/, '') | |
| const basePath = baseUrl.pathname | |
| .replace(/\/$/, '') | |
| .replace(/\/v1$/, '') |
| 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' |
There was a problem hiding this comment.
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>
| 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' |
| const trimmed = raw.trim() | ||
| if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { | ||
| for (const entry of trimmed | ||
| .split(/\r?\n|,(?=[^,\s:]+:)/) |
There was a problem hiding this comment.
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>
| .split(/\r?\n|,(?=[^,\s:]+:)/) | |
| .split(/\r?\n|,\s*(?=[^,\s:]+:)/) |
| const rewritten = rewriteUrl(input) | ||
| const passthroughHeaders = mergeHeaders(input, init) | ||
| applyCustomHeaders(passthroughHeaders) | ||
| let passthroughBody = init?.body |
There was a problem hiding this comment.
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>
| requestUrl.pathname.endsWith('/messages') && | ||
| !requestUrl.pathname.endsWith('/v1/messages') | ||
| ) { | ||
| requestUrl.pathname = requestUrl.pathname.replace( | ||
| /\/messages$/, | ||
| '/v1/messages', | ||
| ) |
There was a problem hiding this comment.
P2: The /v1 repair rewrites any path ending in /messages that isn't literally /v1/messages, so a proxy base with a non-v1 version segment (e.g. /v2/messages becomes /v2/v1/messages) or a sibling resource named messages is corrupted. Since the base-path prepend already ran, gate the repair on the exact {basePath}/messages form.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/transform.ts, line 358:
<comment>The /v1 repair rewrites any path ending in `/messages` that isn't literally `/v1/messages`, so a proxy base with a non-v1 version segment (e.g. `/v2/messages` becomes `/v2/v1/messages`) or a sibling resource named `messages` is corrupted. Since the base-path prepend already ran, gate the repair on the exact `{basePath}/messages` form.</comment>
<file context>
@@ -340,10 +341,27 @@ export function rewriteUrl(
+ // 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')
+ ) {
</file context>
| requestUrl.pathname.endsWith('/messages') && | |
| !requestUrl.pathname.endsWith('/v1/messages') | |
| ) { | |
| requestUrl.pathname = requestUrl.pathname.replace( | |
| /\/messages$/, | |
| '/v1/messages', | |
| ) | |
| if (requestUrl.pathname === `${basePath}/messages`) { | |
| requestUrl.pathname = `${basePath}/v1/messages` | |
| } |
| record.channel === 'custom-headers' && | ||
| record.message === 'ignoring malformed ANTHROPIC_CUSTOM_HEADERS', | ||
| ), | ||
| ).toHaveLength(1) |
There was a problem hiding this comment.
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>
Summary
For LiteLLM-style proxies and API-key accounts:
ANTHROPIC_CUSTOM_HEADERSadds headers to those requests,ANTHROPIC_MODEL/ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE}_MODELrewrite the model id to the alias the backend expects, andANTHROPIC_BASE_URLkeeps its path (previously dropped) with/messagesrepaired to/v1/messagesunder an override.None of this touches an OAuth request. The plugin replicates the Claude Code OAuth identity byte-for-byte and Anthropic bills on it, so the first shape of this change — which applied the headers inside the OAuth identity builder and ran the remap on every route — was reworked in review:
applyClaudeCodeHeadersis untouched. A test pins the OAuth header set byte-identical with the env exported (including attempts to overrideauthorization,user-agent,x-app,anthropic-beta,anthropic-version).options.modelRemapEnabled), and last — after Fable/Sonnet/Opus normalisation, server-side fallback, effort markers, and cache shaping — so everything keyed on the requested model sees the original id. A test pins thatANTHROPIC_MODELexported in the shell (a common Claude Code setting) leaves an OAuthclaude-fable-5-1request untouched.rewriteUrlis byte-identical to today's behaviour when no base-URL override is active; the base-path prepend and the/v1repair apply only under an override.Credit: original patch by randomvariable (CortexKit Discord, 2026-09-03), who could not open the PR themselves. Reviewed, re-scoped to proxy routes, and re-authored here with
Co-authored-byon every commit.Changes
packages/core/src/custom-headers.ts(new) — parser (JSON object orname: valuelines), memoised, warn-once on malformed input.packages/core/src/model-remap.ts(new) — tier-aware alias resolution;ANTHROPIC_DEFAULT_FABLE_MODELis plugin-specific (not a Claude Code variable) and documented as such.packages/opencode/src/index.ts,packages/opencode/src/transform.ts,packages/pi/src/stream.ts— route-scoped application;rewriteUrloverride gating.claude-code.test.ts,transform.test.ts,stream.test.ts; README section for the six variables and their scope.Verification
/messagesrewrite — each failed on the original shape; re-adding the OAuth call, the early remap, or the unguarded rewrite reddens them again.Base:
upstream/main360b68e(v1.22.0).Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Adds custom headers and model aliasing to API-key and proxy routes so requests to LiteLLM-style backends carry the headers and model IDs those backends expect. OAuth requests are untouched and keep the Claude Code identity byte-for-byte, so Pro/Max billing and behavior is unaffected.
Key changes
ANTHROPIC_CUSTOM_HEADERSapplies at the API-key send path, the non-OAuth passthrough, and Pi's API-route header builder.name: valuelines; malformed values log one warning and apply nothing.ANTHROPIC_MODELand tier-specificANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE}_MODELremap the requested model to the backend alias.ANTHROPIC_BASE_URLpath is preserved (previously dropped), and/messagesis repaired to/v1/messagesunder an active override.ANTHROPIC_DEFAULT_FABLE_MODELis plugin-specific and documented as such; the README covers all six variables.Written for commit fc46127. Summary will update on new commits.