Last updated 2026-05-17
The Provider Interface
Every upstream LLM in llmrouter — OpenAI, Anthropic, Azure
OpenAI, AWS Bedrock, Vertex (planned), and any OpenAI-compatible
endpoint reached via WithBaseURL — implements a single
two-method interface. That interface is the seam the rest of the
library is built on: requests in, normalized chunks out, with the
OpenAI Chat Completions wire format as the lingua franca.
The interface
The contract lives in llmrouter.go
and is intentionally tiny:
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)}
Two methods. No options bag on the call site, no synchronous
one-shot path, no model registry, no token counter. Everything else
— retries, fallback, fan-out, budgeting — is a wrapper you compose
around this surface. If you can satisfy these two methods, you can
plug into any code that takes a Provider.
Why streaming-only
The most common question people ask about this API is: "where's the non-streaming version?" There isn't one, on purpose.
- Gateways always stream. If you're building a proxy, an OpenAI-compatible aggregator, or a chat UI, you're streaming already. A non-streaming path would just be a buffer-and-drop on top of the streaming one.
- A one-shot request is the degenerate case of streaming — open the stream, drain it into a string, return the string. Two extra lines of code, no extra concept to learn, no second code path in the library to keep in sync.
- Streaming is the surface that exposes cancellation, backpressure, partial-result handling, and incremental UI rendering. If we offered a synchronous API we'd quietly encourage callers to give those things up.
The shape of the buffer-and-collect helper you'd write looks like this. It's small enough that it doesn't need to live in the library:
package main
import ( "context" "strings"
"github.com/elloloop/llmrouter")
// collect drains a Provider into a single string. Use this when you// don't actually need streaming and want a synchronous-style call.func collect(ctx context.Context, p llmrouter.Provider, req llmrouter.ChatRequest) (string, error) { stream, err := p.CompletionStream(ctx, req) if err != nil { return "", err } var out strings.Builder for chunk := range stream.Chunks() { for _, c := range chunk.Choices { out.WriteString(c.Delta.Content) } } if err := stream.Err(); err != nil { return "", err } return out.String(), nil}Polymorphism: code that takes any provider
Because Provider is an interface, any code that operates
on it works across every upstream. Here's a function that doesn't
care whether it's talking to OpenAI directly, OpenRouter, a
self-hosted vLLM instance, or Anthropic:
package main
import ( "context" "fmt" "strings"
"github.com/elloloop/llmrouter")
// chat asks the given provider one question and returns its answer.// Works with any Provider implementation — OpenAI, Anthropic, etc.func chat(ctx context.Context, p llmrouter.Provider, prompt string) (string, error) { req := llmrouter.ChatRequest{ Model: defaultModel(p.Name()), Messages: []llmrouter.Message{ llmrouter.TextMessage("user", prompt), }, } stream, err := p.CompletionStream(ctx, req) if err != nil { return "", fmt.Errorf("%s: open stream: %w", p.Name(), err) } var out strings.Builder for chunk := range stream.Chunks() { for _, c := range chunk.Choices { out.WriteString(c.Delta.Content) } } if err := stream.Err(); err != nil { return "", fmt.Errorf("%s: stream: %w", p.Name(), err) } return out.String(), nil}
// defaultModel picks a sensible default per provider. Real code would// take this from config; this is just illustration.func defaultModel(name string) string { switch name { case "anthropic": return "claude-sonnet-4-5" default: return "gpt-4o-mini" }}
The same chat function plugs into both providers from
providers/openai
and
providers/anthropic:
package main
import ( "context" "fmt" "log" "os"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/anthropic" "github.com/elloloop/llmrouter/providers/openai")
func main() { ctx := context.Background()
oa, err := openai.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) if err != nil { log.Fatal(err) } an, err := anthropic.New(llmrouter.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))) if err != nil { log.Fatal(err) }
for _, p := range []llmrouter.Provider{oa, an} { ans, err := chat(ctx, p, "in one sentence, what is a goroutine?") if err != nil { log.Printf("%s: %v", p.Name(), err) continue } fmt.Printf("%-10s %s", p.Name(), ans) }}The ChatRequest shape
ChatRequest is the OpenAI Chat Completions payload,
transposed into Go:
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:"-"`}-
Model— the model id, passed through to the upstream. Anthropic ids (claude-sonnet-4-5) work with the Anthropic provider; OpenAI ids (gpt-4o-mini) work with the OpenAI provider and with OpenAI-compatible endpoints behindWithBaseURL. -
Messages— the conversation. See below for the message shape. -
MaxTokens— upper bound on completion length. Anthropic requires this (the provider defaults to 4096 if you leave it zero); OpenAI treats it as optional. -
Temperature,TopP— sampling knobs. Pointer types so zero values are distinguishable from "not set." -
Stop— stop sequences. The Anthropic provider maps these ontostop_sequences. -
User— opaque end-user id forwarded to OpenAI for abuse-detection. -
Stream— ignored. Providers always forcestream: trueon the wire. -
Raw— original request bytes for passthrough mode. See Byte Passthrough.
The Message shape
type Message struct { Role string `json:"role"` Content json.RawMessage `json:"content"`}
The unusual part is Content json.RawMessage. OpenAI's
content field is a union — it's either a plain string (text
message) or an array of typed parts (multimodal: text + image_url +
audio + …). Modelling that as a Go union would force you into a
sum-type pattern with constructors and visitors. Using
json.RawMessage instead means the bytes pass through
untouched: plain strings stay strings, vision arrays stay arrays,
and any future content type is forward-compatible without a library
change.
For the common case of plain text, TextMessage hides
the JSON entirely:
// TextMessage is a convenience constructor for plain-text messages.func TextMessage(role, text string) Message { b, _ := json.Marshal(text) return Message{Role: role, Content: b}}And to read text out, PlainText handles both shapes:
// PlainText extracts text from a Message. For multimodal content arrays// it concatenates the "text" parts and ignores image bytes.func (m Message) PlainText() string { /* ... */ }A complete plain-text example:
req := llmrouter.ChatRequest{ Model: "gpt-4o-mini", Messages: []llmrouter.Message{ llmrouter.TextMessage("system", "you are concise"), llmrouter.TextMessage("user", "what is the capital of france?"), },}And a multimodal example — passed in as raw bytes:
vision := json.RawMessage(`[ {"type":"text","text":"what is in this image?"}, {"type":"image_url","image_url":{"url":"https://example.com/cat.jpg"}}]`)req := llmrouter.ChatRequest{ Model: "gpt-4o", Messages: []llmrouter.Message{ {Role: "user", Content: vision}, },}Full example: provider-agnostic chat
Putting it all together — a small program that takes a provider name on the command line and asks it a question:
package main
import ( "context" "flag" "fmt" "log" "os" "strings" "time"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/anthropic" "github.com/elloloop/llmrouter/providers/openai")
func main() { var ( provider = flag.String("provider", "openai", "openai|anthropic") model = flag.String("model", "", "model id (defaults per provider)") prompt = flag.String("prompt", "say hi in five words", "user prompt") ) flag.Parse()
p, err := buildProvider(*provider) if err != nil { log.Fatal(err) } if *model == "" { *model = defaultModel(p.Name()) }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()
answer, err := chat(ctx, p, *model, *prompt) if err != nil { log.Fatal(err) } fmt.Println(answer)}
func buildProvider(name string) (llmrouter.Provider, error) { switch name { case "openai": return openai.New(llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))) case "anthropic": return anthropic.New(llmrouter.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))) default: return nil, fmt.Errorf("unknown provider %q", name) }}
func defaultModel(name string) string { switch name { case "anthropic": return "claude-sonnet-4-5" default: return "gpt-4o-mini" }}
// chat is provider-agnostic — it only depends on the Provider interface.func chat(ctx context.Context, p llmrouter.Provider, model, prompt string) (string, error) { req := llmrouter.ChatRequest{ Model: model, Messages: []llmrouter.Message{ llmrouter.TextMessage("user", prompt), }, } stream, err := p.CompletionStream(ctx, req) if err != nil { return "", err } var out strings.Builder for chunk := range stream.Chunks() { for _, c := range chunk.Choices { out.WriteString(c.Delta.Content) } } return out.String(), stream.Err()}Next steps
- The Streaming Model — how chunks flow from the producer goroutine to your consumer.
- Byte Passthrough — preserving original wire bytes through proxies.
- Configuration & Options — the functional-options pattern used by every provider.