# Chat Completions

URL: https://docs.router.ezat.io/openai/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](https://docs.router.ezat.io/openai/base-url.md).

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

```python tab="Python" test
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)
```

```ts tab="Node.js" test
import OpenAI from "openai";

const client = new OpenAI({ baseURL: "https://api.router.ezat.io/openai/v1", apiKey: process.env.EZAT_API_KEY });

const completion = await 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?" },
  ],
});
console.log(completion.choices[0].message.content);
console.log(completion.choices[0].finish_reason, completion.usage);
```

```bash tab="curl" test
curl --fail-with-body https://api.router.ezat.io/openai/v1/chat/completions \
  -H "Authorization: Bearer $EZAT_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "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?"}
    ]
  }'
```

```json
{
  "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 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}`.

```python tab="Python" test
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)
```

```ts tab="Node.js" test
import OpenAI from "openai";

const client = new OpenAI({ baseURL: "https://api.router.ezat.io/openai/v1", apiKey: process.env.EZAT_API_KEY });

const stream = await 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 await (const chunk of stream) {
  const text = chunk.choices[0]?.delta?.content;
  if (text) process.stdout.write(text);
  if (chunk.usage) console.log("\n", chunk.usage);
}
```

```bash tab="curl" test
curl --fail-with-body -N https://api.router.ezat.io/openai/v1/chat/completions \
  -H "Authorization: Bearer $EZAT_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "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."}]
  }'
```

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](https://docs.router.ezat.io/billing.md)).

## Tools

Function tools work as on OpenAI: `tools`, `tool_choice`, `parallel_tool_calls`, `tool_calls` in the response and `tool` messages with the results.

```python tab="Python" test
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:

```json
{
  "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_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_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_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](https://docs.router.ezat.io/pricing.md).

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

```python tab="Python" test
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](https://docs.router.ezat.io/guides/prompt-caching.md).

## 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](https://docs.router.ezat.io/reference/errors.md), except that `529` becomes `503`. The types and codes are OpenAI's: for example, an unknown model is `404` with code `model_not_found`.

```json
{
  "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](https://docs.router.ezat.io/reference/errors.md) 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.