How it works Features Docs Compare Blog GitHub
Self-host it Sign up
Documentation

API reference.

polyrouter speaks the OpenAI and Anthropic wire protocols, so any SDK that takes a base URL and a key works unchanged. This is the exact HTTP contract — endpoints, auth, the routing-control header, streaming and fallback behavior, error kinds, and the dashboard's management REST API.

Maintained by Anthony Izzo · Last updated

Base URLs & auth

Mint a key in the dashboard (Agents → New agent — it looks like poly_… and is shown once). An OpenAI client points at /v1 and sends a Bearer token; an Anthropic client points at the root and sends x-api-key (it appends /v1/messages itself). The same poly_… key is accepted either way.

OpenAI · base URLhttps://<your-instance>/v1
OpenAI · authAuthorization: Bearer poly_…
Anthropic · base URLhttps://<your-instance>
Anthropic · authx-api-key: poly_…
OPENAI-COMPATIBLE
curl https://<your-instance>/v1/chat/completions \
  -H "Authorization: Bearer poly_your_key" \
  -H "Content-Type: application/json" \
  -d '{"model":"auto","messages":[{"role":"user","content":"hi"}]}'
ANTHROPIC-COMPATIBLE
curl https://<your-instance>/v1/messages \
  -H "x-api-key: poly_your_key" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-5","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'
Note The raw endpoints are /v1/chat/completions, /v1/messages, and /v1/models. An OpenAI client → Anthropic provider (and the reverse) is translated through polyrouter's intermediate representation — tool calls, system prompts, stop reasons, and usage carry across, locked by a golden-file contract suite. Protocol-specific controls (cache_control, thinking, output_config on Anthropic; response_format, reasoning_effort on OpenAI; image_url.detail) are carried opaquely on their own protocol and deliberately dropped — never mis-mapped — when crossing to the other.

POST /v1/chat/completions

The OpenAI-compatible endpoint. The body is a standard Chat Completions request — a model, a messages array, and the OpenAI parameters polyrouter recognises (temperature, tools, response_format, …). Set stream: true for token streaming. The output cap may be sent as either max_tokens or max_completion_tokens — polyrouter accepts both inbound and emits the spelling each provider needs.

The recognised fields are model, messages, tools, tool_choice, parallel_tool_calls, temperature, top_p, max_tokens/max_completion_tokens, stop, stream, stream_options, response_format, reasoning_effort and n. Anything else (frequency_penalty, presence_penalty, seed, logprobs, logit_bias, user, store, service_tier, prediction, …) is silently dropped rather than forwarded. n > 1 is rejected with a 400 — the router always returns exactly one choice.

The model field is where routing begins. It takes four forms:

FormExampleResolves to
Direct model idgpt-5That exact model on the single provider that exposes it — matched against your whole model catalog, independently of tiers, with no fallback chain (a single target). If two providers expose the same id the request is a 404 ambiguous_model — qualify it as <providerId>:<model>. If the string matches no model but is a tier key, it resolves as that tier.
Provider-prefixed<providerId>:gpt-5That exact provider's copy of the model. <providerId> is the provider row's UUID — call GET /v1/models for the exact qualified ids.
Tier namefastThe named tier's ordered chain — primary entry, then up to four fallbacks.
Auto aliasautoThe enabled smart layers, degrading to the default tier. L1 structural classifies both a complexity band and a workload classcode, vision, structured, plus research and writing from the optional semantic module. An auto_workload rule for that class claims the request ahead of every band target; otherwise a class-scoped band target decides before the generic one, then opt-in L2 semantic and L3 cascade.
WORKED EXAMPLE · streaming
curl https://<your-instance>/v1/chat/completions \
  -H "Authorization: Bearer poly_your_key" \
  -H "Content-Type: application/json" \
  -d '{"model":"auto","stream":true,"messages":[{"role":"user","content":"Summarize this diff in two sentences."}]}'
Tip A tier key or auto resolves to an ordered chain and gets fallback on provider failure; a directly-named model has none — its failure is returned to you. Budgets are enforced and the decision (decision_layer + human-readable routing_reason) is recorded for the inspector either way. decision_layer is one of explicit, header, default, structural, semantic, cascade and workload, and a class-scoped band decision ends its reason with scope=<class>. See Routing for the full layered pipeline.

POST /v1/messages

The Anthropic-compatible endpoint. The body is a standard Messages request — a model, a required max_tokens, a messages array, and an optional system. Set stream: true for SSE. An Anthropic SDK targets the root base URL and appends /v1/messages itself; the same routing forms in the model field apply here too.

WORKED EXAMPLE · max_tokens
curl https://<your-instance>/v1/messages \
  -H "x-api-key: poly_your_key" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-5","max_tokens":1024,"messages":[{"role":"user","content":"Write a haiku about load balancers."}]}'
Note Route this request to an OpenAI-compatible provider and polyrouter translates it end to end — Anthropic's cache_control markers are carried opaquely and dropped when crossing to OpenAI; multi-turn tool loops preserve round-trip semantics.

GET /v1/models

Returns everything routable by your key in the OpenAI list shape, so clients that fetch a model list for validation or pickers work unchanged. Authenticate exactly as for chat completions. The list is auto, then every tier key (always including default), then <providerId>:<model> for every model, plus the bare model id when it is unique across your providers. Every entry is {id, object:"model", owned_by:"polyrouter"} — no created, no permission.

WORKED EXAMPLE
curl https://<your-instance>/v1/models \
  -H "Authorization: Bearer poly_your_key"
RESPONSE
{"object":"list","data":[
  {"id":"auto","object":"model","owned_by":"polyrouter"},
  {"id":"default","object":"model","owned_by":"polyrouter"},
  {"id":"fast","object":"model","owned_by":"polyrouter"},
  {"id":"5d3c1f8a-…:gpt-5-mini","object":"model","owned_by":"polyrouter"},
  {"id":"gpt-5-mini","object":"model","owned_by":"polyrouter"}
]}

Routing control headers

Two mechanisms steer routing without hard-coding a concrete model. Send the x-polyrouter-tier header to pin a tier, and/or set "model":"auto" in the body to engage the smart layers.

MechanismEffect & precedence
x-polyrouter-tier: <tier>
request header
Pins that tier's chain. The highest-precedence header — it beats every other header rule regardless of their priority, but still yields to a concrete model in the body. The value resolves in two steps: a header rule on x-polyrouter-tier whose value matches remaps the ask to that rule's target — a remap wins even when its value is also a literal tier key — and otherwise the value is looked up as a tier key directly. A value that matches neither is advisory: never an error, the request simply falls through to the remaining rules and the default.
"model": "auto"
body field
Engages the enabled smart layers — but only for a request that Layer 0 already landed on the default tier. A concrete model, the tier header, or a rule on any other header preempts it entirely; the layers then refine that default decision — L1 structural classifies a complexity band and a workload class, an auto_workload rule for that class claims the request first, then class-scoped and generic band targets, then opt-in L2 semantic and L3 cascade.
WORKED EXAMPLE · tier header beats auto
curl https://<your-instance>/v1/chat/completions \
  -H "Authorization: Bearer poly_your_key" \
  -H "x-polyrouter-tier: fast" \
  -H "Content-Type: application/json" \
  -d '{"model":"auto","messages":[{"role":"user","content":"hi"}]}'
Note Layer 0, first match wins: (1) a concrete model in the body, (2) x-polyrouter-tier — a value-remap rule first, then the value as a tier key, with an unmatched value advisory and falling through, (3) a rule on any other header (priority desc, then oldest), (4) a default-match rule, (5) the seeded default tier. "model":"auto" is not a precedence step — the smart layers refine a request that already landed on default, in the order workload claim, band target, semantic, cascade. Above, the tier header matches at phase 2, so the request routes to fast and the smart layers never run.

Streaming & fallback semantics

Set "stream": true and the response is a text/event-stream of SSE frames in the wire format of the client's protocol.

OPENAI · SSE
data: {"id":"chatcmpl-…","object":"chat.completion.chunk",
       "created":1753430000,"model":"gpt-5-mini",
       "choices":[{"index":0,"delta":{"content":"Hel"},
                   "finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"content":"lo"},
                   "finish_reason":null}]}
data: [DONE]
Send stream_options: {"include_usage": true} to receive the terminal usage chunk (a final frame with choices: []). polyrouter always requests usage upstream so its own cost records are exact, but only relays that chunk when you asked for it — without the flag an OpenAI client gets no usage at all. Anthropic clients always get usage.
ANTHROPIC · SSE
event: message_start
event: content_block_start
event: content_block_delta
event: content_block_stop
event: message_delta
event: message_stop
polyrouter buffers the tail and emits exactly one message_delta immediately before message_stop, carrying stop_reason, stop_sequence and usage.output_tokens — always a number, as Anthropic SDKs require. A stream that ends with no stop reason at all is anomalous and is surfaced honestly: an event: error frame of type incomplete with the message stream ended without a stop reason, never a fabricated end_turn.
Commit Fallbacks run freely before the first token — polyrouter can walk the tier's chain (and, under cascade, buffer and grade the cheap tier) with no client-visible effect. Once the first token has been sent the model is committed: a mid-stream upstream failure ends the stream with a clear error, and the model is never silently swapped mid-response.

The HTTP status stays 200 — headers were flushed before the failure, so the error arrives in-band. An OpenAI client receives data: {"error":{"message":"the upstream model failed mid-stream","type":"upstream_error","code":null}} followed by data: [DONE]; an Anthropic client receives an event: error frame with error.type: "api_error" and no message_stop. Check for an error frame rather than trusting the status code.

How long polyrouter waits is per-provider: firstByteTimeoutMs bounds time-to-first-token and — plus PROXY_EVENT_TIMEOUT_MARGIN_MS (500 ms) — the gap between streamed events, so this is the knob to raise for slow streaming models. idleTimeoutMs bounds the inter-chunk gap on non-streaming (buffered) reads only; raising it can never keep a slow stream alive. Both are fields on POST/PATCH /api/providers, each 1 s–1 h, with null inheriting the instance defaults PROXY_FIRST_EVENT_TIMEOUT_MS / PROXY_IDLE_TIMEOUT_MS — both 30 s, and readable at GET /api/providers/timeout-defaults. Raise the first-byte bound for research-class models — Deep Research, or Opus with thinking — whose long prefill would otherwise 503 and trip the provider's circuit breaker.

Errors

When a provider in the resolved tier's chain fails, polyrouter classifies the error. Every provider kind except bad_request is fallback-eligible — a fault at one provider should not fail the request, so the chain walks to the next entry. A terminal error returns immediately. Fallback only applies before the response commits — see Streaming.

Error kindClassOn failure
authfallback-eligibleTry the next entry in the chain.
rate_limitfallback-eligibleTry the next entry in the chain.
unavailablefallback-eligibleTry the next entry in the chain.
unknown_model
provider
fallback-eligibleTry the next entry in the chain — a model retired at one provider must not fail the request. Breaker-neutral: the provider is healthy, only the model is gone.
credentialfallback-eligibleA revoked OAuth grant or an identity-provider outage. Try the next entry in the chain; breaker-neutral. If the whole chain is exhausted: 503, type: "api_error", code: "upstream_credential".
circuit openfallback-eligibleThe entry is skipped before an adapter is even built. If every entry in the chain is open the request ends 503.
bad_requestterminalThe only fallback-ineligible provider kind — the request itself is at fault, so another provider would reject it too. Returned immediately as a 400.
unknown_model
routing
terminalA different error with the same name: the model field named nothing in your catalog and no tier key, so there is no chain to walk. 404, code: "model_not_found".
Note Two more cases never retry: your own client aborting the call, and an unclassified throw inside a chain attempt — anything that is not a typed provider error or a circuit-open skip stops the walk on the spot rather than trying the next entry. It is still rendered in the caller's envelope as 503 upstream_unavailable; only a throw outside the chain walk — routing, admission or recording — reaches the exception filter unmapped and returns 500 internal proxy error. When the chain is exhausted or the error is terminal, the request fails and the terminal provider error detail — which provider, what kind — is recorded on the row in the dashboard's Requests inspector. A failed request is never dressed up as a silent success.
Error response shape

Every /v1 failure is rendered in the caller's own envelope with a fixed, sanitized message — never the raw upstream body, request id, or credential.

OPENAI CALLERS
{"error": {"message": "upstream rate limited",
           "type": "rate_limit_error",
           "code": "rate_limited"}}
ANTHROPIC CALLERS
{"type": "error",
 "error": {"type": "rate_limit_error",
           "message": "upstream rate limited"}}
StatusCodeMeaning
400bad_requestTerminal, never retried. Returned when the upstream rejects the request — and also by polyrouter itself, before any upstream is contacted, for an unparseable body or n > 1.
400empty_tierThe resolved tier has no models configured.
400unresolved_targetThe routing target could not be resolved.
401invalid_api_keyThe poly_… key is missing, malformed, or revoked.
402budget_exceededA block budget is at or over its threshold. The message names the budget and its reset time, so you know when to retry.
404model_not_foundTwo cases share this code. Routing: the model field matched nothing in your catalog and no tier key — model not found. Provider: the LAST attempt in an exhausted chain reported the model gone upstream — model not found upstream, after the earlier entries were tried.
404ambiguous_modelTwo providers expose that id — qualify it as <providerId>:<model>.
413request_too_largeThe body exceeded PROXY_MAX_BODY_BYTES (10 MiB default).
429rate_limitedThe LAST attempt in an exhausted chain was rate limited. The status reflects that final failure only — earlier entries may have failed differently; the inspector lists each one.
500no_defaultNo default tier is configured — the catch-all is missing.
502upstream_authThe LAST attempt in an exhausted chain rejected polyrouter's credential — again the final failure, not a state shared by every entry.
503upstream_unavailableThe LAST attempt was down, timed out, or was skipped with an open breaker. Also the code an unclassified in-chain throw is rendered as.
503unavailablepolyrouter itself cannot serve the route, and the message says which: server is shutting down during a graceful drain, no usable provider for the route, provider temporarily unavailable when a circuit-open error surfaces outside the chain walk, or a provider with no base URL, no credential, or an address the SSRF gate rejected.
503upstream_credentialA subscription credential could not be resolved — usually a revoked OAuth grant needing reauthorization in the dashboard.
503budget_enforcement_unavailableThe budget check could not be trusted (Redis fault or stale reconciliation) and the operator chose to fail closed. See BUDGET_FAIL_OPEN.

A 500 with a null code is the unclassified-throw case — internal proxy error. Anthropic envelopes carry type and message only; the code column above appears in the OpenAI envelope.

Management REST API

The dashboard is driven by a REST API under /api/*. These use dashboard session auth (the Better Auth session cookie from signing in) — not poly_… agent keys, which authenticate only the inference endpoints under /v1. Disabling a user cuts both planes at once. Three endpoints sit outside the session plane on purpose: GET /api/health so orchestration can probe the container, GET /api/login-config so the sign-in screen knows which methods to render, and POST /api/invites/accept so an invitee can redeem a token before they have a session. GET /metrics is outside /api altogether — network-guard the port.

EndpointPurpose
/api/agentsAgent API keys — mint poly_…, rotate (POST :id/rotate-key), and delete (DELETE :id, which revokes the key immediately). Disabling the owning user cuts every one of their keys at once.
/api/providersProvider CRUD, plus POST :id/test-connection, POST :id/sync-models and GET timeout-defaults (the instance patience defaults, non-secret). The subscription OAuth wizard lives on the same prefix: GET oauth/presets, POST oauth/start, POST oauth/complete, POST oauth/reauthorize/:id.
/api/modelsThe dashboard's model catalog — GET and PATCH only; records are created by provider sync, never by hand. Distinct from the agent-facing GET /v1/models.
/api/routing/tiersNamed routing targets and their ordered entry chains.
/api/routing/tiers/:tierId/entriesGET returns the ordered chain; PUT replaces it atomically (position 0 = primary, up to four fallbacks). There is no per-entry create or delete.
/api/routing/rulesRouting rules, matched in priority order. matchType is one of header, default, auto_high, auto_low and auto_workload. workloadClass is required on auto_workload, optional on the two band types as a class scope, and rejected on header and default; target is tier:<key> or model:<id>.
/api/routing/auto-layersRead/set the enabled smart layers. The view reports capability honestly beside preference — structuralAvailable, cascadeAvailable, semanticAvailable with its two halves semanticFlagEnabled and semanticClassifierReady, the workload pair semanticWorkloadAvailable/semanticWorkload, semanticLearning, and a calibration block carrying the stored, instance and effective thresholds.
/api/routing/semantic-learningLayer-2 learning /status and one-click /revert.
/api/routing/calibrationThreshold calibration — GET /history for the move log and POST /revert for the one-click return to the instance pair.
/api/analyticsUsage and cost — /summary, /timeseries, /breakdown, /requests, /inflight, and /auto for band mix, workload mix, cascade outcomes and net-savings. The /requests listing takes a layer filter accepting any of the seven decision layers — workload included. Captured bodies, where the opt-in is on, are read and deleted at /requests/:id/bodies.
/api/budgetsBudgets — day/week/month, global or per-agent, alert-or-block.
/api/notification-channelsSMTP and Apprise channels for alerts and summaries, plus POST :id/test.
/api/authBetter Auth — dashboard sessions (email/password + optional OAuth).
/api/pricingThe bundled versioned price catalog — list, GET /status, GET /:modelKey, POST /:modelKey/override, POST /refresh. Prices are snapshotted onto each request at request time and never recomputed.
/api/body-captureThe prompt/response capture opt-in — read and PATCH the policy, PATCH /agents/:id/override per agent, POST /purge to delete what was stored. Off by default.
/api/eventsOne multiplexed SSE stream for live dashboard pushes; every future push feature rides this endpoint rather than opening another.
/api/adminAdmin only — users (GET /users, role, disabled, delete), invites (POST/GET/DELETE /invites) and /settings/registration.
/api/invitesPOST /accept — redeem an invite token issued from the admin surface.
/api/me · /api/login-configThe signed-in principal, and which sign-in methods this instance offers.
/api/health{"status":"ok"}, unauthenticated, for orchestration probes.
/metricsPrometheus exposition — outside /api and session-free, instance-level metadata aggregates only. METRICS_ENABLED=false hides it entirely (404).
Providers & models Security & privacy