Chat Completions
POST /openai/v1/chat/completions in detail - messages, streaming, tools, images, reasoning, caching, which fields are ignored or refused, and errors.
When to use this page
You send OpenAI Chat Completions requests to EZAT Router and need to know how each field behaves. Set-up (base URL, key, SDKs) is on OpenAI-compatible API.
EZAT translates each request into Anthropic's Messages format, runs it like any other request (same routing, limits and billing) and translates the answer back. Most requests work unchanged. The differences are listed below.
Request and response
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.router.ezat.io/openai/v1", api_key=os.environ["EZAT_API_KEY"])
completion = client.chat.completions.create(
model="anthropic/claude-sonnet-5-5",
max_completion_tokens=64,
messages=[
{"role": "system", "content": "Answer in one sentence."},
{"role": "user", "content": "What is a prompt cache?"},
],
)
print(completion.choices[0].message.content)
print(completion.choices[0].finish_reason, completion.usage){
"id": "chatcmpl-…",
"object": "chat.completion",
"created": 1793318400,
"model": "anthropic/claude-sonnet-5-5",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "A prompt cache stores…", "refusal": null },
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 27,
"completion_tokens": 20,
"total_tokens": 47,
"prompt_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 0 },
"completion_tokens_details": { "reasoning_tokens": 0 }
}
}idischatcmpl-followed by EZAT's request id, the same value as therequest-idresponse header. Quote it when you contact support.modelis exactly the name you sent.choicesalways has one choice.usage.prompt_tokenscounts all input, cached or not.prompt_tokens_details.cached_tokensis the part read from the prompt cache andcache_write_tokensthe part written to it.completion_tokens_details.reasoning_tokenscounts thinking tokens, which are billed as output. The thinking text itself isn't returned.
Messages
| Role or part | What EZAT does |
|---|---|
system, developer | Joined into one system prompt, wherever they appear in messages. Text only. |
user | A string, or parts: text, image_url, file (a PDF as file_data). |
assistant | Text and tool_calls. refusal parts are dropped. |
tool | A tool result for tool_call_id. Results that follow one assistant turn are sent together. |
function role, input_audio parts | Refused with 400. |
messages needs at least one message that isn't system or developer; otherwise the request fails with messages: must contain at least one user message.
Streaming
Set "stream": true. To get token counts in the stream, also set "stream_options": {"include_usage": true}.
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.router.ezat.io/openai/v1", api_key=os.environ["EZAT_API_KEY"])
stream = client.chat.completions.create(
model="anthropic/claude-sonnet-5-5",
max_tokens=64,
stream=True,
stream_options={"include_usage": True},
messages=[{"role": "user", "content": "Count from 1 to 10."}],
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
if chunk.usage:
print("\n", chunk.usage)The stream is a series of data: lines with chat.completion.chunk objects:
- The first chunk has
delta: {"role": "assistant", "content": ""}. - Text arrives as
delta.content. Tool calls arrive asdelta.tool_calls: first theidandfunction.name, thenfunction.argumentsin pieces. - The last choice chunk has an empty
deltaand thefinish_reason. - With
include_usage, one more chunk with"choices": []andusagefollows. - The stream ends with
data: [DONE]. It is sent after EZAT has recorded the request's usage.
During long pauses EZAT sends : ping comment lines, which the SDKs skip. If the request fails after the stream has started, you get data: {"error": {…}} and then data: [DONE], with no usage chunk, and the request isn't billed. If you close the stream early, you pay for what was produced, with the output tokens estimated (see Billing).
Tools
Function tools work as on OpenAI: tools, tool_choice, parallel_tool_calls, tool_calls in the response and tool messages with the results.
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.router.ezat.io/openai/v1", api_key=os.environ["EZAT_API_KEY"])
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
]
messages = [{"role": "user", "content": "What's the weather in Oslo? Use the tool."}]
first = client.chat.completions.create(model="anthropic/claude-sonnet-5-5", max_tokens=64, messages=messages, tools=tools)
reply = first.choices[0].message
print(first.choices[0].finish_reason, reply.tool_calls)
if first.choices[0].finish_reason == "tool_calls":
messages.append(reply)
for call in reply.tool_calls:
messages.append({"role": "tool", "tool_call_id": call.id, "content": "9°C and clear"})
second = client.chat.completions.create(model="anthropic/claude-sonnet-5-5", max_tokens=64, messages=messages, tools=tools)
print(second.choices[0].message.content)| Field | Becomes |
|---|---|
tools (type function, with strict if set) | Tools with the same name, description and JSON schema. Other tool types are refused. |
tool_choice: "auto" / "none" | The same |
tool_choice: "required" | The model must call a tool |
tool_choice: {"type": "function", "function": {"name": …}} | The model must call that tool |
parallel_tool_calls: false | At most one tool call per turn |
Without tools, tool_choice and parallel_tool_calls are ignored. Some models refuse a forced choice (required or a named function) with a 400; use auto with those. Arguments come back as a JSON string in function.arguments, as on OpenAI.
Images and PDFs
Images go in image_url parts, as data URLs or as http(s) URLs:
{
"role": "user",
"content": [
{ "type": "text", "text": "What is in this image?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo…" } },
{ "type": "image_url", "image_url": { "url": "https://example.com/photo.jpg" } }
]
}- Data URLs must be base64:
data:<type>;base64,<data>, with typeimage/jpeg,image/png,image/giforimage/webp. - http(s) URLs are downloaded by EZAT, because the models take images only inline. Limits: 10 MB per image, 20 image URLs and 32 MB of downloads per request, 15 seconds per download, 3 redirects. The host must be a public address. The file must be a JPEG, PNG, GIF or WebP image (EZAT checks the bytes, not the
content-type). A failed download returns400naming the part, for examplemessages[0].content[2].image_url.url: could not download the image (HTTP 404). detailis ignored.- PDFs go in a
filepart as a data URL:{"type": "file", "file": {"file_data": "data:application/pdf;base64,…"}}.file_idisn't supported, because there is no Files API.
The whole request body can be up to 32 MB.
Reasoning
reasoning_effort sets the model's effort and asks it to think:
reasoning_effort | Effort | Thinking requested |
|---|---|---|
none, minimal | low | No |
low, medium, high | The same | Yes (adaptive: the model decides how much) |
xhigh, max | The same | Yes (adaptive) |
Any other value is refused. Some older models have no xhigh level. Thinking isn't requested when tool_choice forces a tool call. When thinking isn't requested, or reasoning_effort isn't sent, the model's own default applies, and recent models may still think. Either way, thinking tokens are billed as output and counted in usage.completion_tokens_details.reasoning_tokens, and the thinking text isn't returned.
Sampling parameters
temperature and top_p are forwarded only to models whose capabilities.sampling is true (see GET https://api.router.ezat.io/api/models), and only when thinking is off. temperature is capped at 1, and top_p is sent only below 1. For every other model they are dropped without an error, because those models accept only their defaults. Many OpenAI clients send temperature: 0 by default, so this keeps them working.
Output length and format
max_completion_tokensormax_tokensset the output limit. If you send both,max_completion_tokenswins. With neither, the limit is 32,000 tokens, or the model's maximum output if that is lower. You're billed for the tokens produced, not the limit.stop(a string or a list) ends the output at any of the strings.response_format: {"type": "json_schema", "json_schema": {"schema": …}}makes the model answer with JSON that matches the schema. Objects in the schema need"additionalProperties": false.response_format: {"type": "json_object"}is refused: there is no schema to enforce. Usejson_schema.
Finish reasons
finish_reason | Why the model stopped |
|---|---|
stop | It finished, or hit a stop string |
length | It reached the output limit or the context window |
tool_calls | It wants to call a tool |
content_filter | It refused to answer |
Prompt caching
Caching is automatic on this API. When a request has no cache breakpoints of its own and the model supports caching, EZAT marks two 5-minute breakpoints: one after the stable prefix (the system prompt, or the tools when there is no system prompt) and one on the newest message, which the next turn reads back. A long multi-turn conversation then pays the cheaper cache-read price for everything it has already sent. See Pricing.
- Your own breakpoints. Put
cache_controlon content parts (text,image_url,file, system text parts) or on a tool, as OpenRouter does, and EZAT leaves your breakpoints alone and adds none:{"type": "text", "text": "…", "cache_control": {"type": "ephemeral"}}. - Opt out with the header
x-ezat-cache: off. A cache write costs more than plain input, so one-off prompts that are never repeated are cheaper without caching. Prompts shorter than the model's minimum cacheable length aren't cached anyway. prompt_cache_key(up to 256 characters) names the conversation, so its requests stay on the deployment that holds its cache. It does the same as thex-ezat-sessionheader, and the header wins if you send both.
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.router.ezat.io/openai/v1", api_key=os.environ["EZAT_API_KEY"])
completion = client.chat.completions.create(
model="anthropic/claude-sonnet-5-5",
max_tokens=64,
# Keeps every turn of this conversation on one deployment.
prompt_cache_key="conversation-42",
messages=[{"role": "user", "content": "Say hello in five words."}],
# Turns automatic caching off: this prompt won't be sent again.
extra_headers={"x-ezat-cache": "off"},
)
print(completion.usage)How sessions and caches work across both APIs is in Prompt caching and sessions.
Ignored and refused fields
Fields EZAT has no use for are ignored and never reach the model: user, seed, presence_penalty, frequency_penalty, store, metadata, service_tier, and any field not described on this page. n: 1 and logprobs: false are accepted.
Fields that would change the answer and have no equivalent are refused with 400 invalid_request_error. The message names the field:
| Field | Error message |
|---|---|
n above 1 | n: only 1 choice per request is supported |
logprobs: true | logprobs: log probabilities are not available |
top_logprobs | top_logprobs: log probabilities are not available |
logit_bias (not empty) | logit_bias: is not supported |
audio, or modalities other than ["text"] | modalities: only text output is supported |
web_search_options | web_search_options: is not supported |
functions, function_call | functions: the legacy functions API is not supported; use tools |
response_format type json_object | response_format: type "json_object" is not supported; use "json_schema" with a schema (objects need "additionalProperties": false) |
A tool whose type isn't function | tools[0].type: only function tools are supported |
An unknown reasoning_effort | reasoning_effort: use one of none, minimal, low, medium, high, xhigh, max |
input_audio content part | messages[0].content[0]: audio input is not supported |
file part with file_id | messages[0].content[0].file: send the PDF as file_data (a base64 data URL); file_id is not supported |
function role | messages[0].role: the legacy "function" role is not supported; use tools |
The indexes in the messages point at the part of your request that was refused.
Errors
Errors use OpenAI's envelope and the HTTP status codes of the Anthropic-format API, except that 529 becomes 503. The types and codes are OpenAI's: for example, an unknown model is 404 with code model_not_found.
{
"error": {
"message": "The model `gpt-9` does not exist or you do not have access to it.",
"type": "invalid_request_error",
"param": "model",
"code": "model_not_found"
}
}| Status | type | code | When |
|---|---|---|---|
400 | invalid_request_error | null | The request is invalid, or a field is refused |
401 | invalid_request_error | invalid_api_key | Missing, invalid, revoked or expired key |
402 | insufficient_quota | insufficient_quota | The balance is used up, or the key reached its spend limit |
403 | permission_error | null | The key may not use this model, or the organization is suspended |
404 | invalid_request_error | model_not_found | Unknown model (param is model) |
413 | invalid_request_error | request_too_large | The body is over 32 MB |
429 | rate_limit_error | rate_limit_exceeded | Too many requests; wait for retry-after seconds |
500 | server_error | null | An error on EZAT's or the provider's side |
503 | server_error | overloaded | No capacity right now; wait for retry-after seconds |
504 | server_error | timeout | A non-streaming request took longer than 590 seconds; stream it instead |
Failed requests aren't billed. The openai SDKs retry 429 and 5xx responses on their own. Errors says which errors are worth retrying.
For AI agents
POST https://api.router.ezat.io/openai/v1/chat/completionswithAuthorization: Bearer $EZAT_API_KEY.- Use a model id from
GET https://api.router.ezat.io/openai/v1/models. - Don't send
nabove 1,logprobs,logit_bias, audio,web_search_options, the legacyfunctionsAPI,response_formatjson_objectorfile_id: they return400. - Don't rely on
temperatureortop_p: most models ignore them here. - For streams, set
stream_options.include_usageto get token counts; skip chunks whosechoicesis empty when reading text. - Leave prompt caching on for multi-turn conversations and pass a stable
prompt_cache_keyper conversation. Sendx-ezat-cache: offfor one-off prompts.