POST /v1/chat/completions
The OpenAI-compatible surface.
The OpenAI-compatible surface. Point any OpenAI SDK (or OpenAI-compatible client) at https://api.routerplus.com/v1 with your marketplace key and it works — including against Claude models. Every chat model in the catalog is callable from this endpoint no matter which provider serves it; image models answer on POST /v1/images/generations only; the gateway translates requests, streams, and errors between dialects. The Anthropic-native twin of this endpoint is POST /v1/messages.
POST https://api.routerplus.com/v1/chat/completionsAuthentication
| Header | Format |
|---|---|
Authorization | Bearer tm_vk_... (the standard OpenAI-style header) |
x-api-key | tm_vk_... — also accepted, same key |
A missing or invalid key returns 401 with error_type: "auth". Optionally send HTTP-Referer and X-Title to identify your app (content-free attribution).
Request
JSON body, capped at 10 MB (413 request_too_large above that).
| Parameter | Type | Behavior |
|---|---|---|
model | string, required | A catalog id — see Model discovery. Unknown ids are an honest 404, never a silent substitute. Each Claude model also answers to one rp- name, for clients such as Cursor that do not send claude- names to another base URL: rp-sonnet-5-5 is claude-sonnet-5-5 (see Models & catalog). A deployed endpoint from Optimize (tm/<name>-v<n>) is served here too, with a key of the organization that owns it, and so is a dedicated endpoint (<your-namespace>/<name>). |
messages | array, required | Roles system, developer, user, assistant, tool. Content: a string, or text parts, plus image_url, file, input_audio and video_url parts for a model that takes them (architecture.input_modalities in GET /v1/models). A part the model does not take is a typed 400 naming the part. Images and PDFs also reach Claude: an image_url part (a JPEG, PNG, GIF or WebP data URL, or an http(s) URL) and a file part with a PDF data URL are translated for Anthropic. On a route that needs translation, other media parts (audio, video, a file id, another file type) are a typed 400 naming the part. |
stream | boolean | Server-sent events; see Streamed response. |
max_tokens | number | Output cap, from 1 to the model's own maximum (max_output_tokens in the catalog; 128,000 on the Claude 5, GPT-6 and GPT-5.6 models). A declared maximum can be below 32,768 (GPT-4o: 16,384); 32,768 is the fallback when no maximum is declared. When you send neither max_tokens nor max_completion_tokens, the gateway sets the model's own maximum, lowered when needed so that your input and the output fit the model's context window, on the request it forwards, whichever provider serves it. A model the catalog does not list (on your own connection) gets 32,768. null counts as not set. A limit under 1 (0, -1) or above the maximum is a 400. |
max_completion_tokens | number | Honored as an alias. Sending both with different values is a 400. |
temperature | number | Passed through. Above 1 it cannot be translated to an Anthropic-dialect provider — see Wire compatibility. |
top_p | number | Passed through. |
stop | string | string[] | Becomes Anthropic stop_sequences on cross-dialect routes. |
tools | array | type: "function" tools on every model. A tool without parameters gets an empty object schema on Anthropic-dialect routes. Custom (free-form) tools, {"type":"custom","custom":{"name":"…","description":"…","format":{"type":"text"}}} or with a grammar format in lark or regex syntax, work on the GPT-6 and GPT-5.6 models (all seven call OpenAI's Responses API): the call comes back as a tool_calls entry {id, type: "custom", custom: {name, input}}. Claude models take function tools only: a custom tool to a Claude model is a 400 that names it. See Wire compatibility. |
tool_choice | string | object | "auto", "none", "required", {"type":"function","function":{"name":"..."}}, or, on the models above, {"type":"custom","custom":{"name":"..."}}. |
parallel_tool_calls | boolean | false becomes disable_parallel_tool_use on Anthropic-dialect routes. |
n | number | Rejected when > 1 — 400 invalid_request. See below. |
stream_options | object | Usage is always on. An explicit {"include_usage": false} is honored: you are billed the same, but no usage chunk is written to you. Otherwise the object reaches an OpenAI-dialect provider with include_usage set. On a translated route the gateway acts on it itself: it is not forwarded, and not named in x-tm-dropped-params. |
provider | object | Routing controls read by the gateway and never forwarded: require_parameters, order, only, ignore, allow_fallbacks and the rest — see Routing policies. An unknown control is a 400. |
session_id | string | A session id: the gateway sends it to OpenAI and Azure as prompt_cache_key, and to Morph as the header x-session-id. The headers x-session-id, x-session-affinity and x-claude-code-session-id work too. See Sessions and prompt caching. |
prompt_cache_key | string | OpenAI's cache key: sent unchanged to OpenAI and Azure. |
prompt_cache_retention | string | "in_memory" or "24h": sent unchanged to OpenAI and Azure; dropped and recorded elsewhere. |
safety_identifier | string | Your end-user id. On a house route the gateway sends a hash of it with your organization; see Sessions and prompt caching. |
cache_control | object | Automatic prompt caching, as on the Anthropic API: sent to Anthropic; dropped and recorded on OpenAI and Azure. A field that Anthropic would refuse is dropped and recorded on every route (see Wire compatibility). A cache_control mark on a text part is kept for Anthropic, and passes unchanged to OpenAI and Azure, which ignore it. |
A top-level api_key, base_url, connection_id or similar credential or destination field is a 400: keys and endpoints are configured in the console, never sent in a request.
Everything else
The table above is the portable set — it survives translation to any provider. What happens to parameters outside it depends on who serves the request: when the serving provider speaks the OpenAI dialect, the OpenAI chat parameters (response_format, reasoning_effort, seed, user, logprobs, top_logprobs, frequency_penalty, presence_penalty, logit_bias, metadata, store, prediction) reach it as you sent them; when translation to the Anthropic dialect is needed, response_format, reasoning_effort, prediction, audio and modalities are a typed 400 (they change what the model produces), and other unknown parameters are dropped — never silently mutated into something else. Any top-level key outside those sets is dropped and recorded on every route. Every drop is named in the x-tm-dropped-params header; "provider": {"require_parameters": true} turns a drop into a 400 instead. The full tables are on Wire compatibility.
Unsupported content is different from unsupported parameters: it is never dropped, because dropped content would still be billed upstream. A part the model does not take — an image to a model that takes text only, say — is a typed 400 naming the exact field (e.g. messages[0].content[1]), on every route, before anything is billed or sent. On routes that need translation to the Anthropic dialect, images (JPEG, PNG, GIF and WebP) and PDF files are translated; audio parts, video parts, files given by id, other file types and unknown content-part types are a typed 400 naming the field. When the serving provider speaks the OpenAI dialect and the model takes the part, content passes through as sent.
Why n > 1 is rejected
The gateway's normalizer emits exactly one choice. Accepting n: 3 and billing you for a garbled single-choice response would be dishonest, so the request is refused up front:
{
"error": {
"code": "invalid_request",
"message": "n>1 is not supported in v1; request a single choice",
"type": "invalid_request_error",
"metadata": { "error_type": "invalid_request" }
},
"request_id": "..."
}Non-streamed response
A standard chat.completion object. On translated responses the id is chatcmpl-<request-id>; when an OpenAI-dialect provider serves the request, the body passes through with the provider's own id. For the /v1/generation?id= audit, use the x-request-id response header — it carries the bare request id on every route.
On a closed dedicated endpoint the body keeps a fixed set of fields on every route: model is the endpoint id, the id is chatcmpl- and the request id without dashes, and provider fields such as system_fingerprint and native_finish_reason are dropped.
{
"id": "chatcmpl-1c9a7b2e-...",
"object": "chat.completion",
"created": 1757000000,
"model": "claude-sonnet-4-5",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello!" },
"finish_reason": "stop",
"native_finish_reason": "end_turn"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 5,
"total_tokens": 17,
"prompt_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 0 },
"cost": 0.000111
}
}Usage fields (the billing contract — see Wire compatibility):
| Field | Meaning |
|---|---|
prompt_tokens | All billed input tokens — cache-inclusive: uncached input + cache reads + cache writes. |
prompt_tokens_details.cached_tokens | Cache reads (billed at the cached rate). |
prompt_tokens_details.cache_write_tokens | Cache writes (billed at their own rate). Always present on billed responses, 0 when none. |
completion_tokens | Output tokens. Reasoning tokens are a subset, reported in completion_tokens_details.reasoning_tokens when the provider reports them (streams always carry the field). |
cost | Settled cost in USD — billed keys only. Computed with the exact integer micro-USD math the ledger settles with, covering the full request debit including attempts that failed over before your answer started. You can recompute your bill from the wire. |
finish_reason is one of stop, length, tool_calls, content_filter. On responses translated from an Anthropic-dialect provider, choices[0].native_finish_reason preserves the provider-raw stop reason (e.g. end_turn, stop_sequence) — a documented extension. A model's thinking, when the provider returns it, is in message.reasoning_content.
Streamed response
With stream: true, SSE frames in chat.completion.chunk shape, in a fixed order:
- 01A role-priming delta (
{"role":"assistant","content":""}) as soon as the upstream proves alive. - 02Content deltas:
delta.content,delta.reasoning_content(thinking models), anddelta.tool_callsfragments addressed byindex(id andfunction.namearrive on each call's first fragment). - 03A finish chunk with
finish_reasonset, plusnative_finish_reasonon translated streams. - 04A usage chunk (empty
choices) — always sent, nostream_optionsneeded:
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":1757000000,"model":"claude-haiku-4-5","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"completion_tokens_details":{"reasoning_tokens":0},"cost":0.000037}}
data: [DONE]- 05
data: [DONE].
When an OpenAI-dialect provider serves the request, its stream is relayed record for record, with usage.cost added to its usage chunk; the shapes above are what a translated stream looks like. On a closed dedicated endpoint each chunk keeps only the fixed set of fields, and a provider's own SSE comment lines are dropped. Full detail on Streaming.
During post-start silences of 15 s or more (slow reasoning models), the gateway emits an SSE comment (: processing) so proxies don't kill an idle-but-healthy stream — spec-ignorable, and your SDK already ignores it.
If the serving provider dies after your answer started, the stream carries one terminal error chunk — a full chunk envelope with finish_reason: "error" and a top-level error object carrying metadata.error_type — and nothing after it. No [DONE] follows an error. The answer is never silently restarted on a different provider mid-stream; failover happens only before the first byte of output, invisibly. The same chunk ends a stream that reaches the request deadline (450 s from dispatch, or a routing policy's timeout_ms).
Errors
Every error uses the OpenAI envelope with a stable error.metadata.error_type (auth, rate_limit, insufficient_quota, model_unavailable, invalid_request, context_overflow, content_policy, upstream_error, gateway_error, ...). A provider's raw error body is never relayed. When a provider rejected the request, the class says so, the message carries the provider's own words (up to 1,000 characters, with keys, account ids and host names removed), and the x-tm-upstream-status header carries its status. A prompt too long for the model is OpenAI's standard 400: error.code is context_length_exceeded, with OpenAI's wording ("This model's maximum context length is M tokens. However, your messages resulted in N tokens. …"), so coding agents compact and retry. Full table with remediations: Errors.
Out-of-credits is the OpenAI-standard 429 insufficient_quota (your SDK already recognizes it), covering an empty balance or a monthly spend cap — cap breaches include the exact UTC reset time in the message and the x-tm-cap-reset header. First trial keys shown after verified sign-in have a 20 RPM limit, and until an organization buys credit its keys together run at 20 RPM; 429 rate_limit carries retry-after.
Billed requests reserve an estimated cost before dispatch: the request's size in bytes as the input estimate (one token per byte, plus 65,536 per image part; an image sent inline as base64 counts the 65,536 only, not its bytes) and max_tokens as the output (when omitted, at most 32,768 tokens, although the request is sent the model's own maximum), at the model's rates, rounded up. If your balance can't cover it — including your org's other in-flight reservations — you get insufficient_quota before any provider is called. Settlement is on observed usage only, rounded down, also when that is more than the reserve. A generous max_tokens never costs extra, but it is held in full, so it can make a low balance refuse early. On a small balance, set it realistically, or leave it out.
Response headers
| Header | Meaning |
|---|---|
x-request-id | Gateway request id; joins /v1/usage and /v1/generation?id=. |
x-tm-provider | Deployment that served the request; routerplus on a closed dedicated endpoint. |
x-tm-attempts | Physical dispatches, failovers included. |
x-tm-upstream-status | The provider's own HTTP status. |
x-tm-error-code | Canonical error class, on failures. x-tm-error-origin says where the failure came from, and x-tm-limit-scope / x-tm-limit-kind name a limit that refused the request — see Errors. |
x-tm-upstream-model | Present when the serving provider knows this model under its own id: the id the gateway actually sent. The response's model field echoes the provider's id. Absent on a closed dedicated endpoint. |
x-tm-served-by | The provider that ran the request, when the route reports one. Absent on a closed dedicated endpoint. |
x-tm-dedicated-endpoint, x-tm-route-role | On a dedicated endpoint: its id, and primary or fallback. |
x-tm-queue-ms | On a dedicated endpoint with a paced limit: how long the request waited for its turn, in milliseconds. |
x-tm-auto-cache | On a Claude route: applied when the gateway turned on Anthropic's automatic caching for the request, else skipped: and the reason. Send the request header x-tm-auto-cache: off to turn it off. Absent on a closed dedicated endpoint. See Automatic caching on Claude. |
x-tm-dropped-params | Comma-joined field paths of any parameters the gateway stripped for this route (D8 §2: drops are recorded, never silent). Absent when nothing was dropped. Send "provider": {"require_parameters": true} to get a typed 400 instead of any drop. |
x-tm-route-plan-id | On billed requests: the id of the route plan that chose the deployments. |
x-tm-admission-mode, x-tm-remaining-rpm, x-tm-remaining-tpm | On billed requests: how the request was admitted and the smallest allowance left across the limits it claimed — see Limits and capacity. |
Examples
curl -N https://api.routerplus.com/v1/chat/completions \
-H "Authorization: Bearer $TM_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"Say hello."}]}'openai SDK with the base URL swappedimport os
from openai import OpenAI
client = OpenAI(base_url="https://api.routerplus.com/v1", api_key=os.environ["TM_API_KEY"])
resp = client.chat.completions.create(
model="claude-sonnet-4-5", # a Claude model over the OpenAI wire — the gateway translates
max_tokens=200,
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(resp.choices[0].message.content)
print(resp.usage) # prompt/completion tokens, cache splits, cost (USD)openai packageimport OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.routerplus.com/v1",
apiKey: process.env.TM_API_KEY,
});
const stream = await client.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: "Count to five." }],
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
if (chunk.usage) console.log("\n", chunk.usage); // final chunk: tokens, cache splits, cost
}Model discovery
GET https://api.routerplus.com/v1/models (authenticated) returns the catalog in the OpenAI list shape — one row per model id — and your organization's dedicated endpoints. The public directory with prices and context lengths is https://app.routerplus.com/api/models.json. A model id that is not listed returns 404 model_unavailable naming the id you asked for; if every deployment serving a listed model is cooling down after failures, you get a 503 gateway_error with retry-after: 5 and x-tm-limit-kind: health instead of a fake 404 — retry, don't fix.
See also: POST /v1/messages · Errors · Wire compatibility & billing contract · Quickstart
Markdown source for agents: /docs/api-chat-completions.md · index at /llms.txt