Skip to content

feat: custom headers and model aliases for API-key and proxy routes - #215

Open
iceteaSA wants to merge 5 commits into
cortexkit:mainfrom
iceteaSA:rv/patch-1
Open

feat: custom headers and model aliases for API-key and proxy routes#215
iceteaSA wants to merge 5 commits into
cortexkit:mainfrom
iceteaSA:rv/patch-1

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

For LiteLLM-style proxies and API-key accounts: ANTHROPIC_CUSTOM_HEADERS adds headers to those requests, ANTHROPIC_MODEL / ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE}_MODEL rewrite the model id to the alias the backend expects, and ANTHROPIC_BASE_URL keeps its path (previously dropped) with /messages repaired to /v1/messages under 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:

  • Custom headers are applied only at the API-key send path, the non-OAuth passthrough, and Pi's API-route header builder; applyClaudeCodeHeaders is untouched. A test pins the OAuth header set byte-identical with the env exported (including attempts to override authorization, user-agent, x-app, anthropic-beta, anthropic-version).
  • The remap runs once, gated on the API-key route (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 that ANTHROPIC_MODEL exported in the shell (a common Claude Code setting) leaves an OAuth claude-fable-5-1 request untouched.
  • The header value is parsed once per distinct string; a malformed value logs one warning and applies nothing instead of throwing on the request path.
  • rewriteUrl is byte-identical to today's behaviour when no base-URL override is active; the base-path prepend and the /v1 repair 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-by on every commit.

Changes

  • packages/core/src/custom-headers.ts (new) — parser (JSON object or name: value lines), memoised, warn-once on malformed input.
  • packages/core/src/model-remap.ts (new) — tier-aware alias resolution; ANTHROPIC_DEFAULT_FABLE_MODEL is 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; rewriteUrl override gating.
  • Tests in claude-code.test.ts, transform.test.ts, stream.test.ts; README section for the six variables and their scope.

Verification

  • core 169/0 · pi 95/0 · opencode 1571/0 (+18) · root typecheck 0 · format/biome clean.
  • Red-first tests: OAuth header override, malformed value throw, OAuth model remap, remap-before-normalisation, root /messages rewrite — each failed on the original shape; re-adding the OAuth call, the early remap, or the unguarded rewrite reddens them again.

Base: upstream/main 360b68e (v1.22.0).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with 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_HEADERS applies at the API-key send path, the non-OAuth passthrough, and Pi's API-route header builder.
  • It accepts a JSON object or name: value lines; malformed values log one warning and apply nothing.
  • ANTHROPIC_MODEL and tier-specific ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE}_MODEL remap the requested model to the backend alias.
  • Remapping runs last in the rewrite pipeline and only on non-OAuth routes, so earlier stages still see the original model.
  • The ANTHROPIC_BASE_URL path is preserved (previously dropped), and /messages is repaired to /v1/messages under an active override.
  • ANTHROPIC_DEFAULT_FABLE_MODEL is plugin-specific and documented as such; the README covers all six variables.

Written for commit fc46127. Summary will update on new commits.

Review in cubic

iceteaSA and others added 5 commits September 11, 2026 07:32
- 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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 11 files

Confidence score: 2/5

  • packages/opencode/src/transform.ts: URL repair can both duplicate /v1 and rewrite valid non-v1 or sibling /messages paths, causing requests to miss or corrupt the configured proxy endpoint — constrain normalization to the intended /v1 case and strip only a trailing /v1 from the base path.
  • packages/opencode/src/index.ts: Request-body payloads supplied through a Request are 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 as claude-sonnetx as Sonnet, potentially routing them to the wrong alias — require a hyphen boundary for each family prefix.
  • packages/core/src/custom-headers.ts and packages/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
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

? parseBaseUrl(options.baseURL)
: resolveBaseUrl()
if (baseUrl) {
const basePath = baseUrl.pathname.replace(/\/$/, '')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Suggested change
const basePath = baseUrl.pathname.replace(/\/$/, '')
const basePath = baseUrl.pathname
.replace(/\/$/, '')
.replace(/\/v1$/, '')

Comment on lines +27 to +31
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'

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'

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:]+:)/)

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>

Comment on lines +358 to +364
requestUrl.pathname.endsWith('/messages') &&
!requestUrl.pathname.endsWith('/v1/messages')
) {
requestUrl.pathname = requestUrl.pathname.replace(
/\/messages$/,
'/v1/messages',
)

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: 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>
Suggested change
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)

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>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant