# Chat Completions

URL: https://docs.router.ezat.io/zh/openai/chat-completions

> 详解 POST /openai/v1/chat/completions：消息、流式输出、工具、图片、推理、缓存、会被忽略或拒绝的字段，以及错误。

## 何时阅读本页

你向 EZAT Router 发送 OpenAI Chat Completions 请求，需要了解每个字段的行为。配置方法（base URL、key、SDK）见 [OpenAI 兼容 API](https://docs.router.ezat.io/zh/openai/base-url.md)。

EZAT 把每个请求转换为 Anthropic 的 Messages 格式，像其他请求一样处理（相同的路由、限额和计费），再把回答转换回来。大多数请求无需修改即可使用。差异列在下文。

## 请求与响应

```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`** 是 `chatcmpl-` 加上 EZAT 的请求 id，与响应头 `request-id` 的值相同。联系支持团队时请提供它。
- **`model`** 与你发送的名称完全一致。
- **`choices`** 始终只有一个选项。
- **`usage.prompt_tokens`** 统计全部输入，无论是否命中缓存。`prompt_tokens_details.cached_tokens` 是从提示缓存（prompt caching）读取的部分，`cache_write_tokens` 是写入缓存的部分。`completion_tokens_details.reasoning_tokens` 统计思考 token，按输出计费。思考文本本身不会返回。

## 消息

| 角色或内容部分 | EZAT 如何处理 |
| --- | --- |
| `system`、`developer` | 合并为一个系统提示词，无论它们出现在 `messages` 的什么位置。仅支持文本。 |
| `user` | 字符串，或由以下部分组成：`text`、`image_url`、`file`（以 `file_data` 发送的 PDF）。 |
| `assistant` | 文本和 `tool_calls`。`refusal` 部分会被丢弃。 |
| `tool` | `tool_call_id` 对应的工具结果。同一个 assistant 轮次之后的多个结果会一起发送。 |
| `function` 角色、`input_audio` 部分 | 以 `400` 拒绝。 |

`messages` 中至少要有一条不是 `system` 或 `developer` 的消息，否则请求会失败，报错 `messages: must contain at least one user message`。

## 流式输出

设置 `"stream": true`。要在流中获得 token 数，还要设置 `"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."}]
  }'
```

流由一系列 `data:` 行组成，每行是一个 `chat.completion.chunk` 对象：

1. 第一个 chunk 带有 `delta: {"role": "assistant", "content": ""}`。
2. 文本以 `delta.content` 返回。工具调用以 `delta.tool_calls` 返回：先是 `id` 和 `function.name`，然后 `function.arguments` 分段返回。
3. 最后一个带选项的 chunk 有一个空的 `delta` 和 `finish_reason`。
4. 设置了 `include_usage` 时，之后还有一个 `"choices": []` 且带有 `usage` 的 chunk。
5. 流以 `data: [DONE]` 结束。它在 EZAT 记录完该请求的用量之后发送。

长时间停顿时，EZAT 会发送 `: ping` 注释行，SDK 会跳过它们。如果请求在流开始后失败，你会收到 `data: {"error": {…}}`，然后是 `data: [DONE]`，没有用量 chunk，且该请求不计费。如果你提前关闭流，需要为已生成的内容付费，其中输出 token 为估算值（见[计费](https://docs.router.ezat.io/zh/billing.md)）。

## 工具

函数工具的用法与 OpenAI 相同：`tools`、`tool_choice`、`parallel_tool_calls`、响应中的 `tool_calls`，以及携带结果的 `tool` 消息。

```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)
```

| 字段 | 转换为 |
| --- | --- |
| `tools`（type 为 `function`，设置了 `strict` 时一并保留） | 名称、描述和 JSON schema 都相同的工具。其他类型的工具会被拒绝。 |
| `tool_choice: "auto"` / `"none"` | 相同 |
| `tool_choice: "required"` | 模型必须调用一个工具 |
| `tool_choice: {"type": "function", "function": {"name": …}}` | 模型必须调用该工具 |
| `parallel_tool_calls: false` | 每轮最多调用一个工具 |

没有 `tools` 时，`tool_choice` 和 `parallel_tool_calls` 会被忽略。有些模型会以 `400` 拒绝强制选择（`required` 或指定函数）；对这些模型请使用 `auto`。参数以 JSON 字符串形式放在 `function.arguments` 中返回，与 OpenAI 相同。

## 图片和 PDF

图片放在 `image_url` 部分中，可以是 data URL，也可以是 http(s) URL：

```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 URL** 必须是 base64：`data:<type>;base64,<data>`，type 为 `image/jpeg`、`image/png`、`image/gif` 或 `image/webp`。
- **http(s) URL** 由 EZAT 下载，因为模型只接受内联图片。限制：每张图片 10 MB，每个请求最多 20 个图片 URL、下载总量 32 MB，每次下载 15 秒，最多 3 次重定向。主机必须是公网地址。文件必须是 JPEG、PNG、GIF 或 WebP 图片（EZAT 检查的是文件字节，而不是 `content-type`）。下载失败会返回 `400` 并指出是哪个部分，例如 `messages[0].content[2].image_url.url: could not download the image (HTTP 404)`。
- `detail` 会被忽略。
- **PDF** 以 data URL 形式放在 `file` 部分中：`{"type": "file", "file": {"file_data": "data:application/pdf;base64,…"}}`。不支持 `file_id`，因为没有 Files API。

整个请求体最大 32 MB。

## 推理

`reasoning_effort` 设置模型的 effort，并要求模型思考：

| `reasoning_effort` | effort | 是否请求思考 |
| --- | --- | --- |
| `none`、`minimal` | `low` | 否 |
| `low`、`medium`、`high` | 相同 | 是（adaptive：由模型决定思考多少） |
| `xhigh`、`max` | 相同 | 是（adaptive） |

其他值会被拒绝。一些较早的模型没有 `xhigh` 等级。当 `tool_choice` 强制调用工具时，不会请求思考。没有请求思考，或没有发送 `reasoning_effort` 时，使用模型自己的默认设置，较新的模型仍可能会思考。无论哪种情况，思考 token 都按输出计费，并计入 `usage.completion_tokens_details.reasoning_tokens`，思考文本不会返回。

## 采样参数

`temperature` 和 `top_p` 只会转发给 `capabilities.sampling` 为 `true` 的模型（见 `GET https://api.router.ezat.io/api/models`），并且只在思考关闭时转发。`temperature` 最大为 1，`top_p` 只在小于 1 时发送。对其他所有模型，它们会被直接丢弃而不报错，因为这些模型只接受默认值。很多 OpenAI 客户端默认发送 `temperature: 0`，这样处理能让它们正常工作。

## 输出长度与格式

- **`max_completion_tokens`** 或 **`max_tokens`** 设置输出上限。两者都发送时，以 `max_completion_tokens` 为准。两者都不发送时，上限为 32,000 个 token；如果模型的最大输出更低，则以模型的最大输出为准。按实际生成的 token 计费，而不是按上限计费。
- **`stop`**（字符串或列表）：输出遇到其中任一字符串时结束。
- **`response_format: {"type": "json_schema", "json_schema": {"schema": …}}`** 让模型输出符合该 schema 的 JSON。schema 中的对象需要 `"additionalProperties": false`。
- **`response_format: {"type": "json_object"}`** 会被拒绝：没有可以强制执行的 schema。请使用 `json_schema`。

## 结束原因

| `finish_reason` | 模型停止的原因 |
| --- | --- |
| `stop` | 正常结束，或遇到了 `stop` 字符串 |
| `length` | 达到了输出上限或上下文窗口 |
| `tool_calls` | 模型要调用工具 |
| `content_filter` | 模型拒绝回答 |

## 提示缓存

这个 API 上的缓存是自动的。当请求自己没有缓存断点、且模型支持缓存时，EZAT 会标记两个 5 分钟的断点：一个在稳定前缀之后（系统提示词；没有系统提示词时为工具），另一个在最新的消息上，供下一轮读取。这样，长的多轮对话中已经发送过的内容都按更便宜的缓存读取价格计费。见[价格](https://docs.router.ezat.io/zh/pricing.md)。

- **自己设置断点**。像 OpenRouter 一样，在内容部分（`text`、`image_url`、`file`、系统消息的文本部分）或工具上加上 `cache_control`，EZAT 就不会改动你的断点，也不会再添加：`{"type": "text", "text": "…", "cache_control": {"type": "ephemeral"}}`。
- **关闭缓存**：使用请求头 `x-ezat-cache: off`。缓存写入比普通输入贵，所以不会重复发送的一次性提示词不缓存更省钱。短于模型最小可缓存长度的提示词本来就不会被缓存。
- **`prompt_cache_key`**（最多 256 个字符）为对话命名，让它的请求留在保存其缓存的部署上。它的作用与 `x-ezat-session` 请求头相同，两者都发送时以请求头为准。

```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)
```

两种 API 上会话和缓存如何工作，见[提示缓存与会话](https://docs.router.ezat.io/zh/guides/prompt-caching.md)。

## 被忽略和被拒绝的字段

EZAT 用不到的字段会被忽略，不会传给模型：`user`、`seed`、`presence_penalty`、`frequency_penalty`、`store`、`metadata`、`service_tier`，以及本页没有提到的任何字段。`n: 1` 和 `logprobs: false` 会被接受。

会改变回答、但又没有对应功能的字段，会以 `400` `invalid_request_error` 拒绝。错误消息会指出字段名：

| 字段 | 错误消息 |
| --- | --- |
| `n` 大于 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`（非空） | `logit_bias: is not supported` |
| `audio`，或 `modalities` 不是 `["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)` |
| type 不是 `function` 的工具 | `tools[0].type: only function tools are supported` |
| 未知的 `reasoning_effort` | `reasoning_effort: use one of none, minimal, low, medium, high, xhigh, max` |
| `input_audio` 内容部分 | `messages[0].content[0]: audio input is not supported` |
| 带有 `file_id` 的 `file` 部分 | `messages[0].content[0].file: send the PDF as file_data (a base64 data URL); file_id is not supported` |
| `function` 角色 | `messages[0].role: the legacy "function" role is not supported; use tools` |

错误消息中的索引指向你请求中被拒绝的那一部分。

## 错误

错误采用 OpenAI 的外层结构，HTTP 状态码与 [Anthropic 格式的 API](https://docs.router.ezat.io/zh/reference/errors.md) 相同，只有 `529` 变为 `503`。type 和 code 使用 OpenAI 的取值，例如未知模型为 `404`，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"
  }
}
```

| 状态码 | `type` | `code` | 何时出现 |
| --- | --- | --- | --- |
| `400` | `invalid_request_error` | `null` | 请求无效，或某个字段被拒绝 |
| `401` | `invalid_request_error` | `invalid_api_key` | 缺少 key，或 key 无效、已吊销或已过期 |
| `402` | `insufficient_quota` | `insufficient_quota` | 余额已用完，或 key 达到了消费上限 |
| `403` | `permission_error` | `null` | key 无权使用该模型，或组织已被暂停 |
| `404` | `invalid_request_error` | `model_not_found` | 未知模型（`param` 为 `model`） |
| `413` | `invalid_request_error` | `request_too_large` | 请求体超过 32 MB |
| `429` | `rate_limit_error` | `rate_limit_exceeded` | 请求过多；等待 `retry-after` 秒后再试 |
| `500` | `server_error` | `null` | EZAT 或模型提供方出错 |
| `503` | `server_error` | `overloaded` | 暂无可用容量；等待 `retry-after` 秒后再试 |
| `504` | `server_error` | `timeout` | 非流式请求耗时超过 590 秒；请改用流式 |

失败的请求不计费。`openai` SDK 会自动重试 `429` 和 `5xx` 响应。[错误](https://docs.router.ezat.io/zh/reference/errors.md)说明了哪些错误值得重试。

## 给 AI agent 的说明

1. `POST https://api.router.ezat.io/openai/v1/chat/completions`，带上 `Authorization: Bearer $EZAT_API_KEY`。
2. 使用 `GET https://api.router.ezat.io/openai/v1/models` 返回的模型 id。
3. 不要发送大于 1 的 `n`、`logprobs`、`logit_bias`、音频、`web_search_options`、旧版 `functions` API、`response_format` `json_object` 或 `file_id`：它们会返回 `400`。
4. 不要依赖 `temperature` 或 `top_p`：在这里大多数模型会忽略它们。
5. 流式请求要设置 `stream_options.include_usage` 才能拿到 token 数；读取文本时跳过 `choices` 为空的 chunk。
6. 多轮对话保持提示缓存开启，并为每个对话传入一个固定的 `prompt_cache_key`。一次性提示词发送 `x-ezat-cache: off`。