Last updated 2026-05-17
Architecture Overview
llmrouter is built around one interface and three shared types. The
interface is Provider; the types are
ChatRequest (in), Stream (out), and
Chunk (the unit a stream yields). Everything else —
auth, base URLs, body translation, SSE parsing, error wrapping — is
a per-provider concern hidden behind that interface.
This page covers the big idea, the type wiring, the two translation strategies (passthrough vs full translator), the streaming model, and the byte-passthrough escape hatch. For per-provider behavior see the OpenAI and Anthropic reference pages.
The big idea
One Provider interface, multiple backends, OpenAI-shaped
types as the lingua franca. Application code holds a
llmrouter.Provider and never knows which upstream it's
talking to. Provider subpackages translate between the shared types
and whatever the upstream API actually wants.
The interface itself is two methods:
// Provider is the upstream-LLM contract. Each implementation// translates to/from the OpenAI /v1/chat/completions wire format// internally.type Provider interface { // Name returns the provider's stable id (e.g. "openai", "anthropic"). Name() string
// CompletionStream issues a streaming chat completion request. The // returned Stream yields normalized chunks until upstream finishes, // the context cancels, or an error occurs. Callers must drain the // stream (or cancel ctx) to release resources. CompletionStream(ctx context.Context, req ChatRequest) (*Stream, error)}That is the entire public surface a provider exposes. The implications:
- One call shape for every provider. Application
code that takes
llmrouter.Provideris provider-agnostic by construction. - Streaming is the only mode. There is no
Complete()that returns a string. If you want the full response as a single value, drain the stream and concatenate the deltas — but doing so explicitly is the point: the library doesn't pretend the upstream isn't streaming. - Construction is per-provider. Each provider
subpackage exposes
New(opts ...llmrouter.Option)which returns a value implementing the interface. The constructor is where API keys, base URLs, and timeouts get baked in.
How the pieces fit
The flow from your application code down to the upstream HTTPS request looks like this:
┌──────────────────────────────────────────────────────────┐│ Your application code ││ ││ p := /* one of the providers below */ ││ stream, _ := p.CompletionStream(ctx, llmrouter.ChatRequest{...})│ for chunk := range stream.Chunks() { ... } ││ if err := stream.Err(); err != nil { ... } │└────────────────────────┬─────────────────────────────────┘ │ │ llmrouter.Provider interface │ (Name, CompletionStream) │ ┌─────────────────────┼─────────────────────────────────┐ │ │ │ ▼ ▼ ▼┌────────────┐ ┌────────────────┐ ┌──────────────────────────┐│ OpenAI │ │ Anthropic │ │ v0.2 roadmap: ││ passthrough│ │ full translator│ │ Azure / Bedrock / Vertex││ │ │ │ │ (full translators + ││ - body: │ │ - body: │ │ SigV4 / ADC auth) ││ verbatim │ │ OpenAI → │ └──────────────────────────┘│ if Raw, │ │ Anthropic ││ else │ │ - SSE: ││ marshal │ │ Anthropic → ││ - SSE: │ │ OpenAI ││ parse │ │ - headers: ││ OpenAI │ │ x-api-key, ││ chunks │ │ anthropic- ││ - headers: │ │ version ││ Bearer │ │ │└─────┬──────┘ └────────┬───────┘ │ │ ▼ ▼┌───────────────────────────────────────────────────────────┐│ upstream HTTPS (api.openai.com, ││ api.anthropic.com, your-vllm-host, …) │└───────────────────────────────────────────────────────────┘The shape generalises cleanly. Every new provider is one more box in the middle row; the interface above and the HTTP below are invariant.
Three core types
Provider
Already shown above. Two methods, both pure: Name()
is a stable string id used in error messages and metrics;
CompletionStream is the workhorse. The
Stream it returns is owned by the caller — the
provider's only obligation after returning is to keep the producer
goroutine fed until the upstream finishes or the context cancels.
ChatRequest
OpenAI-shaped request. Required fields are Model and
Messages; everything else is optional. The
Raw json.RawMessage field is the byte-passthrough hook
discussed below.
type ChatRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` Stop []string `json:"stop,omitempty"` User string `json:"user,omitempty"` Stream bool `json:"stream,omitempty"` Raw json.RawMessage `json:"-"`}
type Message struct { Role string `json:"role"` Content json.RawMessage `json:"content"`} Message.Content is json.RawMessage, not
string, on purpose: OpenAI's multimodal content is an
array of typed parts, not a plain string. By leaving it as raw
JSON, the library lets passthrough providers forward multimodal
requests without modeling every content-part variant. For the
plain-text case use the
llmrouter.TextMessage(role, text) convenience.
See API reference: ChatRequest.
Stream
The streaming-completion handle. Three operations:
Chunks() returns a receive-only channel that closes
when the upstream finishes or the context cancels;
Err() blocks until the producer finishes, then returns
the terminal error (nil on success); Cancel() asks
the producer to stop and is equivalent to cancelling the parent
context.
type Stream struct { chunks chan Chunk cancel context.CancelFunc errMu chan struct{} err error}
func (s *Stream) Chunks() <-chan Chunkfunc (s *Stream) Err() errorfunc (s *Stream) Cancel()Single-consumer by contract. The internal channel is buffered at 16 chunks so a slow consumer can absorb short bursts without backpressuring the upstream HTTP read.
See API reference: Stream and Streaming model.
Translation strategy
Each provider picks one of two strategies for the request body and the response stream: byte passthrough or full translator. The choice is determined by how similar the upstream is to OpenAI's wire format.
Passthrough (OpenAI and OpenAI-compatibles)
The OpenAI upstream speaks /v1/chat/completions
natively. Any provider talking to that endpoint — OpenAI itself,
OpenRouter, Together, Groq, a self-hosted vLLM, Ollama — uses the
passthrough path:
- Request body. If
ChatRequest.Rawis non-nil, the provider forwards those bytes verbatim. Otherwise it serializes the typed fields directly. There is no intermediate Go struct that drops unknown fields — fields the typed surface doesn't model (tools, vision,response_format, structured outputs, vendor-specific extensions) survive when sent viaRaw. - Response stream. The SSE event stream is
already OpenAI-shaped, so each event is parsed exactly once
into a
Chunk, with the original wire JSON preserved onChunk.Raw. A gateway forwarding to a downstream caller emitsChunk.Rawbytes directly with no re-marshaling. - Headers.
Authorization: Bearer <apiKey>,Content-Type: application/json,Accept: text/event-stream.
Full translator (Anthropic, future Bedrock/Vertex)
When the upstream wire format diverges meaningfully from OpenAI's — different body shape, different SSE event types, different auth — the provider runs a full translator on both sides.
Anthropic specifically:
- Request body. OpenAI
messages→ Anthropic/v1/messagesshape. Thesystemrole is lifted out of the messages array into Anthropic's top-levelsystemfield.max_tokensis required by Anthropic and is always included. - Response stream. Anthropic emits a richer SSE
event vocabulary (
message_start,content_block_start,content_block_delta,content_block_stop,message_delta,message_stop). The provider folds these into OpenAI delta chunks: text-delta events becomeDelta.Content, the first chunk carriesDelta.Role = "assistant", themessage_stopevent becomes a final chunk with aFinishReason. - Headers.
x-api-key: <apiKey>,anthropic-version: 2023-06-01,Content-Type: application/json,Accept: text/event-stream.
The future Bedrock and Vertex providers will follow the same pattern: SigV4 / ADC for auth, per-model-family body shapes, event-stream-framing → OpenAI chunk translation. The interface above doesn't change.
Streaming model
The streaming machinery is owned by the root package, not by each
provider. llmrouter.NewStream(parent context.Context)
returns a *Stream, a derived context.Context
(with cancellation chained to the consumer's
Cancel()), and a ProducerHooks struct
with Send and Finish callbacks.
type ProducerHooks struct { // Send returns false if the consumer cancelled — the producer // should then stop and call Finish(ctx.Err()). Send func(Chunk) bool
// Finish closes the stream and finalises the terminal error. // Pass nil on a clean stream. Finish func(error)}The provider's CompletionStream implementation looks roughly like:
func (p *Provider) CompletionStream(parent context.Context, req llmrouter.ChatRequest) (*llmrouter.Stream, error) { httpReq, err := p.buildRequest(parent, req) if err != nil { return nil, err }
stream, ctx, hooks := llmrouter.NewStream(parent)
go func() { // HTTP request uses ctx so cancellation propagates to the wire. httpReq = httpReq.WithContext(ctx) resp, err := p.config.HTTP().Do(httpReq) if err != nil { hooks.Finish(err) return } defer resp.Body.Close()
if resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) hooks.Finish(&llmrouter.ErrUpstream{ Provider: p.Name(), StatusCode: resp.StatusCode, Body: body, }) return }
// Parse SSE events; translate to Chunks; deliver via hooks.Send. for evt := range parseSSE(resp.Body) { chunk := translate(evt) if !hooks.Send(chunk) { return // consumer cancelled } } hooks.Finish(nil) }()
return stream, nil}Key invariants:
- The producer is a single goroutine. No shared mutable state between the consumer and the producer except the channel. The buffered channel (capacity 16) absorbs short bursts.
- Cancellation propagates downward.
NewStreamderivesctxfrom the caller'sparentusingcontext.WithCancel. The producer attaches thatctxto the HTTP request viaWithContext, so the standard library cancels the in-flight connection when the consumer callsStream.Cancel()or the parent context expires. - Send is bounded.
hooks.Sendusesselectwithctx.Done(), so a consumer that bails mid-stream does not pin the producer. IfSendreturns false, the producer returns immediately. - Finish is terminal. The producer must call
Finish(nil)on success orFinish(err)on failure exactly once. AfterFinish, the consumer'sChunks()channel closes andErr()unblocks.
The Raw byte field
Chunk.Raw is a deliberate design choice: every chunk
carries the original wire-format JSON, exactly as it arrived from
the upstream (or, for translating providers, exactly as the
translated event was serialized into the OpenAI shape). This makes
the proxy/gateway use case a one-liner.
Concretely, a passthrough proxy looks like:
func proxyHandler(w http.ResponseWriter, r *http.Request) { var req llmrouter.ChatRequest // Preserve the original request body verbatim so the upstream // sees fields we don't model (tools, vision, response_format). body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &req) req.Raw = body
stream, err := provider.CompletionStream(r.Context(), req) if err != nil { http.Error(w, err.Error(), 502) return }
w.Header().Set("Content-Type", "text/event-stream") flusher := w.(http.Flusher)
for chunk := range stream.Chunks() { fmt.Fprintf(w, "data: %s\n\n", chunk.Raw) flusher.Flush() } if err := stream.Err(); err == nil { fmt.Fprint(w, "data: [DONE]\n\n") flusher.Flush() }}Note what is not happening: no re-marshaling, no field enumeration, no risk of losing fields llmrouter doesn't model. The upstream's bytes go out to the downstream caller verbatim.
Even with a translating provider (Anthropic), Chunk.Raw
still carries the translated-into-OpenAI-shape bytes, so the same
proxy code works against Anthropic with zero changes — the
downstream caller sees OpenAI SSE no matter which upstream you
chose.
See Byte passthrough for the deeper treatment.
Dependency hygiene
llmrouter has exactly one third-party dependency:
github.com/google/uuid (used by the Anthropic provider
to mint request ids). The OpenAI provider uses nothing beyond the
standard library.
The reasoning:
- No vendor SDKs. A library that aggregates four official SDKs inherits four module trees, four release cadences, and four sets of breaking changes. llmrouter implements every provider's HTTP transport directly so we own the surface end to end.
- v0.2 still keeps the bar low. Azure OpenAI is
pure standard library (HTTPS + headers). AWS Bedrock will add
github.com/aws/aws-sdk-go-v2only for SigV4 signing — the request and response bodies are read/written directly. Google Vertex will addgolang.org/x/oauth2+ ADC for auth; the generative endpoints are HTTPS + JSON. - You don't pay for providers you don't import.
Provider subpackages are independent. Importing
providers/openaidoes not pullproviders/anthropic; importingproviders/anthropicdoes not pull the futureproviders/bedrockwith the AWS SDK.
Next steps
- Streaming model — producer goroutine, buffered channel, cancellation propagation.
- Byte passthrough —
Chunk.RawandChatRequest.Rawin depth. - Error handling —
ErrUpstream, context errors, parse failures. - OpenAI provider — passthrough behavior and base-URL override patterns.
- Anthropic provider — request + SSE translation in detail.
- API reference — every exported type and option.