Last updated 2026-05-17
Build an OpenAI-compatible Chat Gateway
This is the headline use case for llmrouter. We're going
to build a single HTTP endpoint at /v1/chat/completions
that accepts requests in the OpenAI Chat Completions shape and routes
them — by model name — to OpenAI, Anthropic, or any other configured
upstream. The response stream stays byte-identical to OpenAI's SSE
format, so any OpenAI SDK pointed at this gateway just works.
At the end you'll have a ~250-line Go program that:
- routes by model prefix (
gpt-,claude-, …); - streams bytes back to the client without re-marshaling;
- cancels upstream on client disconnect;
- translates upstream errors to correct HTTP status codes;
- enforces a per-request token budget read from the final SSE chunk.
Goal
Pretend you're running a small AI product and you don't want every
service that needs an LLM to embed three different SDKs. Instead, one
internal gateway speaks the OpenAI dialect and dispatches to whichever
upstream owns the requested model. Clients keep using
openai SDK, point its base URL at your gateway, and get
free vendor-routing.
curl -X POST http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role":"user","content":"hello"}], "stream": true }'
# same gateway, different upstream — just change the modelcurl -X POST http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-5", "messages": [{"role":"user","content":"hello"}], "stream": true }'Architecture
The shape of the gateway is small. We use chi for
routing because it's the standard Go HTTP router and it gets out of
the way; the actual logic is in one handler.
HTTP request (OpenAI-shape JSON) │ ▼chi router ──► /v1/chat/completions handler │ ▼pick provider by model prefix │ gpt-* ──► llmrouter openai.Provider │ claude-* ──► llmrouter anthropic.Provider │ ... ▼CompletionStream(ctx, ChatRequest{Raw: bodyBytes}) │ ▼for each Chunk: write "data: " + chunk.Raw + "\n\n" │ ▼on stream end: write "data: [DONE]\n\n"Step 1: project scaffold
mkdir chat-gateway && cd chat-gatewaygo mod init example.com/chat-gateway
go get github.com/elloloop/llmrouter@latestgo get github.com/elloloop/llmrouter/providers/openai@latestgo get github.com/elloloop/llmrouter/providers/anthropic@latestgo get github.com/go-chi/chi/v5@latestSet your upstream credentials:
export OPENAI_API_KEY=sk-...export ANTHROPIC_API_KEY=sk-ant-...Step 2: provider registry
We hold every configured upstream in a map[string]llmrouter.Provider
keyed by a short id ("openai", "anthropic").
Providers are concurrency-safe; one instance serves the whole
process.
type Registry struct { byID map[string]llmrouter.Provider}
func NewRegistry() (*Registry, error) { oa, err := openai.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) if err != nil { return nil, fmt.Errorf("openai: %w", err) } an, err := anthropic.New(llmrouter.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))) if err != nil { return nil, fmt.Errorf("anthropic: %w", err) } return &Registry{byID: map[string]llmrouter.Provider{ "openai": oa, "anthropic": an, }}, nil}
// pick returns the provider that owns the given model id, or "", nil// if no provider claims it.func (r *Registry) pick(model string) (string, llmrouter.Provider) { switch { case strings.HasPrefix(model, "gpt-"), strings.HasPrefix(model, "o1-"), strings.HasPrefix(model, "o3-"): return "openai", r.byID["openai"] case strings.HasPrefix(model, "claude-"): return "anthropic", r.byID["anthropic"] } return "", nil}Step 3: the HTTP handler
The handler decodes only what it needs (the model field),
keeps the rest of the body as raw bytes, looks up the provider, and
forwards.
// onlyModel is a small struct used to peek at the model field without// fully decoding the request. We re-use the original body bytes as// ChatRequest.Raw to preserve any fields llmrouter doesn't model.type onlyModel struct { Model string `json:"model"`}
func (g *Gateway) handleChatCompletions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only POST is supported") return }
body, err := io.ReadAll(io.LimitReader(r.Body, 4*1024*1024)) if err != nil { writeJSONError(w, http.StatusBadRequest, "read_body", fmt.Sprintf("could not read body: %v", err)) return } var peek onlyModel if err := json.Unmarshal(body, &peek); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid_json", "request body must be JSON with a 'model' field") return } providerID, provider := g.reg.pick(peek.Model) if provider == nil { writeJSONError(w, http.StatusBadRequest, "unknown_model", fmt.Sprintf("no provider configured for model %q", peek.Model)) return }
// Budget check before we open the upstream. if err := g.budget.check(r); err != nil { writeJSONError(w, http.StatusTooManyRequests, "budget_exceeded", err.Error()) return }
req := llmrouter.ChatRequest{ Model: peek.Model, Raw: body, } stream, err := provider.CompletionStream(r.Context(), req) if err != nil { translateUpstreamError(w, providerID, err) return }
// We don't write headers until we know the upstream accepted the // request — that way a 4xx/5xx from upstream becomes the same status // back to the client. writeSSEHeaders(w) flusher, _ := w.(http.Flusher)
var lastUsage *llmrouter.Usage for chunk := range stream.Chunks() { if _, err := w.Write([]byte("data: ")); err != nil { return } if _, err := w.Write(chunk.Raw); err != nil { return } if _, err := w.Write([]byte("\n\n")); err != nil { return } if flusher != nil { flusher.Flush() } if chunk.Usage != nil { lastUsage = chunk.Usage } } if err := stream.Err(); err != nil { // The headers are already on the wire — best we can do is log it // and let the client see the truncated stream. log.Printf("stream error from %s: %v", providerID, err) return } if _, err := w.Write([]byte("data: [DONE]\n\n")); err == nil { if flusher != nil { flusher.Flush() } } if lastUsage != nil { g.budget.record(r, lastUsage.TotalTokens) }}Step 4: byte-identical SSE writer
The crucial line above is w.Write(chunk.Raw). The OpenAI
provider populates Chunk.Raw with the exact payload it
received from api.openai.com. Writing those bytes back
out — wrapped in the standard SSE envelope — means downstream
clients can't tell whether they're talking to OpenAI directly or to
our proxy.
For Anthropic the bytes in chunk.Raw aren't literal
Anthropic SSE — they're the OpenAI-shaped chunk that the Anthropic
provider synthesized from Anthropic's event stream. So clients still
see a valid OpenAI-shape response; they just don't see Anthropic's
internal events.
func writeSSEHeaders(w http.ResponseWriter) { h := w.Header() h.Set("Content-Type", "text/event-stream; charset=utf-8") h.Set("Cache-Control", "no-cache, no-transform") h.Set("Connection", "keep-alive") // Disable nginx response buffering — required for SSE through most reverse proxies. h.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK)}Step 5: client disconnect handling
Pass r.Context() as the first argument to
CompletionStream. When the client closes the TCP
connection, the request's context is cancelled, which propagates
through the SSE pump goroutine inside the provider — it stops
reading from the upstream, closes the response body, and the upstream
HTTP/2 stream is torn down.
stream, err := provider.CompletionStream(r.Context(), req)
That single line gives you correct fan-in/fan-out cancellation. No
extra goroutines watching r.Context().Done(). No
per-request bookkeeping. If you've worked with the standard library
long enough, this is the payoff for the streaming-first design.
Step 6: error responses
The provider returns *llmrouter.ErrUpstream when the
upstream sends a non-2xx status. We translate that back into the
correct HTTP status for the client; network errors become 502.
func translateUpstreamError(w http.ResponseWriter, providerID string, err error) { var upstream *llmrouter.ErrUpstream if errors.As(err, &upstream) { // Re-use the original upstream status code so SDK clients see the // same shape they'd see talking to OpenAI directly (401 for auth, // 429 for rate limit, 400 for malformed request, etc.). w.Header().Set("Content-Type", "application/json") w.WriteHeader(upstream.StatusCode) _, _ = w.Write([]byte(upstream.Body)) return } // Network / DNS / timeout — surface as 502. log.Printf("provider %s: %v", providerID, err) writeJSONError(w, http.StatusBadGateway, "upstream_unavailable", fmt.Sprintf("upstream %s did not respond", providerID))}
func writeJSONError(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{ "error": map[string]any{ "type": code, "message": msg, }, })}Step 7: token counting and per-request budgets
On the last chunk before [DONE], both OpenAI (when
stream_options.include_usage: true is set — which the
provider sets automatically) and Anthropic report token usage in
Chunk.Usage. We read that and feed it back into a
budget store keyed by client identity.
For brevity the example below buckets by source IP. Real deployments key by API key, tenant id, or workspace.
type Budget struct { mu sync.Mutex used map[string]int cap int windowKey func(*http.Request) string}
func NewBudget(cap int) *Budget { return &Budget{ used: make(map[string]int), cap: cap, windowKey: func(r *http.Request) string { // 1-minute window keyed by client IP. ip, _, _ := net.SplitHostPort(r.RemoteAddr) return fmt.Sprintf("%s|%d", ip, time.Now().Unix()/60) }, }}
func (b *Budget) check(r *http.Request) error { b.mu.Lock() defer b.mu.Unlock() if b.used[b.windowKey(r)] >= b.cap { return fmt.Errorf("per-minute token cap of %d exceeded", b.cap) } return nil}
func (b *Budget) record(r *http.Request, tokens int) { b.mu.Lock() defer b.mu.Unlock() b.used[b.windowKey(r)] += tokens}Two things to note about this budget store:
- The budget is checked before opening the upstream stream. This stops new requests from queueing once the cap is hit instead of cutting them off mid-stream. (Cutting off mid-stream is also possible — see cancellation & timeouts — but it's a worse UX.)
-
Usage is read from the final chunk. Earlier
chunks don't have usage; the OpenAI provider asks for
include_usage: trueso the upstream emits a final chunk with totals.
The full program
Put it all in main.go:
package main
import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "os" "os/signal" "strings" "sync" "syscall" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/anthropic" "github.com/elloloop/llmrouter/providers/openai")
type Registry struct { byID map[string]llmrouter.Provider}
func NewRegistry() (*Registry, error) { oa, err := openai.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) if err != nil { return nil, fmt.Errorf("openai: %w", err) } an, err := anthropic.New(llmrouter.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))) if err != nil { return nil, fmt.Errorf("anthropic: %w", err) } return &Registry{byID: map[string]llmrouter.Provider{ "openai": oa, "anthropic": an, }}, nil}
func (r *Registry) pick(model string) (string, llmrouter.Provider) { switch { case strings.HasPrefix(model, "gpt-"), strings.HasPrefix(model, "o1-"), strings.HasPrefix(model, "o3-"): return "openai", r.byID["openai"] case strings.HasPrefix(model, "claude-"): return "anthropic", r.byID["anthropic"] } return "", nil}
type Budget struct { mu sync.Mutex used map[string]int cap int windowKey func(*http.Request) string}
func NewBudget(cap int) *Budget { return &Budget{ used: make(map[string]int), cap: cap, windowKey: func(r *http.Request) string { ip, _, _ := net.SplitHostPort(r.RemoteAddr) return fmt.Sprintf("%s|%d", ip, time.Now().Unix()/60) }, }}
func (b *Budget) check(r *http.Request) error { b.mu.Lock() defer b.mu.Unlock() if b.used[b.windowKey(r)] >= b.cap { return fmt.Errorf("per-minute token cap of %d exceeded", b.cap) } return nil}
func (b *Budget) record(r *http.Request, tokens int) { b.mu.Lock() defer b.mu.Unlock() b.used[b.windowKey(r)] += tokens}
type Gateway struct { reg *Registry budget *Budget}
type onlyModel struct { Model string `json:"model"`}
func (g *Gateway) handleChatCompletions(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 4*1024*1024)) if err != nil { writeJSONError(w, http.StatusBadRequest, "read_body", fmt.Sprintf("could not read body: %v", err)) return } var peek onlyModel if err := json.Unmarshal(body, &peek); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid_json", "request body must be JSON with a 'model' field") return } providerID, provider := g.reg.pick(peek.Model) if provider == nil { writeJSONError(w, http.StatusBadRequest, "unknown_model", fmt.Sprintf("no provider configured for model %q", peek.Model)) return } if err := g.budget.check(r); err != nil { writeJSONError(w, http.StatusTooManyRequests, "budget_exceeded", err.Error()) return }
req := llmrouter.ChatRequest{Model: peek.Model, Raw: body} stream, err := provider.CompletionStream(r.Context(), req) if err != nil { translateUpstreamError(w, providerID, err) return }
writeSSEHeaders(w) flusher, _ := w.(http.Flusher)
var lastUsage *llmrouter.Usage for chunk := range stream.Chunks() { if _, err := w.Write([]byte("data: ")); err != nil { return } if _, err := w.Write(chunk.Raw); err != nil { return } if _, err := w.Write([]byte("\n\n")); err != nil { return } if flusher != nil { flusher.Flush() } if chunk.Usage != nil { lastUsage = chunk.Usage } } if err := stream.Err(); err != nil { log.Printf("stream error from %s: %v", providerID, err) return } _, _ = w.Write([]byte("data: [DONE]\n\n")) if flusher != nil { flusher.Flush() } if lastUsage != nil { g.budget.record(r, lastUsage.TotalTokens) }}
func translateUpstreamError(w http.ResponseWriter, providerID string, err error) { var upstream *llmrouter.ErrUpstream if errors.As(err, &upstream) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(upstream.StatusCode) _, _ = w.Write([]byte(upstream.Body)) return } log.Printf("provider %s: %v", providerID, err) writeJSONError(w, http.StatusBadGateway, "upstream_unavailable", fmt.Sprintf("upstream %s did not respond", providerID))}
func writeJSONError(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{ "error": map[string]any{"type": code, "message": msg}, })}
func writeSSEHeaders(w http.ResponseWriter) { h := w.Header() h.Set("Content-Type", "text/event-stream; charset=utf-8") h.Set("Cache-Control", "no-cache, no-transform") h.Set("Connection", "keep-alive") h.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK)}
func main() { reg, err := NewRegistry() if err != nil { log.Fatal(err) } g := &Gateway{reg: reg, budget: NewBudget(50_000)}
r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Post("/v1/chat/completions", g.handleChatCompletions)
srv := &http.Server{ Addr: ":8080", Handler: r, ReadHeaderTimeout: 10 * time.Second, // No WriteTimeout — streams can be long. }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop()
go func() { log.Printf("listening on %s", srv.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } }()
<-ctx.Done() log.Println("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx)}Test it
go run .
# in another terminalcurl -N -X POST http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role":"user","content":"count to 5"}], "stream": true }'
# you should see SSE chunks streaming back, ending in:# data: [DONE]## then try Anthropic — same endpoint:curl -N -X POST http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-5", "messages": [{"role":"user","content":"count to 5"}], "stream": true, "max_tokens": 200 }'Use it from the OpenAI SDK
Because the wire format is OpenAI-shape SSE, any OpenAI client works. For example, with the Python SDK:
pip install openai
OPENAI_BASE_URL=http://localhost:8080/v1 \OPENAI_API_KEY=ignored \python -c 'import openaiclient = openai.OpenAI()stream = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{"role": "user", "content": "hi"}], stream=True, max_tokens=200,)for chunk in stream: print(chunk.choices[0].delta.content or "", end="", flush=True)'What to add next
- Multi-provider failover — automatically retry against a backup provider when the primary returns 5xx or 429.
- Switching providers at runtime — route by header, feature flag, or tenant id instead of model prefix.
- Custom HTTP client & retries — add OpenTelemetry tracing, exponential-backoff retries, or proxy support.
- Byte-passthrough proxy — go even thinner: no decoding, no budget, just forward bytes.