This is the full 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.
A different auth-file location
shunt looks at `$CODEX_AUTH_FILE` first, then `$HOME/.codex/auth.json`, then `.codex/auth.json`. Point it elsewhere for CI, a sandbox, or a second account:
```bash
export CODEX_AUTH_FILE=/etc/shunt/codex-auth.json
```
## 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.
ApiKey mode goes to the `openai` provider
If `~/.codex/auth.json` is in **`ApiKey`** mode (you logged in with an OpenAI API key, not a ChatGPT account), the `codex` OAuth path finds no tokens and errors. That key is instead picked up by the **`openai`** provider as a fallback when `OPENAI_API_KEY` is unset. `codex` is specifically the ChatGPT-subscription path.
## 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.
`Model not found ` is client-version gating, not entitlement
Some slugs carry a `minimal_client_version` (e.g. `gpt-5.6-luna` needs ≥ 0.144.0). When the request’s client identity is missing or too old the backend answers `Model not found `. shunt avoids this by sending the pinned Codex CLI identity headers (`originator: codex_cli_rs`, `user-agent`, `version`), pinned to **openai/codex rust-v0.144.1**. See [openai/codex#31967](https://github.com/openai/codex/issues/31967).
## 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.
Ready-made agents
The **[`shunt-codex` plugin](https://github.com/pleaseai/shunt/tree/main/plugins/shunt-codex)** ships subagents for `gpt-5.6-sol` / `-terra` / `-luna` — install with `/plugin install shunt-codex@shunt` after `/plugin marketplace add pleaseai/shunt`.
### 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.
Getting it right
* The resolved ids don’t start with `claude-`, so set `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1` for the effort dial. `gpt-5.6-sol` and `gpt-5.6-luna` are **both 372k**, so one global `CLAUDE_CODE_MAX_CONTEXT_TOKENS=372000` fits both tiers.
* The `_SUPPORTED_CAPABILITIES` companion is documented for third-party providers (Bedrock, …), not confirmed for gateways — on shunt use `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1` for effort.
* The **haiku tier is the background “small-fast” model** (summaries, titles, quick classification). Routing it to a reasoning model is fine, but it spends ChatGPT quota on that frequent traffic and can be slower — pick your cheapest entitled slug there if that matters.
* Remapping is **global and session-wide**; with an allowlist (`availableModels` / `enforceAvailableModels`) an alias can’t be redirected outside the list (Claude Code enforces this on the tier-alias env vars as of **v2.1.176**).
## 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).
Required for custom ids
For an id Claude Code doesn’t recognize as effort-capable (like `gpt-5.6-sol`) you must set:
```bash
export CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1
```
Otherwise Claude Code omits the effort field and shunt falls back to `medium`. A config `route.effort` / `[providers.codex].effort` override wins over the client value.
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.
Why this authenticates Claude passthrough
Claude Code sends an `apiKeyHelper` value in **both** `x-api-key` and `Authorization: Bearer`. A subscription OAuth token (`sk-ant-oat…`) is valid only as a bearer, so the copy in `x-api-key` would make `api.anthropic.com` reject the request. On the passthrough path shunt strips that duplicated `x-api-key` when the bearer is an OAuth token, leaving it to stand alone. Without this, `apiKeyHelper` + an OAuth token would cover only discovery and mapped models — passthrough would 401.
## 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.
Or remap the tier aliases
A third option repoints Claude Code’s built-in `haiku`/`sonnet`/`opus` aliases at Codex slugs (e.g. `haiku → gpt-5.6-luna`, `sonnet → gpt-5.6-sol`), so the whole session’s tier system resolves to your ChatGPT subscription without `ANTHROPIC_CUSTOM_MODEL_OPTION`. See [ChatGPT / Codex → Remap the tier aliases](/guides/codex/#remap-the-tier-aliases-to-codex).
### 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`).
Custom model ids need a flag
For a custom gateway id like `gpt-5.6-sol` you must set `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1` — otherwise Claude Code omits `output_config.effort` for model ids it doesn’t recognize as effort-capable, and shunt falls back to `medium`.
```bash
export CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1
```
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.
Note
The exporters are initialized once at startup. Editing `[otel]` and hot-reloading warns and **requires a restart** to take effect — unlike most config, which reloads live.
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`.
Overrides
`SHUNT_CURSOR_BASE_URL` overrides the endpoint, `SHUNT_CURSOR_AUTH_FILE` the credential path, and `SHUNT_CURSOR_CLIENT_VERSION` the `x-cursor-client-version` header (bump it without a rebuild if Cursor starts rejecting a stale client version). A `cursor_oauth` provider is pinned to a Cursor host over HTTPS — pointing `base_url` off-origin is refused so the bearer token cannot leak.
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: "
```
Note
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.
## 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 |
Off-origin token guard
An `xai_oauth` provider will only send its subscription bearer to an **x.ai or grok.com host over HTTPS**, and only with `kind = "responses"`. Point one elsewhere and `shunt check` refuses it — shunt won’t leak a subscription token to an arbitrary `base_url`.
## 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.
A different auth-file location
Override the path with `$SHUNT_XAI_AUTH_FILE` for CI, a sandbox, or a second account:
```bash
export SHUNT_XAI_AUTH_FILE=/etc/shunt/xai-auth.json
```
### 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.
Ready-made agents
The **[`shunt-xai` plugin](https://github.com/pleaseai/shunt/tree/main/plugins/shunt-xai)** ships subagents for `grok-4.5` / `grok-4.3` / `grok-build-0.1` — install with `/plugin install shunt-xai@shunt` after `/plugin marketplace add pleaseai/shunt`. Each agent pins its `model:`, so only that subagent diverts; the main session stays on Claude. Route the slugs to `grok` or `xai` in `shunt.toml` per the credential you have.
## 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).