This is the abridged developer documentation for shunt
# shunt
> Shunt Claude Code to any model. A transparent gateway that diverts only the models you map to another provider — everything else passes through to Anthropic unchanged.
Per-model, not global Routing is driven by each request’s `model` id. Keep the main session on Claude and divert only the models you name — down to a single subagent — with no prompt fingerprinting. Spec-compliant gateway Implements the official [Claude Code LLM gateway protocol](https://code.claude.com/docs/en/llm-gateway-protocol): `/v1/messages`, `/v1/models` discovery, token counting, and attribution pass-through. OpenAI & ChatGPT/Codex Translates Anthropic Messages ⇄ the OpenAI Responses API — over an OpenAI API key, or a reused ChatGPT subscription via `codex login`. Any Anthropic-compatible backend Kimi, DeepSeek, GLM, MiniMax, OpenRouter, Vercel AI Gateway and friends are one `[providers.]` table away — no code changes.
# Installation
> Install shunt via Homebrew, cargo, prebuilt binaries, or from source.
## Homebrew (macOS / Linux) [Section titled “Homebrew (macOS / Linux)”](#homebrew-macos--linux)
```bash
brew install pleaseai/tap/shunt
```
## Cargo [Section titled “Cargo”](#cargo) The crate is published as `shunt-gateway` (the binary is still `shunt`):
```bash
cargo install shunt-gateway
```
## Prebuilt binaries [Section titled “Prebuilt binaries”](#prebuilt-binaries) Each [GitHub release](https://github.com/pleaseai/shunt/releases) attaches standalone binaries for macOS (arm64/x64) and Linux (arm64/x64), plus a `SHA256SUMS` file:
```bash
# Selects the right asset for your OS/CPU (darwin/linux × arm64/x64)
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m); case "$ARCH" in x86_64) ARCH=x64 ;; aarch64) ARCH=arm64 ;; esac
curl -fsSLO "https://github.com/pleaseai/shunt/releases/latest/download/shunt-${OS}-${ARCH}"
chmod +x "shunt-${OS}-${ARCH}" && mv "shunt-${OS}-${ARCH}" /usr/local/bin/shunt
```
## From source [Section titled “From source”](#from-source) Requires stable Rust with `cargo`:
```bash
git clone https://github.com/pleaseai/shunt
cd shunt
cargo build --release # -> target/release/shunt
```
You can also run straight from the source tree with `cargo run -- `. ## Prerequisites for connecting Claude Code [Section titled “Prerequisites for connecting Claude Code”](#prerequisites-for-connecting-claude-code) * **Claude Code** — any recent version; v2.1.129+ only if you want [model discovery](/guides/model-discovery/). * A credential for whichever provider you map: * an **OpenAI API key** for the `openai` provider, or * a **ChatGPT login** via the Codex CLI (`codex login`) for the `codex` provider. * Your normal **Anthropic credential** (claude.ai login or an API key) — shunt forwards it through for every model you *don’t* map. Next: the [Quickstart](/getting-started/quickstart/).
# Quickstart
> Configure shunt, run the gateway, and point Claude Code at it in five minutes.
This walkthrough takes you from an installed `shunt` binary to a Claude Code session where a `gpt-*` model runs inside Claude Code’s own harness. Install shunt first — see [Installation](/getting-started/installation/). ## 1. Configure [Section titled “1. Configure”](#1-configure) shunt ships with all providers preconfigured, so a minimal config only declares routing. Create `shunt.toml` (in the working directory, or `~/.config/shunt/shunt.toml`):
```toml
# Exact model id -> provider
[[routes]]
model = "gpt-5.6-sol"
provider = "codex" # reuses your ChatGPT login via `codex login`
# Or send every gpt-* id to the OpenAI API
[[route_prefixes]]
prefix = "gpt-"
provider = "openai" # uses OPENAI_API_KEY
```
Validate it:
```bash
shunt check
# -> config ok
```
## 2. Provide the provider credential [Section titled “2. Provide the provider credential”](#2-provide-the-provider-credential) Pick the provider you routed to:
```bash
codex login # codex provider: ChatGPT subscription login
# or
export OPENAI_API_KEY=sk-... # openai provider: API key
```
## 3. Run the gateway [Section titled “3. Run the gateway”](#3-run-the-gateway)
```bash
shunt run
# -> shunt listening on 127.0.0.1:3001
```
## 4. Point Claude Code at it [Section titled “4. Point Claude Code at it”](#4-point-claude-code-at-it)
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol"
export CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 # so /effort maps to reasoning.effort
claude
```
Inside Claude Code, run `/model` and pick `gpt-5.6-sol`. Unmapped models (all your `claude-*` ids) keep working exactly as before — shunt forwards them to Anthropic with your own credential. ## 5. Verify [Section titled “5. Verify”](#5-verify) Test the gateway directly before (or instead of) opening Claude Code:
```bash
# 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. Inside Claude Code, `/status` should show the **Anthropic base URL** as `http://127.0.0.1:3001`. ## Where to next [Section titled “Where to next”](#where-to-next) * [Configuration](/guides/configuration/) — config files, env overrides, routing precedence. * [Providers](/guides/providers/) — add Kimi, DeepSeek, GLM, OpenRouter, and other backends. * [Connect Claude Code](/guides/connect-claude-code/) — credentials in depth, per-agent routing. * [Troubleshooting](/reference/troubleshooting/) — common errors and fixes.
# Why shunt
> What shunt is, how it differs from other Claude Code proxies, and when to use it.
`shunt` is a spec-compliant [Claude Code LLM gateway](https://code.claude.com/docs/en/llm-gateway-protocol): a transparent proxy that, for the **models you map**, diverts inference to another LLM provider at the **inference layer**. It routes by the request’s `model` id — by default, everything else passes through to Anthropic unchanged (the “shunt”; the fallback is configurable via `server.default_provider`). The name is the mechanism: an electrical/railway *shunt* diverts a selected part of the flow onto a parallel path. Here, a mapped model’s inference is diverted to another provider while Claude Code’s tools and skills stay intact. ## How it works [Section titled “How it works”](#how-it-works) Claude Code sends every turn to the Anthropic API. `shunt` sits in front (via `ANTHROPIC_BASE_URL`) and, for the models you map, diverts their inference to another provider (OpenAI, Codex/ChatGPT, …). Because routing happens at the HTTP/inference layer — not by handing the task off to a different CLI — the session keeps running inside Claude Code’s harness: same tool loop, same preloaded skills, same bundled-script path resolution. Only token generation is outsourced. Contrast this with handing a subagent off to another runtime (like the Codex CLI), which cuts higher in the stack and drops persona and preloaded skills. ## Per-model, not per-agent — and not a global swap [Section titled “Per-model, not per-agent — and not a global swap”](#per-model-not-per-agent--and-not-a-global-swap) Most Claude Code proxies route **all** traffic to one alternative provider (a global model swap). `shunt`’s focus is **selective, per-model** diversion driven by the request’s `model` id: keep the main session on Claude, and shunt only the models you name onto other providers. Selectivity is decided in Claude Code itself, which already lets you choose a model per context: * the `/model` picker for the main session, * a subagent definition’s `model:` frontmatter, * `CLAUDE_CODE_SUBAGENT_MODEL` for all subagents, * `ANTHROPIC_CUSTOM_MODEL_OPTION` to add a custom entry to the picker. shunt just honors the model id it receives — no fragile per-agent system-prompt fingerprinting. That same selectivity reaches down to individual agents without shunt ever inspecting who the caller is. ## What shunt implements [Section titled “What shunt implements”](#what-shunt-implements) * **`POST /v1/messages`** — inference, routed per the request’s `model` id. Unmapped models are forwarded to Anthropic byte-for-byte with the caller’s own credential. * **Anthropic Messages ⇄ OpenAI Responses translation** — for mapped OpenAI-family models, including streaming. * **ChatGPT subscription reuse** — the `codex` provider reuses (and auto-refreshes) the Codex CLI’s `~/.codex/auth.json` login. * **`GET /v1/models`** — [model discovery](/guides/model-discovery/) for Claude-named aliases. * **Token counting** — local tiktoken counts for translated providers, exact upstream counts for passthrough. * **Streaming resilience** — [SSE keepalive pings](/guides/shared-gateway/#sse-keepalive-pings) so proxies like Cloudflare don’t kill long reasoning stretches. * **Optional inbound auth** — [per-client tokens](/guides/shared-gateway/) for shared deployments. Ready to try it? Head to [Installation](/getting-started/installation/).
# ChatGPT / Codex
> Route Claude Code inference to your ChatGPT/Codex subscription by reusing ~/.codex/auth.json — auth, model slugs, effort, and context window.
The **`codex`** provider routes a mapped model’s inference to your **ChatGPT / Codex subscription** instead of an API key. It reuses the credential the Codex CLI already wrote to `~/.codex/auth.json`, so there’s nothing to paste and no per-token billing — the request is authenticated as your ChatGPT account and answered by the same backend the `codex` CLI talks to. This page is the end-to-end setup. It links out to the deeper topic pages ([Effort & Context](/guides/effort-and-context/), [Model Discovery](/guides/model-discovery/), [Providers](/guides/providers/)) rather than repeating them. ## How it works [Section titled “How it works”](#how-it-works) `codex` is a built-in **`kind = "responses"`** provider: shunt translates Claude Code’s Anthropic Messages request into the OpenAI **Responses API**, sends it to the ChatGPT-account Codex backend, and translates the streamed reply back. Three things make it “Codex” rather than plain OpenAI: | Aspect | Value | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Endpoint | `/codex/responses` | | Auth | ChatGPT OAuth from `~/.codex/auth.json`, auto-refreshed | | Responses dialect | `Chatgpt` flavor — drops params codex never sends (e.g. `max_output_tokens`), sends `store: false`, round-trips encrypted reasoning | The dialect is keyed on `auth = "chatgpt_oauth"`, not the provider name. ## 1. Log in [Section titled “1. Log in”](#1-log-in) Log in once with the Codex CLI. shunt reads and refreshes the file it writes — it does **not** run its own login for Codex.
```bash
codex login
```
This creates `~/.codex/auth.json`. If that file is missing, has no tokens, or its refresh token is gone, shunt returns an `authentication_error` telling you to run `codex login` again. ## 2. The provider block (optional) [Section titled “2. The provider block (optional)”](#2-the-provider-block-optional) `codex` is built in — you don’t need to declare it. This is the full default; a partial table overrides only the keys you set (config maps deep-merge):
```toml
[providers.codex]
kind = "responses"
base_url = "https://chatgpt.com/backend-api" # shunt appends /codex/responses
auth = "chatgpt_oauth" # read + auto-refresh ~/.codex/auth.json
# effort = "high" # optional default reasoning effort (§4)
# count_tokens = "tiktoken" # default; "estimate" opts out
```
Common overrides: pin a default `effort` for all Codex traffic, or set `count_tokens = "estimate"`. `api_key_env` / `api_key_header` don’t apply to `chatgpt_oauth` — the credential comes from the auth file. See the [Configuration Reference](/reference/configuration/#providersname) for every key. ## 3. Route a model to `codex` [Section titled “3. Route a model to codex”](#3-route-a-model-to-codex) A request’s `model` id picks the provider. Precedence: exact `[[routes]]` → `[[route_prefixes]]` → `server.default_provider`.
```toml
[[routes]]
model = "gpt-5.6-sol" # the id Claude Code sends (see §4 below)
provider = "codex"
# upstream_model = "gpt-5.6-sol" # optional: forward a different slug upstream
# effort = "high" # optional: pin effort for this route
```
`upstream_model` lets the id Claude Code sends differ from the slug the backend receives — the mechanism behind [discovery aliases](/guides/model-discovery/) and a way to swap the real slug without touching your Claude Code env. Model slugs — no `-codex` The ChatGPT-account backend **rejects** `gpt-*-codex` slugs (e.g. `gpt-5.2-codex`) with a `400`; it only accepts your account’s **live-entitled** slugs. The authoritative catalog (and the reasoning levels each accepts) is openai/codex’s [`models.json`](https://github.com/openai/codex/blob/main/codex-rs/models-manager/models.json). Current slugs: `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` (frontier) and `gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2`. Older accounts may only be entitled to the earlier ones (a free account has resolved to `gpt-5.5`). shunt surfaces the backend’s own error `detail`, so a wrong slug returns the real reason. ## 4. Select the model in Claude Code [Section titled “4. Select the model in Claude Code”](#4-select-the-model-in-claude-code) Claude Code’s `/model` picker only honors discovery ids beginning with `claude`/`anthropic`, so a raw `gpt-*` id needs one of two paths — they split on the `claude-` prefix and don’t overlap: | | `claude-…` discovery alias | non-`claude-` id (`gpt-5.6-sol`) | | --------------------------------------- | -------------------------- | -------------------------------- | | `/model` picker via discovery | ✅ auto-listed, many models | ❌ dropped by Claude Code | | `ANTHROPIC_CUSTOM_MODEL_OPTION` | ❌ not honored | ✅ adds to picker (one id) | | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` window | ❌ ignored → 200k | ✅ real window | **Primary path** — add the slug to the picker directly:
```bash
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol"
```
That id is exactly what shunt routes on, so it must match a `[[routes]]`/`[[route_prefixes]]` rule. This is the recommended path — it’s the only one that also lets you set an accurate context window. For auto-listing several Codex models in the picker instead, use a `claude-`-named [discovery alias](/guides/model-discovery/) (accepting the 200k window trade-off). #### Put a subagent on a Codex slug [Section titled “Put a subagent on a Codex slug”](#put-a-subagent-on-a-codex-slug) A subagent can run on a Codex slug while the main session stays on Claude. The `model:` frontmatter field accepts **any string** (unlike the Agent/Task tool’s `model` parameter, which only takes the built-in aliases). To point an **existing** subagent at `gpt-5.6-sol`, edit its `.claude/agents/.md` and set `model:`:
```markdown
---
name: researcher
description: Deep research agent.
model: gpt-5.6-sol # was: sonnet (or absent → inherited)
---
```
Spawn it **without** a `model` override (the tool parameter outranks frontmatter). Resolution order: `CLAUDE_CODE_SUBAGENT_MODEL` > tool `model` > frontmatter > `inherit`. To force **every** subagent onto one slug, set `export CLAUDE_CODE_SUBAGENT_MODEL="gpt-5.6-sol"`. Either way the slug needs a `[[routes]]` entry and, being non-`claude-`, obeys `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1` and `CLAUDE_CODE_MAX_CONTEXT_TOKENS` — the window follows the id automatically. ### Remap the tier aliases to Codex [Section titled “Remap the tier aliases to Codex”](#remap-the-tier-aliases-to-codex) Instead of adding one custom id, repoint Claude Code’s **built-in tier aliases** at Codex slugs, so the whole session’s tier system resolves to your ChatGPT subscription ([model-config env vars](https://code.claude.com/docs/en/model-config#environment-variables)). | Env var | Controls | | ---------------------------------------------------------------- | ----------------------------------------------------------- | | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the `haiku` alias **and the background “small-fast” model** | | `ANTHROPIC_DEFAULT_SONNET_MODEL` | the `sonnet` alias | | `ANTHROPIC_DEFAULT_OPUS_MODEL` / `ANTHROPIC_DEFAULT_FABLE_MODEL` | the `opus` / `fable` aliases | A two-tier setup — `haiku → gpt-5.6-luna`, `sonnet → gpt-5.6-sol`:
```bash
export ANTHROPIC_DEFAULT_HAIKU_MODEL="gpt-5.6-luna"
export ANTHROPIC_DEFAULT_SONNET_MODEL="gpt-5.6-sol"
# nicer picker labels (the _NAME/_DESCRIPTION companions work on a gateway)
export ANTHROPIC_DEFAULT_SONNET_MODEL_NAME="GPT-5.6-Sol"
export ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION="ChatGPT/Codex Sol via shunt"
export ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME="GPT-5.6-Luna"
export ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION="ChatGPT/Codex Luna via shunt (background tier)"
```
```toml
# shunt.toml — both resolved ids need a route
[[routes]]
model = "gpt-5.6-luna"
provider = "codex"
[[routes]]
model = "gpt-5.6-sol"
provider = "codex"
```
Selecting **Sonnet** in `/model` now runs `gpt-5.6-sol` via Codex, and every background/haiku task runs `gpt-5.6-luna` — the resolved id is exactly what shunt routes on, so no `ANTHROPIC_CUSTOM_MODEL_OPTION` is needed. ## 5. Reasoning effort [Section titled “5. Reasoning effort”](#5-reasoning-effort) Set the effort with Claude Code’s usual controls (`/effort`, the `/model` slider, `--effort`). shunt maps it to the Responses `reasoning.effort`, folding `max → xhigh` for slugs that don’t support `max` (only the **gpt-5.6** family does). Full precedence and the effort table: [Effort & Context](/guides/effort-and-context/#reasoning-effort). ## 6. Context window [Section titled “6. Context window”](#6-context-window) Claude Code sizes its context bar at a fixed **200k** for mapped ids. `gpt-5.6-sol`’s real window is **372k** (`gpt-5.5` is 272k), so raise it for a non-`claude-` id:
```bash
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000
```
It’s **global** (one value per session) and setting it larger than the real window causes `prompt is too long` overflow churn — match it to the smallest real window among your mapped models. shunt rewrites that overflow so Claude Code auto-compacts and retries, but each round-trip is wasted latency. Details, the live-verified boundary, and `count_tokens` behavior: [Effort & Context](/guides/effort-and-context/#context--usage-display-for-mapped-models). ## Full example [Section titled “Full example”](#full-example) `shunt.toml`:
```toml
[server]
bind = "127.0.0.1:3001"
default_provider = "anthropic"
[providers.codex]
effort = "high" # optional: pin high effort for all Codex traffic
[[routes]]
model = "gpt-5.6-sol"
provider = "codex"
```
Shell (both shunt and Claude Code run with these):
```bash
codex login # one-time
./target/release/shunt run # start the gateway
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_CUSTOM_MODEL_OPTION="gpt-5.6-sol" # add to /model picker
export CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 # let the effort slider reach Codex
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000 # gpt-5.6-sol's real window
```
Pick **gpt-5.6-sol** from `/model`. Everything else in the session still flows to Anthropic unchanged; only the mapped model’s inference is answered by your ChatGPT/Codex subscription. ## Web search [Section titled “Web search”](#web-search) Claude Code’s built-in **web search** works through the Codex path with no extra setup. When you enable it, Claude Code sends the hosted `web_search_20250305` tool; shunt registers it as the Responses API’s hosted **`web_search`** tool, so the backend actually performs the search instead of handing it back as an unfulfilled tool call. * Domain filters carry over — Claude Code’s `allowed_domains` / `blocked_domains` become the Responses `web_search` `filters`. * Applies to the `codex` (ChatGPT) and `openai` (stock Responses) providers. * **xAI / Grok routes don’t support it** — Grok’s Responses API only accepts function tools, so shunt drops the hosted web-search tool there; use a `codex` or `openai` route for web search. ## Tool search [Section titled “Tool search”](#tool-search) Claude Code’s **tool search** — deferring MCP / LSP tool schemas and revealing them on demand via a `ToolSearch` tool, so the model’s context isn’t spent on tools it never calls — works through the Codex path, but it is **off by default** behind shunt. Opt in:
```bash
export ENABLE_TOOL_SEARCH=true
```
Claude Code disables its optimistic tool search whenever the base URL is not a first-party Anthropic host — shunt isn’t one — so without this flag every tool’s full schema is sent upstream from turn 1 and the feature is inert (it still works, it just reclaims nothing). The client’s own contract is to set `ENABLE_TOOL_SEARCH=true` **if your proxy forwards `tool_reference` blocks** — shunt does. With it on, Claude Code lists the deferrable tools by **name** in the prompt but withholds their schemas. shunt keeps those not-yet-loaded tools out of the upstream tool set until the model loads one via `ToolSearch`; the resulting `tool_reference` then reveals that tool’s full schema on demand. That reclaims the context window the deferred schemas would otherwise occupy from the first turn — the whole point of tool search. * No `shunt.toml` change is needed — it’s purely a Claude Code environment variable. * Applies to the `codex` (ChatGPT) and `openai` (stock Responses) providers. * Non-deferred tools (and the hosted `web_search` tool above) are always forwarded; only deferrable tools are progressively revealed. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) | Symptom | Cause / Fix | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ChatGPT auth not found; run codex login` | No `~/.codex/auth.json` (or wrong `$CODEX_AUTH_FILE`). Run `codex login`. | | `ChatGPT auth tokens missing` | Auth file is in `ApiKey` mode — that’s the `openai` provider. Re-`codex login` with a ChatGPT account. | | `400 … not supported when using Codex with a ChatGPT account` | You used a `gpt-*-codex` slug. Use an entitled non-`-codex` slug. | | `Model not found ` | Client-version gating or an unentitled slug — confirm via `models.json`. | | Effort slider ignored on a `gpt-*` id | Set `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1`, or a route/provider `effort` override is winning. | | Context bar over-reports / compacts early | Set `CLAUDE_CODE_MAX_CONTEXT_TOKENS`; a discovery alias can’t take it — use a non-`claude-` id. | | Web search returns nothing on a Grok route | xAI/Grok’s Responses API doesn’t support web search; shunt drops the tool. Use a `codex` or `openai` route. | | Tool search does nothing / all tool schemas sent every turn | Set `ENABLE_TOOL_SEARCH=true` — Claude Code disables tool search by default behind a non-Anthropic base URL. shunt forwards `tool_reference` blocks and reveals deferred schemas on demand. | See the full [Troubleshooting](/reference/troubleshooting/) reference for more.
# Configuration
> How shunt loads configuration — files, environment variables, and routing.
shunt loads configuration from, in increasing precedence: 1. **Built-in defaults** — every provider (`anthropic`, `openai`, `codex`, …) is preconfigured. 2. A **TOML or YAML file**. The format is chosen by extension — `.toml` is TOML, `.yaml`/`.yml` is YAML (any other extension is parsed as TOML). With `--config ` that exact file is used (a missing file is an error). Otherwise shunt probes each directory for `shunt.toml`, then `shunt.yaml`, then `shunt.yml`, and takes the first file found: * `./` * `$XDG_CONFIG_HOME/shunt/` (default `~/.config/shunt/`) * `$HOMEBREW_PREFIX/etc/` (default `/opt/homebrew` and `/usr/local` prefixes) A local `shunt.yaml` therefore still wins over a config file in a later directory, while an existing `shunt.toml` alongside it takes priority within the same directory. Boot logs report which file was loaded, or that defaults are in use. 3. **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 [`shunt.toml.example`](https://github.com/pleaseai/shunt/blob/main/shunt.toml.example) for TOML or [`shunt.yaml.example`](https://github.com/pleaseai/shunt/blob/main/shunt.yaml.example) for YAML. ## Annotated example [Section titled “Annotated example”](#annotated-example)
```toml
[server]
bind = "127.0.0.1:3001" # address shunt listens on
default_provider = "anthropic" # provider for any model with no route (pass-through)
# Each provider is a [providers.] table.
[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"
# --- Routing: how a request's `model` id picks a provider ---
# Exact match wins first. `upstream_model` and `effort` are optional overrides.
[[routes]]
model = "gpt-5.6-sol"
provider = "codex"
# upstream_model = "gpt-5.6-sol"
# effort = "high"
# Then prefix match.
[[route_prefixes]]
prefix = "gpt-"
provider = "openai"
# Optional: expose Claude-named aliases in the /model picker via discovery.
# The id MUST start with "claude" or "anthropic" or Claude Code ignores it.
# [[models]]
# id = "claude-opus-via-codex"
# display_name = "Opus (via Codex)"
```
### YAML equivalent [Section titled “YAML equivalent”](#yaml-equivalent) The same schema in YAML — save as `shunt.yaml` or `shunt.yml`. Tables become mappings and `[[...]]` arrays become lists:
```yaml
server:
bind: "127.0.0.1:3001"
default_provider: anthropic
providers:
openai:
kind: responses
base_url: https://api.openai.com/v1
auth: api_key
api_key_env: OPENAI_API_KEY
routes:
- model: gpt-5.6-sol
provider: codex
route_prefixes:
- prefix: gpt-
provider: openai
```
Quote boolean-like string values The YAML backend parses **YAML 1.1**, where the bare tokens `yes`, `no`, `on`, `off`, `y`, `n` (any case) are read as booleans, not strings. If a string field’s value is one of these, quote it — e.g. `api_key_env: "no"` — or deserialization fails with an opaque type error. All the built-in keys take normal identifiers, so this only bites unusual values, but quoting is the safe habit. TOML is unaffected. ## Routing precedence [Section titled “Routing precedence”](#routing-precedence) 1. Exact `[[routes]]` match on the request’s `model` id. 2. `[[route_prefixes]]` prefix match. 3. `server.default_provider` — by default `anthropic`, so a model with no match falls through to Anthropic unchanged. A route can override the forwarded model id (`upstream_model`) and the reasoning effort (`effort`) per model. ## Partial overrides [Section titled “Partial overrides”](#partial-overrides) Config maps are deep-merged, so a partial override of a built-in provider keeps the rest of its defaults:
```toml
# Only raise codex's default effort; everything else stays at the built-in values.
[providers.codex]
effort = "high"
```
## Validate [Section titled “Validate”](#validate)
```bash
shunt check
# -> prints "config ok", or a specific error (bad bind address, unknown provider, …)
```
See the [Configuration Reference](/reference/configuration/) for every key, and [Providers](/guides/providers/) for adding new backends.
# Connect Claude Code
> Point Claude Code at shunt, choose the right Anthropic credential, and select mapped models.
Based on the official [Connect Claude Code to an LLM gateway](https://code.claude.com/docs/en/llm-gateway-connect) guide — shunt *is* the gateway you connect to. ## 1. Point Claude Code at shunt [Section titled “1. Point Claude Code at shunt”](#1-point-claude-code-at-shunt) Set the base URL to your running gateway (default bind `127.0.0.1:3001`), in your shell or persisted in a [settings file](https://code.claude.com/docs/en/settings) `env` block:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
```
\~/.claude/settings.json
```json
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:3001"
}
}
```
Keep your existing Anthropic credential — 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; Claude Code never sends them. ## 2. Choose the Anthropic credential [Section titled “2. Choose the Anthropic credential”](#2-choose-the-anthropic-credential) The credential Claude Code sends to shunt plays two roles: it authenticates **Claude passthrough models**, and it **gates [model discovery](/guides/model-discovery/)** — Claude Code only issues the `GET /v1/models` request when `ANTHROPIC_AUTH_TOKEN`, an API key, or an `apiKeyHelper` is set. Mapped models (`gpt-*` etc.) are unaffected either way. | Credential | Token refresh | Discovery | Claude passthrough | Billing | | ------------------------------------------------------------------ | ---------------------------- | ------------- | ------------------ | -------------------------- | | claude.ai OAuth **login** only | automatic | ❌ never fires | ✅ | subscription | | `ANTHROPIC_AUTH_TOKEN` from `claude setup-token` — **recommended** | none needed (one-year token) | ✅ | ✅ | subscription | | `apiKeyHelper` = `shunt token` | the helper refreshes it | ✅ | ✅ | subscription | | `ANTHROPIC_AUTH_TOKEN=` | 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](https://code.claude.com/docs/en/authentication#generate-a-long-lived-token)), so nothing needs refreshing, and one value covers both roles:
```bash
claude setup-token # browser sign-in → prints sk-ant-oat…
export ANTHROPIC_AUTH_TOKEN=sk-ant-oat… # or persist it in a settings `env` block
```
The refresh trap Once a gateway credential is active, Claude Code **stops refreshing its own login**, so the short-lived access token inside `~/.claude/.credentials.json` expires within hours and a helper that just *reads* that file breaks. Don’t refresh it by hand either — `platform.claude.com/v1/oauth/token` is aggressively rate-limited. To reuse the live subscription login, use the built-in [`shunt token`](/reference/cli/#shunt-token) helper, which refreshes it safely. ### The `shunt token` credential helper [Section titled “The shunt token credential helper”](#the-shunt-token-credential-helper) `shunt token` prints a Claude subscription OAuth token to stdout, so it wires straight into Claude Code’s `apiKeyHelper`: \~/.claude/settings.json
```json
{
"apiKeyHelper": "/path/to/shunt token"
}
```
* **Static mode** — if `SHUNT_GATEWAY_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN` is set, it echoes that value unchanged. Point it at a `claude setup-token` value and nothing is ever refreshed. * **Auto-refresh mode** — otherwise it reads `~/.claude/.credentials.json` (override with `CLAUDE_CREDENTIALS`), returns the access token, and refreshes it only within 5 minutes of expiry, writing back atomically at `0600`. The static + `setup-token` route stays the simplest and safest default. ## 3. Provide the mapped provider’s credential [Section titled “3. Provide the mapped provider’s credential”](#3-provide-the-mapped-providers-credential) These go to **shunt’s environment**, not Claude Code’s:
```bash
export OPENAI_API_KEY=sk-... # openai provider
codex login # codex/ChatGPT provider (auto-refreshed thereafter)
```
## 4. Select a mapped model [Section titled “4. Select a mapped model”](#4-select-a-mapped-model) 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:
```bash
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 match a `[[routes]]`/`[[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: | What | `claude-`/`anthropic-` id (discovery alias) | non-`claude-` id (e.g. `gpt-5.6-sol`) | | -------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------- | | [`/v1/models` discovery](/guides/model-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 | So a `claude-…-via-codex` discovery alias is convenient (auto-listed, one-tap) but its context window is **stuck at the 200k default** — the override can’t reach a `claude-`-prefixed id ([Effort & Context](/guides/effort-and-context/)). Pick the **discovery alias** for picker convenience across several models (accept the 200k denominator), or a **non-`claude-` id via `ANTHROPIC_CUSTOM_MODEL_OPTION`** for an accurate window, one model at a time. ### Per-agent diversion [Section titled “Per-agent diversion”](#per-agent-diversion) Per-context selection works via Claude Code’s own knobs — divert one agent to a mapped model while the main session stays on Claude: .claude/agents/researcher.md
```yaml
---
name: researcher
model: gpt-5.6-sol # this agent's inference is diverted; the main session stays on Claude
---
```
A named subagent’s `model:` frontmatter is the **only** way to put a subagent on a `gpt-*` id: that field takes any string, whereas the Agent/Task tool’s `model` parameter is restricted to the built-in aliases (`opus`/`sonnet`/`haiku`/`fable`) and can’t take a gateway id. Spawn the agent by its type **without** a `model` override — the tool parameter outranks frontmatter (`CLAUDE_CODE_SUBAGENT_MODEL` > tool `model` > frontmatter > `inherit`), so passing one would shadow the mapped model. `CLAUDE_CODE_SUBAGENT_MODEL` forces every subagent onto one model. The window follows the model id automatically, so one global `CLAUDE_CODE_MAX_CONTEXT_TOKENS` sizes the mapped subagent while the Claude main keeps its own. ## 5. Verify [Section titled “5. Verify”](#5-verify)
```bash
# 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"}]}'
```
Then start `claude`, run `/status`, and check the **Anthropic base URL** line shows your gateway. See also [Effort & Context](/guides/effort-and-context/) for reasoning-effort and context-window tuning.
# Effort & Context
> How reasoning effort, token counting, and the context indicator behave for mapped models.
## Reasoning effort [Section titled “Reasoning effort”](#reasoning-effort) 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** family), else folded to `xhigh` | Which reasoning levels a Codex slug accepts is listed per-model in openai/codex’s [`models.json`](https://github.com/openai/codex/blob/main/codex-rs/models-manager/models.json) (`supported_reasoning_levels`). 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`. ## Token counting (`count_tokens`) [Section titled “Token counting (count\_tokens)”](#token-counting-count_tokens) For an **Anthropic-routed** model shunt passes `POST /v1/messages/count_tokens` through to the upstream (exact counts). For a **`responses`-routed** model 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’s `o200k_base` encoder and returns `{"input_tokens": N}`. Near-exact for text on GPT-family models, and answered in-process (\~ms) — which matters because Claude Code’s `/context` issues one `count_tokens` call per displayed item (30–50 calls per invocation). * **`count_tokens = "estimate"` (opt-in)** — shunt returns **404**, which the gateway protocol explicitly allows. The main-loop context bar then estimates locally, but `/context` re-runs every category count against Haiku over the network — slow, and silently reported as 0 tokens when no Anthropic credential is available. Either way the request never reaches the responses adapter, so a count request is never turned into (and billed as) a full inference call. ## Context / usage display for mapped models [Section titled “Context / usage display for mapped models”](#context--usage-display-for-mapped-models) Claude Code computes the context indicator locally from the assistant message’s token `usage` divided by the model’s context-window size. For models routed to a `responses` provider: * **Token count (the numerator) is accurate.** shunt reads `input_tokens` (and cached tokens) from the Responses `usage` and forwards them in the Anthropic `message_delta`, peeling the cached part into `cache_read_input_tokens`. * **The window (the denominator) defaults to a fixed 200k for unrecognized ids.** A model with a larger real window (e.g. `gpt-5.6-sol` at 372k) shows a conservative, over-reported percentage — this only makes auto-compact trigger a little early. The 200k default can be overridden client-side with `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (Claude Code 2.1.205+); it applies to any model id that does **not** start with `claude-`:
```bash
# e.g. gpt-5.6-sol's real window
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000
```
Because the override applies **only** to ids that don’t start with `claude-`, a [discovery alias](/guides/model-discovery/) (which *must* begin with `claude-`) can’t take it — its window stays pinned at the 200k default. Convenient in the picker, but use a non-`claude-` id (via `ANTHROPIC_CUSTOM_MODEL_OPTION`, or by [remapping the tier aliases](/guides/codex/#remap-the-tier-aliases-to-codex)) when you need the accurate window. When two mapped tiers share a window — `gpt-5.6-sol` and `gpt-5.6-luna` are both 372k — one global value covers both. Caution The value is **global** — one value for every non-`claude-` model in the session — and setting it larger than the real upstream window delays auto-compact until requests overflow the real limit. shunt [rewrites that overflow error](#context-overflow-recovery) so Claude Code compacts and retries automatically, but each overflow round-trip is wasted latency — match the value to the smallest real window among your mapped models. Live-verified boundary for `gpt-5.6-sol` (real window 372k): 365k input tokens answer normally; at 372k+ the streaming request returns a `prompt is too long` error that triggers auto-compaction (`gpt-5.5` is 272000). A *non*-streaming request instead degrades to an empty `200` with `input_tokens: 0`, but Claude Code’s main loop always streams. The other client-side lever is the `[1m]` model-id suffix, which forces a 1M window — only use it when the upstream really has that window. (shunt strips a trailing `[1m]` before route matching and forwarding, so the hint stays purely client-side and the provider never sees it.) | Field | Mapped (`responses`) model | Claude passthrough | | ---------------------------- | ----------------------------------------------------- | ------------------ | | Context tokens used | ✅ accurate (forwarded by shunt) | ✅ accurate | | Context window (denominator) | ⚠️ 200k default; set `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | ✅ exact | | `count_tokens` (pre-flight) | ⚠️ local tiktoken count (default) | ✅ exact (upstream) | | `rate_limits` (5h / weekly) | ❌ needs Anthropic headers | ✅ shown | ## Context overflow recovery [Section titled “Context overflow recovery”](#context-overflow-recovery) When a conversation outgrows the upstream model’s real window, the provider rejects the request with its own wording — OpenAI’s `context_length_exceeded`, `"This model's maximum context length is N tokens…"`, or a proxy’s `"prompt token count of N exceeds the limit of M"`. Claude Code’s automatic compact-and-retry only fires on Anthropic’s phrasing, so unrewritten these errors would strand the session until a manual `/compact` ([documented gateway pitfall](https://code.claude.com/docs/en/llm-gateway-connect#troubleshoot-gateway-errors)). shunt detects context-overflow errors on `responses`-routed models and rewrites them into the Anthropic shape Claude Code matches:
```json
{"type": "error", "error": {"type": "invalid_request_error", "message": "prompt is too long: 372982 tokens > 272000 maximum"}}
```
When the upstream message carries both token counts, shunt preserves them (whatever order the upstream states them in) — Claude Code parses the `N tokens > M maximum` gap and compacts past the whole overshoot in a single retry. When the upstream gives no counts (e.g. the Responses API’s plain *“Your input exceeds the context window of this model”*), shunt emits `prompt is too long` alone, which still triggers compaction. Non-overflow errors are passed through with their original message. ## Attribution block [Section titled “Attribution block”](#attribution-block) Claude Code prepends an attribution line to the system prompt. Anthropic strips it before processing, but shunt forwards it unchanged, so a mapped provider receives it as the first line of `instructions`. It’s harmless but meaningless noise for a non-Anthropic model. To drop it:
```bash
export CLAUDE_CODE_ATTRIBUTION_HEADER=0
```
This is global, so it also removes attribution from Anthropic-passthrough traffic (used for cost tracking) — fine when you’re routing to another provider.
# Model Discovery
> Populate Claude Code's /model picker automatically with Claude-named aliases.
Discovery (`GET /v1/models`) can populate Claude Code’s `/model` picker automatically — **but Claude Code ignores any id that doesn’t begin with `claude`/`anthropic`** ([protocol reference](https://code.claude.com/docs/en/llm-gateway-protocol#model-discovery)). So a `gpt-*` id is dropped client-side no matter what; discovery is only useful when you expose a **Claude-named alias** that a `[[routes]]` entry rewrites to the real upstream slug:
```toml
[[models]]
id = "claude-gpt-5.6-sol-via-codex" # must begin with claude/anthropic
display_name = "GPT-5.6-Sol (via Codex)"
[[routes]]
model = "claude-gpt-5.6-sol-via-codex" # the alias Claude Code sends
provider = "codex"
upstream_model = "gpt-5.6-sol" # real slug forwarded to the ChatGPT backend
```
Then enable discovery (Claude Code v2.1.129+) and restart shunt + Claude Code:
```bash
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
```
The 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`. For `gpt-*` ids without an alias, use `ANTHROPIC_CUSTOM_MODEL_OPTION` instead — see [Connect Claude Code](/guides/connect-claude-code/#4-select-a-mapped-model). ## Discovery needs a gateway credential [Section titled “Discovery needs a gateway credential”](#discovery-needs-a-gateway-credential) A claude.ai OAuth *login* alone won’t trigger discovery. Claude Code only issues the `/v1/models` request when `ANTHROPIC_AUTH_TOKEN`, an API key, or an `apiKeyHelper` is set; under a plain Max/Pro subscription login it sends nothing — no request reaches shunt, no cache is written — even with the flag on. See [choosing the credential](/guides/connect-claude-code/#2-choose-the-anthropic-credential); `claude setup-token` is the recommended route. ## Debugging [Section titled “Debugging”](#debugging) 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.
# OpenTelemetry
> Opt-in OTLP export of traces, metrics, and logs to your own collector or backend.
shunt can export **traces, metrics, and logs** over OTLP/HTTP to your own OpenTelemetry Collector (or any OTLP-compatible backend). It is **opt-in and off by default** — with no `[otel]` section, nothing leaves the machine — and runs independently of Sentry, so you can enable either or both. ## Enable it [Section titled “Enable it”](#enable-it) One key turns it on — point it at your Collector’s OTLP/HTTP receiver:
```toml
[otel]
endpoint = "http://localhost:4318" # OTLP/HTTP base URL; shunt appends /v1/{traces,metrics,logs}
```
Everything else has a sensible default:
```toml
[otel]
endpoint = "http://localhost:4318"
service_name = "shunt" # (default) service.name resource attribute
environment = "prod" # optional deployment.environment.name
sample_ratio = 1.0 # (default) head-based trace sampling, 0.0–1.0
traces = true # (default) export request spans
metrics = true # (default) export usage metrics
logs = true # (default) export log events (stderr logs unaffected)
include_session_id = false # (default) keep the client session id off spans
[otel.headers] # optional per-request headers, e.g. a hosted-collector token
authorization = "Bearer "
```
Setting `endpoint = ""` (e.g. `SHUNT_OTEL__ENDPOINT=""`) disables export again without deleting the section. An invalid endpoint, a non-`http(s)` URL, or an out-of-range `sample_ratio` is a **startup error**, so a typo can’t silently drop every export. ## The three signals [Section titled “The three signals”](#the-three-signals) | Signal | What’s exported | Notes | | ----------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | **Traces** | The per-request `proxy_request` span | Head-based sampling via `sample_ratio`. Low-cardinality; no request/response bodies. | | **Metrics** | `shunt.requests` (count) and `shunt.latency` (ms) | Tagged `provider`, `model`, `http.response.status_code` — the same series shunt sends to Sentry. | | **Logs** | shunt’s `tracing` log events, bridged to OTLP | The stderr logs are unaffected. | Each signal toggles independently via `traces` / `metrics` / `logs`. ## Privacy [Section titled “Privacy”](#privacy) shunt never exports request/response bodies, headers, or credentials in **metrics and traces**. * **Metrics and traces** stay low-cardinality and body-free. With OTLP trace export, the request span’s client **session id** is sent to the collector **only** when `include_session_id = true` (default off), and only while trace export is active. The same rule governs Sentry trace export (`[sentry] traces_sample_rate` / `include_session_id`); with no span export active at all, the id stays on the local request span as before. * **Logs** mirror shunt’s own diagnostic events as written, so — like the stderr logs — they can include request-derived fields (an upstream error body, an authenticated client id). For strictly body-free export, set `logs = false` and keep metrics/traces. The exported resource advertises `service.*`, `telemetry.sdk.*`, and — when `environment` is set — `deployment.environment.name`; no host or process detector runs, so the machine’s hostname is not attached, plus whatever you put in the standard `OTEL_RESOURCE_ATTRIBUTES`. Caution If `[otel.headers]` carries a secret (e.g. a collector bearer token) and the endpoint is plaintext `http://` to a non-loopback host, shunt logs a warning at startup: the token would travel in the clear. Use `https://` for a remote collector. ## Standard `OTEL_` env vars [Section titled “Standard OTEL\_ env vars”](#standard-otel_-env-vars) * `endpoint` and `service_name` come from this config and **take precedence** over `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_SERVICE_NAME`. * The standard `OTEL_EXPORTER_OTLP_HEADERS` and `OTEL_RESOURCE_ATTRIBUTES` are still **merged in**, on top of `[otel.headers]` and the built-in resource attributes. See the [`[otel]` configuration reference](/reference/configuration/#otel-optional) for every key.
# Providers
> The built-in providers and how to add any Anthropic-compatible backend with a TOML table.
Providers are a **name → config map**: a new upstream is just another `[providers.]` table — no code change. Three adapter kinds cover everything: * **`kind = "anthropic"`** — the upstream speaks the Anthropic Messages API. shunt passes the request through, optionally injecting a different API key. * **`kind = "responses"`** — the upstream speaks the OpenAI Responses API. shunt translates Anthropic Messages ⇄ Responses, including streaming. * **`kind = "cursor"`** — the native Cursor adapter. shunt bridges Cursor’s ConnectRPC/protobuf AgentService (and its tool protocol) to the Anthropic Messages API, streaming included. Used by the built-in `cursor` provider. ## Built-in providers [Section titled “Built-in providers”](#built-in-providers) | Name | Kind | Auth | Backend | | ----------- | ----------- | ---------------------------- | ---------------------------------------------------------------------------------- | | `anthropic` | `anthropic` | `passthrough` | `api.anthropic.com` — forwards the caller’s own credential | | `openai` | `responses` | `api_key` (`OPENAI_API_KEY`) | `api.openai.com/v1` | | `codex` | `responses` | `chatgpt_oauth` | `chatgpt.com/backend-api` — reuses `~/.codex/auth.json` | | `xai` | `responses` | `api_key` (`XAI_API_KEY`) | `api.x.ai/v1` — the developer API, billed per token | | `grok` | `responses` | `xai_oauth` | `cli-chat-proxy.grok.com/v1` — the Grok CLI proxy; reuses `~/.shunt/xai-auth.json` | | `cursor` | `cursor` | `cursor_oauth` | `api2.cursor.sh` — reuses `~/.shunt/cursor-auth.json` (`shunt login cursor`) | ### The codex provider (ChatGPT subscription) [Section titled “The codex provider (ChatGPT subscription)”](#the-codex-provider-chatgpt-subscription) Log in once with the Codex CLI; shunt reads and auto-refreshes `~/.codex/auth.json`:
```bash
codex login
```
If the file is missing or expired, shunt returns an `authentication_error` telling you to run `codex login`. For the full setup — auth-file handling, model selection, effort, and context sizing — see the dedicated [ChatGPT / Codex guide](/guides/codex/). ### The grok provider (SuperGrok / X Premium+ subscription) [Section titled “The grok provider (SuperGrok / X Premium+ subscription)”](#the-grok-provider-supergrok--x-premium-subscription) Log in once with the built-in device-code flow; shunt writes and auto-refreshes `~/.shunt/xai-auth.json`:
```bash
shunt login xai
```
xAI may gate OAuth access by subscription tier — if `grok` returns 403, use the `xai` API-key provider instead (`export XAI_API_KEY=…`). Model slugs The ChatGPT-account Codex backend **rejects** `gpt-*-codex` slugs — it only accepts the account’s live-entitled slugs. The authoritative catalog is openai/codex’s [`models.json`](https://github.com/openai/codex/blob/main/codex-rs/models-manager/models.json). Current slugs are `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` (frontier) and `gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2`; older accounts may only be entitled to the earlier ones. Use `upstream_model` in a route to map any alias onto an entitled slug. ### The cursor provider (Cursor subscription) [Section titled “The cursor provider (Cursor subscription)”](#the-cursor-provider-cursor-subscription) The built-in `cursor` provider reaches your **Cursor** subscription through Cursor’s own ConnectRPC/protobuf AgentService (`api2.cursor.sh`) — the `kind = "cursor"` native adapter translates it to and from Anthropic Messages, streaming and Cursor’s native tool calls included. Log in once:
```bash
shunt login cursor
```
This runs the Cursor OAuth flow and writes `~/.shunt/cursor-auth.json`, which shunt reads and auto-refreshes. If the file is missing or expired, shunt returns an `authentication_error` telling you to run `shunt login cursor`. Route a `cursor:*` model id to it — the provider is seeded by default, so no `[providers.cursor]` table is required:
```toml
[[routes]]
model = "cursor:gpt-5.5"
provider = "cursor"
```
**Model ids and agent modes.** The prefix selects Cursor’s agent mode and the suffix is the Cursor model id: | Form | Agent mode | Example | | ----------------------------------- | ---------- | --------------------- | | `cursor:` / `cursor-agent:` | Agent | `cursor:gpt-5.5` | | `cursor-plan:` | Plan | `cursor-plan:gpt-5.5` | | `cursor-ask:` | Ask | `cursor-ask:gpt-5.5` | Legacy bare names are also accepted: `cursor`, `cursor-agent`, `cursor-composer`, `cursor-composer-fast` (Agent); `cursor-plan`, `composer-2.5` (Plan); `cursor-ask`, `composer-2.5-fast` (Ask). Any other model id is rejected with an `invalid_request_error`. Your own call Reusing a Cursor subscription from an unofficial client is your own call — it may run afoul of Cursor’s terms or account enforcement. Use at your own risk. ### The xai / grok providers (Grok) [Section titled “The xai / grok providers (Grok)”](#the-xai--grok-providers-grok) Two built-in providers reach xAI’s **Grok** models, split by credential: **`grok`** spends your **SuperGrok / X Premium+** subscription over OAuth (`shunt login xai`, no per-token billing), while **`xai`** uses an `XAI_API_KEY` against the metered developer API. A subscription bearer and an API key are **not** interchangeable — each works only against its own provider. For the full setup — login, both provider blocks, model slugs, the opt-in effort dial, and the entitlement gotchas — see the dedicated [xAI / Grok guide](/guides/xai/). ## Adding an Anthropic-compatible backend [Section titled “Adding an Anthropic-compatible backend”](#adding-an-anthropic-compatible-backend) Most third-party “use Claude Code with X” gateways are Anthropic-Messages-compatible: `kind = "anthropic"` with `auth = "api_key"`, differing only in `base_url` and the key env var. Ready-to-use bases: | Provider | `base_url` | Example model IDs | | ----------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------- | | Kimi (Moonshot) | `https://api.moonshot.ai/anthropic` | `kimi-k2.7-code` | | 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` | | MiniMax | `https://api.minimax.io/anthropic` | see [MiniMax docs](https://platform.minimax.io/docs/token-plan/claude-code) | | Mimo (Xiaomi) | `https://api.xiaomimimo.com/anthropic` | `mimo-v2.5-pro` — see [Mimo docs](https://mimo.mi.com/docs/en-US/tokenplan/integration/claudecode) | | OpenRouter | `https://openrouter.ai/api` | `anthropic/claude-opus-4.8` | | Vercel AI Gateway | `https://ai-gateway.vercel.sh` | `anthropic/claude-opus-4.8` (accepts `x_api_key`) | For example, to route Kimi’s model through shunt:
```toml
[providers.kimi]
kind = "anthropic"
base_url = "https://api.moonshot.ai/anthropic"
auth = "api_key"
api_key_env = "KIMI_API_KEY"
[[routes]]
model = "kimi-k2.7-code"
provider = "kimi"
```
Then `export KIMI_API_KEY=…`, [point Claude Code at shunt](/guides/connect-claude-code/), and select `kimi-k2.7-code` (via `ANTHROPIC_CUSTOM_MODEL_OPTION` or `ANTHROPIC_MODEL`). Run `shunt check` to validate — it reports an unknown provider in a route, a missing `api_key_env`, or a bad `base_url`. Every provider key (`kind`, `auth`, `api_key_header`, `count_tokens`, …) is documented in the [Configuration Reference](/reference/configuration/). ## Subagent plugins [Section titled “Subagent plugins”](#subagent-plugins) The [`pleaseai/shunt` marketplace](https://github.com/pleaseai/shunt/tree/main/plugins) ships ready-made Claude Code subagents pinned to each provider’s models — one agent per model. Install a plugin, then `@`-mention a model or set `CLAUDE_CODE_SUBAGENT_MODEL`. Each agent’s `model:` frontmatter diverts only that subagent; the main session stays on Claude. | Plugin | Models (one agent each) | Provider | | ---------------- | ---------------------------------------------- | ---------------------------------------- | | `shunt-codex` | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | `codex` (ChatGPT subscription) | | `shunt-xai` | `grok-build-0.1`, `grok-4.5`, `grok-4.3` | `xai` (API key) or `grok` (subscription) | | `shunt-kimi` | `kimi-k2.7-code` | `kimi` | | `shunt-deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash` | `deepseek` | | `shunt-zai` | `glm-5.2`, `glm-4.7` | `zai` | | `shunt-minimax` | `MiniMax-M3[1m]` | `minimax` | | `shunt-mimo` | `mimo-v2.5-pro` | `mimo` |
```bash
/plugin marketplace add pleaseai/shunt
/plugin install shunt-xai@shunt
```
Each plugin still needs its provider routed in `shunt.toml` (see the sections above) and the matching credential exported — the plugin’s own README lists the exact route and env var. The grok models can be served by either xAI provider: `xai` (API key, billed per token) or `grok` (SuperGrok / X Premium+ subscription via `shunt login xai`; tier-gated — fall back to `xai` on 403).
# Sharing a Gateway
> Per-client tokens for shared deployments, and SSE keepalive pings for proxies and tunnels.
## Inbound client tokens [Section titled “Inbound client tokens”](#inbound-client-tokens) 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 exactly the injected-credential routes with per-client tokens:
```toml
[server.auth] # both keys optional; defaults shown
header = "x-shunt-token"
tokens_env = "SHUNT_CLIENT_TOKENS"
```
```bash
# 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 without a valid token get a 401 `authentication_error`; `GET /v1/models`, `GET /routes`, `GET|HEAD /`, `GET /health`, and passthrough models stay open. `GET /routes` is unauthenticated by the same discovery-endpoint design as `GET /v1/models` — it exposes routing metadata (the configured provider/upstream-model mapping), never credentials, which live only in provider config and are never read by that handler. The token header is always stripped before forwarding, matching is constant-time, and token values are never logged (client *names* are, per request). Client side, one line (`ANTHROPIC_CUSTOM_HEADERS` takes one `Name: Value` per line):
```bash
export ANTHROPIC_CUSTOM_HEADERS="x-shunt-token: "
```
## SSE keepalive pings [Section titled “SSE keepalive pings”](#sse-keepalive-pings) 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:
```toml
[server]
sse_keepalive_seconds = 30 # default; 0 disables
```
Pings 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.
# xAI / Grok
> Route Claude Code inference to xAI's Grok — either your SuperGrok / X Premium+ subscription (the grok provider, OAuth) or the xAI developer API (the xai provider, API key).
Two built-in providers route Claude Code to xAI’s **Grok** models. They differ only in **how they authenticate and which xAI surface they hit** — pick one: | Provider | Auth | Backend | Billing | | ---------- | --------------------------------------------------- | ------------------------------------------------------ | --------------------------------------- | | **`grok`** | `xai_oauth` — your **SuperGrok / X Premium+** login | `cli-chat-proxy.grok.com/v1` (the Grok CLI chat proxy) | your subscription — no per-token charge | | **`xai`** | `api_key` (`XAI_API_KEY`) | `api.x.ai/v1` (the developer API) | metered API credits | Both are **`kind = "responses"`** providers speaking xAI’s Responses dialect — the same translation path as [Codex](/guides/codex/), which this page mirrors. It links out to the deeper topic pages ([Effort & Context](/guides/effort-and-context/), [Model Discovery](/guides/model-discovery/), [Providers](/guides/providers/)) rather than repeating them. The two paths are not interchangeable A **subscription** bearer only works against the **`grok`** proxy — the developer API (`api.x.ai`) rejects it with a `402` (`personal-team-blocked:spending-limit`, *“…need a Grok subscription…”*). An **API key** only works against **`xai`**. Route your Grok slugs to whichever provider matches the credential you have. ## How it works [Section titled “How it works”](#how-it-works) shunt translates Claude Code’s Anthropic Messages request into the OpenAI **Responses API**, sends it to xAI, and translates the streamed reply back. The `xai` **Responses flavor** (drops params xAI rejects, keeps tools as function tools) is selected two ways: by an **`api.x.ai` host**, or by **`auth = "xai_oauth"`** (the `grok` proxy isn’t an x.ai host, so its dialect is keyed on the auth). | Aspect | `grok` (subscription) | `xai` (API key) | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | Endpoint | `cli-chat-proxy.grok.com/v1/responses` | `api.x.ai/v1/responses` | | Auth | Grok CLI OAuth from `~/.shunt/xai-auth.json`, auto-refreshed | `Bearer $XAI_API_KEY` | | Identity headers | Grok CLI headers (`x-xai-token-auth`, `x-grok-client-identifier`, `x-grok-client-version`) so the proxy honors the subscription | none | ## Path A — SuperGrok subscription (`grok`) [Section titled “Path A — SuperGrok subscription (grok)”](#path-a--supergrok-subscription-grok) ### 1. Log in [Section titled “1. Log in”](#1-log-in) Run shunt’s own device-code login (RFC 8628). It prints a URL and a code; approve in a browser on any device — no loopback callback server:
```bash
shunt login xai
```
On success shunt writes the tokens to **`~/.shunt/xai-auth.json`** with `0600` permissions and refreshes them automatically (a 5-minute expiry buffer; xAI rotates the refresh token on every refresh, so shunt persists the rotated one under a single-flight lock). If the refresh token is gone or the response omits a rotated token, shunt tells you to run `shunt login xai` again. ### 2. The provider block (optional) [Section titled “2. The provider block (optional)”](#2-the-provider-block-optional) `grok` is built in — you don’t need to declare it. This is the full default; a partial table overrides only the keys you set (config maps deep-merge):
```toml
[providers.grok]
kind = "responses"
base_url = "https://cli-chat-proxy.grok.com/v1" # shunt appends /responses
auth = "xai_oauth" # read + auto-refresh ~/.shunt/xai-auth.json
# effort = "high" # optional — opt in to reasoning effort (§ Reasoning effort)
```
### 3. Route a model to `grok` [Section titled “3. Route a model to grok”](#3-route-a-model-to-grok)
```toml
[[routes]]
model = "grok-4.5"
provider = "grok"
# upstream_model = "grok-4.5" # optional: forward a different slug upstream
```
## Path B — xAI developer API (`xai`) [Section titled “Path B — xAI developer API (xai)”](#path-b--xai-developer-api-xai) ### 1. Export the key [Section titled “1. Export the key”](#1-export-the-key)
```bash
export XAI_API_KEY=xai-…
```
### 2. The provider block (optional) [Section titled “2. The provider block (optional)”](#2-the-provider-block-optional-1)
```toml
[providers.xai]
kind = "responses"
base_url = "https://api.x.ai/v1" # shunt appends /responses
auth = "api_key"
api_key_env = "XAI_API_KEY"
```
### 3. Route a model to `xai` [Section titled “3. Route a model to xai”](#3-route-a-model-to-xai)
```toml
[[routes]]
model = "grok-4.5"
provider = "xai"
```
Needs API credits, not a subscription `api.x.ai` bills against your xAI **API** credits. A SuperGrok / X Premium+ subscription does **not** entitle the developer API — an unfunded account returns `402 Payment Required` (*“You have run out of credits or need a Grok subscription…”*). Add credits at [console.x.ai](https://console.x.ai/), or use **Path A** to spend your subscription instead. ## Model slugs [Section titled “Model slugs”](#model-slugs) The slug catalog is **xAI’s**, not shunt’s — shunt forwards whatever slug you route. Current coding/frontier slugs are `grok-4.5`, `grok-4.3`, and `grok-build-0.1`. Use `upstream_model` in a route to map an alias onto a live slug without touching your Claude Code env. (Model [discovery](/guides/model-discovery/) only surfaces `claude-`named aliases you declare, so it can’t list a raw Grok slug — reach these via `ANTHROPIC_CUSTOM_MODEL_OPTION` or a tier remap below.) ## Select the model in Claude Code [Section titled “Select the model in Claude Code”](#select-the-model-in-claude-code) Grok slugs don’t start with `claude-`, so Claude Code’s `/model` picker won’t list them from discovery. The mechanics are **identical to Codex** — add the id to the picker directly:
```bash
export ANTHROPIC_CUSTOM_MODEL_OPTION="grok-4.5" # must match a [[routes]] rule
```
The same [Codex section](/guides/codex/#4-select-the-model-in-claude-code) covers the rest verbatim: putting a **subagent** on a Grok slug via `model:` frontmatter, and remapping the **tier aliases** (`ANTHROPIC_DEFAULT_SONNET_MODEL`, …) onto Grok slugs for a whole session. ## Reasoning effort [Section titled “Reasoning effort”](#reasoning-effort) **Unlike Codex, effort is opt-in for Grok.** Several Grok models (`grok-4*`, `grok-3`, `grok-code-fast`, …) return `400` on a `reasoning.effort` field even though they reason natively, so shunt sends the dial **only when you configure it** on the provider or route (or pass it per request) — otherwise the model uses its native reasoning:
```toml
[providers.grok]
effort = "high" # applies to all grok traffic
# …or per route
[[routes]]
model = "grok-4.5"
provider = "grok"
effort = "high"
```
`grok-4.5` accepts `reasoning.effort` (verified live). Leave `effort` unset for any slug that `400`s on it. Full precedence and the effort table: [Effort & Context](/guides/effort-and-context/#reasoning-effort). ## Context window [Section titled “Context window”](#context-window) Claude Code sizes its context bar at a fixed **200k** for a mapped id. If your Grok slug’s real window is larger, raise it — the value follows a non-`claude-` id automatically:
```bash
export CLAUDE_CODE_MAX_CONTEXT_TOKENS=256000 # set your slug's real window, per xAI's model docs
```
It’s **global** (one value per session); set it to the smallest real window among your mapped models, since exceeding a model’s real window causes `prompt is too long` overflow churn. Details and `count_tokens` behavior: [Effort & Context](/guides/effort-and-context/#context--usage-display-for-mapped-models). ## Web search [Section titled “Web search”](#web-search) Claude Code’s built-in **web search does not work on Grok routes.** xAI’s Responses API accepts only function tools, so shunt drops the hosted `web_search` tool on the `xai` flavor (both `grok` and `xai`). Use a [`codex` or `openai` route](/guides/codex/#web-search) when you need hosted web search. ## Full example (subscription path) [Section titled “Full example (subscription path)”](#full-example-subscription-path) `shunt.toml`:
```toml
[server]
bind = "127.0.0.1:3001"
default_provider = "anthropic"
[providers.grok]
effort = "high" # optional: opt in to reasoning effort for all Grok traffic
[[routes]]
model = "grok-4.5"
provider = "grok"
```
Shell (both shunt and Claude Code run with these):
```bash
shunt login xai # one-time device-code login
./target/release/shunt run # start the gateway
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_CUSTOM_MODEL_OPTION="grok-4.5" # add to /model picker
```
Pick **grok-4.5** from `/model`. Everything else in the session still flows to Anthropic unchanged; only the mapped model’s inference is answered by your SuperGrok subscription. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) | Symptom | Cause / Fix | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `run shunt login xai` on startup | No `~/.shunt/xai-auth.json` (or wrong `$SHUNT_XAI_AUTH_FILE`). Run `shunt login xai`. | | `xAI refresh response missing refresh_token; run shunt login xai` | The stored refresh token was consumed/rotated away. Log in again. | | `402 … personal-team-blocked:spending-limit` / *“need a Grok subscription”* | On the **`xai`** (developer API) path without API credits. Add credits at [console.x.ai](https://console.x.ai/), or route to **`grok`** to use your subscription. | | `403 … not authorized for API access` (subscription tier gate) | On the **`grok`** path your subscription tier doesn’t include API access — **re-logging in won’t help**. Set `XAI_API_KEY` and use the `xai` path, or upgrade at [x.ai/grok](https://x.ai/grok). | | `refusing to send a subscription token off-origin` (from `shunt check`) | An `xai_oauth` provider’s `base_url` host isn’t `x.ai`/`grok.com`, isn’t HTTPS, or isn’t `kind = "responses"`. Fix the block. | | `400` when effort is set | That Grok slug rejects `reasoning.effort`. Remove `effort` from the provider/route for it. | | `model is not enabled for this account` | Unentitled slug — confirm the slug against xAI’s catalog. | | Web search returns nothing | Not supported on Grok routes; shunt drops the tool. Use a `codex`/`openai` route. | See the full [Troubleshooting](/reference/troubleshooting/) reference for more.
# CLI
> The shunt command line — run, check, and token.
## `shunt run` [Section titled “shunt run”](#shunt-run) Start the gateway. `run` is the default subcommand, so a bare `shunt` also works.
```bash
shunt run
shunt run --config /path/to/shunt.toml
```
On start it logs `shunt listening` with the bound address (default `127.0.0.1:3001`). Set log verbosity with `RUST_LOG`, e.g. `RUST_LOG=shunt=debug shunt run`. Config files may be TOML or YAML, chosen by extension (`.toml`, or `.yaml`/`.yml`). Without `--config`, shunt probes each directory for `shunt.toml` → `shunt.yaml` → `shunt.yml` across `./` → `~/.config/shunt/` → `$HOMEBREW_PREFIX/etc/`; with `--config`, a missing file is an error. See [Configuration](/guides/configuration/). ## `shunt check` [Section titled “shunt check”](#shunt-check) Validate the resolved configuration and exit (`shunt --check` also works):
```bash
shunt check
# -> config ok
```
Reports specific errors: a bad bind address, an unknown provider in a route, a missing `api_key_env`, a bad `base_url`, a wrong adapter/auth combination. ## `shunt token` [Section titled “shunt token”](#shunt-token) Print a Claude subscription OAuth token to **stdout** (logs go to stderr), designed to be wired into Claude Code’s `apiKeyHelper`. Two modes: * **Static** — if `SHUNT_GATEWAY_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN` is set, echoes that value unchanged. Point it at a `claude setup-token` value and nothing is ever refreshed. * **Auto-refresh** — otherwise reads `~/.claude/.credentials.json` (override the path with `CLAUDE_CREDENTIALS`), returns the `claudeAiOauth` access token, and when it is within 5 minutes of `expiresAt` refreshes it against `platform.claude.com/v1/oauth/token` (the same grant Claude Code uses), then writes the new token back atomically at `0600`, preserving every other field. Refresh happens only on actual expiry, to respect the endpoint’s rate limit. \~/.claude/settings.json
```json
{
"apiKeyHelper": "/path/to/shunt token"
}
```
See [Connect Claude Code](/guides/connect-claude-code/#2-choose-the-anthropic-credential) for when you need this. ## Environment variables [Section titled “Environment variables”](#environment-variables) | Variable | Effect | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `SHUNT_*` (e.g. `SHUNT_SERVER__BIND`) | Override any config key; `__` separates nested keys | | `RUST_LOG` | Log filter, e.g. `shunt=debug` | | `SHUNT_CLIENT_TOKENS` | Client tokens for [`[server.auth]`](/guides/shared-gateway/) (name configurable via `tokens_env`) | | `SHUNT_GATEWAY_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` | Static token for `shunt token` | | `CLAUDE_CREDENTIALS` | Alternate credentials file path for `shunt token` | | `OPENAI_API_KEY` | Default key env for the `openai` provider (per-provider via `api_key_env`) |
# Configuration Reference
> Every shunt.toml key — server, providers, routes, models.
The keys below are shown in TOML, but a config file may also be written in YAML (`shunt.yaml`/`shunt.yml`) — the schema is identical, only the syntax differs. See [Configuration](/guides/configuration/) for file locations, precedence, and an annotated example. Full template: [`shunt.toml.example`](https://github.com/pleaseai/shunt/blob/main/shunt.toml.example). ## `[server]` [Section titled “\[server\]”](#server) | Key | Default | Meaning | | ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- | | `bind` | `127.0.0.1:3001` | Address shunt listens on | | `default_provider` | `anthropic` | Provider for any model with no matching route | | `sse_keepalive_seconds` | `30` | Idle seconds before an SSE `ping` is injected; `0` disables ([details](/guides/shared-gateway/#sse-keepalive-pings)) | ## `[server.auth]` (optional) [Section titled “\[server.auth\] (optional)”](#serverauth-optional) Presence of this table enables inbound client-token auth ([details](/guides/shared-gateway/)): | Key | Default | Meaning | | ------------ | --------------------- | -------------------------------------------------- | | `header` | `x-shunt-token` | Header carrying the client token | | `tokens_env` | `SHUNT_CLIENT_TOKENS` | Env var holding comma-separated `name:token` pairs | ## `[providers.]` [Section titled “\[providers.\\]”](#providersname) Each provider is a table under a name of your choosing. Built-ins (`anthropic`, `openai`, `codex`, `xai`, `grok`, `cursor`) can be partially overridden — config maps deep-merge. | 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` = the native Cursor ConnectRPC/protobuf AgentService adapter. | | `base_url` | URL | Upstream base; shunt appends the endpoint path. | | `auth` | `passthrough` \| `api_key` \| `chatgpt_oauth` \| `xai_oauth` \| `cursor_oauth` | `passthrough` forwards the client’s own credential; `api_key` injects a key from `api_key_env`; `chatgpt_oauth` reuses `~/.codex/auth.json`; `xai_oauth` reuses `~/.shunt/xai-auth.json` from `shunt login xai` (only sent to x.ai/grok.com hosts over HTTPS); `cursor_oauth` reuses `~/.shunt/cursor-auth.json` (`shunt login cursor`). | | `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). | | `count_tokens` | `tiktoken` (default) \| `estimate` | `responses` providers only: local tiktoken count vs. 404 fallback ([details](/guides/effort-and-context/#token-counting-count_tokens)). | ## `[[routes]]` [Section titled “\[\[routes\]\]”](#routes) Exact-match routing entries — checked first: | Key | Required | Meaning | | ---------------- | -------- | --------------------------------------- | | `model` | ✅ | The exact `model` id Claude Code sends | | `provider` | ✅ | Name of a `[providers.]` table | | `upstream_model` | — | Rewrite the model id forwarded upstream | | `effort` | — | Per-route reasoning-effort override | ## `[[route_prefixes]]` [Section titled “\[\[route\_prefixes\]\]”](#route_prefixes) Prefix-match routing entries — checked after exact routes: | Key | Required | Meaning | | ---------- | -------- | ------------------------------------ | | `prefix` | ✅ | Model-id prefix, e.g. `gpt-` | | `provider` | ✅ | Name of a `[providers.]` table | ## `[[models]]` [Section titled “\[\[models\]\]”](#models) Entries returned by `GET /v1/models` for [model discovery](/guides/model-discovery/). Ids must begin with `claude` or `anthropic` or Claude Code ignores them. | Key | Required | Meaning | | -------------- | -------- | ---------------------------------- | | `id` | ✅ | Model id exposed to Claude Code | | `display_name` | — | Label shown in the `/model` picker | ## `[sentry]` (optional) [Section titled “\[sentry\] (optional)”](#sentry-optional) Opt-in error reporting to your own Sentry project. Off unless `dsn` is set; independent of `[otel]`. Reports gateway-owned diagnostics only — fatal gateway startup/serve errors, panics, and `error`-level log events (`warn`/`info` as breadcrumbs, message only); request/response bodies, headers, and credentials are never sent. Metrics and tracing are each a further, separate opt-in. | Key | Default | Meaning | | -------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dsn` | — | Sentry project DSN. Empty disables; an invalid DSN is a startup error. | | `environment` | — | Optional environment tag on reported events | | `metrics` | `false` | Also send usage metrics — the `shunt.requests` / `shunt.latency` series (aggregates only) | | `traces_sample_rate` | `0.0` | Also send performance traces: the per-request span becomes a Sentry transaction, head-sampled at this rate in `[0.0, 1.0]`. `0.0` sends no spans; out of range is a startup error. | | `include_session_id` | `false` | Attach the client session id to request spans sent to Sentry | ## `[otel]` (optional) [Section titled “\[otel\] (optional)”](#otel-optional) Opt-in OpenTelemetry (OTLP/HTTP) export of traces, metrics, and logs to your own collector ([details](/guides/opentelemetry/)). Off unless `endpoint` is set; independent of Sentry. | Key | Default | Meaning | | -------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `endpoint` | — | OTLP/HTTP base URL (e.g. `http://localhost:4318`); shunt appends `/v1/{traces,metrics,logs}`. Empty disables; a non-`http(s)` URL is a startup error. | | `service_name` | `shunt` | `service.name` resource attribute (takes precedence over `OTEL_SERVICE_NAME`) | | `environment` | — | Optional `deployment.environment.name` | | `sample_ratio` | `1.0` | Head-based trace sampling in `[0.0, 1.0]`; out of range is a startup error | | `traces` | `true` | Export the per-request `proxy_request` span | | `metrics` | `true` | Export the `shunt.requests` / `shunt.latency` series | | `logs` | `true` | Export `tracing` log events (stderr logs unaffected) | | `include_session_id` | `false` | Attach the client session id to request spans | ## `[otel.headers]` (optional) [Section titled “\[otel.headers\] (optional)”](#otelheaders-optional) Extra headers on every OTLP request (e.g. a hosted-collector token). Merged under the standard `OTEL_EXPORTER_OTLP_HEADERS`. | Key | Meaning | | --- | ------------------------------------------------------------ | | any | Header name → value, e.g. `authorization = "Bearer "` | ## Routing precedence [Section titled “Routing precedence”](#routing-precedence) Exact `[[routes]]` match → `[[route_prefixes]]` prefix match → `server.default_provider`.
# HTTP Endpoints
> The endpoints shunt serves as a Claude Code LLM gateway.
| 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](/guides/model-discovery/) — returns your `[[models]]` entries | | `GET` | `/routes` | shunt-native route discovery — returns the configured `[[routes]]` table verbatim (model → provider/upstream\_model/effort mapping, including claude-prefixed discovery aliases); distinct from `/v1/models`, which serves the narrower Anthropic-protocol discovery response (`id`/`display_name` only) | | `POST` | `/v1/messages` | Inference — routed per the request’s `model` id | | `POST` | `/v1/messages/count_tokens` | [Token counting](/guides/effort-and-context/#token-counting-count_tokens) | `GET /` and `GET /health` stay open even when [`[server.auth]`](/guides/shared-gateway/) is enabled (healthcheck tools usually cannot attach tokens) and expose nothing sensitive — only status, version, and the already-public endpoint list. ## Gateway protocol [Section titled “Gateway protocol”](#gateway-protocol) shunt implements the official [Claude Code LLM gateway protocol](https://code.claude.com/docs/en/llm-gateway-protocol): correct header and body-field forwarding, feature pass-through, and system-prompt attribution handling. Gateway-owned errors are returned in the Anthropic error shape, upstream context-overflow errors are rewritten to Anthropic’s `prompt is too long` wording so Claude Code’s [compact-and-retry](/guides/effort-and-context/#context-overflow-recovery) fires, and streaming responses are relayed without buffering (with optional [keepalive pings](/guides/shared-gateway/#sse-keepalive-pings)).
# Troubleshooting
> Common shunt errors and how to fix them.
| 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](https://github.com/openai/codex/blob/main/codex-rs/models-manager/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](/guides/model-discovery/) only surfaces `claude`/`anthropic`-prefixed ids. | | Discovery never fires | It’s gated on a gateway credential (`ANTHROPIC_AUTH_TOKEN`, API key, or `apiKeyHelper`) plus `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`. Debug with `claude --debug` → `[gatewayDiscovery]` lines. | | `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. | | Effort stuck at `medium` on a mapped model | Set `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1` — see [Effort & Context](/guides/effort-and-context/#reasoning-effort). | | Tool search inactive on a mapped model (every tool’s schema sent each turn) | Set `ENABLE_TOOL_SEARCH=true`. Claude Code auto-disables optimistic tool search behind a non-Anthropic base URL; shunt forwards `tool_reference` blocks and reveals deferred schemas on demand — see [ChatGPT / Codex → Tool search](/guides/codex/#tool-search). | | Session stuck after a context-length error on a mapped model | shunt rewrites upstream overflow errors to `prompt is too long …` so Claude Code auto-compacts and retries — see [Context overflow recovery](/guides/effort-and-context/#context-overflow-recovery). If it recurs every few turns, lower `CLAUDE_CODE_MAX_CONTEXT_TOKENS` to the model’s real window. | | Stream dies behind Cloudflare (524) | Keep [`sse_keepalive_seconds`](/guides/shared-gateway/#sse-keepalive-pings) at its default (30) instead of `0`. | | 401 on mapped models on a shared gateway | Missing/invalid client token — set `ANTHROPIC_CUSTOM_HEADERS="x-shunt-token: "`; see [Sharing a Gateway](/guides/shared-gateway/). | | 429 on Anthropic-adapter models | Check `rate_limit_kind` in the gateway log. `quota` (has `retry-after` / `anthropic-ratelimit-*` headers) is a real rate limit — back off or reduce parallel load. `client-shape-rejection` (OAuth request, neither header, body just `"Error"`) means api.anthropic.com rejected a subscription-OAuth request that doesn’t look like Claude Code — non-Claude-Code clients must use an API key instead of an OAuth token; a burst of these can also knock out Claude Code’s auto-mode classifier (“model temporarily unavailable”). `no-ratelimit-headers` (non-OAuth credential) is a provider 429 without rate-limit metadata — treat it as `quota`. | For the full gateway troubleshooting table, see [Connect Claude Code to an LLM gateway](https://code.claude.com/docs/en/llm-gateway-connect#troubleshoot-gateway-errors).