How to build, configure, and run
shunt, then point Claude Code at it. Based on the Connect Claude Code to an LLM gateway guide — shunt is the gateway you connect to.
shunt is a spec-compliant Claude Code LLM gateway. You run it locally, set
ANTHROPIC_BASE_URL so Claude Code talks to it instead of api.anthropic.com, and it
diverts only the models you map to another provider (OpenAI or a reused ChatGPT/Codex
subscription). Everything else passes through to Anthropic unchanged.
- Rust (stable) with
cargo— seeCargo.toml. Build withcargo build. - Claude Code v2.1.129+ (only if you want model discovery;
the primary
ANTHROPIC_CUSTOM_MODEL_OPTIONpath works on any recent version). - A credential for whichever provider you map:
- OpenAI API key for the
openaiprovider, or - A ChatGPT login via the Codex CLI (
codex login) for thecodex/chatgptprovider.
- OpenAI API key for the
- Your normal Anthropic credential (claude.ai login or an API key) — shunt forwards it through for every model you don't map.
# Debug build
cargo build
# Release build (recommended for daily use)
cargo build --release # -> target/release/shuntYou can also run straight from the source tree with cargo run -- <args> (examples below).
shunt loads configuration from, in increasing precedence:
-
Built-in defaults (all providers preconfigured — see
src/config.rs). -
A TOML or YAML file. The format is chosen by extension —
.tomlis TOML,.yaml/.ymlis YAML (any other extension is parsed as TOML). With--config <path>that exact file is used (a missing file is an error). Otherwise shunt takes the first file found, probing each directory forshunt.toml, thenshunt.yaml, thenshunt.yml, in this directory order:./→$XDG_CONFIG_HOME/shunt/(defaulting to~/.config/shunt/) →$HOMEBREW_PREFIX/etc/(defaulting to the/opt/homebrewand/usr/localprefixes). A localshunt.yamltherefore still wins over a config file in a later directory. Boot logs report which file was loaded, or that defaults are in use.YAML 1.1 caveat: the YAML backend parses YAML 1.1, where bare
yes,no,on,off,y,n(any case) become booleans. Quote any string value that is one of these tokens (e.g.api_key_env: "no"), or deserialization fails with a type error. TOML is unaffected. -
Environment variables prefixed
SHUNT_, using__for nested keys (e.g.SHUNT_SERVER__BIND=0.0.0.0:3001).
Because the defaults already define every provider, your config only needs the parts you want to change. Start from either template:
cp shunt.toml.example shunt.toml # TOML
cp shunt.yaml.example shunt.yaml # YAML[server]
bind = "127.0.0.1:3001" # address shunt listens on
default_provider = "anthropic" # provider for any model with no route (pass-through)
max_concurrent_requests = 1024 # shed excess in-flight requests with 503; 0 disables
shutdown_timeout_seconds = 30 # drain deadline after first SIGTERM/SIGINT; 1..=3600
# Each provider is a [providers.<name>] table (see §3.2 for every key).
[providers.anthropic]
kind = "anthropic" # forward Claude Code's own credential unchanged
base_url = "https://api.anthropic.com"
[providers.openai]
kind = "responses" # translate Anthropic Messages -> OpenAI Responses
base_url = "https://api.openai.com/v1"
auth = "api_key"
api_key_env = "OPENAI_API_KEY" # env var the OpenAI key is read from
# effort = "high" # optional default reasoning effort for this provider
[providers.codex]
kind = "responses"
base_url = "https://chatgpt.com/backend-api"
auth = "chatgpt_oauth" # reuses ~/.codex/auth.json
# effort = "high"
# service_tier = "priority" # Codex CLI's "Fast" mode; also accepts "fast" (alias), "flex"
# --- Routing: how a request's `model` id picks a provider ---
# Legacy exact-match form. `upstream_model` and `effort` are optional overrides.
# Prefer [[models]] + [models.upstream_model] for exact ids.
[[routes]]
model = "gpt-5.6-sol"
provider = "codex"
# upstream_model = "gpt-5.6-sol"
# effort = "high" # gpt-5.6 and gpt-6 slugs also accept "max"
# Then prefix match.
[[route_prefixes]]
prefix = "gpt-"
provider = "openai"
# Optional: expose Claude-named aliases in the /model picker via discovery (§5.5).
# The id MUST start with "claude" or "anthropic" or Claude Code ignores it.
# [[models]]
# id = "claude-opus-via-codex"
# display_name = "Opus (via Codex)"
#
# [models.upstream_model] # optional: advertise, route, and translate in one entry
# codex = "gpt-5.2" # exactly one configured provider is supported
# Optional: error reporting to your own Sentry project. Off unless a DSN is
# set; nothing is ever sent by default. Reports gateway-owned diagnostics —
# fatal gateway startup/serve errors, panics, and error-level log events, with
# warn/info as breadcrumbs — plus, unconditionally once a DSN is set, an
# error/warning event whenever an upstream provider itself returns a failure:
# error for a 5xx response, warning for 429/529 (rate limit/overload), each
# tagged with model, provider, and upstream_status only. A streaming request
# that answers 200 and then fails mid-stream (an `event: error` frame, or the
# connection cut before a terminal event) also reports: error for the former,
# warning for the latter, tagged with model, provider, and outcome
# (issue #287). A cut also carries a cut_kind tag (eof, transport_error, or
# marker) plus diagnostic context — SSE events and bytes forwarded, the last
# event type, elapsed/TTFT in ms, and the upstream error message for a
# transport_error (issue #310). Mid-stream events are rate-limited in-process
# so a retrying client cannot flood the project; the shunt.stream_outcome
# metric below is never throttled. Request/response bodies, headers,
# credentials, and the host name are never sent: breadcrumbs keep only the log
# message (field values are stripped), and tracing spans attach only when you
# opt into tracing via traces_sample_rate below. An empty DSN (e.g.
# SHUNT_SENTRY__DSN="") disables reporting again; an invalid DSN or an
# out-of-range traces_sample_rate is a startup error.
# [sentry]
# dsn = "https://<key>@<org>.ingest.sentry.io/<project>"
# environment = "home-lab" # optional environment tag on events
# metrics = false # (default) separate opt-in: also send usage metrics.
# # See the metric series below. Aggregates only — no
# # prompts, client names, account ids, or session ids.
# traces_sample_rate = 0.0 # (default) separate opt-in: also send performance
# # traces. The per-request span (method, path) becomes
# # a Sentry transaction, head-sampled at this rate in
# # [0.0, 1.0]; 0.0 sends no spans at all. Mirrors
# # [otel] sample_ratio.
# include_session_id = false # (default) withhold the client session id from
# # spans sent to Sentry (mirrors [otel])
# Optional: OpenTelemetry (OTLP) export to your own collector/backend. Off
# unless an endpoint is set. Independent of [sentry] — both can run together.
# Exports up to three signals over OTLP/HTTP (protobuf): request spans
# (traces), the metric series below (metrics), and shunt's log events (logs;
# the stderr logs are unaffected).
# Metrics and traces stay low-cardinality and carry no request/response bodies —
# the span's client session id is attached only when `include_session_id = true`.
# The logs signal exports shunt's diagnostic events as written, so like the
# stderr logs it can include request-derived fields (an upstream error body, a
# client id); set `logs = false` for body-free export. The resource carries
# service.*/telemetry.sdk.* (no host or process detector) plus any
# OTEL_RESOURCE_ATTRIBUTES you set. An empty endpoint (e.g. SHUNT_OTEL__ENDPOINT="")
# disables again; an invalid endpoint or an out-of-range sample_ratio is a
# startup error. The endpoint and service_name come from this config (they take
# precedence over OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_SERVICE_NAME); the standard
# OTEL_EXPORTER_OTLP_HEADERS and OTEL_RESOURCE_ATTRIBUTES env vars are merged in.
# Editing [otel] then hot-reloading warns and needs a restart to take effect.
# [otel]
# endpoint = "http://localhost:4318" # OTLP/HTTP base; /v1/{traces,metrics,logs} appended
# service_name = "shunt" # (default) service.name resource attribute
# environment = "prod" # optional deployment.environment.name
# sample_ratio = 1.0 # (default) head-based trace sampling in [0.0, 1.0]
# traces = true # (default) export request spans
# metrics = true # (default) export usage metrics
# logs = true # (default) export log events
# include_session_id = false # (default) withhold the client session id from spans
# [otel.headers] # optional per-request headers (e.g. a hosted-collector token)
# authorization = "Bearer <token>"Both metric sinks export the same low-cardinality series:
| Series | Type | Attributes | Meaning |
|---|---|---|---|
shunt.requests |
Counter | provider, model, http.response.status_code, caller, client_kind, managed |
Inference requests; token-count requests are excluded. |
shunt.latency |
Histogram (ms) | provider, model, http.response.status_code, caller, client_kind, managed |
Time to response headers for streams and full latency for non-streaming responses; the committed streaming responses sample from dispatch to attempt classification, since their headers commit before the upstream responds. |
shunt.ttft |
Histogram (ms) | provider, model, client_kind, managed |
Time from request start to the first complete non-keepalive SSE frame. |
shunt.stream_outcome |
Counter | provider, model, outcome, client_kind, managed |
Exactly one stream result: completed, error_event, upstream_cut, or client_disconnect. |
shunt.tokens |
Counter | provider, model, kind, client_kind, managed |
Last reported streaming usage for input, output, cache_read, or cache_creation; non-streaming usage is not recorded. |
shunt.codex_continuation |
Counter | provider, outcome |
Codex WebSocket continuation hit or full-input fallback. |
shunt.codex_ws_overflow |
Counter | provider, outcome |
Codex WebSocket dedicated overflow connection opened or ceiling-refused (issue #248). |
shunt.upstream_retries |
Counter | provider, reason |
Bounded transient retries. |
shunt.router.decisions |
Counter | model, algorithm, target, source |
Routing decisions for every [models.router] type, and for the [models.subagents] overlay (algorithm = "subagents"), by the deciding [[models]] entry's id (as matched, so with any [1m] hint already stripped), the configured type, the model id the decision resolved to, and why. count_tokens probes are excluded. |
shunt.stage_router.decisions |
Counter | model, tier, source |
Stage-router tier decisions, by the router's model id (as matched, so with any [1m] hint already stripped), the chosen tier, and why it was chosen; count_tokens probes are excluded. |
shunt.stage_router.flips |
Counter | model, from, to |
Decisions that moved a session off its pinned tier. The two directions stay separate because escalation is deliberately easier than de-escalation. |
shunt.pool.quota_utilization |
Gauge | provider, window |
Minimum utilization across enabled, non-stale accounts for 5h, 7d, or 7d_oi. |
shunt.pool.quota_headroom_seconds |
Gauge | provider, window |
Seconds of quota margin before the window resets, as the maximum across enabled, unpaused accounts at the hard threshold ([server.pool] hard_threshold, else 0.98), for 5h, 7d, or 7d_oi. Burn speed is the window average (utilization / elapsed), not the recent pace, and the maximum is optimistic because traffic shifts to the surviving account and its burn rises. Negative means projected to run dry before the reset, or minus the seconds until the reset when already exhausted; the series is omitted while any such account projects no exhaustion (no utilization observed, utilization 0, or no reset time). Alert with shunt_pool_quota_headroom_seconds < 0. |
shunt.pool.rotations |
Counter | provider, reason |
Account rotations and pool exhaustion by low-cardinality cause. |
shunt.pool.reprobes |
Counter | provider |
Reprobes committed at the first HTTP dispatch for stale near-quota Codex/ChatGPT accounts; WebSocket-enabled providers count inbound HTTP probes only. |
shunt.pool.session_account_switches |
Counter | provider, reason, thinking_bound |
Pooled sessions whose binding moved to another account (ADR-0008). reason is proactive (the bound account was soft-near for 60 s, hard-over, quota-rejected, capped, paused or disabled), cooldown (it was cooling down for the request's scope) or failover (it was attempted and failed over, or left the pool). thinking_bound is true only for a Claude subscription account on a claude-sonnet-5-5* or claude-haiku-5-5* model, whose thinking blocks are lost on another account. A first placement is not a switch. |
shunt.pool.session_binding_evictions |
Counter | provider |
Session bindings evicted, least recently used first, when an insert found the 10,000-binding table full. Labelled with the provider whose placement evicted. |
The five per-request series above carry two extra attributes: client_kind is
claude-code, codex, or other, derived from the request's own client
headers (x-app, originator, user-agent; the raw value is never exported),
and managed is true only when the request authenticated with a
gateway-issued JWT — the credential that also makes a client eligible for the
pushed OTEL_* environment. Together they separate shunt's server-derived
series from a client's own claude_code.* OTLP export, so a dashboard can take
claude_code.* where it exists and shunt.*{managed="false"} everywhere else
without double counting; see otlp-client-attribution.md.
That filter assumes the backend receives the managed clients' claude_code.*
metrics, relayed through a [server.gateway.telemetry].forward_to destination
with metrics on or exported by the clients directly. When it does not, read
shunt.* across both managed values instead.
A judge call a [models.router] entry makes (caller="router") carries the
originating turn's client_kind but is always managed="false": it is shunt's
own traffic, which no client exporter reports. The inbound Codex endpoint
authenticates only [server.auth], so its requests are always
managed="false". The same two values are recorded on the request span as
shunt.client_kind and shunt.managed. An inbound Codex WebSocket turn has no
request span, so its attribution appears on the metrics only.
Routing precedence (src/routing.rs): matching [models.upstream_model] entry → exact
[[routes]] match → [[route_prefixes]] prefix match → server.default_provider. A model
with no match falls through to Anthropic.
Providers are a name → config map, so a new upstream is just another [providers.<name>]
table — no code change. figment deep-merges the map, so a partial override of a built-in
(e.g. only [providers.codex] effort = "high") keeps the rest of that provider's defaults, while
a brand-new table adds a provider. Every provider takes these keys:
| Key | Values | Meaning |
|---|---|---|
kind |
anthropic | responses | cursor |
Upstream protocol / adapter. anthropic = Messages API (passed through, optionally re-keyed); responses = Anthropic Messages translated to the OpenAI Responses API; cursor = Cursor's native ConnectRPC/protobuf AgentService. |
base_url |
URL | Upstream base; shunt appends the provider endpoint path. |
auth |
passthrough | api_key | chatgpt_oauth | claude_oauth | kimi_oauth | xai_oauth | cursor_oauth |
passthrough forwards the client's credential; api_key injects api_key_env; chatgpt_oauth uses Codex/ChatGPT OAuth; claude_oauth selects an Anthropic subscription account pool (see §3.3); kimi_oauth selects a Kimi Code subscription account pool (shunt login kimi; see the Kimi Code example below); xai_oauth reuses shunt login xai and pools named accounts (shunt login xai --name; see m18-xai-multi-account.md); cursor_oauth reuses its shunt-managed subscription login. |
api_key_env |
env var name | Where the key is read from, when auth = "api_key". |
api_key_header |
bearer (default) | x_api_key |
Header the injected key is sent in. |
effort |
low…max |
Optional default reasoning effort (responses providers). |
service_tier |
fast, priority, flex, default |
Optional Codex CLI "Fast" mode opt-in (responses providers); fast normalizes to the priority wire value, default is a client-only sentinel that is never sent. Off by default. See codex-configuration.md §9. |
count_tokens |
tiktoken (default) | estimate |
For responses and cursor providers: tiktoken computes a local count (o200k_base) and returns {"input_tokens": N}; estimate returns 501 not_supported so the client falls back on its own. See §4. |
Most third-party "use Claude Code with X" gateways are Anthropic-Messages-compatible: they are
kind = "anthropic" with auth = "api_key", differing only in base_url and the key env var.
shunt injects the key and forwards the request. When the routed upstream model is not an
Anthropic id (claude* / anthropic/*), it also strips Claude Code's deferred-tool protocol
(defer_loading, tool_search_tool_*) — OpenRouter's skin (and similar hosts) reject that
protocol on stealth and other non-Anthropic slugs with HTTP 400. Anthropic models keep the
fields. Ready-to-use entries (uncomment in
shunt.toml.example, set the env var, add a [[routes]] line):
| Provider | base_url |
Example model IDs |
|---|---|---|
| Kimi (Moonshot) | https://api.moonshot.ai/anthropic |
kimi-k3[1m], kimi-k2.7-code |
| Kimi Code (subscription, OAuth) | https://api.kimi.com/coding |
use the ids your subscription exposes |
| DeepSeek | https://api.deepseek.com/anthropic |
deepseek-v4-pro, deepseek-v4-flash |
| Z.ai (GLM) | https://api.z.ai/api/anthropic |
glm-5.2, glm-4.7 |
| Zhipu (GLM China) — built-in preset | https://open.bigmodel.cn/api/anthropic |
glm-5.3, glm-5.3-flash |
| MiniMax | https://api.minimax.io/anthropic |
see MiniMax docs |
| MiniMax China — built-in preset | https://api.minimax.cn/anthropic |
MiniMax-M3 |
| OpenCode Zen — built-in preset | https://opencode.ai/zen |
claude-fable-5-1, gpt-6-astra — curated cross-vendor catalog; reads x-api-key |
| Cheaper Inference — built-in preset | https://api.cheaperinference.com |
gpt-5.4, deepseek-v4-flash, glm-5.3 — OpenAI-compatible gateway with a Messages endpoint |
| Mimo (Xiaomi) | https://api.xiaomimimo.com/anthropic |
mimo-v2.5-pro — see Mimo docs |
| OpenRouter | https://openrouter.ai/api |
anthropic/claude-opus-4.8, ~anthropic/claude-sonnet-latest |
| Vercel AI Gateway | https://ai-gateway.vercel.sh |
anthropic/claude-opus-4.8 (accepts x_api_key) |
zhipu, minimax-cn, opencode, and cheaperinference are also built-in presets, so they do not need a [providers.*] table
from shunt.toml.example. Use the ordered [[upstreams]] form instead — for example,
provider = "zhipu" or provider = "minimax-cn" — and keep the anthropic passthrough default.
Every row above but one takes auth = "api_key". Kimi Code is the exception: a separate,
subscription-billed Kimi service from the metered Moonshot API in the row above it — different
host, and OAuth instead of an API key. It has a dedicated built-in kimi-code preset (kind = "anthropic", base_url = "https://api.kimi.com/coding", auth = "kimi_oauth"), so it needs no
manual [providers.*] table, just a logged-in account and the ordered [[upstreams]] form
(§1.2 in upstreams-failover.md) that resolves preset shortcuts —
the plain [providers.<name>] map used elsewhere in this section has no preset lookup:
shunt login kimi --name <account-name> # RFC 8628 device flow -> ~/.shunt/accounts/kimi/<account-name>.json# shunt.toml — route to your Kimi Code subscription
[[upstreams]]
name = "kimi-code"
provider = "kimi-code"
auth = { mode = "kimi_oauth", account = "<account-name>" }
# Declaring [[upstreams]] replaces the built-in provider set, so keep a trailing
# anthropic passthrough — without it `shunt check` rejects the default
# server.default_provider. This is the same entry `shunt init` appends.
[[upstreams]]
name = "anthropic"
provider = "anthropic"
[[routes]]
model = "<model-id-your-subscription-exposes>"
provider = "kimi-code"kimi_oauth is pool-capable like claude_oauth/chatgpt_oauth — use accounts = [...] in place
of account to spread load across several stored Kimi accounts. See
Kimi → Kimi Code (OAuth subscription)
for the full walkthrough.
For example, to route Kimi's (Moonshot) model through shunt:
[providers.kimi]
kind = "anthropic"
base_url = "https://api.moonshot.ai/anthropic"
auth = "api_key"
api_key_env = "MOONSHOT_API_KEY"
[[routes]]
model = "kimi-k3[1m]"
provider = "kimi"
[[routes]]
model = "kimi-k2.7-code"
provider = "kimi"Then export MOONSHOT_API_KEY=…, point Claude Code at shunt (§5.1), and select kimi-k3[1m]
(via ANTHROPIC_MODEL or the /model picker). Run shunt check to validate — it reports an
unknown provider in a route, a missing api_key_env, or a bad base_url.
When an Anthropic provider uses auth = "claude_oauth", create each private store account with one of three login modes:
# Recommended: create a new refreshable credential.
shunt login claude --name primary --mode oauth
# Copy the current Claude Code refreshable login.
shunt login claude --name imported --mode import
# Create a one-year, inference-only static token.
shunt login claude --name ci --mode setup-tokenIf --mode is omitted on a TTY, shunt presents these three choices and defaults to OAuth; non-interactive input retains the historical import default. --long-lived remains a deprecated alias for --mode setup-token.
Full OAuth first binds an ephemeral listener to 127.0.0.1, opens the authorization URL, and receives the redirect at http://127.0.0.1:<port>/callback. Browser-open, bind, callback, and 5-minute timeout failures fall back to a hidden manual-paste prompt. Use --manual to choose the paste flow immediately, especially over SSH:
shunt login claude --name remote --mode oauth --manualThe optional [server.admin] dashboard can provision full-OAuth or setup-token accounts remotely with the manual redirect. Its UI defaults to full OAuth; the start API accepts mode = "oauth" or mode = "setup_token" and defaults an omitted mode to setup_token for backward compatibility. Import remains CLI-only because it reads the host's existing Claude Code credential.
Refreshable files contain rotating credentials. A successful refresh can replace the refresh token and invalidate its previous value, so give each file exactly one active shunt owner. Do not share the same file across processes or independently run copied snapshots on multiple hosts; provision each process separately. Setup-token accounts are non-refreshable and do not have this rotation hazard.
See m8-anthropic-multi-account.md, m9-admin-surface.md, and the user-facing CLI reference for the complete pool and provisioning behavior.
cargo run -- check # or: shunt check / shunt --check
# -> prints "config ok", or a specific error (bad bind address, unknown provider, …)Beyond config validation, check runs the boot path's routed-Antigravity
credential guard (issue #382): a config that routes to the native antigravity
upstream with no credential in ~/.shunt/antigravity-auth.json fails here, with
the same message shunt run refuses to start with, rather than passing the
check and dying at startup. A configured account/accounts selection or named
files in ~/.shunt/accounts/antigravity satisfy the guard without the singleton
file. The guard is keyed on the config being able to route
to such a provider — not on the built-in antigravity table merely existing,
which every default config has — and is offline: it probes credential
existence and never refreshes a token or writes to it.
# From the source tree
cargo run -- run # default subcommand is `run`, so `cargo run` also works
cargo run -- run --config ./shunt.toml
# From a release build
./target/release/shunt run
./target/release/shunt run --config /path/to/shunt.tomlOn start it logs shunt listening with the bound address. Set log verbosity with RUST_LOG,
e.g. RUST_LOG=shunt=debug cargo run -- run.
Installed via Homebrew, shunt can run under brew services instead of a foreground terminal:
brew services start shunt # launches `shunt run` in the background
brew services restart shunt # e.g. after a binary upgrade
brew services stop shunt # sends SIGTERM; shunt drains to its deadline, then exits
brew services info shuntSIGTERM and ctrl-c both stop new admission and start the same bounded drain. Active HTTP responses,
SSE streams, and upgraded WebSocket sessions may finish within [server] shutdown_timeout_seconds
(default 30, valid 1..=3600). One absolute deadline starts at the first signal and covers every
connection; when it expires, shunt cancels the remaining server work and returns normally so Rust
drop cleanup and telemetry flushing still run. See Bounded shutdown.
On Unix, Antigravity agy runs are the exception: shunt terminates their isolated process groups as soon
as shutdown starts, because gateway signals do not reach those groups and an unattended agent must not hold
the drain open. Send a second signal (another ctrl-c, or kill again) to skip the drain and
exit immediately with the conventional 128+signal exit status: 143 for a second SIGTERM, 130 for
a second ctrl-c/SIGINT. The immediate-exit path also terminates any Antigravity groups before the
process exits.
Logs go to $(brew --prefix)/var/log/shunt.log (stdout and stderr combined). Config discovery
works the same as any other invocation (see §3 Configure): a service has no
meaningful working directory, so the search starts at $XDG_CONFIG_HOME/shunt (or
~/.config/shunt) — an existing config there still wins over the Homebrew prefix. Absent one,
the effective candidate is $HOMEBREW_PREFIX/etc/shunt.toml ($(brew --prefix)/etc/shunt.toml,
or .yaml/.yml). Run shunt init --root "$(brew --prefix)/etc" to create a starter file
there, or place your own wherever should win — check the boot log to see which file the service
actually loaded.
Editing the file the service actually loaded does not require brew services restart — the
running process picks up the change automatically via config hot-reload
(file-watch, or send SIGHUP yourself). Only the handful of fields listed under §4 Fields that
require a restart need an actual brew services restart.
| Method | Path | Purpose |
|---|---|---|
HEAD |
/ |
Liveness probe |
GET |
/ |
Human-readable landing (version + endpoint list) |
GET |
/health |
Healthcheck — {"status":"ok","version":"x.y.z"} |
GET |
/v1/models |
Model discovery (returns your [[models]] entries) |
GET |
/routes |
Route discovery (returns your [[routes]] table) |
POST |
/v1/messages |
Inference — routed per the request's model id |
POST |
/v1/messages/count_tokens |
Token counting (see below) |
GET / and GET /health stay open even when [server.auth] is enabled (healthcheck tools
usually cannot attach tokens) and expose nothing sensitive — only status, version, and the
already-public endpoint list. They also bypass max_concurrent_requests, so saturation never
causes a load balancer to evict an instance that is still serving existing work.
All other routes count against server.max_concurrent_requests (default 1024). shunt sheds an
excess request immediately with 503 overloaded_error and Retry-After: 1; it never queues the
request. A streaming response holds its slot until the response body finishes or the client
disconnects. Set the value to 0 to retain unlimited concurrency. The limit is fixed at startup,
so changing it through config reload requires a restart.
count_tokens: for an Anthropic-routed model shunt passes the request through to the
upstream's count_tokens endpoint (exact counts). For a responses-routed model (codex/OpenAI)
there is no equivalent upstream endpoint, so the provider's count_tokens setting decides:
count_tokens = "tiktoken"(default) — shunt computes the count locally with tiktoken'so200k_baseencoder and returns{"input_tokens": N}. o200k_base is the GPT-family encoder, so for responses-routed models the text count is near-exact, though it can't see the backend's image/tool-schema encoding or cache accounting. Each count is answered in-process (~ms), which matters because Claude Code's/contextissues onecount_tokenscall per displayed item (system-prompt section, memory file, agent, deferred tool, …) — 30–50 calls per invocation. The encoder's vocabulary loads once at startup (about half a second) whenever a non-Anthropic provider uses this setting, so neither the first count nor the first streaming turn'smessage_startinput estimate waits on it.count_tokens = "estimate"(opt-in) — shunt returns 501not_supported, telling Claude Code that the endpoint is unavailable and triggering its fallback. Note what Claude Code actually does then: the main-loop context bar estimates locally, but/contextre-runs every category count against Haiku over the network — slow, and silently reported as 0 tokens when no Anthropic credential is available. Use it only if you want shunt to carry no tokenizer.
Either way the request never reaches the responses adapter, so a count request is never turned into (and billed as) a full inference call. Opt out per provider:
[providers.codex]
kind = "responses"
count_tokens = "estimate"Set the base URL to your running gateway (default bind 127.0.0.1:3001). Set it in your shell,
or persist it in a settings file env block.
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001Or in ~/.claude/settings.json:
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:3001"
}
}Keep your existing Anthropic credential (claude.ai login, or ANTHROPIC_API_KEY /
ANTHROPIC_AUTH_TOKEN — §5.2 covers which to choose). shunt forwards it unchanged to
api.anthropic.com for every model you didn't map, so unmapped models keep working exactly as
before. Provider credentials for mapped models are injected by shunt itself (§5.3) — Claude Code
never sends them.
The credential Claude Code sends to shunt plays two roles: it authenticates Claude passthrough
models (forwarded unchanged to api.anthropic.com), and it gates model discovery — Claude
Code only issues the GET /v1/models request (§5.5) when ANTHROPIC_AUTH_TOKEN, an API key, or
an apiKeyHelper is set; under a plain claude.ai login it sends nothing. Mapped models (gpt-*
etc.) are unaffected either way — shunt injects the provider credential for them (§5.3).
| Credential | Token refresh | Discovery (§5.5) | Claude passthrough | Billing |
|---|---|---|---|---|
| claude.ai OAuth login only | Claude Code refreshes it automatically | ❌ never fires | ✅ | subscription |
ANTHROPIC_AUTH_TOKEN=sk-ant-oat… (claude setup-token) — recommended |
none needed (one-year token) | ✅ | ✅ | subscription |
apiKeyHelper = shunt token (reuse the live login) |
the helper refreshes it | ✅ | ✅ | subscription |
ANTHROPIC_AUTH_TOKEN=<real API key> |
none needed | ✅ | ✅ | API (not subscription) |
(A dummy value like sk-dummy satisfies the discovery gate but breaks passthrough — it is
forwarded to Anthropic and returns 401.)
Prefer claude setup-token. It mints a one-year OAuth token
(authentication docs),
so nothing needs refreshing, and one value covers both roles — it satisfies the discovery gate
and authenticates Claude-passthrough models on your subscription:
claude setup-token # browser sign-in → prints sk-ant-oat…
export ANTHROPIC_AUTH_TOKEN=sk-ant-oat… # or persist it in a settings `env` blockThe login-only row works because Claude Code keeps refreshing its own login while no gateway
credential is set — you only lose discovery (use ANTHROPIC_CUSTOM_MODEL_OPTION, §5.4, instead).
The trap sits in between: once a gateway credential is active, Claude Code stops refreshing the
login, so the short-lived access token inside ~/.claude/.credentials.json (macOS: Keychain)
expires within hours and a helper that just reads that file breaks. Refreshing it manually is
discouraged — platform.claude.com/v1/oauth/token is aggressively rate-limited/WAF-guarded (a
single stray call can return 429), and it rewrites your live login file. To reuse the live
subscription login instead of minting a separate token, use the built-in shunt token helper,
which refreshes it safely.
shunt token prints a Claude subscription OAuth token to stdout (logs go to stderr), so it
can be wired straight into Claude Code's apiKeyHelper. It has two modes:
- Static — if
SHUNT_GATEWAY_TOKENorCLAUDE_CODE_OAUTH_TOKENis set, it echoes that value unchanged. Point it at aclaude setup-tokenvalue and nothing is ever refreshed. - Auto-refresh — otherwise it reads
~/.claude/.credentials.json(override withCLAUDE_CREDENTIALS), returns theclaudeAiOauthaccess token, and when it is within 5 minutes ofexpiresAtrefreshes it againstplatform.claude.com/v1/oauth/token(the same grant Claude Code uses), then writes the new token back atomically at0600, preserving every other field. Refresh happens only on actual expiry, to respect the endpoint's rate limit.
Wire it up via apiKeyHelper in ~/.claude/settings.json (or the project .claude/settings.json):
{
"apiKeyHelper": "/path/to/shunt token"
}Claude Code calls the helper for its gateway credential, so discovery fires; SHUNT_GATEWAY_TOKEN
(static) vs. no override (auto-refresh) selects the mode. The refresh path touches your real login
file, so the static + setup-token route stays the simplest and safest default.
Why this works for Claude passthrough even though
apiKeyHelperis an API-key mechanism. Claude Code sends anapiKeyHelpervalue in both thex-api-keyandAuthorization: Bearerheaders. A Claude subscription OAuth token (sk-ant-oat…) authenticates only as a bearer, so the copy echoed intox-api-keywould otherwise makeapi.anthropic.comreject the request as an invalid API key. shunt normalizes this on the passthrough path: when the forwarded bearer is an OAuth token it drops the duplicatedx-api-keybefore forwarding, leaving the bearer to stand alone (outbound_headers,src/adapters/anthropic/mod.rs). A real API key (theANTHROPIC_API_KEYpath, which sendsx-api-keyand no bearer) is never touched. Without this normalization,apiKeyHelper+ an OAuth token would only satisfy the discovery gate and mapped-model routes — Claude passthrough would 401.
A different helper for a different credential. shunt token above prints an upstream Claude
subscription token; shunt gateway token prints an access token issued by a shunt deployment
that has [server.gateway] enabled, so each user carries their own gateway session instead of a
shared client token. The two are independent — neither reads the other's storage — and shunt login <provider> and shunt token keep their existing upstream meaning.
Sign in once per machine, then wire the helper:
shunt gateway login https://gateway.example.com # device flow; approve in a browser
shunt gateway token # prints the access token
shunt gateway logout # discards the stored sessionshunt gateway login reads the deployment's /.well-known/oauth-authorization-server document,
prints a verification URL plus a user code, opens a browser (--manual prints the URL instead),
and polls until you approve. A URL that is plain http:// and not loopback is accepted but warns:
the device code and refresh token would travel unencrypted, on every later refresh as well as
during this login. shunt gateway token repeats that warning on stderr each time it actually
refreshes — tied to a refresh rather than to every helper invocation, since the cached-token fast
path makes no network call at all. If the deployment answers the discovery request with 404, it is most likely
missing [server.gateway]. Discovery and the device-code request are each bounded on the same
budget as the refresh path, so a deployment that completes the TCP handshake and then never answers
reports a timeout instead of waiting indefinitely with only Ctrl-C to end it. The approval poll is
deliberately excluded: waiting is its job, and it already has its own deadline plus a per-attempt
transport retry.
The discovery document is remote input, so two floors apply to what it can direct shunt to do. The
device code and refresh token are POSTed only to an endpoint advertised over https, or over http
to a loopback address — anything else fails discovery naming the offending endpoint, rather than
receiving the credential. That floor has one narrow widening, for the private-network and VPN
deployments that have no certificate to present: a gateway reached over plain http, accepted with
the unencrypted-traffic warning that repeats on every refresh, may advertise plaintext endpoints on
its own origin — scheme, host, and port all matching the operator-supplied base URL. The origin
is computed from that base URL and never from the document, so a hostile document cannot nominate
its own. Any other plaintext endpoint is refused unless its host is loopback — that carve-out is
independent of this widening and holds for every gateway, an https one included. (The check has to
happen here rather than falling out of the HTTP clients: the refresh POST follows no redirects at
all, and the redirect-hardened client that discovery uses vets only redirect targets, which a
document naming a bad endpoint outright never produces.) The verification URL is likewise handed to
the OS handler only when it is http or https: the gateway picks that string, and a file:// or
smb:// value would otherwise be acted on with no confirmation. It is printed either way.
The session lands in ~/.shunt/gateway/session.json (override with SHUNT_GATEWAY_SESSION_FILE),
written owner-only — file 0600 inside a 0700 directory. Note that the similarly named
SHUNT_GATEWAY_TOKEN is unrelated: it is the static override for shunt token, described above.
{
"apiKeyHelper": "/path/to/shunt gateway token"
}shunt gateway token prints the token and nothing else to stdout; every message goes to stderr.
It serves the stored token until it is within 5 minutes of expiry, then refreshes it. The
gateway's refresh tokens rotate and are single-use, so the refresh runs under an exclusive file
lock and re-reads the session after acquiring it: several Claude Code sessions can call the helper
at the same moment and only one refresh happens. Without that, the losing caller would replay a
spent refresh token, which revokes the whole rotation family and signs the user out everywhere.
shunt gateway login and shunt gateway logout take the same lock and hold it across their own
write, so neither can be undone by an in-flight refresh's writeback landing after it.
Neither the lock wait nor the refresh itself is unbounded. Each gateway round-trip is wrapped in a
timeout, and the lock is acquired non-blocking on a deadline rather than with a bare LOCK_EX. The
case that motivates both: a deployment that completes the TCP handshake and then never answers.
The holder would wait forever inside the critical section, and every other apiKeyHelper on the
machine would block behind it silently — one unreachable gateway taking down every Claude Code
session. The timeout is applied with tokio::time::timeout inside the gateway module rather than
as reqwest's .timeout(), which is process-wide on the shared refresh client and also covers the
response body (streaming paths must never set it).
Crossing the 5-minute buffer makes a refresh due, not mandatory. If the refresh fails while the
cached token is still genuinely unexpired, that token is served with a warning on stderr, so a
brief network blip four minutes before real expiry does not become a user-visible auth failure;
once the token has actually expired the command fails hard. That fallback needs proof that nothing
was rotated, so it is restricted to the two failures that provably never presented the refresh
token: discovery, which never carries it, and a token POST that could not open a connection. From
the moment the POST is sent the gateway may already have rotated the token, so the bound expiring,
an unreadable or unparseable body, and any error the gateway names all fail the command instead —
serving the cached token there would leave a spent refresh token on disk for the next helper run
to replay. expires_in is clamped on the way in — a gateway reporting it in milliseconds would
otherwise cache a dead token as valid for weeks, with no recovery but deleting session.json by
hand. Clamping is safe even when it is wrong, since a too-short cached expiry only triggers an
earlier refresh. Finally, a token that would fail Claude Code's apiKeyHelper validator
(1..=16384 characters of printable ASCII, no whitespace) is refused with a diagnostic naming the
gateway rather than printed to fail authentication silently — and shunt gateway login puts the
token it is issued through that same gate before storing anything, so a deployment that cannot
produce a conforming one fails the login instead of saving a session that reports success and can
never be used.
shunt gateway logout removes the session but deliberately leaves the empty sibling .lock file
in place: that inode is what logout, login, and refresh serialize on, and unlinking it would let an
in-flight holder keep a lock on the old inode while the next process locks a freshly created one,
so the two would stop excluding each other.
shunt gateway claude does the wiring per invocation instead of asking you to edit settings:
shunt gateway claude
shunt gateway claude -p 'summarize this repo' --model opusIt launches claude with an inline --settings document that points ANTHROPIC_BASE_URL at the
gateway from the stored session and sets apiKeyHelper to this shunt binary's absolute path plus
gateway token. The document applies to that one process — ~/.claude/settings.json is never
modified — and it takes precedence over an ANTHROPIC_BASE_URL already exported in the shell, so
it is safe to run inside a shell configured for a different gateway (measured against Claude Code
2.1.234).
Everything after claude is forwarded verbatim, so Claude Code's own flags reach it unchanged.
Two exceptions when they lead the argument list: --help prints shunt's help, and --config is
rejected with an error rather than silently consumed by shunt's global option. Pass either
after -- to forward it:
shunt gateway claude -- --config /path/to/claude/configThis route supplies the credential through apiKeyHelper, which leaves Claude Code in its
ordinary first-party mode; it does not put the client into a signed-in Claude apps gateway
session the way forceLoginMethod: "gateway" does. Which credential slot the token arrives in is
what selects that mode, and a gateway session additionally resolves the opus/sonnet aliases to
older model ids. This is not "no restrictions": the route still sets a credential, so Claude Code's
credential-type gate — the one any apiKeyHelper trips, covering prompt-cache TTL defaults, Remote
Control, voice dictation, and artifact publishing — applies here exactly as it does to shunt token
above. The two gates are independent. The consequence to plan around: shunt's per-user GET /managed/settings policy
channel is fetched by a signed-in gateway session, and a helper-credential session was not
observed requesting it (measured against Claude Code 2.1.234) — use forceLoginMethod: "gateway"
when you need the client to enforce per-user policy.
- OpenAI provider: export the key named by
api_key_env(defaultOPENAI_API_KEY) in the environment shunt runs in. shunt also reads a key from~/.codex/auth.jsonwhen it's inApiKeymode.export OPENAI_API_KEY=sk-... - Codex / ChatGPT provider: log in with the Codex CLI once; shunt reads and auto-refreshes
~/.codex/auth.json.If the file is missing/expired, shunt returns ancodex login
authentication_errortelling you to runcodex login.
⚠️ Experimental — not yet verified against the live xAI API. Implemented from the reference clients (Hermes, OpenCode) and covered by unit tests with mocked endpoints; it has not been exercised with a real SuperGrok account orXAI_API_KEYyet. Expect rough edges and report issues.
shunt ships a built-in xai provider (kind = "responses", base_url = https://api.x.ai/v1).
It defaults to the API-key path; flip it to xai_oauth to reuse a SuperGrok / X Premium+
subscription. Full spec: m6-xai-provider.md.
API key (default) — just export the key and add routes:
# built-in defaults already define [providers.xai]; you only add routes
[[routes]]
model = "grok-4.6" # current frontier model
provider = "xai"
[[routes]]
model = "grok-build-0.1" # flagship coding model
provider = "xai"export XAI_API_KEY=xai-...Subscription OAuth — flip the provider's auth and log in once with the device-code flow:
[providers.xai]
auth = "xai_oauth" # reuse the SuperGrok / X Premium+ login instead of a key
# base_url stays https://api.x.ai/v1 — shunt refuses xai_oauth on a non-x.ai or non-https host
[[routes]]
model = "grok-4.6"
provider = "xai"
[[routes]]
model = "grok-build-0.1"
provider = "xai"shunt login xai # prints a URL + code; approve in any browsershunt login xai runs the RFC 8628 device-code flow: it prints a verification URL and short
code, you approve in a browser (on any device — no loopback port needed, so it works over
SSH/VPS/Docker), and it saves ~/.shunt/xai-auth.json (override with SHUNT_XAI_AUTH_FILE).
shunt refreshes the token automatically. A 403 on refresh means the account isn't entitled to
xAI API access (a subscription-tier gate) — shunt says so distinctly and points you at the
XAI_API_KEY path rather than telling you to re-login; a 400/401 tells you to run
shunt login xai again.
Reasoning effort is opt-in for grok. Several grok models reject
reasoning.effortwith a 400, so shunt sends the reasoning dial only when an effort was explicitly chosen — anefforton the route or provider, or a per-requestoutput_config.effortfrom the client (e.g.effort = "high"under[providers.xai]or a[[routes]]entry). Without it, grok reasons on its own and shunt sends noreasoningobject.
Several SuperGrok accounts. shunt login xai --name <name> stores a login at ~/.shunt/accounts/xai/<name>.json (override the directory with SHUNT_XAI_ACCOUNTS_DIR). With no config change, an xai_oauth provider pools every account there and rotates on 401/402/429/5xx (a 403 is relayed); scope it with [[providers.grok.accounts]] or auth = { mode = "xai_oauth", accounts = [...] }. ~/.shunt/xai-auth.json is used only while that pool is empty. Spec: m18-xai-multi-account.md.
Claude Code's model-discovery only honors ids beginning with claude/anthropic, so for
OpenAI/Codex ids (gpt-*) use ANTHROPIC_CUSTOM_MODEL_OPTION — it adds a picker entry whose id
skips validation:
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol"Then pick it from /model in Claude Code. That id is what shunt routes on, so it must resolve
through a matching [models.upstream_model] entry, [[routes]], or [[route_prefixes]] rule in
your config.
The two picker-exposure methods split cleanly on the claude-/anthropic- prefix — they don't
overlap. Discovery honors only claude-/anthropic- ids; ANTHROPIC_CUSTOM_MODEL_OPTION and
the CLAUDE_CODE_MAX_CONTEXT_TOKENS window override apply only to ids that do not start with
that prefix. The consequence: a claude-…-via-codex discovery alias is convenient (auto-listed,
one-tap selectable) but its context window is stuck at the 200k default — the override can't
reach a claude--prefixed id (§5.8).
| What | claude-/anthropic- id (discovery alias) |
non-claude- id (e.g. gpt-5.6-sol) |
|---|---|---|
/v1/models discovery → /model picker |
✅ auto-listed ("From gateway"), many models | ❌ dropped by Claude Code |
ANTHROPIC_CUSTOM_MODEL_OPTION |
❌ not honored | ✅ adds to picker (one id only) |
CLAUDE_CODE_MAX_CONTEXT_TOKENS window |
❌ ignored → 200k default | ✅ applies → set the real window (e.g. 372k) |
So choose by priority: the discovery alias for picker convenience across several models (accept
the conservative 200k denominator), or a non-claude- id via ANTHROPIC_CUSTOM_MODEL_OPTION for
an accurate window, one model at a time. (Subagents are a separate path — see below.)
Model slugs: the ChatGPT-account Codex backend rejects
gpt-*-codexslugs (e.g.gpt-5.2-codex) — it only accepts the account's live-entitled slugs. The authoritative catalog of Codex slugs (and the reasoning levels each accepts) is openai/codex'scodex-rs/models-manager/models.json. The current listed slugs aregpt-6-astra,gpt-6-sol,gpt-6-luna,gpt-6.1-sol,gpt-6.1-terra(latest),gpt-5.6-sol,gpt-5.6-terra,gpt-5.6-luna(frontier), andgpt-5.5/gpt-5.4; older accounts may only be entitled to the earlier ones. Useupstream_modelin a route, or pass an entitled slug viaANTHROPIC_CUSTOM_MODEL_OPTION. Seem2-chatgpt-oauth.md§0.
Client-version gating: some slugs additionally carry a
minimal_client_version(e.g.gpt-6-astrarequires ≥ 0.153.0,gpt-6-solandgpt-6-luna≥ 0.155.0) and the backend answersModel not found <slug>— not an entitlement error — when the request's client identity is missing or too old. The gate keys on theoriginator+versionheaders (openai/codex#31967). shunt therefore sends the Codex CLI identity headers (originator: codex_cli_rs,version, and a matchinguser-agent) on ChatGPT OAuth requests. shunt pins these headers to openai/codex rust-v0.159.3. If a future slug demands a newer client, bump the pinned version insrc/adapters/responses/request.rs(CODEX_USER_AGENT/CODEX_CLIENT_VERSION).
Per-context selection also works via Claude Code's own knobs — divert one agent to a mapped model while the main session stays on Claude:
- A named subagent's
model:frontmatter (.claude/agents/<name>.md) is the only way to put a subagent on agpt-*id: that field accepts any string, whereas the Agent/Task tool'smodelparameter is restricted to the built-in aliases (opus/sonnet/haiku/fable) and cannot take a gateway id. Spawn the agent by its type without amodeloverride — the tool parameter outranks frontmatter (resolution order:CLAUDE_CODE_SUBAGENT_MODEL> toolmodel> frontmatterinherit), so passing one would shadow the mapped model. CLAUDE_CODE_SUBAGENT_MODELforces every subagent onto one model (global).
The context window follows the model automatically: CLAUDE_CODE_MAX_CONTEXT_TOKENS (§5.8) is keyed
on the id, so one global value sizes the mapped subagent (e.g. 372k) while the Claude main keeps its
own — no per-subagent env is needed, and the same overflow/compact behavior (§5.8) applies.
Verify named-subagent routing at both ownership boundaries. Claude Code's stream-JSON Agent result
records the client-side selection as agentType + resolvedModel; this field requires Claude Code
2.1.174 or newer, and no output on an older client is not evidence that routing fell back. For an
exact [[routes]] entry, shunt's GET /routes records the gateway-side model → provider /
upstream_model mapping:
claude --output-format stream-json --verbose -p \
"Use the researcher agent once without a model override, then stop." \
| jq -Rr '
fromjson?
| .tool_use_result?
| objects
| select(.resolvedModel?)
| [(.agentType // null), .resolvedModel]
| @tsv
'
# `GET /routes` exposes exact `[[routes]]` entries only; it does not expand
# `[[route_prefixes]]`.
curl -s "${ANTHROPIC_BASE_URL%/}/routes" \
| jq '.data[] | select(.model == "gpt-5.6-sol")'Diagnose in that order: a resolvedModel that differs from the agent's frontmatter means Claude
Code chose a different model ID before shunt saw the request (check the global env override,
tool-level override, and agent frontmatter, then inspect the matching exact route because shunt may
intentionally remap that ID); a correct resolvedModel with a wrong exact /routes entry is shunt
configuration. For a prefix-routed ID, inspect the active [[route_prefixes]] entry instead, then
send a minimal request through that ID to exercise the effective route. Correct client-side and
gateway-side evidence points to provider auth, entitlement, quota, or compatibility. Do not use
model self-identification as routing evidence.
Discovery (GET /v1/models) can populate /model automatically — but Claude Code ignores
any id that doesn't begin with claude/anthropic (protocol
reference). So a gpt-*
id is dropped client-side no matter what; discovery is only useful when you expose a
Claude-named alias. The alias can route and translate directly through a single-provider
[models.upstream_model] table:
[[models]]
id = "claude-gpt-5.6-sol-via-codex" # must begin with claude/anthropic
display_name = "GPT-5.6-Sol (via Codex)"
[models.upstream_model]
codex = "gpt-5.6-sol" # provider = real upstream slugThis map takes precedence over [[routes]], [[route_prefixes]], and server.default_provider
for the advertised id. It must name exactly one configured provider; an unknown provider, an empty
or multi-provider map, or a same-id [[routes]] entry is a startup error. Existing map-less
[[models]] entries can continue to use a separate route:
[[models]]
id = "claude-gpt-5.6-sol-via-codex"
display_name = "GPT-5.6-Sol (via Codex)"
[[routes]]
model = "claude-gpt-5.6-sol-via-codex"
provider = "codex"
upstream_model = "gpt-5.6-sol"Then enable discovery (Claude Code v2.1.129+) and restart shunt + Claude Code:
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1The alias appears in /model labeled From gateway; selecting it sends
claude-gpt-5.6-sol-via-codex, which shunt routes to codex and rewrites to gpt-5.6-sol. Discovery
fails silently (3-second timeout, any redirect counts as failure) and falls back to the
cached/built-in list — run claude --debug and look for [gatewayDiscovery] lines to confirm
it ran. For gpt-* ids without an alias, use ANTHROPIC_CUSTOM_MODEL_OPTION (§5.4) instead.
See m3-discovery.md.
Discovery needs a gateway credential — a claude.ai OAuth login alone won't trigger it. Claude Code only issues the
/v1/modelsrequest whenANTHROPIC_AUTH_TOKEN, an API key, or anapiKeyHelperis set; under a plain Max/Pro subscription login it sends nothing (no request reaches shunt, no cache is written) even with the flag on. Verified: shunt served/v1/modelscorrectly, but the request never left Claude Code until a token was set. See §5.2 for choosing the credential —claude setup-tokenis the recommended route.
Claude Code's effort level (/effort, the /model slider, --effort, or
CLAUDE_CODE_EFFORT_LEVEL) is sent as the output_config.effort request field, and shunt maps
it to the Responses reasoning.effort for mapped models:
| Claude Code effort | → reasoning.effort |
|---|---|
low / medium / high / xhigh |
passthrough |
max |
passthrough on models that accept it (the gpt-5.6 and gpt-6 families), else folded to xhigh |
Which reasoning levels a Codex slug accepts is listed per-model in openai/codex's
models.json
(supported_reasoning_levels): gpt-5.6-sol/-terra/-luna and the gpt-6 slugs
(gpt-6-astra/-sol/-luna) accept up to max, and every one except the two Luna slugs also
lists ultra, which Claude Code never sends. gpt-5.5/5.4/5.2 cap at xhigh. shunt folds
max → xhigh only for slugs that don't support it.
A custom gateway id like gpt-5.6-sol carries effort on its own. Verified on the wire against Claude Code v2.1.224: the request already
includes output_config.effort with CLAUDE_CODE_ALWAYS_ENABLE_EFFORT unset, and setting it to 1
produces a byte-identical request. The flag applies to non-first-party providers (Bedrock, Vertex,
Foundry, gateway login), not to a plain ANTHROPIC_BASE_URL setup.
Effort is instead lost on ids the client normalizes onto its legacy effort deny list
(claude-sonnet-4-5, claude-haiku-4-5): those send no output_config.effort at all, no
environment variable overrides it, and shunt falls back to medium.
Precedence in shunt: a config route.effort / [providers.*].effort override wins first;
otherwise the request's output_config.effort is honored; otherwise thinking.enabled → high,
then a model-name suffix (-xhigh/-high/-medium/-low, with -spark treated as -low), else medium.
Claude Code prepends an attribution line to the system prompt
(x-anthropic-billing-header: cc_version=…; cc_entrypoint=cli;). Anthropic strips it before
processing, but shunt forwards it unchanged, so a mapped provider such as the ChatGPT Codex
backend receives it as the first line of instructions. It's harmless (requests still succeed)
but meaningless noise for a non-Anthropic model. To drop it:
export CLAUDE_CODE_ATTRIBUTION_HEADER=0This is global, so it also removes attribution from any Anthropic-passthrough traffic (used for cost tracking) — which is fine when you're routing to another provider.
With =0, some Claude Code side requests carry no first-party marker at all: mods'
$.model.complete queries lose the attribution block that was their only one, and the auto-mode
classifier never carries the identity prompt. On a subscription-OAuth Anthropic route those would
otherwise reach api.anthropic.com unmarked and come back as a bare 429 (client-shape-rejection). shunt
re-adds the Claude Code identity block on Claude Code requests (identified by
x-claude-code-session-id, a claude-cli/ user agent, or x-app: cli) that carry no accepted marker, so =0
stays safe.
Claude Code's statusline and prompt footer compute the context indicator locally from the
assistant message's token usage (input_tokens + cache_read + cache_creation) divided by the
model's context-window size — no server field controls it. Two consequences for models routed to
a responses provider (codex/OpenAI):
- Token count (the numerator) is accurate. shunt reads
input_tokens(and cached tokens) from the Responsesusageand forwards them in the Anthropicmessage_delta, so the bar fills as the conversation grows. (The OpenAIinput_tokenstotal includes cached tokens; shunt peels the cached part intocache_read_input_tokens, preserving the total.) - The window (the denominator) defaults to a fixed 200k for unmapped ids.
getContextWindowForModelreturns200_000for any model id it doesn't recognize, and its accurate per-model lookup (max_input_tokensfrom the gateway's/v1/models) is disabled unless the base URL isapi.anthropic.com— so a gateway can't set it. A model with a larger real window (e.g.gpt-5.6-solat 372k) therefore shows a conservative, over-reported percentage. This only makes Claude Code's auto-compact trigger a little early; it is otherwise harmless.
The 200k default can be overridden client-side with CLAUDE_CODE_MAX_CONTEXT_TOKENS
(verified in Claude Code 2.1.205): the window function uses this value for any model id that does
not start with claude-, which is exactly the mapped-model case:
# e.g. gpt-5.6-sol's real window
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000Caveats: it is global — one value for every non-claude- model in the session, so it can't be
set per-model when routing models with different window sizes — and setting it larger than the
real upstream window means auto-compact won't trigger in time, so requests overflow the real
limit. On the streaming path Claude Code actually uses, the ChatGPT/Codex backend surfaces a
prompt is too long error mid-stream, which shunt's context-overflow rewrite normalizes
(context_overflow_message, src/model/responses.rs) and Claude Code auto-compacts + retries on —
so the session recovers, but every overflow round-trip
is wasted latency. Match it to the smallest real window among your mapped models to avoid the
churn. (Live-verified for gpt-5.6-sol: 365k tokens answers normally, 372k+ overflows — the
boundary is its models.json context_window of 372000; gpt-5.5 is 272000. A non-streaming
request instead degrades to an empty 200 with input_tokens: 0, but the main loop always
streams.) Claude passthrough models
(claude-* ids) ignore it and keep their exact built-in sizes. (With DISABLE_COMPACT also set,
the value applies unconditionally — claude-* ids included.) This same claude- gate is why a
discovery alias (which must begin with claude-, §5.4) can't take this override — its window
stays pinned at the 200k default: safe (auto-compact fires early) but not raisable to the model's
real size without DISABLE_COMPACT. Use a non-claude- id when you need the accurate window.
The other client-side lever is the [1m] model-id suffix, which forces a 1M window — useful
for a genuinely 1M-context model, but misleading (under-reporting) for a smaller one, so avoid it
unless the upstream really has that window. shunt strips a trailing [1m] from the model id before
route matching and before forwarding upstream (routing.rs), so gpt-5.6-sol[1m] (or a
claude-…-via-codex[1m] discovery alias) still routes correctly and the provider never sees the
suffix — the hint stays purely client-side.
| Field | Mapped (responses) model |
Claude passthrough |
|---|---|---|
| Context tokens used | ✅ accurate (forwarded by shunt) | ✅ accurate |
| Context window (denominator) | CLAUDE_CODE_MAX_CONTEXT_TOKENS (or [1m] → 1M) |
✅ exact |
count_tokens (pre-flight) |
char/4, or count_tokens = "tiktoken" for a closer local count (§4) |
✅ exact (upstream) |
rate_limits (5h / weekly) |
❌ needs Anthropic anthropic-ratelimit-* headers |
✅ shown |
By default shunt has no inbound auth — fine for a loopback-only personal gateway, but once
you share it over a VPN/tunnel, anyone who can reach it can spend the operator's account
on mapped models (shunt injects its own api_key/chatgpt_oauth credential for those).
Passthrough models are not the concern: they forward each caller's own Anthropic credential.
[server.auth] gates injected-credential routes and model discovery with per-client tokens
(spec: m4-inbound-auth.md):
[server.auth] # both keys optional; defaults shown
header = "x-shunt-token"
tokens_env = "SHUNT_CLIENT_TOKENS"# Gateway side: name:token pairs (names are labels for logging; tokens are secrets)
export SHUNT_CLIENT_TOKENS="minsu:$(openssl rand -hex 32),alice:$(openssl rand -hex 32)"Startup fails closed if [server.auth] is present but the env var is unset or
malformed. Requests to mapped models and GET /v1/models without a valid token get a 401
authentication_error. Both gates accept the client token in any standard Anthropic
credential slot — the configured header (default x-shunt-token), Authorization: Bearer,
or x-api-key, in that priority when several carry valid tokens. GET /routes,
GET|HEAD /, GET /health, and passthrough models
stay open. GET /routes remains unauthenticated because it is a shunt-native endpoint exposing
routing metadata (the configured provider/upstream-model mapping), never credentials, which live
only in provider config and are never read by that handler.
On gated routes the accepted credential headers are always stripped before forwarding,
matching is constant-time, and token values are never logged (client names are, per request).
A passthrough route injects nothing and forwards the caller's own credential as presented,
except per slot: authorization and x-api-key are each cleared when that slot's own value is
one of shunt's own inbound credentials (an accepted client token, or a [server.gateway] JWT),
so a credential accepted at the gate is never relayed upstream while a genuine upstream
credential in the other slot keeps flowing (docs/m4-inbound-auth.md §2).
Client side, on a pool/mapped-only gateway (e.g. claude_oauth as the default provider)
the client token can simply be the Anthropic credential Claude Code already sends:
export ANTHROPIC_AUTH_TOKEN="<your client token>" # sent as Authorization: BearerWhen the gateway also serves passthrough models, the caller's real Anthropic credential
needs a slot of its own, so hand out dedicated tokens in the configured header and keep
authorization / x-api-key free for it (ANTHROPIC_CUSTOM_HEADERS takes one Name: Value
per line). This is hygiene rather than the only thing preventing a leak: a client token that
does arrive in authorization or x-api-key is cleared from that slot by value before any
passthrough forward.
export ANTHROPIC_CUSTOM_HEADERS="x-shunt-token: <your token>"This is application-layer identification only — transport encryption still comes from the deployment (WireGuard/Tailscale tunnel, or TLS termination in front); shunt itself serves plain HTTP.
Middleboxes kill quiet streams — Cloudflare's proxy returns 524 after 100 seconds without
a byte (fixed below Enterprise), and long reasoning stretches can be silent that long.
shunt therefore injects the Anthropic protocol's own ping event (which api.anthropic.com
itself emits and every client ignores) whenever a streaming response has been idle:
[server]
sse_keepalive_seconds = 30 # default; 0 disablesPings are injected only between complete SSE events (never inside a half-sent frame), only
on text/event-stream responses, and stop with the upstream stream. Behind a tunnel with
no idle timeout (WireGuard/Tailscale) the pings are harmless; disable with 0 if you want
byte-identical relaying. Spec: m5-sse-keepalive.md.
1. Test the gateway directly (proves the URL + routing work before opening Claude Code):
# Unmapped model -> forwarded to Anthropic (uses your Anthropic credential)
curl -s -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-sonnet-4-6","max_tokens":1,"messages":[{"role":"user","content":"."}]}'
# Mapped model -> diverted to the provider (uses shunt's provider credential)
curl -s -X POST "$ANTHROPIC_BASE_URL/v1/messages" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"gpt-5.6-sol","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'A JSON response starting with {"id":"msg_ means it worked.
2. Confirm inside Claude Code: start claude from the same shell, run /status, and check
the Anthropic base URL line shows http://127.0.0.1:3001. Then open /model and pick your
mapped model.
| Symptom | Cause / Fix |
|---|---|
ChatGPT auth not found; run codex login |
shunt can't read ~/.codex/auth.json. Run codex login. |
authentication_error on a mapped model |
Expired/absent provider credential — re-run codex login, or export OPENAI_API_KEY. shunt surfaces the backend's real detail message. |
400 ... model is not supported when using Codex with a ChatGPT account |
You used a -codex slug (or one your account isn't entitled to). Use an entitled slug from models.json (e.g. gpt-5.6-sol, gpt-5.5) or set upstream_model. |
/model doesn't list your model |
For gpt-* ids use ANTHROPIC_CUSTOM_MODEL_OPTION; discovery only surfaces claude/anthropic-prefixed ids. |
config check failed |
Run shunt check for the exact reason (bind address, unknown provider in a route, wrong adapter/auth). |
| Claude Code asks you to log in | Set an Anthropic credential (ANTHROPIC_AUTH_TOKEN / login) that shunt can forward for unmapped models. A base URL alone is not a credential. |
For the full gateway troubleshooting table, see Connect Claude Code to an LLM gateway.
# 1. Build
cargo build --release
# 2. Configure
cp shunt.toml.example shunt.toml
./target/release/shunt check
# 3. Provider credential (pick one)
codex login # Codex/ChatGPT provider
# export OPENAI_API_KEY=sk-... # OpenAI provider
# 4. Run the gateway
./target/release/shunt run &
# 5. Point Claude Code at it and select a mapped model
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol"
claude # then /model -> pick gpt-5.6-sol