Skip to content

Repository files navigation

subswitch

CI License: MIT Node 22+

Route Claude Code subagents to a different model - keep everything else on Claude.

subswitch is a local subscription-routing proxy for Claude Code. Give a subagent a Codex model in its frontmatter (model: sol) and that subagent alone runs on your Codex subscription; your main agent and every other request stay on your claude.ai subscription, untouched. No API keys — subswitch forwards the subscription credential each leg already uses.

Why this is different

Every other Claude Code proxy is all-or-nothing. ANTHROPIC_BASE_URL is a single global setting, so existing routers point all of Claude Code's traffic at one endpoint and typically swap the model for the entire session — your orchestrator, your utility calls, everything moves at once.

subswitch is the first proxy that splits traffic per subagent, by model name:

  • Requests whose model resolves to a canonical id in the built-in model registry (by exact match, family alias, or custom alias) are translated and sent to the Codex backend.
  • Everything else — the main agent, background utility calls (token counting, context management), all non-matching models — is relayed to Anthropic as verbatim bytes, credentials and all.

So you keep Claude Opus/Sonnet driving the session and delegate a specific subagent to GPT for a second opinion, a cheaper worker, or a specialized task — without giving up either subscription and without touching an API key.

Claude Code ──► subswitch (127.0.0.1:4141)
                  ├─ model ∈ registry ─────► chatgpt.com Codex backend
                  │    (Anthropic Messages ⇄ OpenAI Responses translation,
                  │     ~/.codex/auth.json OAuth, reasoning round-trip cache)
                  └─ everything else ──────► api.anthropic.com
                       (verbatim byte relay, claude.ai OAuth untouched)

Routing is by the request body's model field, resolved against the built-in model registry (by exact id, family alias, or custom alias). Unresolvable models pass through to Anthropic; non-matching utility traffic is never misrouted.

Requirements

  • Node 22+
  • A claude.ai subscription login in Claude Code (no ANTHROPIC_API_KEY set)
  • Codex CLI logged in (codex login~/.codex/auth.json)

Quick start

1. Install — use it on demand with npx, or install the CLI globally:

npm install -g subswitch          # then run `subswitch <command>`
# or, without installing, prefix any command with npx, e.g. `npx subswitch serve`

2. Run interactive setup:

subswitch init

init walks you through port selection and model configuration, then writes ANTHROPIC_BASE_URL into .claude/settings.local.json (per-developer, typically gitignored — safe default) and saves subswitch.config.json in your project directory.

Non-interactive / CI:

subswitch init --yes --port 4141 --settings-target local

--settings-target local (default) writes .claude/settings.local.json — per-developer, typically gitignored. Use --settings-target shared to write .claude/settings.json instead (committed, visible to all team members).

init without --yes refuses to write anything when stdin is not a TTY (e.g., in CI) and exits with code 1. Use --dry-run to preview what would be written without actually writing — --dry-run works in non-TTY and CI contexts.

3. Start the proxy and verify your setup:

subswitch serve      # starts on 127.0.0.1:4141
subswitch doctor     # checks config + codex auth + network (exits non-zero on problems)

4. Route a subagent to Codex — add a model: line to the subagent's frontmatter:

---
name: gpt-worker
model: sol   # family alias — always the latest sol generation
effort: low  # optional reasoning effort (see Effort control below)
---

That subagent alone now runs on Codex; your main agent and every other request stay on Claude.

CLI reference

subswitch — local subscription-routing proxy for Claude Code

Usage: subswitch [command] [flags]

Commands:
  serve     Start the proxy (default command)
  doctor    Check config, codex auth, and network reachability
  init      Interactive setup — writes config + wires Claude Code
  models    Show effective alias table (registry × aliases)
            --json   Output model registry as JSON (no color, no TTY check)

Flags (global):
  -h, --help       Show this help message
  -v, --version    Print version

Flags (serve):
      --verbose    Set log level to debug for this run
      --quiet      Set log level to warn for this run
      --port <n>   Override listen port (default: 4141)

Flags (init):
  -y, --yes                  Non-interactive mode — use flags + defaults
      --dry-run              Show what would be written; writes nothing
      --port <n>             Proxy port (default: 4141)
      --settings-target <t>  "local" (.claude/settings.local.json, default)
                             or "shared" (.claude/settings.json)

Examples:
  subswitch serve                      # start proxy on port 4141
  subswitch serve --port 8080          # start proxy on a custom port
  subswitch init                       # interactive setup
  subswitch init --yes                 # non-interactive with defaults
  subswitch init --dry-run             # preview what would be written
  subswitch doctor                     # check config + auth health
  subswitch models                     # show alias table (registry × aliases)

Environment:
  NO_COLOR      Disable color output (also respected as standard)
  FORCE_COLOR   Force color output even when not a TTY
  CI            Non-interactive detection — init refuses without --yes

Exit codes:

Command Condition Code
serve server listening 0 (kept alive)
serve invalid --port, EADDRINUSE, config error 1
doctor all checks pass 0
doctor any check fails 1
init success (interactive, non-interactive, or dry-run) 0
init cancel at any prompt / empty selection / write failure / invalid flag 1
init non-TTY or CI without --yes (fail-closed, zero writes) 1
unknown command or flag always 1

doctor exits 1 whenever any preflight check fails — use it as a gate in scripts. init without --yes refuses to write anything when stdin is not a TTY (e.g. in CI) and exits 1 immediately with no filesystem side effects.

Advanced: manual setup

If you prefer to configure manually instead of using init:

Point Claude Code at subswitch in your project's .claude/settings.local.json (recommended, gitignored) or .claude/settings.json:

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://127.0.0.1:4141"
  }
}

Optionally create subswitch.config.json in your project root for custom port or model selection (all fields optional — see subswitch.config.example.json).

Run from source

git clone https://github.com/dean0x/subswitch.git
cd subswitch
npm install
npm run serve      # same as `subswitch serve`
npm run doctor     # same as `subswitch doctor`

Effort control

The optional effort frontmatter field works on the Codex leg too. Claude Code sends it as output_config.effort, and subswitch forwards it verbatim as Responses reasoning.effort. The Codex backend accepts none, minimal, low, medium, high, xhigh, and max (Claude Code itself emits the last five); a value outside that set is dropped with an unsupported_effort_dropped warning and the backend default applies. When effort is forwarded, subswitch logs codex_effort_applied.

Configuration

Optional configuration goes in subswitch.config.json (gitignored). See subswitch.config.example.json for every knob and its default.

The config file is located by the following precedence (highest wins):

  1. SUBSWITCH_CONFIG env var — absolute or ~-relative path; missing file is an error
  2. subswitch.config.json in the current working directory — silently uses defaults if absent

An unrecognised key is rejected, not ignored. Two checks run against the raw file before it is parsed, and a hit on either is a hard load failure — subswitch prints the offending key and exits 1 rather than starting:

  1. Keys from an older layout. A top-level codex block or a codex.models key is rejected with a message naming exactly where each key moved.
  2. Unknown provider ids. A providers.<id> block whose id this build does not ship — a typo like providers.codexx, or a provider from a future release — is rejected, and the message lists the ids that are known. Today that is codex, so providers.codex is the only valid block.

This is deliberate, and the reason is the same in both cases: the config schema strips keys it does not recognise instead of reporting them, so the only alternative to failing the load is a config file that still sits on disk looking correct while the proxy runs entirely on defaults — custom aliases gone, baseUrl silently back to the public endpoint, userAgent back to the built-in value, and your configured provider reported as absent. A stripping schema can never tell you what it discarded, which is why the check has to run on the raw file first and why a refusal to start is the better failure.

Routable set and aliases

The routable set is the built-in model registrygpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, and gpt-5.5. It is not configurable: routing follows the registry so a new model becomes available on upgrade with no config edit, and everything outside it passes through to Anthropic. Run subswitch models --json for the machine-readable registry.

Family aliases (sol, terra, luna) let you write a model name that auto-tracks the latest generation in that family — model: sol always resolves to whichever gpt-5.6-sol (or future gpt-5.7-sol) generation is in the registry, without any config change. Exact canonical ids (gpt-5.6-sol) are also accepted and resolve to themselves. Run subswitch models to see the current alias table.

An exact model id always wins over an alias, so a providers.codex.aliases entry can never hijack a real model name. Neither side of an alias entry may be an Anthropic model name (claude-*, sonnet, opus, haiku, inherit) — such a config is rejected at load, because either the key or the target would route your main agent's traffic to Codex.

Config reference

Minimal example — only override what you need:

{
  "providers": {
    "codex": {
      "aliases": {
        "fast": "gpt-5.6-sol"
      }
    }
  }
}

All keys and their defaults:

Key Default Description
port 4141 Port the proxy listens on
logLevel "info" Log verbosity: debug, info, warn, or error
anthropic.baseUrl "https://api.anthropic.com" Anthropic passthrough base URL
anthropic.connectTimeoutMs 10000 (10 s) Anthropic leg only — DNS resolution and TCP connection establishment timeout (see note below)
anthropic.maxUpstreamSockets 256 Anthropic leg only — max sockets in the keep-alive pool (see note below)
anthropic.allowInsecureBaseUrl false Security opt-in — when false (the default), subswitch serve refuses to start if anthropic.baseUrl points at a host other than api.anthropic.com. Set to true only when routing through a trusted proxy in front of Anthropic's API. Loopback addresses are always exempt.
providers.codex.baseUrl "https://chatgpt.com/backend-api/codex" Codex backend base URL — override to route subswitch through the wire recorder
providers.codex.oauthTokenUrl "https://auth.openai.com/oauth/token" Token refresh endpoint for the Codex OAuth flow
providers.codex.authFile "~/.codex/auth.json" Path to the Codex credential file written by codex login
providers.codex.userAgent "codex_cli_rs/0.144.6" User-agent string sent on Codex leg requests
providers.codex.aliases {} Custom alias overrides — map a short name to a canonical model id. Wins over derived family aliases; loses to exact registry ids.
providers.codex.reasoningCache.maxEntries 4096 Maximum LRU entries in the reasoning round-trip cache
providers.codex.reasoningCache.maxBytes 67108864 (64 MiB) Maximum total byte footprint of the reasoning cache
providers.codex.requestTimeoutMs 600000 (10 min) Wall-clock time limit per Codex request
providers.codex.streamIdleTimeoutMs 300000 (5 min) Codex stream idle timeout — resets on each SSE chunk
providers.codex.maxSseEventBytes 4194304 (4 MiB) Maximum bytes per individual SSE event from the Codex upstream
providers.codex.maxAggregateBytes 67108864 (64 MiB) Maximum total accumulated frame bytes for non-streaming response aggregation; exceeding this returns 502
providers.codex.allowInsecureBaseUrl false Security opt-in — when false (the default), subswitch serve refuses to start if providers.codex.baseUrl or providers.codex.oauthTokenUrl points at a host other than chatgpt.com or auth.openai.com. This prevents credential forwarding to an untrusted host. Set to true only when routing through a trusted proxy. Loopback addresses are always exempt.
limits.maxBodyBytes 33554432 (32 MiB) Maximum request body bytes buffered before the routing decision
limits.pingIntervalMs 15000 (15 s) Interval between SSE ping frames sent to clients during long Codex streams

Why connectTimeoutMs and maxUpstreamSockets are Anthropic-leg-only: the Anthropic passthrough uses a node:http agent with an explicit keep-alive pool, so both knobs have meaningful effect there. The Codex leg uses Node's global fetch (undici's global dispatcher), which these knobs do not control — shipping them as per-provider keys would be config that bounds nothing on the Codex side.

Operator caveats for connectTimeoutMs: (1) No effect on pooled sockets. With maxUpstreamSockets: 256 and keep-alive on, steady-state traffic reuses existing connections — there is no connect phase — so the budget does nothing after warm-up. (2) TLS negotiation is not covered. On https://api.anthropic.com, the budget ends when the TCP connection is established ('connect' event); the TLS handshake occurs after and is not bounded by this knob.

BREAKING (0.3.0): Two config keys were removed: anthropic.streamIdleTimeoutMs and limits.maxConcurrentRequests. If your config contains either of these, subswitch will refuse to start and print a message naming each offending key. Delete them from your config to continue. See the 0.3.0 changelog for details.

Behavior when an upstream connects but never responds: the Anthropic leg has no relay-side timer on the response phase. If an upstream accepts the TCP connection and request body but then goes silent, the client will receive no response until its own timeout fires (measured: STATUS=000 TOTAL=20s curl_rc=28 with a 20 s curl timeout). This is deliberate per ADR-010: a relay that invents a 504 for a wedged origin produces a status the origin never emitted. A direct connection to api.anthropic.com would hang the same way. server.requestTimeout (600 s, above) bounds only request receipt, not the response, so nothing relay-side fires in this case.

Server connection tuning

subswitch configures its inbound http.Server with the following fixed values:

Property Value Rationale
requestTimeout 600 000 ms (10 min) Bounds receipt of the request only — it stops once the request body has arrived, so it can never cut short a slow completion or a long stream. On expiry subswitch returns an Anthropic-shaped 408.
headersTimeout 120 000 ms (2 min) Bounds receipt of the request headers only. Same 408 on expiry.
keepAliveTimeout 300 000 ms (5 min) Deliberately long. Claude Code's connection pool keeps sockets open for reuse. At Node's default 5 s, an idle socket gets a FIN/RST from subswitch; if that close races an outgoing POST, undici will not retry the resulting ECONNRESET for a non-idempotent request (PF-018). 300 s means sockets outlive any realistic inter-request gap.
maxRequestsPerSocket 0 (unlimited) Prevents connection cycling on long-lived agents
maxHeaderSize 65 536 bytes (64 KiB) Anthropic's own limit; subswitch returns a 431 Request Header Fields Too Large (Anthropic-shaped) when exceeded

When a client sends a request body larger than limits.maxBodyBytes, subswitch returns 413 and then drains the remaining upload bytes before closing the connection. This lets the client read the 413 response; without the drain, the TCP write-buffer fills and the client's recv() never sees the 413 body.

Token counting on the Codex leg

/v1/messages/count_tokens requests that route to the Codex leg return an estimate rather than forwarding to Anthropic. This is deliberate: forwarding to Anthropic would count tokens against a different model's vocabulary and return a misleading count. The estimate is the least-wrong option available.

subswitch models --json

subswitch models --json outputs a single JSON object describing the full model registry and alias resolution under the current config. It is the machine-readable counterpart to the human-readable subswitch models table.

subswitch models --json | jq .models[].id

Schema (schemaVersion: 1):

{
  "kind": "models",
  "schemaVersion": 1,
  "subswitchVersion": "0.1.0",
  "name": "subswitch",
  "fallbackProvider": "anthropic",
  "configPath": "/path/to/subswitch.config.json",
  "configFileFound": false,
  "providers": [
    { "id": "anthropic", "displayName": "Anthropic", "routing": "passthrough" },
    { "id": "codex", "displayName": "Codex", "routing": "registry" }
  ],
  "models": [
    {
      "id": "gpt-5.6-sol",
      "provider": "codex",
      "aliases": [{ "name": "sol", "source": "derived" }],
      "family": "sol",
      "gen": [5, 6],
      "routable": true,
      "preview": false,
      "retired": false,
      "source": "registry"
    }
  ]
}

Field notes:

  • schemaVersion is an integer that bumps on any breaking change to this structure. Consumers must check schemaVersion === 1 before reading other fields.
  • gen is an integer tuple ([5, 6]), not a string ("5.6"). String comparison sorts "5.10" before "5.9" — the tuple is the correct form for numeric comparison. gen is omitted when the generation is unknown; it is always present for registry entries.
  • preview and retired are always-present booleans — no ?? false needed in consumers.
  • family is omitted for models with no family alias (e.g. gpt-5.5).
  • Anthropic appears in providers with zero model rows. subswitch cannot enumerate Claude model names — it prefix-matches them and relays verbatim — so including a fabricated list would be a lie that consumers might cache. The fallbackProvider: "anthropic" field identifies where everything unresolved goes.
  • aliases[].source is "derived" for family aliases computed from the registry, or "config" for entries you wrote in providers.codex.aliases.

How the Codex leg works

  • Auth: reads ~/.codex/auth.json, proactively refreshes the OAuth access token when it expires within 120 s, and writes back atomically while preserving unknown keys. If the Codex CLI rotates tokens concurrently, the newer file wins. On a 401 mid-flight, subswitch force-refreshes and retries exactly once.
  • Request: Anthropic Messages → OpenAI Responses (systeminstructions, tools → function tools, tool_use/tool_resultfunction_call/function_call_output). Always streams upstream with store: false and include: ["reasoning.encrypted_content"].
  • Reasoning round-trip: encrypted reasoning items are held in a bounded in-memory LRU keyed by tool call_id and re-injected directly before the matching function_call on the follow-up request. A cache miss degrades (logged as reasoning_cache_miss) rather than breaks.
  • Response: Responses SSE is translated to the Anthropic SSE event sequence, with pings during upstream silence; non-streaming clients get an aggregated JSON message.

Logging

Structured single-line logs with a closed field set (model, path, route, status, latency, event/error codes). Token material and request/response content are unrepresentable in the logger by type — nothing sensitive can be logged. When stderr is a TTY and NO_COLOR is unset, level and event tokens are colorized and a timestamp prefix is added; the key=value structure is unchanged.

Color behavior is controlled by three environment variables (all standard): NO_COLOR — disable color output; FORCE_COLOR — force color even when stderr is not a TTY (useful in terminals that misreport TTY state); CI — also suppresses color and disables interactive init prompts (treated as a non-interactive environment).

route field values

The route field on request_complete and client_disconnected events identifies how the request was dispatched. Valid values:

Value When
anthropic Request forwarded to Anthropic verbatim (normal passthrough — unresolved or plain Anthropic model names)
anthropic:ambiguous Fail-open forward: two providers claim the same family name; relay forwards to Anthropic and emits ambiguous_model_name warn
anthropic:fallback Fail-open forward: colon-qualified name whose prefix is not a registered provider; relay forwards to Anthropic and emits unknown_provider_qualifier warn
codex:{endpoint}:{model} Request routed to the Codex provider leg (e.g. codex:messages:gpt-5.6-sol, codex:count_tokens:gpt-5.6-sol)
host_rejected Request refused by the loopback Host/Origin gate before routing; relay returned a synthesized 403
internal_error Unhandled exception during request handling; relay returned a synthesized 500

The anthropic:ambiguous and anthropic:fallback values both carry the anthropic prefix so leg-level filtering (route starts with anthropic) continues to work. The suffix makes fail-open forwards distinguishable from intended Anthropic routes in log queries and alerting.

Log events

Event Level Fields Notes
request_complete info path, route, model, status, latencyMs Emitted once per request that received a response. model is omitted when not present in the request body.
client_disconnected info path, route, model, latencyMs Emitted instead of request_complete when the client closed the connection before any response headers were sent (e.g. cancelled upload). No status field — res.statusCode would be Node's 200 initialiser, not a real status.
ambiguous_model_name warn model Two providers claim the same family name. model carries the ambiguous name annotated with the provider list: "name (p1, p2)". Request is forwarded to Anthropic (route anthropic:ambiguous).
unknown_provider_qualifier warn model Colon-qualified model name whose prefix is not a registered provider. model is the full as-requested name (e.g. "kimee:k2"). Request is forwarded to Anthropic (route anthropic:fallback).
host_rejected warn path, errorCode, status The loopback Host/Origin gate refused the request. errorCode is the reason (missing_host, foreign_host, foreign_origin) followed by the offending value, lower-cased, restricted to the authority charset and capped at 64 characters. The value appears here and nowhere else — it is never reflected into the response body.

x-subswitch-synthesized

Every HTTP response that subswitch generates itself — rather than proxying verbatim from an upstream — carries the response header:

x-subswitch-synthesized: 1

This header is present on:

  • Anthropic-leg relay errors: 502 (upstream connection failure), 504 (upstream connect timeout), 413 (request body too large), 431 (request headers too large), 408 (inbound request not fully received within requestTimeout / headersTimeout), 400 (malformed request), 500 (internal proxy error).
  • Codex-leg responses: every byte returned on the codex leg is synthesized by the relay (it translates OpenAI Responses format → Anthropic Messages format), so the header is present on both streaming and non-streaming codex responses, and on all codex-leg error responses.
  • Relay management endpoints: /__subswitch/health (200 OK), and any unrecognized /__subswitch/* path (404 — fixed body, path not reflected).

The header is absent on responses proxied verbatim from the Anthropic origin — including upstream errors (429 rate-limit, 529 overloaded, 500 upstream internal error, etc.). The header is also stripped from any upstream response that carries it, so the marker is authoritative: its presence means the relay synthesised the response; its absence means the upstream did.

Operators can use this header in load-balancer health rules, log filters, or alerting to distinguish relay faults from upstream outages.

Testing

npm run check   # tsc --noEmit + unit + integration (fake upstreams, no network)

End-to-end verification against the real CLI and real upstreams: e2e/README.md.

Known limitations

  • count_tokens for Codex models is a chars/4 estimate — good enough for Claude Code's context bookkeeping, but not exact.
  • max_tokens is not forwarded: the Codex backend rejects max_output_tokens with a 400 (verified live). Server-side truncation still maps to stop_reason: "max_tokens".
  • The Codex backend API is undocumented and can change without notice; unknown SSE event types are logged at debug level and ignored.
  • Images in tool results are dropped on the Codex leg (logged as image_dropped).
  • One subswitch instance holds the reasoning cache in memory; restarting it mid-conversation degrades the next Codex turn to a cache miss.
  • The wire recorder (e2e/capture/codex-recorder.ts) silently degrades to pass-through when run against the live Codex backend: the production /responses stream carries no content-type header, so the recorder's SSE detection never fires and it records zero events and no usage — with no error and no warning. The recorder works correctly only against local fixture upstreams, which do set the header. Anyone repeating the live-capture workflow with the checked-in recorder will get an empty capture and may wrongly conclude the stream is broken.

Contributing

See CONTRIBUTING.md for prerequisites, quality gates, and commit conventions. By participating you agree to the Code of Conduct.

Security

subswitch is a loopback-only proxy that handles subscription credentials. Report vulnerabilities privately — see SECURITY.md. Do not open a public issue for security reports.

Loopback Host/Origin gate

subswitch listens on 127.0.0.1 and requires no authentication, so reachability is its only access control — and DNS rebinding defeats reachability. A web page served from http://evil.test:4141 whose name resolves to 127.0.0.1 is same-origin with the proxy as far as the browser is concerned: it can send requests and read the responses, including Codex completions billed to your ChatGPT subscription. The one thing that page cannot change is the Host header, which names the domain the page was loaded from rather than the address it resolved to. So every request is checked before it is routed: the Host must name a loopback address (localhost, ::1, or any 127.0.0.0/8 address in dotted-quad form), and an Origin header, when present, must be a loopback origin. Anything else is answered 403 with an Anthropic-shaped permission_error body, is never forwarded to an upstream, and never reaches provider credentials — /__subswitch/* included. Clients that talk to the proxy directly (Claude Code, curl) send a loopback Host and no Origin at all, so they are unaffected, and a page served from another loopback port keeps working — the gate stops the cross-site case, not local development.

License

MIT © 2026 dean0x

About

Route native Claude Code subagents to a different model (Codex) while everything else stays on Claude. No API keys.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages