Build, launch, and drive shunt — the Claude Code LLM gateway (a Rust/axum Anthropic-Messages proxy). Use to run, start, smoke-test, or curl-drive the gateway, exercise /v1/models discovery and /v1/messages proxying, or connect Claude Code to a local shunt instance.
Installs into .claude/skills of the current project.
Are you the author of Run Shunt?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/pleaseai-run-shunt)
---
name: run-shunt
description: Build, launch, and drive shunt — the Claude Code LLM gateway (a Rust/axum Anthropic-Messages proxy). Use to run, start, smoke-test, or curl-drive the gateway, exercise /v1/models discovery and /v1/messages proxying, or connect Claude Code to a local shunt instance.
---
# Run shunt
`shunt` is a Rust ([axum](https://github.com/tokio-rs/axum)) HTTP server — a Claude Code LLM gateway. It has **no GUI**: you drive it with `curl`. It listens on `127.0.0.1:3001` by default and serves an Anthropic-Messages surface (`GET /v1/models`, `POST /v1/messages`, `POST /v1/messages/count_tokens`, `HEAD /`). For each mapped `model` id it diverts inference to another provider (OpenAI / Codex / ChatGPT via the OpenAI Responses API); everything else passes through to Anthropic.
**Primary agent path:** run the committed driver [`.claude/skills/run-shunt/smoke.sh`](smoke.sh). It builds the binary, spins up a local mock upstream (so no real API key is needed), launches the gateway, and drives every route end to end with assertions. That is the way to confirm a change works.
All paths below are relative to the repo root (the `shunt/` directory).
## Prerequisites
Everything the driver needs is already standard on macOS/Linux dev boxes:
```bash
cargo --version # Rust stable (built with 1.94); toolchain via rust-toolchain.toml if present
python3 --version # smoke.sh uses http.server as a stand-in upstream
curl --version
jq --version # smoke.sh asserts JSON responses with jq
lsof -v
```
On a bare Ubuntu container: `apt-get install -y curl jq lsof python3` and install Rust via `rustup` if `cargo` is missing.
## Build
```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
```
`BIN` resolves Cargo's actual executable path, including `CARGO_TARGET_DIR` and a configured target triple.
## Run (agent path) — the smoke driver
This is what you run to see shunt working. It is hermetic (no network, no credentials) and exits non-zero on the first failed assertion.
```bash
.claude/skills/run-shunt/smoke.sh
```
Expected tail:
```
PASS shunt check -> config ok
PASS HEAD / -> 200 (server live)
PASS GET /v1/models returns configured model
PASS POST /v1/messages proxied to upstream and returned its body
PASS malformed request -> 400 invalid_request_error
PASS GET /health reports ok
PASS GET /protocol describes the anthropic-messages contract
PASS GET /routes resolves the configured route
PASS POST /v1/messages/count_tokens proxied to upstream on its own path
All smoke checks passed.
```
What it covers: every route the server registers unconditionally. Config validation (`shunt check`), liveness (`HEAD /`), health (`GET /health`), the gateway contract (`GET /protocol`), model discovery (`GET /v1/models`), the resolved route table (`GET /routes`), both proxy forward paths (`POST /v1/messages` and `POST /v1/messages/count_tokens`, routed to a local mock that stands in for `api.anthropic.com` and asserted by the path it forwarded to), and the routing error path (a body with no `model` field → `400 invalid_request_error`). Routes that only an optional config section registers (the Codex endpoint, the usage surfaces) are out of scope, since the smoke config does not enable them.
`SHUNT_PORT` and `MOCK_PORT` default to `31711` and `31712`. Set either to `0` to bind an ephemeral port instead, which is the conflict-free way to run the driver beside a live gateway.
The driver has its own regression, [`test_smoke.sh`](test_smoke.sh). Run it after editing `smoke.sh`. It proves the driver refuses a port outside `0..65535` by name; refuses a port another listener already holds, where the real process dies at bind and the driver says which one died; and refuses a shunt listening on a port it never requested, which is the case the pid-derived readiness check catches. It then runs the normal path. The stand-in listener answers every assertion the driver makes, so a driver that trusted `$SHUNT_PORT` over the process it started would go green against it and get caught.
### Drive it by hand
Write a config, launch, and curl it yourself:
```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
"$BIN" run --config ./shunt.toml # or copy shunt.toml.example first
```
Then, against the running server:
```bash
curl -s "http://127.0.0.1:3001/v1/models?limit=1000" | jq .
# => {"data":[{"id":"claude-opus-via-codex","display_name":"Opus (via Codex)"}]}
```
Validate a config without starting the server:
```bash
BIN="$(cargo build --locked --message-format=json-render-diagnostics | jq -sr '[.[] | select(.reason == "compiler-artifact" and .target.name == "shunt" and (.target.kind | index("bin"))) | .executable | select(. != null)] | last // empty')"
test -x "$BIN"
"$BIN" check --config ./shunt.toml # prints "config ok" or a precise error
```
CLI shape: `shunt run|check [--config <path>]` (also `shunt --check`). Default config path is `./shunt.toml`; `SHUNT_`-prefixed env vars override (with `__` for nesting).
## Direct invocation (internal logic — most PRs touch this)
The interesting code is the **Anthropic Messages ⇄ OpenAI Responses translation** (`src/adapters/`, `src/model/`) and routing (`src/routing.rs`). These are covered by unit + integration tests — the fastest inner loop for a PR touching them:
```bash
cargo test --workspace # all tests
cargo test --test responses_translate # the translation integration suite
```
Full pre-PR gate (matches CI):
```bash
cargo fmt --all --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --workspace
```
## Run (human path) — connect Claude Code to a local shunt
Point Claude Code at the running gateway. shunt does **not** validate the credential, but Claude Code still needs one set or it drops to its login wizard. Per the [gateway-connect docs](https://code.claude.com/docs/en/llm-gateway-connect):
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:3001
export ANTHROPIC_AUTH_TOKEN=local-dummy # any string; shunt ignores it
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 # opt-in: pull /v1/models into the picker
export ANTHROPIC_CUSTOM_MODEL_OPTION=gpt-5.2-codex # add a non-claude id to /model (see Gotchas)
claude # started from the same shell; /status shows the base URL
```
Verify the wiring without opening Claude Code (this is the docs' own check):
```bash
curl -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-opus-via-codex","max_tokens":1,"messages":[{"role":"user","content":"."}]}'
```
With a default (anthropic-routed) config this forwards to `api.anthropic.com` and needs a real Anthropic key in the header to get a `200`; a mapped model routed to `openai`/`codex` needs that provider's credential instead. For a credential-free run, use `smoke.sh` (mock upstream) rather than this path.
## Gotchas
- **No GUI, no `npm start`.** It's a Rust HTTP server. "Running it" means launch + `curl`. The driver is the smoke script.
- **Model discovery drops non-`claude`/`anthropic` ids.** Claude Code's `/v1/models` importer ignores any `id` not starting with `claude` or `anthropic`, so to route to e.g. `gpt-5.2-codex` you either alias it under a `claude…` discovery id **or** add it via `ANTHROPIC_CUSTOM_MODEL_OPTION` (the primary way). This is a Claude Code constraint, not a shunt bug.
- **shunt ignores the request credential.** `GET /v1/models` reads `authorization`/`x-api-key` but discards it (`src/discovery.rs`), and the proxy just forwards headers upstream. So any dummy token works for local driving — the real key only matters to the *upstream* provider.
- **`shunt check` is strict about provider shape.** `providers.openai.adapter` and `providers.codex.adapter` must be `"responses"`; `openai.auth` must be `api_key`, `codex.auth` must be `chatgpt_oauth` — otherwise `check` fails with a specific error (`src/config.rs`). Partial TOML is fine: figment merges your file over built-in defaults, so you only need to specify what differs.
- **A discovery `[[models]]` entry with no matching `[[routes]]`** logs a `WARN` at startup/`check` but is not fatal.
- **`GET /protocol` is the machine-readable gateway contract.** It is unauthenticated and reports shunt's package version, Anthropic-Messages format, supported endpoints, header handling, attribution behavior, and model-discovery constraints.
- **zsh quoting:** quote URLs containing `?` (globbing) and mind `noclobber` on `>` redirects when driving by hand in this repo's shell.
## Troubleshooting
- **`smoke.sh` reports `shunt exited during startup` or `mock upstream exited during startup`**: the dumped log says `Address already in use`, so a stale process holds that test port. Run `lsof -nP -iTCP:${MOCK_PORT:-31712} -sTCP:LISTEN` for the mock port or replace it with `${SHUNT_PORT:-31711}` for the gateway port. Stop the reported PID, then re-run. `SHUNT_PORT=0 MOCK_PORT=0` skips the conflict outright by binding ephemeral ports.
- **`502 Bad Gateway` / `api_error: error sending request for url (...)`** — shunt reached routing but the upstream `base_url` was unreachable (wrong host/port, or the mock/provider isn't up). This is the correct error mapping (`src/error.rs`), not a crash. Check the target `base_url`.
- **`400 invalid_request_error: request body must include a JSON model field`** — the request body isn't JSON with a `model` key. Routing happens before forwarding (`src/routing.rs`).
- **`cargo clippy` fails the build** — CI sets `RUSTFLAGS=-D warnings`; warnings are errors. Fix them before a PR (`cargo clippy --all-targets --all-features -- -D warnings`).