Last updated 2026-05-17
Byte-passthrough Proxy
"Byte-passthrough" is the property that the bytes leaving your proxy
are bit-identical to the bytes the upstream produced. That's the
feature that lets any OpenAI SDK — Python, Node, Go, Ruby — point
its OPENAI_BASE_URL at your proxy and see no
difference from talking to api.openai.com directly.
This page shows how llmrouter supports this, why it
matters, and where the property does and doesn't hold.
The use case
You're running an OpenAI-shaped proxy in front of one or more upstreams. Maybe it's a gateway for billing, abuse, or governance. Maybe it's an internal multi-tenant router. Whatever the reason, your clients are existing OpenAI SDKs — and you want them to "just work."
The OpenAI SDKs vary in how strict they are about response shape:
- Python
openai1.x — permissive, tolerates unknown fields, parses what it recognises. - Some Go SDKs — strict structs that fail
json.Unmarshalif a field is missing, or panic on type mismatches. - Older Node SDKs — variable; some warn on unknown fields, some throw.
- Community SDKs — even more variable.
Re-marshaling the response through your own JSON encoder loses fields, reorders keys, omits zero values, and otherwise mutates the wire format. Each mutation is a chance to break some client. Byte-passthrough sidesteps the entire problem by never decoding the payload in the first place.
How the mechanic works
Two pieces:
- Outbound: set
ChatRequest.Raw = requestBytesinstead of (or in addition to) the typed fields. The OpenAI provider re-uses those bytes as the upstream POST body, rewriting only themodelandstreamfields. Vision arrays, tool definitions, response_format directives — anything OpenAI accepts that the typedChatRequestdoesn't model — pass through unchanged. - Inbound: for each chunk you receive, write
chunk.Rawto the response with thedata:prefix and\\n\\nseparator. The OpenAI provider populatesRawwith the exact payload it received from the upstream — never re-marshaled.
// Outbound — preserve the original request body.req := llmrouter.ChatRequest{ Model: peek.Model, Raw: requestBytes,}
// Inbound — write chunk.Raw bytes verbatim.for chunk := range stream.Chunks() { w.Write([]byte("data: ")) w.Write(chunk.Raw) w.Write([]byte("\n\n")) flusher.Flush()}Why this matters: the strict-parser problem
Consider a tool-call response. OpenAI returns:
{ "id": "chatcmpl-abc", "object": "chat.completion.chunk", "created": 1717000000, "model": "gpt-4o-mini", "choices": [{ "index": 0, "delta": { "tool_calls": [{ "index": 0, "id": "call_xyz", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city"} }] }, "finish_reason": null }], "system_fingerprint": "fp_a1b2c3"}
The llmrouter.Chunk struct doesn't model
tool_calls or system_fingerprint. If you
decode this chunk and re-encode it from the typed fields, both go
missing — and the client SDK either silently loses tool calls or
explicitly rejects the response for a missing field. By forwarding
chunk.Raw verbatim, those fields survive.
Where byte-passthrough doesn't apply
The OpenAI provider preserves bytes exactly. The Anthropic provider cannot — the upstream wire format is different:
Anthropic upstream emits: OpenAI provider would emit: event: message_start {"id":"chatcmpl-..","object":"chat.completion.chunk", ...} data: {"type":"message_start", {"id":"chatcmpl-..","object":"chat.completion.chunk", ...} "message":{...}} {"id":"chatcmpl-..","object":"chat.completion.chunk", ...} ... event: content_block_delta data: {"type":"content_block_delta", "delta":{"text":"hi"}}
The Anthropic provider does populate Chunk.Raw
— but with synthesized OpenAI-shape JSON, not the original
Anthropic event. So clients still see valid OpenAI-shape SSE; they
just don't see Anthropic's native events. The byte-passthrough
property is preserved relative to a notional OpenAI-shape
response, not byte-identical to what Anthropic itself
would send.
Required response headers
Three headers matter for SSE through a typical reverse-proxy stack:
-
Content-Type: text/event-stream; charset=utf-8— tells clients to parse the body as SSE. -
Cache-Control: no-cache, no-transform— keeps CDNs and reverse proxies from buffering or mutating the stream. -
X-Accel-Buffering: no— disables nginx response buffering. Without this nginx waits for the response to finish before forwarding any bytes, which defeats streaming entirely.
The complete program
package main
import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "os/signal" "strings" "syscall" "time"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/anthropic" "github.com/elloloop/llmrouter/providers/openai")
// PassthroughProxy is the simplest useful llmrouter proxy:// - decode only the model field// - forward the original request body via ChatRequest.Raw// - forward each chunk's Raw bytes back to the client verbatim// - never re-marshal typed structstype PassthroughProxy struct { providers map[string]llmrouter.Provider}
func NewPassthroughProxy() (*PassthroughProxy, 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 &PassthroughProxy{ providers: map[string]llmrouter.Provider{ "openai": oa, "anthropic": an, }, }, nil}
func (p *PassthroughProxy) pick(model string) llmrouter.Provider { switch { case strings.HasPrefix(model, "gpt-"), strings.HasPrefix(model, "o1-"), strings.HasPrefix(model, "o3-"): return p.providers["openai"] case strings.HasPrefix(model, "claude-"): return p.providers["anthropic"] } return nil}
type peekModel struct { Model string `json:"model"`}
func (p *PassthroughProxy) handle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return }
body, err := io.ReadAll(io.LimitReader(r.Body, 4*1024*1024)) if err != nil { http.Error(w, "could not read body", http.StatusBadRequest) return }
var peek peekModel if err := json.Unmarshal(body, &peek); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return }
provider := p.pick(peek.Model) if provider == nil { http.Error(w, fmt.Sprintf("no provider configured for model %q", peek.Model), http.StatusBadRequest) return }
// CRITICAL: pass r.Context() so client disconnects propagate. stream, err := provider.CompletionStream(r.Context(), llmrouter.ChatRequest{ Model: peek.Model, Raw: body, // <-- the entire incoming body, byte-for-byte }) if err != nil { var upstream *llmrouter.ErrUpstream if errors.As(err, &upstream) { // Mirror the upstream status code. w.Header().Set("Content-Type", "application/json") w.WriteHeader(upstream.StatusCode) _, _ = w.Write([]byte(upstream.Body)) return } log.Printf("provider error: %v", err) http.Error(w, "upstream unavailable", http.StatusBadGateway) return }
writeSSEHeaders(w) flusher, _ := w.(http.Flusher)
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 err := stream.Err(); err != nil { log.Printf("stream finished with error: %v", err) return } _, _ = w.Write([]byte("data: [DONE]\n\n")) if flusher != nil { flusher.Flush() }}
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() { p, err := NewPassthroughProxy() if err != nil { log.Fatal(err) }
mux := http.NewServeMux() mux.HandleFunc("/v1/chat/completions", p.handle) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 10 * time.Second, // No WriteTimeout — streams. }
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 from the Python OpenAI SDK
The whole point of byte-passthrough is to be invisible to the client. The fastest way to validate that is to run an off-the-shelf OpenAI SDK against the proxy.
pip install openai
OPENAI_BASE_URL=http://localhost:8080/v1 \OPENAI_API_KEY=ignored \python3 - <<'PY'import openaiclient = openai.OpenAI()
stream = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": "count to 5"}], stream=True,)for event in stream: delta = event.choices[0].delta.content or "" print(delta, end="", flush=True)print()
# Same SDK, same code, different upstream — just change the model:stream = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{"role": "user", "content": "count to 5"}], stream=True, max_tokens=200,)for event in stream: delta = event.choices[0].delta.content or "" print(delta, end="", flush=True)print()PYThe Python SDK doesn't know — and doesn't need to — whether it's talking to OpenAI directly, to your proxy, or (transitively) to Anthropic. It just sees OpenAI-shape SSE on the wire.
Test it with curl
For a smoke test that doesn't depend on the Python ecosystem:
curl -N http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-4o-mini", "messages": [{"role":"user","content":"hi"}], "stream": true, "stream_options": {"include_usage": true} }'
The -N flag tells curl not to buffer the response.
You'll see chunks arrive as soon as the upstream emits them.
What you give up
Byte-passthrough is great for transparency but it's not the right choice for every proxy:
- You can't easily inspect or modify the content. If you want to redact PII, append a system message, or strip forbidden fields, you have to parse the body (and the response chunks) — which means you're no longer in passthrough mode.
- You can't enforce schema constraints. If the client sends an unsupported parameter, the upstream rejects it, not your proxy. Sometimes that's the goal; sometimes you'd rather fail-fast at the proxy.
- Token counting is post-hoc. You can read
chunk.Usagefrom the final chunk to track usage, but you can't estimate cost before the request runs.
Related
- Build a chat gateway — the full feature-rich gateway, building on the same byte-pass mechanic but adding routing, budgets, and error translation.
- Concept: byte passthrough
— the design rationale and what
ChatRequest.Raw/Chunk.Rawguarantee. - The OpenAI provider
— including how
WithBaseURLlets you point at OpenRouter, Groq, Together, or self-hosted OpenAI-compatible endpoints with full passthrough.