EZAT RouterDocs

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 }
  }
}
  • id is chatcmpl- followed by EZAT's request id, the same value as the request-id response header. Quote it when you contact support.
  • model is exactly the name you sent.
  • choices always has one choice.
  • usage.prompt_tokens counts all input, cached or not. prompt_tokens_details.cached_tokens is the part read from the prompt cache and cache_write_tokens the part written to it. completion_tokens_details.reasoning_tokens counts thinking tokens, which are billed as output. The thinking text itself isn't returned.

Messages

Role or partWhat EZAT does
system, developerJoined into one system prompt, wherever they appear in messages. Text only.
userA string, or parts: text, image_url, file (a PDF as file_data).
assistantText and tool_calls. refusal parts are dropped.
toolA tool result for tool_call_id. Results that follow one assistant turn are sent together.
function role, input_audio partsRefused 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:

  1. The first chunk has delta: {"role": "assistant", "content": ""}.
  2. Text arrives as delta.content. Tool calls arrive as delta.tool_calls: first the id and function.name, then function.arguments in pieces.
  3. The last choice chunk has an empty delta and the finish_reason.
  4. With include_usage, one more chunk with "choices": [] and usage follows.
  5. 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)
FieldBecomes
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: falseAt 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 type image/jpeg, image/png, image/gif or image/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 returns 400 naming the part, for example messages[0].content[2].image_url.url: could not download the image (HTTP 404).
  • detail is ignored.
  • PDFs go in a file part as a data URL: {"type": "file", "file": {"file_data": "data:application/pdf;base64,…"}}. file_id isn'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_effortEffortThinking requested
none, minimallowNo
low, medium, highThe sameYes (adaptive: the model decides how much)
xhigh, maxThe sameYes (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_tokens or max_tokens set the output limit. If you send both, max_completion_tokens wins. 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. Use json_schema.

Finish reasons

finish_reasonWhy the model stopped
stopIt finished, or hit a stop string
lengthIt reached the output limit or the context window
tool_callsIt wants to call a tool
content_filterIt 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_control on 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 the x-ezat-session header, 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:

FieldError message
n above 1n: only 1 choice per request is supported
logprobs: truelogprobs: log probabilities are not available
top_logprobstop_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_optionsweb_search_options: is not supported
functions, function_callfunctions: the legacy functions API is not supported; use tools
response_format type json_objectresponse_format: type "json_object" is not supported; use "json_schema" with a schema (objects need "additionalProperties": false)
A tool whose type isn't functiontools[0].type: only function tools are supported
An unknown reasoning_effortreasoning_effort: use one of none, minimal, low, medium, high, xhigh, max
input_audio content partmessages[0].content[0]: audio input is not supported
file part with file_idmessages[0].content[0].file: send the PDF as file_data (a base64 data URL); file_id is not supported
function rolemessages[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"
  }
}
StatustypecodeWhen
400invalid_request_errornullThe request is invalid, or a field is refused
401invalid_request_errorinvalid_api_keyMissing, invalid, revoked or expired key
402insufficient_quotainsufficient_quotaThe balance is used up, or the key reached its spend limit
403permission_errornullThe key may not use this model, or the organization is suspended
404invalid_request_errormodel_not_foundUnknown model (param is model)
413invalid_request_errorrequest_too_largeThe body is over 32 MB
429rate_limit_errorrate_limit_exceededToo many requests; wait for retry-after seconds
500server_errornullAn error on EZAT's or the provider's side
503server_erroroverloadedNo capacity right now; wait for retry-after seconds
504server_errortimeoutA 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

  1. POST https://api.router.ezat.io/openai/v1/chat/completions with Authorization: Bearer $EZAT_API_KEY.
  2. Use a model id from GET https://api.router.ezat.io/openai/v1/models.
  3. Don't send n above 1, logprobs, logit_bias, audio, web_search_options, the legacy functions API, response_format json_object or file_id: they return 400.
  4. Don't rely on temperature or top_p: most models ignore them here.
  5. For streams, set stream_options.include_usage to get token counts; skip chunks whose choices is empty when reading text.
  6. Leave prompt caching on for multi-turn conversations and pass a stable prompt_cache_key per conversation. Send x-ezat-cache: off for one-off prompts.

On this page