The core architectural insight is that shunt is not a second agent runtime. It is a protocol-preserving gateway inserted at Claude Code’s inference boundary. Because Claude Code already sends a model field, shunt can stay stateless for routing: parse the model, choose a provider, and hand the request to the adapter responsible for that provider’s protocol docs/implementation-plan.md:6-249 src/routing.rs:37-89.
| Component |
Responsibility |
Key file |
Source |
AppState |
Holds validated config and shared reqwest client |
src/server.rs |
src/server.rs:13-25 |
proxy::post |
Entry handler for inference and count-token posts |
src/proxy.rs |
src/proxy.rs:19-126 |
Route |
Carries provider, adapter kind, model, upstream model, effort |
src/routing.rs |
src/routing.rs:23-31 |
Adapter trait |
Hides provider protocol differences behind one async forward |
src/adapters/mod.rs |
src/adapters/mod.rs:21-30 |
Credential |
Represents pass-through, API-key, and ChatGPT OAuth modes |
src/auth/mod.rs |
src/auth/mod.rs:17-28 |
AnthropicSseMachine |
Converts Responses event streams into Anthropic event streams |
src/model/responses.rs |
src/model/responses.rs:62-113 |
graph TB
subgraph Entry[Process entry]
CLI[src/main.rs CLI]
Config[Config load and validate]
end
subgraph HTTP[HTTP gateway]
Router[server::build_router]
Proxy[proxy::post]
Discovery[discovery::get]
end
subgraph RoutingCore[Routing core]
Resolver[routing::resolve_model]
Route[Route]
end
subgraph AdapterLayer[Adapter layer]
Anthropic[AnthropicAdapter]
Responses[ResponsesAdapter]
end
subgraph Models[Translation model]
Request[translate_request]
SSE[AnthropicSseMachine]
end
CLI --> Config --> Router
Router --> Proxy --> Resolver --> Route
Router --> Discovery
Route --> Anthropic
Route --> Responses --> Request
Responses --> SSE
classDef dark fill:#2d333b,stroke:#6d5dfc,color:#e6edf3;
class CLI,Config,Router,Proxy,Discovery,Resolver,Route,Anthropic,Responses,Request,SSE dark;
style Entry fill:#161b22,stroke:#30363d,color:#e6edf3;
style HTTP fill:#161b22,stroke:#30363d,color:#e6edf3;
style RoutingCore fill:#161b22,stroke:#30363d,color:#e6edf3;
style AdapterLayer fill:#161b22,stroke:#30363d,color:#e6edf3;
style Models fill:#161b22,stroke:#30363d,color:#e6edf3;
linkStyle default stroke:#8b949e;
classDiagram
class Config {
ServerConfig server
ProvidersConfig providers
Vec_ModelConfig models
Vec_RouteConfig routes
Vec_RoutePrefixConfig route_prefixes
load(path)
validate()
}
class Route {
String provider
AdapterKind adapter
String model
String upstream_model
Option_String effort
}
class Adapter {
forward(state, route, uri, headers, body)
}
class AnthropicAdapter
class ResponsesAdapter
class Credential {
Passthrough
ApiKey
ChatGptOAuth
}
Config --> Route
Route --> Adapter
Adapter <|.. AnthropicAdapter
Adapter <|.. ResponsesAdapter
ResponsesAdapter --> Credential
AnthropicAdapter --> Credential
sequenceDiagram
autonumber
participant Main as main::run
participant Server as server::build_router
participant Proxy as proxy::post
participant Routing as routing::resolve
participant Auth as resolve_credential
participant Adapter as Adapter impl
participant Model as Translation state
Main->>Server: build router with Config
Server->>Proxy: dispatch POST /v1/messages
Proxy->>Routing: resolve(config, body bytes)
Routing-->>Proxy: Route
Proxy->>Adapter: forward(...)
Adapter->>Auth: provider credential
Adapter->>Model: translate or stream-convert if Responses
Adapter-->>Proxy: status + response
stateDiagram-v2
[*] --> ValidatingConfig
ValidatingConfig --> Serving: Config validate ok
ValidatingConfig --> ConfigError: bad bind/provider/route
Serving --> BodyBuffered: POST body within cap
BodyBuffered --> RouteResolved: model JSON parsed
BodyBuffered --> BadRequest: invalid model JSON
RouteResolved --> CredentialResolved
CredentialResolved --> UpstreamCall
CredentialResolved --> AuthenticationError: missing provider credential
UpstreamCall --> SuccessStream
UpstreamCall --> MappedError: upstream error or transport failure
SuccessStream --> Serving
MappedError --> Serving
| Invariant |
Enforced by |
Why it matters |
Source |
| Request bodies are buffered only to choose a route |
to_bytes(..., 64 MiB) before routing::resolve |
Routing needs model, while responses must still stream |
src/proxy.rs:17-112 |
| Response streaming is preserved |
Body::from_stream for both pass-through and Responses |
Claude Code expects incremental SSE |
src/adapters/anthropic.rs:49-62 src/adapters/responses.rs:68-111 |
| Route precedence is deterministic |
resolve_model loops exact routes before prefixes before default |
Prevents broad prefixes from shadowing explicit mappings |
src/routing.rs:37-89 |
| Adapter selection follows provider kind |
ProviderKind to AdapterKind conversion |
Provider config controls protocol boundary |
src/routing.rs:14-21 |
| Gateway-owned errors use Anthropic error envelope |
ShuntError and UpstreamError IntoResponse |
Claude Code can interpret failures consistently |
src/error.rs:7-91 |