Final Router

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.

base url
https://finalrouter.com/api/v1

Requests 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.

headers
Authorization: Bearer fr_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

Keep the key server-side. Anything in a browser bundle is public, and a gateway key can spend money at 8 providers.

Chat completions

POST/v1/chat/completions

The 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
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

FieldTypeDescription
messagesrequiredarrayThe 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.
modelstringWhich 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.
routingstringOverride 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.
streambooleanStream the answer back as server-sent events instead of one JSON body. Defaults to false.
temperaturenumberSampling temperature between 0 and 2. Passed to the provider.
max_tokensintegerUpper bound on completion tokens, between 1 and 200,000. Providers may cap it lower.
top_pnumberNucleus sampling, above 0 up to 1. Passed to every provider.
stopstring | 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.
seedintegerBest-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_penaltynumberRepetition penalties between -2 and 2. Same rule as seed: providers that cannot honour them are skipped, never silently ignored.
response_formatobjectSend {"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_optionsobjectAccepted for OpenAI-client compatibility. Streams always include the usage frame - billing depends on reading it - so include_usage is honoured by already being true.
userstringOpenAI'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.
tagsstring[]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

typescript
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);
python
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.

200 OK
{
  "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": []
  }
}
FieldTypeDescription
idstringThe request id. Quote it to support and we can find the exact routing decision without ever seeing your prompt.
modelstringThe model that actually answered, which is not necessarily the one you asked for if a fallback fired.
choicesarrayOne entry, containing the assistant message and a finish_reason. Send the request again if you want a second completion.
usageobjectprompt_tokens, completion_tokens and total_tokens, normalised across providers so the numbers mean the same thing everywhere.
final_router.strategystringThe routing strategy applied to this request.
final_router.reasonstringWhy the router picked this model, in one human-readable line.
final_router.policyobjectPresent 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_policybooleanWhether 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_centsnumberWhat this request cost, in cents, at provider list price.
final_router.balance_centsnumberYour credit balance after the request was metered.
final_router.used_own_keybooleanTrue when your own provider credential served the request rather than ours.
final_router.fell_back_fromarrayEvery 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.

json
{
  "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_error instead - 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/fast means 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.

typescript
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

GET/v1/models

What 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
curl https://finalrouter.com/api/v1/models \
  -H "Authorization: Bearer $FINAL_ROUTER_KEY"
200 OK
{
  "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

POST/v1/messages

Anthropic'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.

python
# 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)      # -> Paris

Authenticate 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

POST/v1/responses

OpenAI'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.

typescript
// 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);   // -> Paris

Stateless, 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

POST/v1/embeddings

OpenAI-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
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

POST/v1/cost/estimate

What 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
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

POST/v1/cache/flush

Empties 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 (or budget_exceeded for 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.

SignalWhat it matches
override_instructionsan instruction to ignore earlier instructions
reveal_system_prompta request to reveal the system prompt
role_resetan attempt to reassign the model's role
jailbreak_personaa known jailbreak persona
fake_turna forged conversation turn
policy_overridean 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.json and /.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.

FieldTypeDescription
X-Final-Router-ModelstringThe model that answered - readable without parsing the body.
X-Final-Router-LatencyintegerProvider round-trip time in milliseconds.
X-Final-Router-PolicystringThe routing policy that decided the order. Present only when one did.
RateLimit-LimitintegerRequests allowed in the current window for this key.
RateLimit-RemainingintegerRequests left in the window.
RateLimit-ResetintegerUnix timestamp at which the window resets.
Retry-AfterintegerSeconds 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.

400 Bad Request
{
  "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"
  }
}
StatusTypeMeaning
400invalid_request_errorThe 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.
401authentication_errorMissing, malformed or revoked key. Check the Authorization header.
400not_supported_errorA 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.)
402insufficient_creditsThe balance is empty and the day's free allowance is used up. Top up in the dashboard, or come back tomorrow.
403insufficient_creditsOn 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.
403budget_exceededYour 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.
403guardrail_blockedA 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.
403policy_violationRefused by our content policy, which is ours rather than yours. Unlike guardrail_blocked, changing a setting will not lift it. See /content-policy.
403no_route_errorYou 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.
429rate_limit_errorToo many requests for this key. Honour Retry-After; the RateLimit-* headers tell you where you stand before you get here.
502provider_errorEvery 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.
503no_route_errorNothing 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.

typescript
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.