Last updated 2026-05-17
Configuration & Options
Every provider in llmrouter is constructed with the
same shape: a variadic list of functional options.
p, err := openai.New( llmrouter.WithAPIKey("sk-..."), llmrouter.WithBaseURL("https://openrouter.ai/api/v1"), llmrouter.WithTimeout(30*time.Second),)
The options live in llmrouter/options.go
and are shared across every provider. A provider's
New(opts ...llmrouter.Option) walks them, builds a
*llmrouter.Config, then validates the bits it cares
about (API key, base URL).
Why functional options
The alternative — a public Config struct passed to
New(cfg Config) — has three problems this design
avoids:
- Zero-value safety. A struct's zero value is
always valid Go, but it's almost never a valid configuration. A
caller who forgets to set
APIKeysees an upstream 401 at request time, not a clear error at construction time. Functional options can requireWithAPIKeyand tell you whyNewfailed. - Forward-compatibility. Adding a new field to a
struct doesn't break callers, but adding a required field does.
Adding a new
WithXfunction is purely additive — old code keeps compiling and old behavior is preserved. - Per-option validation. Each option returns an
error. The first failing option short-circuitsNewand gives you a precise pointer to which option was wrong, with a message that mentions that option.
The Option type
type Option func(*Config) error
That's it. An Option is a function that mutates a
Config and reports whether the mutation succeeded.
Internally NewConfig walks the slice:
func NewConfig(opts ...Option) (*Config, error) { c := &Config{Timeout: 120 * time.Second} for _, opt := range opts { if opt == nil { continue } if err := opt(c); err != nil { return nil, err } } return c, nil} nil options are skipped silently — this matters when
you build the option list conditionally:
var baseURL llmrouter.Optionif env := os.Getenv("LLM_BASE_URL"); env != "" { baseURL = llmrouter.WithBaseURL(env)}p, err := openai.New( llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY")), baseURL, // nil here is fine; falls back to provider default)Available options
WithAPIKey(key string)
Sets the API key. The string is trimmed; empty (after trim) is an
error. Every provider requires this — New wraps
llmrouter.ErrInvalidConfig if it's missing.
llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY"))WithBaseURL(url string)
Overrides the provider's default base URL. Validated as a URL; trailing slashes are stripped. Most useful for the OpenAI provider pointed at an OpenAI-compatible endpoint:
| Endpoint | Base URL |
|---|---|
| OpenAI (default) | https://api.openai.com/v1 |
| OpenRouter | https://openrouter.ai/api/v1 |
| Together | https://api.together.xyz/v1 |
| Groq | https://api.groq.com/openai/v1 |
| Fireworks | https://api.fireworks.ai/inference/v1 |
| vLLM (self-hosted) | http://localhost:8000/v1 |
| Ollama | http://localhost:11434/v1 |
| Anthropic (default) | https://api.anthropic.com/v1 |
For OpenAI-compatible endpoints that don't require a real key
(vLLM, Ollama in default config), you still must pass
WithAPIKey with something non-empty — the OpenAI
provider always sets the Authorization header. Pass
any placeholder:
p, err := openai.New( llmrouter.WithAPIKey("ollama"), llmrouter.WithBaseURL("http://localhost:11434/v1"),)WithHTTPClient(client *http.Client)
Supplies a custom *http.Client. Useful for wrapping
the transport with retries, OpenTelemetry instrumentation,
proxying, mTLS, or anything else that lives at the HTTP layer.
Required when you want behavior WithTimeout can't
express.
When you pass this, WithTimeout is
ignored. Your client's transport governs all timeouts.
Set them on the client yourself.
package main
import ( "errors" "fmt" "log" "net/http" "os" "time"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/openai")
// retryingTransport retries on 429 and 5xx responses up to maxAttempts// times, with exponential backoff. It does NOT retry on 4xx (other// than 429) or network errors — those need application-level decisions.type retryingTransport struct { base http.RoundTripper maxAttempts int}
func (t *retryingTransport) RoundTrip(req *http.Request) (*http.Response, error) { var lastResp *http.Response var lastErr error for attempt := 0; attempt < t.maxAttempts; attempt++ { if attempt > 0 { select { case <-time.After(backoff(attempt)): case <-req.Context().Done(): return nil, req.Context().Err() } } resp, err := t.base.RoundTrip(req) if err != nil { return nil, err } if resp.StatusCode != 429 && resp.StatusCode < 500 { return resp, nil } lastResp = resp if attempt+1 < t.maxAttempts { resp.Body.Close() } lastErr = errors.New(resp.Status) } if lastResp != nil { return lastResp, nil } return nil, fmt.Errorf("retries exhausted: %w", lastErr)}
func backoff(attempt int) time.Duration { return time.Duration(1<<attempt) * time.Second}
func main() { client := &http.Client{ Timeout: 2 * time.Minute, Transport: &retryingTransport{ base: http.DefaultTransport, maxAttempts: 3, }, } p, err := openai.New( llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY")), llmrouter.WithHTTPClient(client), ) if err != nil { log.Fatal(err) } _ = p // use p ...}WithTimeout(d time.Duration)
Sets the per-request HTTP client timeout used by the default
client. Default is 120 seconds. Ignored if
WithHTTPClient is also supplied.
llmrouter.WithTimeout(30 * time.Second)WithTimeout vs context.WithTimeout
These do different things and you usually want both:
-
WithTimeoutis a setting on the HTTP client, applied at provider-construction time. It governs the entire HTTP request lifecycle — connect, headers, body. It kicks in even if the caller forgets to pass a deadline. -
context.WithTimeoutis a per-call deadline. It governs this request, and it propagates through the stream — cancelling the context cancels the producer goroutine and the upstream HTTP read.
Use both for defense in depth: WithTimeout as the
upper bound the provider will ever wait, WithTimeout
on the context as the request-specific budget.
// Provider-level safety net.p, _ := openai.New( llmrouter.WithAPIKey(os.Getenv("OPENAI_API_KEY")), llmrouter.WithTimeout(2*time.Minute), // hard ceiling)
// Per-call deadline.ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)defer cancel()
stream, err := p.CompletionStream(ctx, req)WithExtra(key string, value any)
Stores a provider-specific value on the Config.
Providers document the keys they read.
// AWS Bedrock (planned):llmrouter.WithExtra("region", "us-east-1")llmrouter.WithExtra("aws_profile", "production")
// Azure OpenAI (planned):llmrouter.WithExtra("api-version", "2024-10-21")llmrouter.WithExtra("deployment", "gpt-4o-prod")
// Vertex AI (planned):llmrouter.WithExtra("project", "my-gcp-project")llmrouter.WithExtra("region", "us-central1")
Empty keys are an error. Values are typed as any;
providers cast or assert on read. Unknown keys are ignored by
providers that don't care about them, so it's safe to pass a
superset of options when you have provider-selecting code.
Validation
Options validate eagerly. NewConfig walks them in
order and returns the first failing option's error. The
caller's New wraps that error with
llmrouter.ErrInvalidConfig for sentinel matching:
func New(opts ...llmrouter.Option) (*Provider, error) { cfg, err := llmrouter.NewConfig(opts...) if err != nil { return nil, err } if cfg.APIKey == "" { return nil, fmt.Errorf("%w: api key required", llmrouter.ErrInvalidConfig) } if cfg.BaseURL == "" { cfg.BaseURL = defaultBaseURL } return &Provider{cfg: cfg}, nil}So you can detect configuration errors specifically:
p, err := openai.New(llmrouter.WithAPIKey(""))if errors.Is(err, llmrouter.ErrInvalidConfig) { log.Fatalf("misconfigured: %v", err)}See Error Handling for the full error taxonomy.
Full example: a provider factory
Real applications usually pick a provider at runtime from configuration. The factory below builds either OpenAI or Anthropic from a config struct, with sensible per-provider defaults:
package main
import ( "context" "errors" "fmt" "log" "net/http" "os" "time"
"github.com/elloloop/llmrouter" "github.com/elloloop/llmrouter/providers/anthropic" "github.com/elloloop/llmrouter/providers/openai")
// ProviderConfig is the application's view of provider settings. Real// code would populate this from flags / env / config file.type ProviderConfig struct { Name string // "openai", "anthropic", "openrouter" APIKey string BaseURL string // optional override (mostly for OpenAI-compatible) Timeout time.Duration // 0 = default 120s}
// NewProvider builds an llmrouter.Provider from a ProviderConfig. It// returns ErrInvalidConfig for misconfiguration so callers can decide// whether to fail-fast or fall back.func NewProvider(cfg ProviderConfig) (llmrouter.Provider, error) { if cfg.APIKey == "" { return nil, fmt.Errorf("%w: api key required for %s", llmrouter.ErrInvalidConfig, cfg.Name) } opts := []llmrouter.Option{llmrouter.WithAPIKey(cfg.APIKey)} if cfg.BaseURL != "" { opts = append(opts, llmrouter.WithBaseURL(cfg.BaseURL)) } if cfg.Timeout > 0 { opts = append(opts, llmrouter.WithTimeout(cfg.Timeout)) }
switch cfg.Name { case "openai": return openai.New(opts...) case "anthropic": return anthropic.New(opts...) case "openrouter": opts = append(opts, llmrouter.WithBaseURL("https://openrouter.ai/api/v1")) return openai.New(opts...) case "groq": opts = append(opts, llmrouter.WithBaseURL("https://api.groq.com/openai/v1")) return openai.New(opts...) default: return nil, fmt.Errorf("%w: unknown provider %q", llmrouter.ErrInvalidConfig, cfg.Name) }}
func main() { cfg := ProviderConfig{ Name: os.Getenv("LLM_PROVIDER"), APIKey: os.Getenv("LLM_API_KEY"), Timeout: 60 * time.Second, } p, err := NewProvider(cfg) if err != nil { if errors.Is(err, llmrouter.ErrInvalidConfig) { log.Fatalf("invalid config: %v", err) } log.Fatal(err) } log.Printf("using provider: %s", p.Name())
ctx := context.Background() stream, err := p.CompletionStream(ctx, llmrouter.ChatRequest{ Model: "gpt-4o-mini", Messages: []llmrouter.Message{ llmrouter.TextMessage("user", "hi"), }, }) if err != nil { log.Fatal(err) } for chunk := range stream.Chunks() { for _, c := range chunk.Choices { fmt.Print(c.Delta.Content) } } fmt.Println() if err := stream.Err(); err != nil { log.Fatal(err) }}
// Suppress unused import in this trimmed example.var _ = http.DefaultClientNext steps
- Error Handling — the full error taxonomy and how to wire up retries.
- Context &
Cancellation — interaction between
WithTimeoutand per-call deadlines. - OpenAI provider —
WithBaseURLfor OpenAI-compatible endpoints.