Chat completions
The OpenAI-compatible chat endpoint.
/v1/chat/completionsThe OpenAI-compatible chat endpoint. Point any OpenAI SDK or client at https://demo-api.routerplus.com/v1 with your RouterPlus key, and call any chat model in the catalog, Claude models included. The gateway translates requests, streams and errors between provider dialects.
Image models answer on POST /v1/images/generations only. The Anthropic-native endpoint is POST /v1/messages.
POST https://demo-api.routerplus.com/v1/chat/completionsExamples
curl -N https://dev2-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."}]}'import os
from openai import OpenAI
client = OpenAI(base_url="https://demo-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)import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://demo-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
}The Python and TypeScript examples use the official openai SDK with only the base URL changed.
Authentication
| Header | Format |
|---|---|
Authorization | Bearer tm_vk_... (the standard OpenAI header) |
x-api-key | tm_vk_... (also accepted, same key) |
A missing or invalid key returns 401 with error_type: "auth". To identify your app, send HTTP-Referer and X-Title. They carry no content.
Request
The body is JSON, at most 10 MB (413 request_too_large above that).
| Parameter | Type | Behavior |
|---|---|---|
model | string, required | A catalog id. See Model discovery. An unknown id is a 404. The gateway never substitutes another model. 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). This route also serves a deployed endpoint from Optimize (tm/<name>-v<n>), with a key of the organization that owns it, and a dedicated endpoint (<your-namespace>/<name>). |
messages | array, required | Roles system, developer, user, assistant, tool. Content is a string or text parts. A model that takes them also accepts image_url, file, input_audio and video_url parts (see architecture.input_modalities in GET /v1/models). A part the model does not take is a typed 400 that names the part. See Content parts. |
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). The maximum is 128,000 on the Claude 5, GPT-6 and GPT-5.6 models, and can be below 32,768 (GPT-4o: 16,384). If you send neither max_tokens nor max_completion_tokens, the gateway sets the model's own maximum on the forwarded request, on every provider. It lowers the value when needed so that your input and the output fit the context window. If the catalog declares no maximum, or does not list the model (on your own connection), the value is 32,768. null counts as not set. A value under 1 (0, -1) or above the maximum is a 400. |
max_completion_tokens | number | An alias of max_tokens. Both with different values is a 400. |
temperature | number | Passed through. A value above 1 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 work on every model. A tool without parameters gets an empty object schema on Anthropic-dialect routes. Custom (free-form) tools work on the GPT-6 and GPT-5.6 models (all seven call OpenAI's Responses API): {"type":"custom","custom":{"name":"…","description":"…","format":{"type":"text"}}}, or a grammar format in lark or regex syntax. 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 | A value above 1 is a 400 invalid_request. See Why n > 1 is rejected. |
stream_options | object | Usage is always on. An explicit {"include_usage": false} is honored: you are billed the same, but you get no usage chunk. Otherwise an OpenAI-dialect provider gets the object with include_usage set. On a translated route the gateway acts on it itself, and does not forward it or name it in x-tm-dropped-params. |
provider | object | Routing controls: require_parameters, order, only, ignore, allow_fallbacks and the rest. The gateway reads them and never forwards them. An unknown control is a 400. See Routing policies. |
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 other credential or destination field is a 400. You set keys and endpoints in the console, never in a request.
Everything else
The table above is the portable set: it works on any provider. Other parameters depend on the dialect of the provider that serves the request:
| Serving provider | What happens |
|---|---|
| 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. |
| Translated to the Anthropic dialect | response_format, prediction, audio and modalities are a typed 400, because they change what the model produces. reasoning_effort becomes Claude's own effort (output_config.effort). A Claude model that takes no effort drops it and records the drop. Other unknown parameters are dropped, never changed into something else. |
- Any other top-level key is dropped and recorded on every route.
- The
x-tm-dropped-paramsheader names every drop. "provider": {"require_parameters": true}turns a drop into a 400.- The full tables are on Wire compatibility.
Content parts
The gateway never drops content, because the provider would still bill for it. A part the model does not take is a typed 400 that names the field (for example messages[0].content[1]), on every route, before anything is billed or sent. An image to a text-only model is one such case.
- On an OpenAI-dialect provider, a part that the model takes passes through as sent.
- On a route translated to the Anthropic dialect (Claude), the gateway translates an
image_urlpart (a JPEG, PNG, GIF or WebP data URL, or anhttp(s)URL) and afilepart with a PDF data URL. - On those routes, audio parts, video parts, files given by id, other file types and unknown part types are a typed 400 that names the field.
Why n > 1 is rejected
The gateway returns one choice. It refuses n above 1 before dispatch, so you never pay for fewer choices than you asked for:
{
"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 a translated response,
idischatcmpl-<request-id>. - On an OpenAI-dialect provider, the body passes through with the provider's own
id, keeping OpenAI's fields and the extensions below only. Fields a provider adds of its own (its cost, its token accounting, its host's name) are dropped. The message passes as the model wrote it (what an answer keeps). modelnames the model you asked for, also when the provider knows it under another id.- For the
/v1/generation?id=audit, use thex-request-idresponse 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.
modelis the endpoint id,idischatcmpl-and the request id without dashes, and provider fields such assystem_fingerprintandnative_finish_reasonare 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 included: 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, in completion_tokens_details.reasoning_tokens when the provider reports them (streams always carry the field). |
cost | Settled cost in USD, on billed keys only. The gateway computes it with the same integer micro-USD math as the ledger. It covers the full request debit, including attempts that failed over before your answer started, so you can recompute your bill from the response. |
finish_reason is one of stop, length, tool_calls, content_filter. On a response translated from an Anthropic-dialect provider, choices[0].native_finish_reason keeps the provider's raw stop reason (for example end_turn, stop_sequence). When the provider returns the model's thinking, it is in message.reasoning_content.
Streamed response
With stream: true, the gateway sends SSE frames in chat.completion.chunk shape, in this order:
- 01A role delta (
{"role":"assistant","content":""}) as soon as the provider answers. - 02Content deltas:
delta.content,delta.reasoning_content(thinking models), anddelta.tool_callsfragments addressed byindex. The id andfunction.namearrive on each call's first fragment. - 03A finish chunk with
finish_reasonset, plusnative_finish_reasonon translated streams. - 04A usage chunk with empty
choices. It is always sent, withoutstream_options:
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].
- These are the shapes of a translated stream.
- On an OpenAI-dialect provider, the gateway relays the stream record for record and adds
usage.costto its usage chunk. Each chunk keeps OpenAI's fields only and names the model you asked for. - On a closed dedicated endpoint, each chunk keeps only the fixed set of fields, and the provider's own SSE comment lines are dropped.
- When a started stream is silent for 15 s or more (slow reasoning models), the gateway sends an SSE comment (
: processing) so that proxies keep the stream open. SDKs ignore it. - See Streaming for the full detail.
If the serving provider fails after your answer started, the stream ends with one error chunk. It is a full chunk envelope with finish_reason: "error" and a top-level error object with metadata.error_type. No [DONE] follows an error. The gateway never restarts an answer on another provider mid-stream. Failover happens only before the first byte of output, and you do not see it. 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, ...). Errors has the full table with fixes.
- The gateway never relays a provider's raw error body. When a provider rejected the request, the class says so. The message has the provider's own words (up to 1,000 characters, without keys, account ids and host names), and the
x-tm-upstream-statusheader has its status. - A prompt too long for the model is OpenAI's standard 400.
error.codeiscontext_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. - Out of credits is the OpenAI-standard 429
insufficient_quota, which your SDK recognizes. It covers an empty balance or a monthly spend cap. On a cap breach, the message and thex-tm-cap-resetheader give the UTC reset time. - First trial keys, shown after verified sign-in, have a 20 RPM limit. Until an organization buys credit, all its keys together run at 20 RPM. The 429
rate_limitcarriesretry-after.
A billed request reserves an estimated cost before dispatch: one token per byte of input, plus 65,536 per image part, and all of max_tokens (at most 32,768 tokens when you omit it). If your balance cannot cover it, with your organization's other in-flight reservations, you get insufficient_quota before any provider is called. You pay the observed usage only, so a large max_tokens never costs extra. The gateway holds it in full, though, so on a small balance set max_tokens to what you need, or leave it out. See Reserve, then settle.
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 | Dispatches made, 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 | The id the gateway sent, when the provider knows the model under its own id. The response's model field still names the model you asked for. 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, 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 every parameter the gateway removed for this route. Absent when nothing was dropped. Send "provider": {"require_parameters": true} to get a typed 400 instead. |
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. |
Model discovery
GET https://demo-api.routerplus.com/v1/models (authenticated) returns the catalog in the OpenAI list shape, one row per model id, plus your organization's dedicated endpoints. The public directory with prices and context lengths is https://demo.routerplus.com/api/models.json.
- A model id that is not listed returns 404
model_unavailablewith the id you asked for. - If every deployment of a listed model is cooling down after failures, you get a 503
gateway_errorwithretry-after: 5andx-tm-limit-kind: health. Retry the same request.
See also: POST /v1/messages · Errors · Wire compatibility & billing contract · Quickstart
For agents: /docs/api-chat-completions.md and /llms.txt