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
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 URL | https://<your-instance>/v1 |
|---|---|
| OpenAI · auth | Authorization: Bearer poly_… |
| Anthropic · base URL | https://<your-instance> |
| Anthropic · auth | x-api-key: poly_… |
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"}]}'
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"}]}'
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:
| Form | Example | Resolves to |
|---|---|---|
| Direct model id | gpt-5 | That 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-5 | That exact provider's copy of the model. <providerId> is the provider row's UUID — call GET /v1/models for the exact qualified ids. |
| Tier name | fast | The named tier's ordered chain — primary entry, then up to four fallbacks. |
| Auto alias | auto | The enabled smart layers, degrading to the default tier. L1 structural classifies both a complexity band and a workload class — code, 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. |
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."}]}'
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.
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."}]}'
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.
curl https://<your-instance>/v1/models \
-H "Authorization: Bearer poly_your_key"
{"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"}
]}
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.
| Mechanism | Effect & 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. |
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"}]}'
Set "stream": true and the response is a text/event-stream of SSE frames in the wire format of the client's protocol.
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]
event: message_start
event: content_block_start
event: content_block_delta
event: content_block_stop
event: message_delta
event: message_stop
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.
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 kind | Class | On failure |
|---|---|---|
| auth | fallback-eligible | Try the next entry in the chain. |
| rate_limit | fallback-eligible | Try the next entry in the chain. |
| unavailable | fallback-eligible | Try the next entry in the chain. |
| unknown_model provider | fallback-eligible | Try 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. |
| credential | fallback-eligible | A 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 open | fallback-eligible | The entry is skipped before an adapter is even built. If every entry in the chain is open the request ends 503. |
| bad_request | terminal | The 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 | terminal | A 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". |
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.
{"error": {"message": "upstream rate limited",
"type": "rate_limit_error",
"code": "rate_limited"}}
{"type": "error",
"error": {"type": "rate_limit_error",
"message": "upstream rate limited"}}
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | Terminal, 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. |
| 400 | empty_tier | The resolved tier has no models configured. |
| 400 | unresolved_target | The routing target could not be resolved. |
| 401 | invalid_api_key | The poly_… key is missing, malformed, or revoked. |
| 402 | budget_exceeded | A block budget is at or over its threshold. The message names the budget and its reset time, so you know when to retry. |
| 404 | model_not_found | Two 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. |
| 404 | ambiguous_model | Two providers expose that id — qualify it as <providerId>:<model>. |
| 413 | request_too_large | The body exceeded PROXY_MAX_BODY_BYTES (10 MiB default). |
| 429 | rate_limited | The 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. |
| 500 | no_default | No default tier is configured — the catch-all is missing. |
| 502 | upstream_auth | The LAST attempt in an exhausted chain rejected polyrouter's credential — again the final failure, not a state shared by every entry. |
| 503 | upstream_unavailable | The LAST attempt was down, timed out, or was skipped with an open breaker. Also the code an unclassified in-chain throw is rendered as. |
| 503 | unavailable | polyrouter 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. |
| 503 | upstream_credential | A subscription credential could not be resolved — usually a revoked OAuth grant needing reauthorization in the dashboard. |
| 503 | budget_enforcement_unavailable | The 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.
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.
| Endpoint | Purpose |
|---|---|
| /api/agents | Agent 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/providers | Provider 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/models | The 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/tiers | Named routing targets and their ordered entry chains. |
| /api/routing/tiers/:tierId/entries | GET 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/rules | Routing 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-layers | Read/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-learning | Layer-2 learning /status and one-click /revert. |
| /api/routing/calibration | Threshold calibration — GET /history for the move log and POST /revert for the one-click return to the instance pair. |
| /api/analytics | Usage 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/budgets | Budgets — day/week/month, global or per-agent, alert-or-block. |
| /api/notification-channels | SMTP and Apprise channels for alerts and summaries, plus POST :id/test. |
| /api/auth | Better Auth — dashboard sessions (email/password + optional OAuth). |
| /api/pricing | The 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-capture | The 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/events | One multiplexed SSE stream for live dashboard pushes; every future push feature rides this endpoint rather than opening another. |
| /api/admin | Admin only — users (GET /users, role, disabled, delete), invites (POST/GET/DELETE /invites) and /settings/registration. |
| /api/invites | POST /accept — redeem an invite token issued from the admin surface. |
| /api/me · /api/login-config | The signed-in principal, and which sign-in methods this instance offers. |
| /api/health | {"status":"ok"}, unauthenticated, for orchestration probes. |
| /metrics | Prometheus exposition — outside /api and session-free, instance-level metadata aggregates only. METRICS_ENABLED=false hides it entirely (404). |