# 对话补全

> /v1/chat/completions 的每个字段我们怎么处理、流式要注意的四件事、工具调用与中途断开怎么计费。

来源：https://www.scirouter.cn/docs/chat-completions · 更新于 2026-09-23

```http
POST https://api.scirouter.cn/v1/chat/completions
```

请求与响应都是 OpenAI Chat Completions 的形状。这一页只写**我们怎么处理**这些字段，
字段本身的语义以 OpenAI 的文档为准。

## 请求体

```json
{
  "model": "bio/protein-72b",
  "messages": [
    { "role": "system", "content": "你是一名结构生物学助手。" },
    { "role": "user", "content": "帮我判断这段序列的二级结构倾向：MKTAYIAKQR…" }
  ],
  "max_tokens": 1024,
  "temperature": 0.3,
  "stream": false
}
```

| 字段 | 必填 | 我们怎么处理 |
|---|---|---|
| `model` | 是 | 模型 id，见「模型列表」。不存在或已下线 404 `model_not_found`，白名单外 403 `model_not_allowed` |
| `messages` | 是 | 不能为空数组。原样转给上游 |
| `max_tokens` | 否 | 必须是正整数；**超过模型或套餐的单次上限直接 400**，`details.limit` 给出上限，不会替你悄悄截短。不填时取两者中较小的那个 |
| `max_completion_tokens` | 否 | `max_tokens` 的新名字，两个都给时以 `max_tokens` 为准 |
| `temperature` / `top_p` | 否 | 原样转给上游 |
| `stop` | 否 | 字符串或字符串数组，其他类型 400 |
| `stream` | 否 | 见下文「流式」 |
| `stream_options.include_usage` | 否 | 流式时在末尾多发一个只带 `usage` 的分片 |
| `tools` / `tool_choice` | 否 | 见下文「工具调用」 |
| `metadata` / `user` | 否 | 你的归因标签，**不转给上游**，见「可观测性」 |
| 其他字段 | 否 | 原样转给上游；厂商专有参数的写法见「多厂商差异」 |

请求体上限默认 4 MB，超过返回 413 `payload_too_large`。

## 非流式响应

```json
{
  "id": "chatcmpl-…",
  "object": "chat.completion",
  "created": 1758585600,
  "model": "bio/protein-72b",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "这段序列……" },
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 42, "completion_tokens": 318, "total_tokens": 360 },
  "scirouter": { "request_id": "req_…", "attempts": 1, "…": "…" }
}
```

`scirouter` 字段与 `X-SciRouter-*` 响应头见「可观测性」。

**`finish_reason` 是 `length` 时，回答被 `max_tokens` 截断了。** 它看起来可能很完整——
一段结构数据、一段代码被截在中间时，解析不报错也不代表内容是全的。批处理请按它判断，而不是看内容长短。

## 流式

带 `"stream": true`，响应是 `text/event-stream`：

```text
: ping

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"这段序列"}}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":42,"completion_tokens":318,"total_tokens":360}}

data: {"id":"chatcmpl-…","object":"chat.completion.chunk","choices":[],"scirouter":{"request_id":"req_…","attempts":1}}

data: [DONE]
```

读流时要处理好四件事：

1. **以 `:` 开头的行是注释，跳过。** 排队或上游很久没吐字时，网关每隔一段时间发一行 `: ping`，
   防止中间的代理把空闲连接掐掉。官方 SDK 会自动跳过。
2. **`choices` 可能是空数组。** 末尾的用量分片（要了 `include_usage` 才有）和 `scirouter` 分片都是这样，
   直接取 `choices[0]` 会越界。
3. **收到 `data: [DONE]` 才算正常结束。** 出错时流里会出现一个 `{"error": {...}}` 分片，之后直接断开、不发 `[DONE]`；
   没收到 `[DONE]` 就结束的流一律按失败处理，形状见「错误码」。
4. **错误分片可能出现在第一个内容分片之前。** 心跳一旦发出，HTTP 状态码就已经是 200 了——
   这之后的任何错误（例如排队太久超时）都只能以错误分片出现。只看状态码会把它们全部当成成功。

故障转移只发生在**第一个字节之前**：上游一旦开始吐字，中途失败不会换线路重来（那会让你收到两段拼起来的回答），
而是按上一条给你一个错误分片。

## 工具调用

`tools` / `tool_choice` 按 OpenAI 的格式写，原样转给上游；模型返回的 `tool_calls` 也原样交还给你。

带 `tools` 的请求**只会发往支持工具调用的线路**。一个模型的所有线路都不支持时，返回 503 `model_unavailable`，
`details.reason` 说明原因——这种 503 重试不会好，请换模型。

## 中途断开与计费

- 非流式请求失败不计费；
- 流式请求在吐出一部分之后失败，按**已经产生的用量**计费（上游给了用量就按它，没给就按已转发的文本估算）；
- 你这边主动断开连接时，网关会同时取消上游，按断开前已产生的部分计费，**结果不保存**——
  所以带 `Idempotency-Key` 重试时会重新调用，见「缓存与幂等」。

## 相关

- 所有错误码与哪些该重试：「错误码」
- 重复请求直接命中、重试不重复扣费：「缓存与幂等」
- 每次调用打给了谁、慢在哪：「可观测性」
