Developers
API reference
Final Router speaks the OpenAI chat completions format, so any client that already talks to OpenAI talks to us after a base URL change. This page documents every endpoint, field and error the gateway can produce.
Base URL
Every endpoint lives under one origin. Point your existing SDK at it and nothing else in your code has to move.
https://finalrouter.com/api/v1Requests must be HTTPS. Gateway responses are marked no-store, so nothing lands in a shared cache.
Authentication
Send your Final Router key as a bearer token. Keys are created in the dashboard and shown exactly once - we store a hash, so we cannot recover one for you. Rotate a leaked key immediately; there is no charge for it.
Authorization: Bearer fr_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/jsonKeep the key server-side. Anything in a browser bundle is public, and a gateway key can spend money at 8 providers.
Chat completions
/v1/chat/completionsThe endpoint that does the work: authenticate, pick a model, forward, meter, return. Unknown fields are rejected with a 400 naming the field rather than dropped silently.
curl https://finalrouter.com/api/v1/chat/completions \
-H "Authorization: Bearer $FINAL_ROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "auto",
"routing": "balanced",
"messages": [
{ "role": "user", "content": "Summarise this changelog in three bullets." }
]
}'Request body
| Field | Type | Description |
|---|---|---|
| messagesrequired | array | The conversation so far. Each entry needs a role of system, user, assistant or tool, and a string content. Between 1 and 200 messages; content is capped at 400,000 characters. |
| model | string | Which model should answer. Omit it, or send "auto", to let the router choose. Send policy/<name> to route through one of your routing policies, or one of ours. An unrecognised id is a 400 rather than a silent fallback, so a typo never quietly bills you for a different vendor. |
| routing | string | Override your account routing strategy for this request only. One of cost, latency, quality or balanced. Final Router extension - OpenAI clients that do not know the field simply omit it. |
| stream | boolean | Stream the answer back as server-sent events instead of one JSON body. Defaults to false. |
| temperature | number | Sampling temperature between 0 and 2. Passed to the provider. |
| max_tokens | integer | Upper bound on completion tokens, between 1 and 200,000. Providers may cap it lower. |
| top_p | number | Nucleus sampling, above 0 up to 1. Passed to every provider. |
| stop | string | string[] | Up to four sequences that end generation, each up to 500 characters. A single string is accepted and treated as a list of one. |
| seed | integer | Best-effort determinism. Models on providers without seed support are skipped from the chain rather than quietly ignoring it - a parameter you set is honoured or the model steps aside. |
| presence_penalty / frequency_penalty | number | Repetition penalties between -2 and 2. Same rule as seed: providers that cannot honour them are skipped, never silently ignored. |
| response_format | object | Send {"type": "json_object"} to demand JSON output. Providers without a JSON mode are skipped from the chain. "json_schema" is not supported yet and says so. |
| stream_options | object | Accepted for OpenAI-client compatibility. Streams always include the usage frame - billing depends on reading it - so include_usage is honoured by already being true. |
| user | string | OpenAI's end-user identifier - the name of *your* customer, if you have them. Recorded on the request log, so analytics can answer what each of your customers costs you. Up to 200 characters. |
| tags | string[] | Final Router extension: up to 10 free-form labels ("prod", "checkout-bot") recorded on the request, beyond the X-Title and X-Session-Id headers. Each up to 40 characters. Shown on the log and export; grouping and budgets by tag build on them. |
SDK examples
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.FINAL_ROUTER_KEY,
baseURL: "https://finalrouter.com/api/v1",
});
const response = await client.chat.completions.create({
model: "auto",
messages: [{ role: "user", content: "Summarise this changelog." }],
});
console.log(response.choices[0].message.content);from openai import OpenAI
client = OpenAI(
api_key=os.environ["FINAL_ROUTER_KEY"],
base_url="https://finalrouter.com/api/v1",
)
response = client.chat.completions.create(
model="auto",
messages=[{"role": "user", "content": "Summarise this changelog."}],
)
print(response.choices[0].message.content)Response
Identical to OpenAI, plus a final_router object describing what the router did. Clients that ignore unknown fields are unaffected.
{
"id": "req_8f2c1a9e",
"object": "chat.completion",
"created": 1756732800,
"model": "claude-sonnet-4.5",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "..." },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 412,
"completion_tokens": 168,
"total_tokens": 580
},
"final_router": {
"strategy": "balanced",
"reason": "Best quality-per-cent among reachable models",
"used_own_key": false,
"cost_cents": 0.41,
"balance_cents": 4821.6,
"fell_back_from": []
}
}| Field | Type | Description |
|---|---|---|
| id | string | The request id. Quote it to support and we can find the exact routing decision without ever seeing your prompt. |
| model | string | The model that actually answered, which is not necessarily the one you asked for if a fallback fired. |
| choices | array | One entry, containing the assistant message and a finish_reason. Send the request again if you want a second completion. |
| usage | object | prompt_tokens, completion_tokens and total_tokens, normalised across providers so the numbers mean the same thing everywhere. |
| final_router.strategy | string | The routing strategy applied to this request. |
| final_router.reason | string | Why the router picked this model, in one human-readable line. |
| final_router.policy | object | Present only when a routing policy decided the order. Carries its name, whether it is one of ours (managed), and how it ordered its own entries (strategy). |
| final_router.policy.from_policy | boolean | Whether the model that answered was one the policy actually named. False means every model it lists was unreachable or failed, and the safety net behind it replied instead - the number worth alerting on. |
| final_router.cost_cents | number | What this request cost, in cents, at provider list price. |
| final_router.balance_cents | number | Your credit balance after the request was metered. |
| final_router.used_own_key | boolean | True when your own provider credential served the request rather than ours. |
| final_router.fell_back_from | array | Every model that was tried and failed first, with the status and message it returned. Empty on a clean first attempt. |
Routing strategies
Your account has a default strategy. Send routing to override it for a single request - useful when one endpoint in your app is latency-critical and the rest are not.
- cost
- Cheapest reachable model that clears the quality floor. The right default for classification, extraction and other high-volume, low-stakes work.
- latency
- Lowest observed median latency. Use it for anything a human is waiting on with a cursor blinking.
- quality
- Highest quality score regardless of price. Reasoning, code generation, anything a person will read closely.
- balanced
- Best quality per cent spent. The account default, and the one most traffic should be on.
Naming a model directly skips scoring and pins the request to that model, with the rest of the chain still available as fallback. The router only ever considers models you have left switched on and providers you can actually reach.
Routing policies
A strategy ranks the whole catalogue. A policy is the other way round: you name the models, in the order you want them tried, and address the list from the model field.
{
"model": "policy/resilient",
"messages": [
{ "role": "user", "content": "Summarise this changelog in three bullets." }
]
}
// 200 OK
{
"model": "openai/gpt-5.1",
"final_router": {
"policy": {
"name": "resilient",
"managed": true,
"strategy": "balance",
"from_policy": true
},
"fell_back_from": []
}
}- fallback
- Every request tries the first model. The rest are only reached when it fails.
- balance
- Each request starts somewhere different, in proportion to the weights, then falls through the rest. Keeps one model from carrying all of the traffic - and one provider's rate limit from becoming yours.
- latency
- Ordered by median time to first token, fastest first. The order is recomputed as those figures change.
- A policy chooses order, never permission. Every model in it still has to be switched on for the account, permitted by the guardrails on the key, and served by a provider we hold a credential for. Anything else is skipped.
- The safety net is on by default. When every model in a policy is unreachable, the rest of your enabled models sit behind it. Turn it off and the request is refused with
503 no_route_errorinstead - right for a benchmark, wrong for anything a customer is waiting on. - The answer says which one served it. Every response carries
final_router.policy.from_policy, which is false when the safety net replied rather than the policy's own models. - Built-in policies work on a new account. policy/resilient, policy/frontier, policy/fast and policy/thrifty are maintained by us and available without configuring anything. A policy of your own with one of those names is refused when you save it, so
policy/fastmeans one thing everywhere.
Policies are written in the dashboard rather than over the API, and one of them can be set as the account default - used whenever a request sends auto or no model at all.
Streaming
Set stream: true to receive server-sent events in OpenAI's delta format, terminated by data: [DONE]. Provider formats are normalised for you, so switching models never changes the shape of the stream.
const stream = await client.chat.completions.create({
model: "auto",
stream: true,
messages: [{ role: "user", content: "Write a haiku about routing." }],
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}Fallback still applies while streaming, but only before the first token is sent - once bytes are on the wire we cannot retract them. Usage is metered when the stream closes, including on a client disconnect.
List models
/v1/modelsWhat this key can reach right now, with live pricing, latency and quality figures attached. It costs nothing, and it works even when your balance is empty - you should be able to see what you would be buying.
curl https://finalrouter.com/api/v1/models \
-H "Authorization: Bearer $FINAL_ROUTER_KEY"{
"object": "list",
"data": [
{
"id": "gpt-4.1-mini",
"object": "model",
"owned_by": "openai",
"final_router": {
"reachable": true,
"input_cost_per_million": 40,
"output_cost_per_million": 160,
"median_latency_ms": 820,
"quality": 78
}
}
]
}Anthropic Messages
/v1/messagesAnthropic's Messages API, in front of every model in the catalogue. A client written against the Anthropic SDK points at our base URL and works unchanged - except that model can now name any model here, not just Claude.
# Anthropic SDK, pointed here. Nothing else changes.
from anthropic import Anthropic
client = Anthropic(
base_url="https://finalrouter.com/api/v1",
api_key=os.environ["FINAL_ROUTER_KEY"], # fr_live_...
)
# ...and now "model" can name ANY model in the catalogue.
msg = client.messages.create(
model="auto", # or gpt-5.1, gemini-2.5-pro, kimi-k3 ...
max_tokens=512,
system="You are terse.",
messages=[{"role": "user", "content": "Capital of France?"}],
)
print(msg.content[0].text) # -> ParisAuthenticate with x-api-key (what the Anthropic SDKs send) or Authorization: Bearer - the same key either way. The request shape is theirs: system as a top-level field, content blocks for text, images, tool_use and tool_result, tools declared with input_schema, and max_tokens required. Responses come back as { type: "message", content: [...] } with Anthropic stop reasons, and streaming emits their event sequence - message_start, content_block_delta, message_stop.
Only the dialect changes. Routing, fallback, guardrails, caching, spend caps and billing are the same code as /chat/completions - and the final_router receipt is kept on the response, where Anthropic's SDK ignores it and you can still read it.
Responses API
/v1/responsesOpenAI's newer request shape, in front of every model. Takes input as a string or a typed item array, instructions for the system prompt, max_output_tokens, flat function tools, and text.format for structured output. Streaming emits the Responses lifecycle - response.created, response.output_text.delta, response.completed - with sequence numbers.
// The OpenAI SDK, Responses style, pointed here.
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://finalrouter.com/api/v1",
apiKey: process.env.FINAL_ROUTER_KEY,
});
const response = await client.responses.create({
model: "auto", // any model in the catalogue
instructions: "You are terse.",
input: "Capital of France?",
max_output_tokens: 400,
});
console.log(response.output_text); // -> ParisStateless, and it says so. previous_response_id and store: true are refused with a 400 naming the field, rather than accepted and ignored. We do not retain conversations server-side, and a caller who believed a thread existed would be building on something that is not there. Send the prior turns in input instead.
Hitting the token ceiling returns status: "incomplete" with incomplete_details.reason, exactly as OpenAI does - a truncated answer is never reported as a completed one.
Embeddings
/v1/embeddingsOpenAI-compatible embeddings through the same key, the same guards and the same billing as chat. Two models - openai/text-embedding-3-small (the default) and openai/text-embedding-3-large - billed at list price with no markup, your own OpenAI key used if you have connected one.
Two differences from chat, both deliberate. Embeddings need credits: the free allowance is a chat allowance - one model, a daily count, an output ceiling - and none of those bounds mean anything for vectors, so rather than invent a fourth kind of free we ask for credit and say so. And the input is never stored: nothing is generated, the text goes to the provider and only numbers come back.
curl https://finalrouter.com/api/v1/embeddings \
-H "Authorization: Bearer $FINAL_ROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/text-embedding-3-small",
"input": ["the quick brown fox", "a second string, batched"]
}'
# {
# "object": "list",
# "data": [{ "object": "embedding", "index": 0, "embedding": [0.013, ...] }],
# "model": "openai/text-embedding-3-small",
# "usage": { "prompt_tokens": 12, "total_tokens": 12 }
# }Up to 256 inputs per request, 32,000 characters each. The request appears in your log like any other, with zero completion tokens.
Estimate cost
/v1/cost/estimateWhat a request would cost, before you run it. Free, and it works on an empty balance - you should be able to price a job without paying to find out. The arithmetic is the same code the biller uses, so the estimate and the invoice cannot drift apart.
curl https://finalrouter.com/api/v1/cost/estimate \
-H "Authorization: Bearer $FINAL_ROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-sonnet-5",
"messages": [{ "role": "user", "content": "Summarise this contract..." }],
"max_tokens": 800
}'
# What it would cost, at list price, before you spend anything.
# The same arithmetic the biller runs - an estimate that cannot
# drift from the invoice, because it is the same code.Flush the cache
/v1/cache/flushEmpties this account's stored responses - the exact-match and semantic layers both. Free. Use it after changing a system prompt that would otherwise keep matching old answers.
For a single request, skip the cache instead of clearing it: send "cache": "off" in the body to bypass both layers, or "cache": "exact" to allow only an identical match.
Guardrails
A guardrail is a named set of rules attached to one or more API keys: a spending cap, which models and providers the key may reach, and whether prompts are inspected for injection attempts or sensitive data before they leave. They are configured in the dashboard rather than over the API, and they apply to every request on an assigned key - including ones made from the playground.
- Guardrails only ever narrow. Where a key carries several, a request has to satisfy all of them, and none of them can grant access to a model that is switched off account-wide.
- A refusal names the guardrail. Anything a guardrail stops comes back as
403 guardrail_blocked(orbudget_exceededfor a spending cap), with the guardrail's name in the message - so the fix is always somewhere you control. - Redaction changes what the provider receives. A sensitive-info guardrail set to replace matches rewrites the prompt before it is sent, so the token counts you are billed for are the redacted ones. Every other action leaves the request untouched.
- The detectors are patterns, not classifiers. They find what they are written to find. Useful against a pasted log file or an opportunistic injection; not a compliance control, and not a defence against somebody who knows they are there.
Prompt-injection signals
The whole list, so nobody has to guess what a refusal meant. Each is a phrase with no innocent reading in a prompt - mentioning a system prompt in passing does not fire; asking a model to reveal one does. The attack is LLM01 in the OWASP list for LLM applications.
| Signal | What it matches |
|---|---|
| override_instructions | an instruction to ignore earlier instructions |
| reveal_system_prompt | a request to reveal the system prompt |
| role_reset | an attempt to reassign the model's role |
| jailbreak_persona | a known jailbreak persona |
| fake_turn | a forged conversation turn |
| policy_override | an instruction to drop safety rules |
Which turns are read is a per-guardrail setting: every message the caller sent, or their user messages only - the second for when an application's own system prompt is written in the same language the signals look for. Assistant turns are never read in either mode, because what a model replied came from a provider that applied its own safety layer.
Compatibility mode
By default a field we cannot honour is refused with a 400 naming the fix, because a parameter silently ignored is an afternoon of debugging waiting to happen. Some SDKs and frameworks, though, send fields the caller never chose - and “flip the base URL and it works” is worth offering.
Send X-Compat-Mode: lenient and the fields we recognise but cannot support are dropped instead of refused - and every dropped name comes back in final_router.dropped_params, so the request is still fully disclosed. Leniency applies only to fields we recognise: a genuine typo is still a 400 in both modes, which is the whole point.
Affected fields: functions, function_call, n, logprobs, top_logprobs and logit_bias.
For agents and tools
An AI assistant writing an integration against this gateway should not have to read a marketing page and guess. Everything below is public, needs no key, and is generated from the same catalogue the gateway routes on - so it cannot drift from what the API actually does.
/openapi.json- the full OpenAPI description of every endpoint on this page./llms.txt- the whole site as one plain-text map, plus every model and post. Any page also serves Markdown to clients that ask for it./api/mcp- a public Model Context Protocol server. Four tools: list models, get one model, estimate a cost, check provider health. See the MCP page./.well-known/agent-skills/- an installable skill describing how to call this API correctly, with a checksum./.well-known/ai-catalog.jsonand/.well-known/api-catalog- machine-readable catalogues of what this service offers./.well-known/mcp/server-card.json- the MCP server's own card, for clients that discover servers rather than being handed a URL./auth.md- how authentication works, written for an agent rather than a person.
Response headers
Enough to log routing and rate-limit state without parsing a body.
| Field | Type | Description |
|---|---|---|
| X-Final-Router-Model | string | The model that answered - readable without parsing the body. |
| X-Final-Router-Latency | integer | Provider round-trip time in milliseconds. |
| X-Final-Router-Policy | string | The routing policy that decided the order. Present only when one did. |
| RateLimit-Limit | integer | Requests allowed in the current window for this key. |
| RateLimit-Remaining | integer | Requests left in the window. |
| RateLimit-Reset | integer | Unix timestamp at which the window resets. |
| Retry-After | integer | Seconds to wait. Present only on a 429. |
Errors
Errors match OpenAI's shape, so a client that already handles theirs needs no second code path. Every message says what went wrong and what to do about it.
{
"error": {
"message": "Unknown model `gpt-5-turbo`. Call GET /api/v1/models for the list, or send \"auto\" to let the router choose.",
"type": "invalid_request_error",
"code": 400,
"param": "model"
}
}| Status | Type | Meaning |
|---|---|---|
| 400 | invalid_request_error | The body is wrong, and the message names the field. Unknown fields are refused rather than dropped, so a request that would have been silently altered fails loudly instead. |
| 401 | authentication_error | Missing, malformed or revoked key. Check the Authorization header. |
| 400 | not_supported_error | A field we recognise but do not implement yet. The message says what to send instead. (Streaming together with tools, long the usual way to meet this error, is now supported - tool calls arrive assembled in the stream's final frames.) |
| 402 | insufficient_credits | The balance is empty and the day's free allowance is used up. Top up in the dashboard, or come back tomorrow. |
| 403 | insufficient_credits | On the free allowance, and you named a model it does not cover. The message lists the models it does. Same type as the 402 because the fix is the same - add credits - but a different status, because nothing is exhausted here. |
| 403 | budget_exceeded | Your own cap stopped this, not us: the account's monthly budget, a per-key limit, or a guardrail's budget. Separate from 402 on purpose - one is our limit, one is yours. The message says which. |
| 403 | guardrail_blocked | A guardrail on this key refused the request - a blocked model, a prompt-injection match, or sensitive data in the prompt. The message names the guardrail so you know which one to change. Nobody needs to appeal to us for these; they are your own rules. |
| 403 | policy_violation | Refused by our content policy, which is ours rather than yours. Unlike guardrail_blocked, changing a setting will not lift it. See /content-policy. |
| 403 | no_route_error | You named a model that is switched off for this account. It is refused rather than quietly answered by a different model - benchmarking one model and being handed another is worse than an error. |
| 429 | rate_limit_error | Too many requests for this key. Honour Retry-After; the RateLimit-* headers tell you where you stand before you get here. |
| 502 | provider_error | Every model in the fallback chain failed. final_router.fell_back_from lists what was tried. A provider's own 4xx is passed through with its status rather than turned into a 502. |
| 503 | no_route_error | Nothing is reachable at all: no provider key connected, every model switched off, or every model blocked by the guardrails on this key. |
Rate limits and retries
Limits are per key and per window, and every response carries the RateLimit-* headers so you can back off before you get a 429 rather than after.
async function complete(body: object, attempt = 0): Promise<Response> {
const response = await fetch("https://finalrouter.com/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FINAL_ROUTER_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
// 429 is the only status worth retrying blind — the gateway has already
// exhausted its own fallback chain before it returns a 502.
if (response.status === 429 && attempt < 3) {
const wait = Number(response.headers.get("Retry-After") ?? 1) * 1000;
await new Promise((resolve) => setTimeout(resolve, wait));
return complete(body, attempt + 1);
}
return response;
}Do not blind-retry a 502. The gateway has already worked through your entire fallback chain by the time it returns one, so an immediate retry usually just spends the same money twice.
Ready to send the first request?
Create a key and route in under five minutes.