This commit is contained in:
2026-08-23 02:12:08 +08:00
commit 5987b2a1f2
19 changed files with 4174 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/.gradle
/.idea
/build
/run
+20
View File
@@ -0,0 +1,20 @@
plugins {
id("java")
}
group = "com.bytenya.ztoken"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:6.0.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}
+43
View File
@@ -0,0 +1,43 @@
# AI API 中转站 — 资料索引
本目录收集各 AI provider 的 API 规格与"怪癖"(quirks),用于实现一个多格式兼容的中转站(gateway)。
**资料采集时间:2026-08-12。** 各家文档变动频繁(尤其是模型名与 thinking 相关参数),落地实现前请对照文件头部的来源 URL 复核。
## 文件清单
| 文件 | 内容 |
| --- | --- |
| **[api-design.md](./api-design.md)** | **本项目的 API 契约设计**(双模板入口、IR、能力降级、thinking 桥接)— 其余文件是它的调研依据 |
| **[transform-spec.md](./transform-spec.md)** | **转写方案 v0.3**(四家求同存异、上游 Profile、请求/响应改写管线 R1–R9 / S1–S9、推理内容可信度分级)— api-design 的落地层 |
| [openai.md](./openai.md) | OpenAI Chat Completions + Responses API 规格与怪癖 |
| [anthropic.md](./anthropic.md) | Anthropic Messages API 规格与怪癖 |
| [deepseek.md](./deepseek.md) | DeepSeek API 规格与怪癖 |
| [kimi.md](./kimi.md) | Kimi(月之暗面 MoonshotAPI 规格与怪癖,**含 Kimi For Coding 独立产品线对比**(§17 |
| [china-providers.md](./china-providers.md) | 国产厂商(通义/GLM/Kimi/豆包/文心/混元/MiniMax/星火)的怪癖 |
| [cross-provider-mapping.md](./cross-provider-mapping.md) | 三家之间的字段/语义映射表,含流式事件映射 |
| [compatibility-layers.md](./compatibility-layers.md) | 各家官方提供的"他家格式兼容层",可作为中转站行为的参考实现 |
| [errors-and-limits.md](./errors-and-limits.md) | 错误码、限流、超时、请求体积上限对照 |
## 中转站要处理的核心难题(速览)
1. **消息模型不同构**OpenAI 是扁平 `messages[]` + `role: system`Anthropic 是 `messages[]` + 独立顶层 `system`,且 content 是 block 数组。转换必然有损。
2. **推理内容(thinking/reasoning)不可互换**Anthropic 的 `thinking` 文本只是**摘要**,真正的推理内容加密在 `signature` 里,服务端靠解密它重建思考——所以"翻译成字符串再翻译回来"是死路,只能把密文当 opaque blob 原样搬运。OpenAI 用 `encrypted_content`DeepSeek 用明文 `reasoning_content`。三者回传规则均不同,详见 [cross-provider-mapping.md](./cross-provider-mapping.md) §7。
3. **工具调用 ID 与结构**Anthropic `tool_use.id` / `tool_result.tool_use_id`(在 user 消息里);OpenAI Chat 用 `tool_calls[].id` / `role: tool` 消息;OpenAI Responses 用 `call_id`
4. **流式协议不同**OpenAI 是增量 delta 的 `chat.completion.chunk` + `[DONE]`Anthropic 是有状态的 block 事件机(`content_block_start/delta/stop`),且 `message_delta.usage` 是**累计值**。
5. **未支持参数的处理策略**:三家普遍**静默忽略**而非报错。中转站应明确选择"静默忽略"还是"显式报错",并保持一致。
**国产厂商的坑是另一个维度**——不在语义层而在协议层:HTTP 200 里藏业务错误(MiniMax `base_resp`)、流式默认返回全量累积文本(阿里 `incremental_output`)、六种互不相同的鉴权方式、`finish_reason: "sensitive"` 打破 OpenAI 枚举。详见 [china-providers.md](./china-providers.md),其中 §0 和 §9 是跨厂商的共性结论。
**还有第三个维度:同一家 API 内部按模型/按入口分裂。** Kimi 是典型——`temperature` 在新模型上写死 1.0 且**传其他值直接 400**(OpenAI 客户端的默认值开箱打不通),但**同样的参数走它自家的 Anthropic 入口却被静默忽略**;流式 `usage` 的位置由 `stream_options.include_usage` 决定(不传就藏在 `choices[0].usage`)。即"合法请求体取决于 `model` 和入口"。详见 [kimi.md](./kimi.md),§16 是可直接落地的接入 checklist。
**第四个维度是文档可信度。** Kimi 已用真实 key 实测,**10 条文档明确写下的规则被推翻**(含"K3 思考恒开不可关"这样的核心设定、以及官方 Claude Code 配置页照抄会 404 的模型名),见 [kimi.md](./kimi.md) §14。教训是:涉及"报错/不支持/必须"的文档表述,落地前一律实测——**两个方向的错都有**(文档说不能实际能、文档说能实际不能)。
**第五个维度:一个"厂商"可能是多个上游。** Kimi 的 `platform``api.moonshot.cn`)和 **Kimi For Coding**`api.kimi.com/coding`)共用推理层却是两套接入实现——模型名、鉴权、限流、端点、错误语义、tool_call id 格式全不同,key 互不通用,且 coding 侧**不校验模型名**(传 `gpt-4` 返回 200 并原样回显)。中转站必须按两个上游配置。见 [kimi.md](./kimi.md) §17。
## 未覆盖 / 待补
- Google Gemini`generateContent`)— 用户未列入,尚未收集。
- 零一万物 Yi、百川、阶跃星辰 StepFun、商汤日日新。
- OpenAI Realtime API、Batch API、Embeddings 等非 chat 端点。
- DeepSeek `multi_round_chat``token_usage``rate_limit` 页面(内容与其他页重合度高,未单独抓取)。
+306
View File
@@ -0,0 +1,306 @@
# Anthropic Claude Messages API
> 来源:
> - https://platform.claude.com/docs/en/api/messages
> - https://platform.claude.com/docs/en/build-with-claude/streaming
> - https://platform.claude.com/docs/en/api/errors
> - https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons
> - https://platform.claude.com/docs/en/build-with-claude/prompt-caching
> - https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
>
> 采集于 2026-08-12。注意 `docs.anthropic.com` 已 301 到 `platform.claude.com`。
端点:`POST https://api.anthropic.com/v1/messages`
必需 header`x-api-key``anthropic-version: 2023-06-01``content-type: application/json`
---
## 1. 请求参数
必填:`model``messages`、**`max_tokens`**(与 OpenAI 不同,这里是必填)。
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `model` | string | 如 `claude-opus-5` |
| `messages` | array\<MessageParam\> | |
| `max_tokens` | number | **必填** |
| `system` | string \| array\<TextBlockParam\> | **顶层独立字段**,不是 messages 里的一条 |
| `temperature` | number | **0.0 ~ 1.0**,默认 1.0(不是 OpenAI 的 0~2 |
| `top_p` | number | |
| `top_k` | number | OpenAI 无此参数 |
| `stop_sequences` | array\<string\> | |
| `stream` | boolean | |
| `tools` | array\<ToolUnion\> | |
| `tool_choice` | ToolChoice | |
| `thinking` | ThinkingConfigParam | 见 §5 |
| `metadata` | Metadata | |
| `cache_control` | CacheControlEphemeral | 顶层写法 = 自动缓存 |
| `container` | string | 代码执行容器 |
| `inference_geo` | string | 推理地理区域 |
| `output_config` | OutputConfig | 含 `effort``format`(结构化输出) |
| `service_tier` | `"auto"` \| `"standard_only"` | |
Header 参数:`anthropic-user-profile-id`(可选)。
### MessageParam
```json
{"role": "user" | "assistant" | "system", "content": "string" | [ContentBlockParam]}
```
---
## 2. Content Block 类型
### 输入侧(ContentBlockParam
| type | 关键字段 |
| --- | --- |
| `text` | `text`, `cache_control`, `citations` |
| `image` | `source`: `{type:"base64", media_type: image/jpeg\|png\|gif\|webp, data}``{type:"url", url}` |
| `document` | `source`: base64(PDF) / text / url / content`title`, `context`, `citations:{enabled}` |
| `tool_use` | `id`, `name`, `input`, `caller` |
| `tool_result` | `tool_use_id`, `content`, `is_error` |
| `thinking` | `thinking`, `signature` |
| `search_result` | `title`, `source`, `content[]`, `citations` |
| `server_tool_use` | `id`, `name`(web_search/web_fetch/code_execution/bash_code_execution/text_editor_code_execution/tool_search_tool_regex/tool_search_tool_bm25), `input` |
| `mid_conv_system` | `content: [TextBlockParam]` — 会话中途插入 system 指令 |
| `container_upload` | `file_id` |
### 输出侧(ContentBlock
`text``thinking``redacted_thinking``{type, data}`)、`tool_use``server_tool_use``web_search_tool_result``web_fetch_tool_result``code_execution_tool_result``bash_code_execution_tool_result``text_editor_code_execution_tool_result``tool_search_tool_result``container_upload`
---
## 3. 响应结构
```json
{
"id": "msg_...",
"type": "message",
"role": "assistant",
"model": "claude-opus-5",
"content": [ContentBlock],
"stop_reason": "end_turn",
"stop_sequence": null,
"stop_details": {"type": "refusal", "category": "...", "explanation": "..."},
"usage": {...},
"container": {"id": "...", "expires_at": "..."}
}
```
### stop_reason
| 值 | 含义 | 处理 |
| --- | --- | --- |
| `end_turn` | 自然结束 | 直接用 |
| `max_tokens` | 达到 `max_tokens` | 截断,可续写 |
| `stop_sequence` | 命中 `stop_sequences` | 见 `stop_sequence` 字段 |
| `tool_use` | 调用了**客户端**工具 | 执行后回 `tool_result` |
| `pause_turn` | **服务端**工具采样循环达到迭代上限(默认 10 次) | 把整个 response.content 作为 assistant 消息发回去继续,**工具定义必须保持一致** |
| `refusal` | 安全拒绝 | 读 `stop_details.category` |
| `model_context_window_exceeded` | 响应填满上下文窗口(先于 max_tokens 触发) | 视作截断 |
`stop_details.category` 取值:`cyber` | `bio` | `frontier_llm` | `reasoning_extraction` | `general_harms`
### usage
```json
{
"input_tokens": 50,
"output_tokens": 503,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 100000,
"cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0},
"output_tokens_details": {"thinking_tokens": 0},
"server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0},
"inference_geo": "...",
"service_tier": "standard" | "priority" | "batch"
}
```
**`input_tokens` 只统计最后一个缓存断点之后的未缓存 token**:
`total = input_tokens + cache_read_input_tokens + cache_creation_input_tokens`
直接把 `input_tokens` 映射到 OpenAI 的 `prompt_tokens` 会严重低报。
### Token 计数端点
`POST /v1/messages/count_tokens`,参数同 create(不需要 `max_tokens`),返回 `input_tokens``cache_creation_input_tokens``cache_read_input_tokens`
---
## 4. 流式(SSE
事件流固定顺序:
1. `message_start` — 含一个 `content` 为空的 Message 对象
2. 若干组 content block`content_block_start` → N × `content_block_delta``content_block_stop`,每组带 `index`
3. 一个或多个 `message_delta` — 顶层变更(`stop_reason``usage`
4. `message_stop`
其间可穿插任意数量的 `ping` 事件,以及 `error` 事件。
### delta 类型
| delta.type | 字段 | 用于 |
| --- | --- | --- |
| `text_delta` | `text` | text block |
| `input_json_delta` | `partial_json` | tool_use 的 `input`,**是部分 JSON 字符串**,累加后在 `content_block_stop` 时解析 |
| `thinking_delta` | `thinking` | thinking block |
| `signature_delta` | `signature` | thinking block**在 `content_block_stop` 之前**发一次 |
### 流式怪癖
- **`message_delta.usage` 的 token 计数是累计值**,不是增量。做统计时不要相加。
- 流式返回 200 之后仍可能中途发 `error` 事件(例如 `overloaded_error`,对应非流式的 HTTP 529)。错误处理不能只看 HTTP 状态码。
- `content_block_delta``index` 对应最终 `content` 数组下标,**不保证连续到达顺序之外的假设**,但同一 block 的 delta 是有序的。
- **server-side fallback** 时会在模型边界插入一个 `fallback` content block:只有 `content_block_start` + `content_block_stop`,中间**没有任何 delta**。解析器不能假设每个 block 至少有一个 delta。
- 工具调用时,模型一次只能吐出一个完整的 key-value,因此**事件之间可能有明显停顿**。
- `thinking` 配置为 `display: "omitted"` 时,thinking block 会开启、只收到一个 `signature_delta`、然后关闭,**没有 `thinking_delta`**。
- 新事件类型会随时新增,解析器必须优雅忽略未知事件(官方 versioning policy 明确要求)。
### 流式中断恢复
- Claude 4.5 及更早:把已收到的部分内容作为 assistant 消息前缀(prefill)续写。
- **Claude 4.6 及更晚:不支持 prefill**,改为加一条 user 消息说明"上次回复被中断于 X,请继续"。
- `tool_use` 和 thinking block **无法部分恢复**,只能从最后一个完整 text block 续。
---
## 5. Thinking 的怪癖(最大坑区)
Anthropic 在不同模型代际上换过三种 thinking 配置,且**互相排斥**
| 模型 | 可用配置 | 报错情况 |
| --- | --- | --- |
| Claude 4.5 及更早 | `thinking: {"type":"enabled", "budget_tokens":N}` | 传 `type:"adaptive"` → 400 `adaptive thinking is not supported on this model` |
| Claude 4.7 及以后 | `thinking: {"type":"adaptive", "display":...}` + `output_config.effort` | 传 `type:"enabled"` → 400 `"thinking.type.enabled" is not supported for this model` |
| Claude Fable 5 / Mythos 5 / Mythos Preview | thinking **恒开**,无法关闭 | 传 `type:"disabled"` → 400。且该错误信息建议的 `"thinking.type.enabled"` 在 Fable 5 / Mythos 5 上**同样被拒**——正确做法是**完全省略 `thinking` 参数** |
`display` 取值:`summarized` | `omitted``omitted` 用于"开启思考但不返回思考内容")。
### signature 的真实机制(关键)
> 来源:https://platform.claude.com/docs/en/build-with-claude/thinking 的 "Thinking encryption" 章节
**`signature` 名为签名,实为加密载荷。** 官方原文:
> "Full thinking content is encrypted and returned in the `signature` field on each thinking block. The API uses the signature to verify that thinking blocks were generated by Claude when you pass them back."
也就是说它身兼两职:**承载完整原始思考内容的密文** + **来源真实性校验**
由此推出几个反直觉的事实:
1. **`thinking` 字段里的文本永远不是原始思考链**,而是摘要(summarized)。官方明确:"what you see is never the raw chain of thought"。**没有任何 `display` 设置能返回原始 CoT。**
2. **真正的推理内容在 `signature` 里。** 回传时服务端**解密 `signature` 来重建原始 thinking** 用于构造 prompt——它不读你传的 `thinking` 文本。
3. **`display: "omitted"``thinking` 为空串,但 `signature` 照常携带完整加密思考。** 且 `signature` 的值在 `summarized``omitted` 两种模式下**完全相同**,中途切换 `display` 是被支持的。
4. 因此 `display: "omitted"` 省的是**延迟不是钱**——完整思考 token 照常计费,只是服务端跳过流式下发思考文本,首个 text token 更快到达。
其他约束:
- **`signature` 是 opaque 的,官方明令不要解析或解释它。**
- Claude 4 及以后模型的 `signature` **显著更长**。若中转站对单字段长度有限制(数据库列宽、日志截断),这是个真实的踩坑点。
- **`signature` 跨平台通用**Claude API、Amazon Bedrock、Google Vertex AI 三方生成的值可以互换使用。对多云路由的中转站是好消息。
- `redacted_thinking` 是**另一种 block type**(安全红线内容被编辑时返回),加密内容在 `data` 字段,同样 opaque、同样必须原样回传。它与 `display: "omitted"` 是两回事。
- Claude Fable 5 / Mythos 5 **从不返回原始思考链**,返回的是常规 `thinking` block 而非 `redacted_thinking`
### 回传规则(分级,不是一刀切)
我最初的理解过严。官方的实际分级是:
| 场景 | 要求 |
| --- | --- |
| **工具使用轮次内** | **必须**回传 thinking block |
| 跨轮次(非工具) | **建议**全部回传 |
| 非工具场景 | **允许**省略历史轮次的 thinking |
但只要你选择回传,就**不能改**:最近一条 assistant 消息里连续的 `thinking` block 序列必须与模型原始生成的完全一致——不能重排、编辑、部分丢弃,`redacted_thinking` 也算在内。否则 400
```
`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified
```
**唯一的例外**`display: "omitted"` 的块里,你写进那个空 `thinking` 字段的任何文本会被**忽略**而不是报错(因为服务端根本不读它,只解密 signature)。
**不需要自己修剪历史 thinking。** 全部传回去,API 会自动过滤,只保留维持推理连续性所需的块,并且**只对实际展示给 Claude 的块计费**。
### 保留策略按模型分两档
| 档位 | 模型 |
| --- | --- |
| **保留所有历史轮次** | Opus 4.5 及以后的 Opus、Sonnet 4.6 及以后的 Sonnet、Fable 5、Mythos 5、Mythos Preview |
| **只保留最后一轮** | 更早的 Opus/Sonnet,以及**所有 Haiku**(含 Haiku 4.5)。传回更早的块 API 会自动剥离 |
在 keep-all 模型上,长对话的历史 thinking block 会**持续占用输入 token**(从缓存读取时也计入 usage)。可用 `clear_thinking_20251015` 这个 context-editing 策略覆盖默认行为。
### 换模型必须剥离 thinking
**thinking block 与生成它的模型绑定。** 会话中途切换模型(例如 refusal fallback 之后)时,必须从历史 assistant 轮次中剥离 `thinking``redacted_thinking`
危险之处:**其他模型不会拒绝,而是静默忽略**——但被忽略的块**照样计入输入 token**。中转站做模型路由/故障转移时如果不清理,用户会为完全无用的内容付费,且不会收到任何错误提示。
### 对中转站的含义
好消息比我最初判断的多:signature 是 opaque 且跨平台通用的 blob,中转站**只需原样搬运,不需要理解它**。
可行的做法:
- 网关侧按会话存储上游返回的原始 content block 数组(thinking 的 `signature` 和 redacted 的 `data` 作为 opaque blob),下游只暴露脱敏后的摘要文本;下一轮用存储的原始块重建上游请求。
- 非工具场景可以直接丢弃 thinking 块,合法且省上下文。
- **绝不能**用"把 thinking 文本转成 `reasoning_content` 字符串再转回来"的方式——转回来的是摘要文本、且没有 signatureAnthropic 侧必然 400 或丢失推理。
---
## 6. Prompt Caching 的怪癖
缓存前缀层级顺序固定:**`tools``system``messages`**。改动某一层会使该层**及之后所有层**失效。
| 改动 | tools 失效 | system 失效 | messages 失效 |
| --- | --- | --- | --- |
| 工具定义 | ✘ | ✘ | ✘ |
| web search / citations 开关 | ✓保留 | ✘ | ✘ |
| speed 设置 | ✓保留 | ✘ | ✘ |
| `tool_choice` | ✓保留 | ✓保留 | ✘ |
| 增删图片 | ✓保留 | ✓保留 | ✘ |
| thinking 配置 | 模型相关 | 模型相关 | ✘ |
- **最多 4 个显式 cache breakpoint**。顶层 `cache_control` 的自动缓存**占用 1 个槽位**,所以自动+显式并用时显式最多 3 个。
- **最小可缓存 token 数按模型不同**,低于阈值时静默不缓存、**不报错**:
| 模型 | 最小 token |
| --- | --- |
| Opus 5 / Fable 5 / Mythos 5 | 512 |
| Opus 4.8 / Sonnet 5 / Sonnet 4.6 / Sonnet 4.5 / Opus 4.1 | 1,024 |
| Opus 4.7 / Mythos Preview / Haiku 3.5 | 2,048 |
| Opus 4.6 / Opus 4.5 / Haiku 4.5 | 4,096 |
- TTL:默认 `5m`,可设 `{"type":"ephemeral","ttl":"1h"}`。**1h 的断点必须出现在 5m 断点之前**。
- 定价倍率(相对基础输入价):5m 写入 **1.25x**1h 写入 **2.0x**,缓存读取 **0.1x**
- 缓存查找**最多回溯 20 个 block**,超出则必须重建缓存。
- 并发请求时,缓存条目要等第一个响应**开始**后才可用——同时打多个相同前缀的请求不会共享缓存。
- `thinking` block 不能显式打 `cache_control`,但会随其他内容一起被缓存。
---
## 7. Tool use 的怪癖
- 工具定义用 **`input_schema`**(不是 OpenAI 的 `parameters`),且没有 `function` 外层包装:`{name, description, input_schema}`
- `tool_result` 必须放在 **user 消息**的 content 数组里,通过 `tool_use_id` 关联。OpenAI 用的是独立的 `role: "tool"` 消息。
- `tool_choice` 取值:`{"type":"auto"}` | `{"type":"any"}` | `{"type":"tool","name":"x"}` | `{"type":"none"}`,另有 `disable_parallel_tool_use: true`(对应 OpenAI 的 `parallel_tool_calls: false`)。注意是 **`any`** 不是 OpenAI 的 `required`
- 错误用 `tool_result``is_error: true` 表达,不是抛异常。
- **不要在 `tool_result` 之后同一条 user 消息里追加 text block**——官方明确指出这会导致 Claude 返回空响应(`stop_reason: end_turn` 但 content 为空)。
- `strict: true` 可加在自定义工具定义上以保证 schema 一致(与 OpenAI 的 strict 语义相近,但配置位置不同)。
- **tools 会消耗额外的系统提示 token**,随模型和 `tool_choice` 而变。例如 Opus 5`auto`/`none` 为 286 tokens`any`/`tool` 为 406 tokens。中转站做 token 预估时必须计入。
- 服务端工具(`web_search` 等)会在**同一次响应内**返回结果 block,无需客户端执行;但若它和客户端工具在同一组并行调用里,行为会变(见 `pause_turn`)。
---
## 8. 其他常见 400
- **Prefill 不支持**Claude 4.6 及以后、Mythos Preview 不允许最后一条是 assistant 消息作为前缀。
```
This model does not support assistant message prefill. The conversation must end with a user message.
```
替代方案:结构化输出 / system 指令 / `output_config.format`。
这条对中转站影响很大——**DeepSeek 的 prefix completion、OpenAI 的部分模式无法映射到新 Claude 模型**。
- `temperature` 上限是 **1.0**。OpenAI 侧 0~2 的值需要 clampAnthropic 官方兼容层的做法是 >1 一律截断为 1)。
+551
View File
@@ -0,0 +1,551 @@
# zend-token API 设计
> 状态:设计草案 v0.2 · 2026-08-12
> 依据:[openai.md](./openai.md)、[anthropic.md](./anthropic.md)、[deepseek.md](./deepseek.md)、[china-providers.md](./china-providers.md)、[cross-provider-mapping.md](./cross-provider-mapping.md)
>
> v0.2 变更:单一 IR → **双规范模型**(§3);`/v1/responses` 提升为一等入口。
>
> **后续**:本文的落地层是 [transform-spec.md](./transform-spec.md)(转写方案 v0.3),它在双规范模型之上补齐了上游 Profile、改写管线与 Kimi 实测结论。以下两处已被 v0.3 修正:§8.1 的"Claude 族上游一律 opaque 搬运 signature"只对 **Anthropic 官方真机**成立(见 transform-spec §7.1 的可信度分级);§6 的能力矩阵需按**上游身份三元组**而非 provider 划分(同一家的两个入口行为可能相反)。
本文只定义**对外 API 契约与桥接语义**,不涉及技术栈、存储、部署选型。
---
## 1. 设计原则
1. **双族对等** — OpenAI 族与 Claude 族是**同等公民**,不是"主格式 + 转换层"。
2. **同族零损** — 同族内的入口/上游组合不经过跨族转换,不付出任何有损代价。
3. **有损集中** — 所有跨族有损只发生在**一处**(§5 跨族桥),集中实现、集中测试、集中记录。
4. **降级必须可见** — 上游能力不足导致的任何语义变化都要可观测。**静默忽略是各家的通病,我们不重复它**(§7)。
5. **上游怪癖不外泄** — MiniMax 的 `base_resp`、阿里的全量流、智谱的 `sensitive` 都在网关内消化。
6. **不发明第三种方言** — 扩展一律走命名空间(`ztoken` 对象 / `X-Ztoken-*` header),不污染标准字段。
---
## 2. 端点总览
| 端点 | 族 | 说明 |
| --- | --- | --- |
| `POST /v1/chat/completions` | OpenAI | 生态最广 |
| `POST /v1/responses` | OpenAI | 新接口,reasoning 能力完整 |
| `POST /v1/messages` | Claude | Claude Code 等客户端 |
| `POST /v1/messages/count_tokens` | Claude | token 预估 |
| `GET /v1/models` | 共用 | 模型列表 |
| `GET /v1/models/{model}` | 共用 | 含**能力矩阵**(本项目扩展,§6) |
认证同时接受两种 header,互为别名:
```
Authorization: Bearer <ztoken-key> # OpenAI 风格
x-api-key: <ztoken-key> # Claude 风格
```
`anthropic-version` 接受但不校验(我们只有一个版本)。
---
## 3. 双规范模型(核心)
### 3.1 为什么不用单一 IR
单一 IR 看起来更"优雅",但有个真实缺陷:**它强迫每条路径都付出一次有损转换**。
实际流量分布是高度不均的——OpenAI 入口 → OpenAI 兼容上游(DeepSeek、智谱、Kimi、豆包、混元、千帆 v2、星火、阿里兼容模式)会是绝大多数。这条路径上,源和目标本来就是同一种方言,却要绕经 block 模型往返,白白丢失顺序信息又白白重建。
改为按族划分后:
```
Chat ─┐ ┌─ DeepSeek / 智谱 / Kimi / 豆包 /
├─→ 【OaiIR】 ──┬── 同族直出 ────────┤ 混元 / 千帆v2 / 星火 / 阿里兼容
Responses ─┘ │ └─ MiniMax / DashScope 原生
╔════╧════╗
║ 跨族桥 ║ ← 唯一的有损点
╚════╤════╝
Messages ──→ 【AnthIR】┴────────────────────── Anthropic / DeepSeek-anthropic /
阿里 Anthropic 兼容
```
**同族路径零转换成本;跨族只有一处,集中攻。** 这也让"Claude 入口 → Claude 上游"这条最脆弱的路径(thinking signature 必须原样保住)天然安全——它根本不进桥。
### 3.2 OaiIR — OpenAI 族规范模型
**以 Responses 的 item 模型为基线**,因为 `Responses item ⊃ Chat message`Chat 把文本放 `content`、工具放 `tool_calls`,无法表达 reasoning 与 text 的交错顺序,Responses 的异构 `output[]` 可以。
```jsonc
{
"model": "<逻辑模型名>",
"instructions": "...", // 顶层系统指令(Chat 的 system/developer 归一到这)
"items": [ // 异构数组,顺序即语义
{"type":"message", "role":"user"|"assistant", "content":[Part]},
{"type":"function_call", "call_id":"...", "name":"...", "arguments":"<json string>"},
{"type":"function_call_output", "call_id":"...", "output":"..."},
{"type":"reasoning", "id":"...", "summary":[...], "encrypted_content":"...?"}
],
"tools": [{"type":"function","name":...,"parameters":{...},"strict":bool}],
"tool_choice": {...},
"sampling": {"max_output_tokens":int, "temperature":0..2, "top_p":..., "stop":[...],
"frequency_penalty":?, "presence_penalty":?, "seed":?, "n":1},
"reasoning": {"effort":"none|minimal|low|medium|high|xhigh|max", "summary":"auto|none"},
"text": {"format": {"type":"text"|"json_object"|"json_schema", "schema":?, "strict":?}},
"stream": bool,
"passthrough": {"<provider>": {...}}
}
```
`Part` 类型:`input_text` / `output_text` / `input_image` / `input_file` / `refusal`
**注意 `arguments` 是 JSON 字符串**而非对象——这是 OpenAI 族的原生形态,保留它可以让同族直出时零解析。
### 3.3 AnthIR — Claude 族规范模型
以 Messages 的 block 模型为基线:
```jsonc
{
"model": "<逻辑模型名>",
"system": [{"type":"text","text":"...","cache_control":?}],
"messages": [
{"role":"user"|"assistant", "content":[Block]}
],
"tools": [{"name":..., "description":..., "input_schema":{...}, "strict":?}],
"tool_choice": {"type":"auto"|"any"|"tool"|"none", "disable_parallel_tool_use":?},
"sampling": {"max_tokens":int, "temperature":0..1, "top_p":?, "top_k":?, "stop_sequences":[...]},
"thinking": {"mode":"auto"|"on"|"off", "effort":?, "budget_tokens":?, "display":"summarized"|"omitted"},
"output_config": {"format":?, "effort":?},
"stream": bool,
"passthrough": {"<provider>": {...}}
}
```
`Block` 类型:`text` / `image` / `document` / `tool_use` / `tool_result` / `thinking` / `redacted_thinking` / `server_tool_use` / 各类 `*_tool_result`
**`tool_use.input` 是对象**(不是字符串)——与 OaiIR 的 `arguments` 字符串形成明确对比,跨族时必须做 parse/stringify。这是个容易漏的点:Anthropic 流式下发的 `input_json_delta` 是部分 JSON 字符串,但终态是对象。
### 3.4 两族各自的不变量
族内归一在入口层立即完成,避免畸形约束泄漏进下游逻辑。
**OaiIR**
1. `system` / `developer` 消息全部提升到 `instructions`,按原顺序用 `\n` 拼接。
2. Chat 的 `tool` role 消息转成 `function_call_output` item。
3. Chat 的 `assistant.tool_calls[]` 拆成独立的 `function_call` item,排在该 message item 之后。
**AnthIR**
1. `role` 只有 `user` / `assistant`
2. `content` 恒为数组(裸字符串包装成 `[{type:"text"}]`)。
3. **连续同 role 消息自动合并**为一条。这一条直接消化百度 v1 的"必须严格交替"约束。
**两族共同:**
- `max_output_tokens` / `max_tokens` 缺省时由能力矩阵填默认值,并标注 `X-Ztoken-Defaulted`。理由:Anthropic 侧必填,取严格的一方可以让每个 adapter 不必各写一份兜底。
### 3.5 路由决策
```
入口族 == 上游族 → 同族直出(零转换)
入口族 != 上游族 → 过跨族桥(§5)
```
`GET /v1/models/{model}` 暴露上游族,客户端可以据此选择同族入口以避开有损。
---
## 4. 入口方言层
三个入口各自负责"方言 → 族规范模型"的归一,不做跨族的事。
### 4.1 `/v1/chat/completions` → OaiIR
按 §3.4 归一。以下字段接受但按能力矩阵处理(可能降级,§7):
`n``logprobs``top_logprobs``logit_bias``seed``frequency_penalty``presence_penalty``response_format``parallel_tool_calls``tools[].function.strict`
响应回投影为 Chat 结构时:
- `function_call` item → `message.tool_calls[]``call_id``id`
- `reasoning` item → `message.reasoning_content`(摘要文本)
- **`logprobs` 字段恒存在**(值可为 `null`)——OpenAI spec 列为 required,省略会打破严格客户端。
### 4.2 `/v1/responses` → OaiIR
近乎恒等映射(OaiIR 本就以它为基线)。需处理的:
| 字段 | 处理 |
| --- | --- |
| `input` 为 string | 包装成单个 user message item |
| `previous_response_id` | **v0.1 不支持**,返回 400 `unsupported_parameter`(我们无状态,见 §12 开放问题 1) |
| `conversation` | 同上 |
| `store` | 恒为 `false`,请求 `true` 时 L1 降级 |
| `background` | 不支持,L3 |
| `include` | 按能力矩阵过滤 |
| `context_management` | L1 丢弃(网关不做自动压缩) |
### 4.3 `/v1/messages` → AnthIR
按 §3.4 归一。`thinking` 参数接受三种历史写法并统一:
| 客户端传入 | AnthIR |
| --- | --- |
| `{"type":"enabled","budget_tokens":N}` | `mode:"on"`, `budget_tokens:N` |
| `{"type":"adaptive","display":D}` | `mode:"auto"`, `display:D` |
| `{"type":"disabled"}` | `mode:"off"` |
**这吃掉了 Anthropic 的代际地雷** —— 客户端不会再踩到"4.5 只认 enabled、4.7 只认 adaptive、Fable 5 两个都拒、Opus 5 的 disabled 还和 effort 冲突"(见 anthropic.md §5 的模型矩阵)。这是相对直连 Anthropic 的明确增值点。
`stop_reason: pause_turn` **不对外暴露**:网关内部完成服务端工具续跑循环后才返回终态。理由是 OpenAI 族无法表达它,两个族的行为必须一致。
### 4.4 扩展字段约定
三个入口统一,请求体顶层接受 `ztoken` 对象(未知子字段忽略不报错):
```jsonc
{
"ztoken": {
"thinking": {"mode":"on","effort":"high"}, // 跨族统一的思考控制
"strict_capability": false, // true = 能力不足报错而非降级
"allow_degradation": ["vision","n"], // 显式放行 L3
"passthrough": {"dashscope": {"enable_search": true}}
}
}
```
用嵌套对象而非顶层字段,是为了避免和未来的官方字段撞名。OpenAI SDK 的 `extra_body` 可直接塞它,客户端零改造。
---
## 5. 跨族桥(难点集中区)
这一节是整个设计里唯一有损的地方,所以要写得最细。
### 5.1 结构差异总览
| 维度 | OaiIR | AnthIR | 桥接难度 |
| --- | --- | --- | --- |
| 容器 | 扁平 `items[]`,工具调用是**独立 item** | `messages[].content[]`,工具调用是**消息内的 block** | 中 |
| 工具入参 | `arguments`: **JSON 字符串** | `input`: **对象** | 低(parse/stringify |
| 工具结果 | 独立 `function_call_output` item | user 消息内的 `tool_result` block | 中 |
| 工具 ID | `call_id` | `tool_use.id` / `tool_result.tool_use_id` | 低(需映射表) |
| 系统指令 | `instructions` 字符串 | `system` block 数组 | 低 |
| 推理 | `reasoning` item + `encrypted_content` | `thinking` block + `signature` | **高**(§8 |
| 温度 | 0~2 | 0~1 | 低(clamp |
| 工具错误 | 靠 output 文本表达 | `tool_result.is_error` 布尔 | 中(有损) |
### 5.2 OaiIR → AnthIR
```
instructions → system: [{type:"text", text}]
message(role=user) → messages[].role=user, content=[blocks]
message(role=assistant) → messages[].role=assistant, content=[blocks]
function_call → 并入前一条 assistant 消息的 content:
{type:"tool_use", id:call_id, name, input:JSON.parse(arguments)}
function_call_output → 新建/并入 user 消息:
{type:"tool_result", tool_use_id:call_id, content:[{type:"text"}]}
reasoning → {type:"thinking", thinking:summary, signature:<信封>}
input_text/output_text → {type:"text"}
input_image → {type:"image", source:{...}}
input_file → {type:"document", source:{...}}
refusal part → 丢弃(L2),原文记入 degraded 说明
```
**关键动作**`function_call` item 必须**回填**进它前面那条 assistant message 的 content 数组,而不是新建消息——否则会产生连续两条 assistant 消息,违反 AnthIR 不变量 3(虽然会被自动合并,但顺序可能错)。
**`arguments` 解析失败**(模型吐出非法 JSON,流式截断时常见):不要抛异常。降级为 `input: {}` 并标记 L2,把原始字符串放进 `ztoken` 诊断字段。
### 5.3 AnthIR → OaiIR
```
system[] → instructions(用 \n 拼接)
content[].text → message part(output_text / input_text)
content[].tool_use → 独立 function_call item:
{call_id:id, name, arguments:JSON.stringify(input)}
content[].tool_result → 独立 function_call_output item
content[].thinking → reasoning item{summary:[thinking], encrypted_content:<信封>}
content[].redacted_thinking → reasoning itemsummary 为空,data 进信封)
content[].image → input_image part
content[].document → input_file partPDF 可直传的上游)/ L3(不可传的)
tool_result.is_error=true → output 文本前缀 "Error: "**有损**,见 5.4
server_tool_use / *_tool_result → L3OpenAI 族无对应概念
citations → annotations(尽力而为,L2
```
### 5.4 已知有损清单
跨族时必然丢失的信息,全部记入 `X-Ztoken-Degraded`
| 丢失项 | 方向 | 级别 | 说明 |
| --- | --- | --- | --- |
| `tool_result.is_error` 语义 | A→O | L2 | OpenAI 无此字段,只能退化成文本前缀 |
| `thinking``text` 的交错顺序 | A→O(Chat) | L2 | Chat 的 message 结构表达不了;**走 Responses 入口则无损** |
| `cache_control` 断点 | O→A / A→非 Anthropic | L1 | 上游若非 Anthropic 则无处安放 |
| `top_k` | O→A 时反向缺失 | L1 | OpenAI 族无此参数 |
| `n > 1` | O→A | L3 | Anthropic 恒为 1 |
| `logprobs` | O→A | L2 | Anthropic 不支持,返回 null |
| server toolsweb_search 等) | A→O | L3 | 概念不存在 |
| `citations` 精确位置 | A→O | L2 | annotations 表达力更弱 |
| **`signature` 真实性** | A→O→A 往返 | **L3** | 见 §8.4,往返后无法发回 Anthropic 上游 |
### 5.5 禁止跨族的情形
以下组合**直接返回 400**,不做勉强转换:
1. **Claude 入口 + 请求含 `thinking` + 非 Claude 族上游 + 后续要回传** — 见 §8.4。
2. **`n > 1` 且上游为 Claude 族** — 除非 `allow_degradation``n`
3. **含 `server_tool_use` 历史的会话切到 OpenAI 族上游** — 历史无法表达,续接必然错乱。
---
## 6. 模型路由与能力矩阵
### 6.1 逻辑模型名
```jsonc
{
"id": "qwen-max",
"upstream": {
"family": "openai", // openai | anthropic —— 决定是否过桥
"provider": "dashscope",
"model": "qwen-max-latest",
"endpoint_ref": null // 火山方舟填 "ep-2024xxxx"
},
"capabilities": { ... }
}
```
**火山方舟的 Endpoint ID 是账号级资源**china-providers.md §4),所以路由表是 `(tenant, logical_model) → upstream`,全局表仅作 fallback。
模型别名支持"伪装":把 `claude-sonnet-4-6` 指向任意上游,让硬编码模型名的客户端直接可用。但**不静默兜底未知模型名**——未匹配返回 404,避免拼写错误被掩盖(DeepSeek 的 Anthropic 兼容层就是反面教材)。
### 6.2 能力矩阵
```jsonc
"capabilities": {
"family": "openai",
"streaming": true,
"streaming_incremental": true, // false = 上游返回全量累积,网关需差分(阿里)
"tools": true,
"tool_strict": false,
"parallel_tools": true,
"vision": true,
"documents": false,
"json_mode": true,
"json_schema": false,
"thinking": "always_on" | "optional" | "none",
"thinking_opaque": true, // 思考内容必须原样回传(Anthropic=true
"prefill": false, // 助手前缀续写(DeepSeek prefix / Kimi partial
"n_max": 1,
"temperature_range": [0.0, 1.0],
"max_output_tokens": 8192,
"cache": "explicit" | "automatic" | "none"
}
```
---
## 7. 能力降级协议
各家的通病是静默忽略。我们保留静默以维持生态兼容,但**强制可观测**。
### 7.1 三级降级
| 级别 | 定义 | 默认行为 |
| --- | --- | --- |
| **L1 无害** | 丢弃不改变输出语义 | 静默丢弃,仅记 header |
| **L2 降质** | 输出仍可用但质量/精度变化 | header + 结构化 warning |
| **L3 变义** | 客户端拿到的东西与请求语义不符 | **报 400**,除非显式放行 |
| 参数/能力 | 级别 |
| --- | --- |
| `seed``logit_bias``metadata``user``store` | L1 |
| `temperature` clamp、`top_k` 丢弃、penalty 丢弃、`logprobs` 返回 null | L2 |
| **`tools[].strict` 失效** | **L3** |
| **图片/文档输入被丢弃** | **L3** |
| **`n > 1` 退化为 1** | **L3** |
| **`json_schema` 降级为 `json_object`** | **L3** |
### 7.2 可观测通道
```
X-Ztoken-Upstream: dashscope/qwen-max-latest (family=openai, bridged=false)
X-Ztoken-Degraded: seed=dropped(L1), temperature=clamped(L2,2.0->1.0), strict=ignored(L3)
X-Ztoken-Defaulted: max_output_tokens=4096
```
`bridged=true/false` 让调用方知道自己是否付了跨族代价——这是选择入口的直接依据。
**严格模式**`ztoken.strict_capability: true` 时,任何 L2 及以上降级直接 400,错误体列出全部冲突项。
L3 默认报错是本设计与所有现有兼容层的最大分歧。宁可让调用方显式声明"我接受图片被丢弃",也不要让他拿到一个看起来正常、实际模型根本没看到图的响应。放行方式:
```jsonc
"ztoken": {"allow_degradation": ["vision", "n", "strict"]}
```
---
## 8. thinking / reasoning 桥接
依据 anthropic.md §5Anthropic 的 `thinking` 文本只是**摘要**,真实推理加密在 `signature` 里,**改一个字节就 400**。所以桥接必须搬运不透明数据,不是翻译文本。
### 8.1 四象限
| 入口族 | 上游族 | 策略 |
| --- | --- | --- |
| Claude | Claude | **原样透传**,不解包不重打包 —— 唯一能保住真 signature 的路径 |
| OpenAI | OpenAI | 原样透传 `encrypted_content` / `reasoning_content` |
| OpenAI | Claude | 摘要 → `reasoning_content`;真 signature 装进信封放 `encrypted_content` |
| Claude | OpenAI | 上游 `reasoning_content``thinking.thinking``signature` 填我们的信封 |
**两条同族路径都不进桥**,这正是双规范模型的最大收益。
### 8.2 信封格式
```
ztk1.<base64url(AES-GCM(payload))>
```
`payload` = 上游原始 block/item 数组 + 元数据(provider、model、family、生成时间)。客户端视其为**完全不透明**。`ztk1.` 前缀用于版本演进;解析不了的信封当作"无思考上下文"降级,不报错。
### 8.3 无状态优先
默认下发信封由客户端原样回传,网关不存储。与 OpenAI 的 `encrypted_content`、Anthropic 的 `signature` 同一思路,行业已验证。
风险是客户端裁剪未知字段。缓解:信封放在**该族的原生位置**(OpenAI 族 → `reasoning.encrypted_content`Claude 族 → `thinking.signature`),这两个位置是各自 SDK 会保留的标准字段,比自定义字段安全得多。
> 有状态 handle 模式作为可选项保留,不进 v0.2 契约。
### 8.4 A→O→A 往返的硬限制
**这是必须写进对外文档的一条。**
Claude 上游的思考经 OpenAI 族客户端往返后,回来的 `signature` 是**我们的信封**,不是 Anthropic 的原始 signature。网关可以解开信封还原原始 block —— 但前提是**信封没被裁剪**。
若客户端丢弃了 `encrypted_content`,原始 signature 就永久丢失,此时:
- 该会话**不能再发回 Anthropic 上游**(会 400 或丢失推理连续性)。
- 网关的行为:剥离全部 thinking block,标记 `X-Ztoken-Degraded: thinking=lost(L3)`,并按 §7 规则报错或放行。
因此 §5.5 规定:Claude 入口 + thinking + 非 Claude 上游 + 需回传的组合,默认拒绝。
### 8.5 三条实现硬约束
1. **切换上游模型时必须剥离 thinking block** —— 其他模型静默忽略但**照样计费**。故障转移路径尤其注意。
2. **信封只走 body,绝不走 header** —— Claude 4+ 的 signature 显著变长,header 有截断风险,截断的 signature 下一轮直接 400。
3. **不得按 `type == "thinking"` 过滤** —— 会漏掉 `redacted_thinking`,直接破坏协议。
---
## 9. 流式契约
### 9.1 对外保证
无论上游行为如何:
1. **增量语义** — 每个事件只含新增内容。上游返回全量累积时(阿里 `incremental_output=false`)网关做差分,由能力矩阵 `streaming_incremental: false` 驱动。
2. **事件顺序合法** — Claude 族保证 `message_start` → block 三段式 → `message_delta``message_stop`OpenAI 族保证首 chunk 带 role、末尾 `data: [DONE]`
3. **usage 位置固定** — Chat 下需 `stream_options.include_usage` 才发末尾 usage chunk`choices` 为空数组);Messages 下 `message_delta.usage` 为**累计值**。
4. **中途错误可表达** — §10.3。
### 9.2 跨族流式的状态机
跨族时流式比非流式难得多,因为要**在流上重建结构**:
**O→A**OpenAI chunk 无"块结束"信号,只能靠 `tool_calls[].index` 变化和 `finish_reason` 推断边界。网关需维护 block index 状态机,在文本↔工具切换时补发 `content_block_stop` / `content_block_start`
**A→O**
- `content_block_start(text)` → 吞掉(无对应)
- `content_block_start(tool_use)` → 发一个带 `id`/`name``arguments:""` 的 tool_calls chunk
- `input_json_delta.partial_json` → 追加到 `arguments`
- `thinking_delta``delta.reasoning_content`
- **`signature_delta` → 无处安放**,必须缓冲到 block 结束后装信封,随末尾 chunk 下发
- `message_delta.usage` 累计值 → 取末值,不累加
- `ping` → 丢弃或转 SSE 注释行保活
### 9.3 上游怪癖消化清单
| 上游怪癖 | 处理 |
| --- | --- |
| 阿里全量累积流 | 差分成增量 |
| 阿里需 `X-DashScope-SSE: enable` | adapter 自动附加 |
| Anthropic `fallback` block 无 delta | 解析器不假设每 block 至少一个 delta |
| Anthropic 200 后发 `error` 事件 | 转成客户端族的错误表达 |
| OpenAI `[DONE]` 非 JSON | 单独识别,不进 JSON 解析器 |
| MiniMax 200 内嵌错误 | 流首帧即检测 `base_resp`,转真实状态码 |
### 9.4 provider 透传
`ztoken.passthrough.<provider>` 在**匹配到该 provider 时**原样并入上游请求体,不匹配则丢弃。用于阿里 `enable_search``repetition_penalty` 这类无法归一的自有参数。透传字段不参与能力校验,风险由调用方承担。
---
## 10. 错误模型
### 10.1 双族错误格式
按入口族返回对应格式,**HTTP 状态码两族一致**:
```jsonc
// OpenAI 族
{"error": {"message":"...", "type":"invalid_request_error", "param":"tools[0].strict", "code":"capability_unsupported"}}
// Claude 族
{"type":"error", "error":{"type":"invalid_request_error","message":"..."}, "request_id":"req_..."}
```
两族都返回 `X-Ztoken-Request-Id`
### 10.2 上游错误归一
| 上游 | 对外 |
| --- | --- |
| MiniMax 200 + `base_resp.status_code != 0` | **翻译成真实状态**1002→429、1004→401、1008→402、1039→400 |
| DeepSeek 402 余额不足 | 429 + `insufficient_quota` |
| Anthropic 529 overloaded | 503 |
| DeepSeek 422 | 400 |
| 智谱 `finish_reason:"sensitive"` | 非错误 → 映射 `content_filter`,原值放 `ztoken.upstream_finish_reason` |
**MiniMax 这条是网关的核心价值之一**:上游用 200 表达限流会让所有标准重试/熔断中间件失效,还原成 429 后通用组件即可正常工作。
### 10.3 流式中途错误
已发 200 后无法改状态码:
- **Claude 族**:发 `event: error`(与官方一致)。
- **OpenAI 族**:发含 `error` 字段的 data chunk,随后 `data: [DONE]`。OpenAI 无官方规范,此为我们的事实约定,需写进对外文档。
---
## 11. usage 归一
两族 usage 互转按 cross-provider-mapping.md §5。**最易错的一条**
```
OpenAI prompt_tokens = Anthropic (input_tokens + cache_read_input_tokens + cache_creation_input_tokens)
```
直接用 `input_tokens` 在缓存命中时会低报到 1%。
网关自身计费**以上游原始 usage 为准**,不依赖任何投影结果。
---
## 12. 开放问题
1. **`previous_response_id` / `conversation` 要不要支持?** 支持就意味着网关有状态(要存会话)。当前设计是 400 拒绝。若要支持,建议只做"信封化"——把历史塞进加密信封由客户端持有,保持无状态。
2. **L3 默认报错**是否可接受?会让"能跑就行"的客户端在我们这里失败、在别家静默成功。我认为值得,但这是产品取向。
3. **信封是否需对客户端可解密?** 目前完全不透明(网关持密钥)。若要客户端可自查,需改成签名而非加密。
4. **`cache_control` 对非 Anthropic 上游**:接受并 L1 丢弃,还是直接拒绝?
5. **多模态是否代下载转码?** 图片 base64/url 支持度各家不一,代下载会引入出网请求和体积限制。
---
## 13. 实现顺序
1. **两个族规范模型 + 三个入口方言层**(不接上游,用 echo adapter 打通)
2. **能力矩阵 + 降级引擎**(§7,纯逻辑,可完整单测)
3. **第一个上游 adapterDeepSeek**(OpenAI 兼容,同族直出,最简单)
4. **跨族桥的非流式路径**(§5)—— 用双向往返测试保证:`O→A→O``A→O→A` 的可逆部分必须恒等
5. **流式规范化层**(§9,含阿里全量差分)—— 最易出 bug,值得先写对照测试
6. **跨族桥的流式路径**(§9.2)—— 全项目最难的一块,状态机需单独设计
7. **Anthropic adapter**thinking 原样透传路径)
8. **国产 adapter 批量接入**
第 4 步和第 6 步是这个项目的技术核心,其余都是工程量。
+281
View File
@@ -0,0 +1,281 @@
# 国产 AI Provider 的怪癖
> 采集于 2026-08-12。
>
> **可信度标注**:本文件里 ✅ 表示来自厂商官方文档页面;⚠️ 表示来自搜索结果聚合或第三方资料,**落地前需要用真实请求验证**。国产厂商文档改版频繁(本次采集就遇到 moonshot.cn → kimi.com、volcengine.com → docs.volcengine.com 两处 301),且很多"历史怪癖"在新版本里已被修掉,不要照抄老博客。
国产 API 的怪癖**和欧美三家不是一个类型**。OpenAI/Anthropic/DeepSeek 的坑主要在**语义层**thinking 怎么回传、tool_result 放哪);国产的坑主要在**协议层和运维层**:鉴权方式五花八门、HTTP 200 里藏业务错误、流式默认全量返回、内容安全是一等公民。
---
## 0. 跨厂商的共性怪癖(最重要)
这五条是中转站架构里必须提前留出的抽象层,逐个厂商去补会很痛苦。
### 0.1 HTTP 200 内嵌业务错误 ⚠️→✅
**MiniMax 是典型**:无论成功失败都返回 **HTTP 200**,真正的状态在 body 的 `base_resp.status_code`0 = 成功)。
```json
{"base_resp": {"status_code": 1002, "status_msg": "rate limit"}}
```
对中转站的直接后果:**只看 HTTP 状态码的限流检测会完全失效**——限流返回的是 200 而不是 429。重试逻辑、熔断器、计费统计全都要能读 body。
建议:网关的上游响应处理层做成"HTTP 状态码 + body 业务码"双通道判定,而不是假设 HTTP 语义可靠。
### 0.2 内容安全是一等公民
欧美三家只有 `content_filter` / `refusal` 一个笼统信号,国产普遍把内容安全做成结构化字段:
| 厂商 | 表现形式 |
| --- | --- |
| 智谱 GLM ✅ | `finish_reason: "sensitive"`(OpenAI 枚举里没有这个值) |
| MiniMax ✅ | `input_sensitive` / `output_sensitive` 布尔,外加 `input_sensitive_type` / `output_sensitive_type`17 的违规分类),还有 `mask_sensitive_info` 参数 |
`finish_reason: "sensitive"` 会直接**打破 OpenAI 客户端的枚举校验**——严格的 SDK(尤其是 Go/Rust 这类强类型的)会反序列化失败。中转站必须把它映射成 `content_filter`,同时用扩展字段保留原值。
### 0.3 鉴权方式没有一个统一的
| 方式 | 厂商 |
| --- | --- |
| `Authorization: Bearer <key>` | 智谱(现行)✅、Kimi ✅、豆包/火山方舟 ✅、腾讯混元兼容端点 ✅、DeepSeek |
| Bearer + 额外 header | 百度千帆 v2`Bearer bce-v3/ALTAK-...` + `appid` header)⚠️ |
| JWT 签名(key 形如 `{id}.{secret}`,需自行签发带 exp 的 token) | 智谱(历史方式,现在仍支持)⚠️ |
| `access_token` 放 query string(用 API Key + Secret Key 先换取) | 百度千帆 v1 ⚠️ |
| TC3-HMAC-SHA256 云厂商签名 | 腾讯混元原生接口 ✅ |
| HMAC-SHA256 签名拼进 **WebSocket URL** | 讯飞星火原生接口 ✅ |
| `AK:SK` 拼接格式 | 讯飞星火 OpenAI 兼容端点 ⚠️ |
中转站的凭据存储不能假设"一个 key 一个字符串"——至少要支持 (key, secret, appid, region) 四元组,且签名逻辑要能按 provider 插拔。
### 0.4 流式默认可能是全量累积
见 §1.2 的阿里 `incremental_output`。这是**最容易在生产上炸**的一条:不设参数时每个 SSE chunk 返回的是**从头累积的完整文本**,直接按 OpenAI 语义拼接会得到指数级重复的内容。
### 0.5 非标参数塞 `extra_body`
国产厂商的 OpenAI 兼容模式普遍保留一批自有参数(思考开关、联网搜索、重复惩罚),官方推荐用 OpenAI SDK 的 `extra_body` 透传。中转站需要一个**参数白名单 + 透传通道**,而不是严格按 OpenAI schema 校验后丢弃未知字段。
---
## 1. 阿里 · 通义千问 / 百炼(DashScope)✅
> 来源:https://help.aliyun.com/zh/model-studio/use-qwen-by-calling-api 、https://help.aliyun.com/zh/model-studio/qwen-api-via-dashscope 、https://www.alibabacloud.com/help/zh/model-studio/qwen-api-via-openai-chat-completions
### 1.1 四套接口并存
百炼同时提供**四种**入口,这本身就是中转站的最佳参考:
1. **OpenAI 兼容 Chat Completions** — 迁移成本最低
2. **OpenAI 兼容 Responses** — 内置联网搜索、代码解释器、网页提取,自动管理对话历史
3. **Anthropic 兼容 Messages** — 支持 thinking 和工具调用
4. **DashScope 原生** — "提供最完整的功能集和参数支持"
**功能集不等价**:原生接口是超集。做中转站时若只对接 OpenAI 兼容模式,会拿不到部分能力。
### 1.2 `incremental_output`:最大的坑 ⚠️
DashScope 原生流式下:
| 取值 | 行为 |
| --- | --- |
| `true` | 每个 chunk 只含**新增**内容(OpenAI 语义) |
| `false`(部分场景默认) | 每个 chunk 返回**从头累积的完整文本** |
如果按 OpenAI 的"delta 累加"逻辑处理 `incremental_output=false` 的流,输出会变成 `我我是我是通我是通义…` 这样的雪球。
**中转站务必显式传 `incremental_output=true`,不要依赖默认值。**
### 1.3 原生接口是嵌套结构
```jsonc
// DashScope 原生
POST /api/v1/services/aigc/text-generation/generation
{
"model": "qwen-plus",
"input": { "messages": [...] }, // ← 嵌套
"parameters": { "temperature": 0.7, "result_format": "message" }
}
// 响应:output.choices[].message ← 比 OpenAI 多一层 output
```
`result_format` 取值 `text` / `message`——**取 `text` 时响应结构完全不同**(不是 choices 数组)。中转站应固定用 `message`
### 1.4 其他
- **原生 HTTP 流式必须加 header `X-DashScope-SSE: enable`** ⚠️,只设 `stream` 参数不生效。
- **base_url 按地域 + workspace 分片** ✅:`https://{WorkspaceId}.{region}.maas.aliyuncs.com/compatible-mode/v1`region 含 cn-beijing / ap-southeast-1 / cn-hongkong / eu-central-1 / ap-northeast-1,另有 `https://dashscope-us.aliyuncs.com/compatible-mode/v1`。中转站的上游配置不能是单一 URL 常量。
- OpenAI 兼容模式下需走 `extra_body` 的非标参数 ✅:`enable_thinking``thinking_budget``top_k``repetition_penalty`0.012.0)、`enable_search` / `search_options``preserve_thinking` / `clear_thinking``enable_code_interpreter``tool_stream``vl_high_resolution_images`
- **QwQ 等思考模型需要流式输出** ✅,非流式体验会因长推理阶段而劣化。
- `seed` 范围 `[0, 2³¹-1]`,与 OpenAI 的 int64 范围不同 ⚠️。
---
## 2. 智谱 GLMBigModel)✅
> 来源:https://docs.bigmodel.cn/api-reference/模型-api/对话补全
端点:`POST https://open.bigmodel.cn/api/paas/v4/chat/completions`(注意路径是 `/api/paas/v4/`,不是 `/v1/`
| 怪癖 | 详情 |
| --- | --- |
| **`do_sample`** | 布尔,默认 `true`。为 `false` 时走贪心解码,**`temperature` / `top_p` 全部失效**。OpenAI 无此参数——中转站收到 `temperature: 0` 时应考虑翻译成 `do_sample: false` 而不是直接透传 |
| **`temperature` 范围 `[0.0, 1.0]`** | 不是 OpenAI 的 0~2,超出需 clamp |
| **`top_p` 范围 `[0.01, 1.0]`,默认 0.95** | 下界不是 0,`top_p: 0` 会被拒 |
| **`finish_reason: "sensitive"`** | 内容被安全拦截。**不在 OpenAI 枚举内**,会打破严格客户端 |
| `thinking` | `{"type": "enabled"\|"disabled", "clear_thinking": bool}`,仅 GLM-4.5+ |
| `reasoning_content` | 思维链输出字段(DeepSeek 风格的事实标准) |
| 工具上限 | 最多 128 个 function,另支持 web_search / 知识库检索 / MCP 工具 |
| 鉴权 | 现行推荐 `Authorization: Bearer <api_key>`;**历史 JWT 方式仍支持** ⚠️——API Key 形如 `{id}.{secret}`,需按点号拆分后签发含 `api_key`/`exp`/`timestamp` 的 JWT |
默认模型为 `glm-5.2`。流式同样以 `data: [DONE]` 结束。
---
## 3. 月之暗面 KimiMoonshot)✅
> **已拆分为独立文件:[kimi.md](./kimi.md)**2026-08-13 全量重采集,**并用真实 API key 实测校验**)。本节仅保留跨厂商对比时用得上的摘要,细节以那边为准——尤其 §14 列出了 10 条被实测推翻的官方文档结论。
>
> ⚠️ **域名分层**:文档站/控制台 `platform.moonshot.cn` → `platform.kimi.com`301),但 **API 数据面仍是 `api.moonshot.cn`**,没有迁到 `api.kimi.com`。另有国际站 `platform.kimi.ai` / `api.moonshot.ai`**账号与 key 与国内站完全隔离**。
| 怪癖 | 详情 |
| --- | --- |
| **采样参数硬 400**(实测) | K3 / K2.7-code 把 `temperature`(1.0) / `top_p`(0.95) / `n`(1) / 两个 penalty 写死,**传其他值 400 而不是静默忽略**(实测 `invalid temperature: only 1 is allowed for this model`)。而 moonshot-v1 的 `temperature` 默认是 **0**、上限是 **1**OpenAI 是 2),且 **`n>1` 要求 temperature>0**(隐藏耦合,文档未提) |
| **同参数两入口行为相反**(实测) | 同一个 `temperature=0.7`OpenAI 入口 **400**,自家 Anthropic 入口 **200 静默忽略**。但 `tool_choice` 的限制两边一致——**是选择性的不一致**,不能"打不通就换入口" |
| **流式 `usage` 位置可控**(实测) | 不传 `stream_options.include_usage` 时藏在 **`choices[0].usage`**OpenAI SDK 的 `chunk.usage` 恒为 `None`,会漏计费);**传 `include_usage:true` 则回到顶层 + `choices:[]`,与 OpenAI 一致**。中转站补这个参数即可,无需改写响应 |
| **Partial Mode(前缀续写)** | 在 messages 末尾加 `{"role": "assistant", "content": "...", "partial": true}`,模型从该前缀继续。**字段名是 `partial`DeepSeek 叫 `prefix`** —— 同一能力三家三个名字(另见 Anthropic 的 prefill,且 Claude 4.6+ 已禁用) |
| **`n` 最多 5** | 且**仅 Moonshot V1 系列支持**,新模型固定为 1 |
| 思考模型分裂成两套参数 | K3 用顶层 `reasoning_effort``low`/`high`/`max`,默认最贵的 `max`);K2.6/K2.5 用 `thinking` 对象;K2.7-code 用 `thinking` 但只接受 `"enabled"`(实测关闭 → 400)。⚠️ **文档称 K3 恒开不可关,实测 `thinking:{"type":"disabled"}` 有效**`prompt_tokens` 88→21,思考模式带约 67 token 隐藏 system prompt)。⚠️ `reasoning_effort``"banana"` 也返回 200——**枚举校验形同虚设,非法值静默跑默认的 max 档** |
| `reasoning_content` 明文回传 | 明文(非 Anthropic 那种加密 signature)。⚠️ **文档称 K3/K2.7-code 多轮缺失会报错,实测不报错**200 正常继续) |
| `tool_choice` 与思考互斥 | 指定具体函数 + 思考开启 → **400 `tool_choice 'specified' is incompatible with thinking enabled`**(两个入口一致)。⚠️ **但关闭思考后再指定函数 → 200 且模型不调用工具、直接幻觉出假数据**——静默错误比 400 更危险,不要把关思考当变通方案 |
| tool_call id 可预测 | 格式是 **`{函数名}_{序号}`**(如 `get_weather_0`),不是 OpenAI 的随机串。多轮同名函数会**重复 id**,用它做去重键会误判 |
| 多模态**不收 http URL** | 只接受 base64 data URI 和 `ms://<file_id>`。与 OpenAI/Anthropic 的硬性差异 |
| 动态工具加载 | 工具可声明在 `{"role":"system","tools":[...]}`**无 `content` 字段**)里,位置决定可见轮次。仅 K3 支持,其他模型报 `tokenization failed` |
| `finish_reason` | `stop` / `length` / `tool_calls`,与 OpenAI 一致,**没有 `sensitive`**——内容安全走 400 `content_filter`,比多数国产厂商干净 |
| 错误体缺字段 + 三种格式 | OpenAI 入口只有 `error.type`/`error.message`**无 `code`/`param`**);Anthropic 入口多 `request_id` 和顶层 `type`**路径写错会掉到网关层**,返回 `{"code":5,"error":"url.not_found","message":"没找到对象","ua":"curl/8.7.1",...}`——连 `error` 字段的类型都从 object 变成 string |
| 三种 429 语义相反 | `engine_overloaded_error`(退避重试)/ `exceeded_current_quota_error`(欠费,重试无用)/ `rate_limit_reached_error`(降并发)。只看状态码的通用重试会在欠费时打爆上游 |
| **错误信息泄漏账号标识** | 429 的 message 原文含 `Your account org-xxx<ak-xxx> request reached organization max RPM: 3`——**org id 和 key 标识直接暴露**。响应头另有 `msh-org-id`/`msh-uid`/`msh-project-id`。中转站原样透传 = 把自己的账号广播给所有下游用户 |
| 无 `x-ratelimit-*` 头 | 只在 429 时给 `retry-after` / `x-retry-after` / `msh-cooldown-seconds`(同一信息重复三份)。**成功时无法得知剩余配额**,配额管理只能本地计数 |
| **两个产品两套栈** | `platform``api.moonshot.cn`key `sk-`)与 **Kimi For Coding**`api.kimi.com/coding`key `sk-kimi-`**key 互不通用**,模型名(`kimi-k3` vs `k3`)、Anthropic 入口路径、限流、错误语义、tool_call id 格式全不同。⚠️ **coding 侧几乎不校验 `model`**:传 `gpt-4`/空字符串都返回 200 并原样回显,只有带方括号的名字被拒(且返回 **401** 而非 404)。详见 [kimi.md](./kimi.md) §17 |
无状态 API,多轮需自行拼接历史。
**context caching 已查证(原"需另行查证"结论作废)**:早期的独立 cache 对象 API 已被移除,**现行是全自动前缀缓存**——无需创建或引用 cache id。⚠️ 文档称"prompt > 256 token 才命中"**实测 21 token 也完整命中**。命中量同时报在 `usage.cached_tokens`Kimi 私有)和 `usage.prompt_tokens_details.cached_tokens`(OpenAI 兼容,**中转站直接用后者即可**);未命中时字段不出现而非返回 0。K3 命中 ¥2/1M、未命中 ¥20/1M,**10 倍价差**意味着中转站任何对 prompt 前缀的改写都会带来 10 倍成本。
---
## 4. 字节 · 豆包 / 火山方舟(Ark)✅
> 来源:https://doubao.apifox.cn/265892759e0 、搜索结果
- Base URL`https://ark.cn-beijing.volces.com/api/v3`OpenAI SDK 直接可用。
- **`model` 字段的历史怪癖** ⚠️:早期必须先在控制台创建"推理接入点"拿到 **Endpoint ID(形如 `ep-2024...`** 填进 `model`,而不是模型名。现在也支持直接填模型名(如 `doubao-pro-32k-240615`)。
中转站的模型别名表必须允许"一个逻辑模型名 → 一个 ep-xxx 字符串"的映射,且**不同用户的 ep id 不同**(接入点是账号级资源)。这意味着模型路由表可能需要**按租户隔离**,而不是全局共享一张表。
- usage 中含缓存相关明细字段。
- ⚠️ 抓取到的 OpenAPI 规范页**未列出 thinking 参数和完整限制**,深度思考模型(doubao-1.5-thinking 等)的参数需另查。
---
## 5. 百度 · 文心 / 千帆 ⚠️
> 来源:https://ai.baidu.com/ai-doc/WENXINWORKSHOP/0m2vwrjwsv2 通告,✅)+ 搜索结果(v1 约束,⚠️)
百度是"新旧两代差异最大"的一家。
### v1(历史,仍在运行)的经典怪癖 ⚠️
- **每个模型一个独立 URL**,模型名在路径里,没有 `model` 参数。
- **鉴权用 `access_token` 放 query string**:先用 API Key + Secret Key 调 `grant_type=client_credentials` 换取。
- **`messages` 必须是奇数条**。
- **role 必须 user / assistant 严格交替**:奇数位是 `user`(或 `function`),偶数位是 `assistant`,且**第一条不能是 `function`**。
- `system` 是**独立顶层字段**,不能放进 messages(和 Anthropic 一样)。
这套约束意味着**任何带连续同角色消息的 OpenAI 请求都无法直通 v1**,中转站必须做消息合并(连续 user 合并成一条)。
### v2(现行,已 OpenAI 兼容)✅
- 端点统一为 `https://qianfan.baidubce.com/v2/chat/completions`,用 `model` 参数选模型。
- 鉴权改为 **IAM Bearer Token**`Authorization: Bearer bce-v3/ALTAK-...`,并需要额外的 `appid` header ⚠️。
- 官方称"完全兼容 OpenAI SDK"。
- v1 在过渡期内继续可用,TPM 配额在两个版本间共享。
**中转站建议直接对接 v2**,但要注意 v2 是否仍保留 messages 交替约束——通告未明确说明,需实测验证。
---
## 6. 腾讯混元 ✅
> 来源:https://cloud.tencent.com/document/product/1729/111007
两套接口差异极大:
### 原生接口
- **腾讯云 TC3-HMAC-SHA256 签名**(云厂商通用签名 v3),需要多步 HMAC 派生密钥(SecretDate → SecretService → SecretSigning)。POST 支持最大 10MB。
- ⚠️ 字段命名是 **PascalCase 大驼峰**`Messages` / `Role` / `Content`),不是 OpenAI 的 snake_case。这是腾讯云全平台 API 的统一风格。
### OpenAI 兼容接口(推荐)
- Base URL`https://api.hunyuan.cloud.tencent.com/v1`
- 直接用 API Key + `Authorization: Bearer`OpenAI SDK 无需改代码。
**中转站结论**:走兼容端点,不要碰 TC3 签名——那套签名实现成本高且容易在时钟偏移、URL 编码细节上出错。
---
## 7. MiniMax ✅
> 来源:https://platform.minimax.io/docs/api-reference/text-post
| 怪癖 | 详情 |
| --- | --- |
| **端点路径非标** | `https://api.minimax.io/v1/text/chatcompletion_v2`**不是** `/v1/chat/completions` |
| **HTTP 200 + `base_resp`** | 业务错误也返回 200,状态在 `base_resp.status_code`0 = 成功) |
| 业务状态码 | `1002` 限流 / `1004` 鉴权失败 / `1008` 余额不足 / `1039` token 超限 / `2013` 参数无效 |
| 内容安全字段 | `input_sensitive``output_sensitive``input_sensitive_type` / `output_sensitive_type`17 分类)、`mask_sensitive_info` |
| `temperature` 范围 | 01,非 OpenAI 的 02 |
| `top_p` 默认 | 0.95 |
| `response_format` | JSON schema **仅 MiniMax-Text-01 支持** |
| 特有响应字段 | `reasoning_content``audio_content``correct_rate`JSON schema 模式下的置信度) |
| usage | 单独统计 `reasoning_tokens` |
`base_resp` 这一条值得再强调:**限流不返回 429**,任何依赖 HTTP 状态码的重试/熔断中间件在 MiniMax 上都是失效的。
---
## 8. 讯飞星火 ✅
> 来源:https://www.xfyun.cn/doc/spark/ 系列页面
- **原生接口是 WebSocket,不是 HTTP** ✅。鉴权方式是用 APPID + APIKey + APISecret 做 HMAC-SHA256,把签名**拼进 WebSocket 连接 URL** 里。这在中转站架构里是完全异类——需要独立的连接管理、心跳、重连逻辑,无法复用 HTTP 连接池。
- **OpenAI 兼容 HTTP 端点**`https://spark-api-open.xf-yun.com/v1`,用 APIPassword 认证;最新的 Spark-X2 还支持 `AK:SK` 拼接格式 ⚠️。
**中转站结论**:只对接 OpenAI 兼容 HTTP 端点。WebSocket 原生接口的接入成本远高于收益,除非需要它独有的能力(如实时语音)。
---
## 9. 给中转站的设计清单
从上面这些怪癖倒推,网关需要的抽象层:
1. **双通道错误判定** — HTTP 状态码 + body 业务码(MiniMax `base_resp`)。不能假设 HTTP 语义可靠。
2. **可插拔签名器** — Bearer / JWT 自签 / query token / TC3-HMAC / WebSocket 签名 URL 至少五种。凭据模型要支持 (key, secret, appid, region) 而非单字符串。
3. **流式规范化层** — 统一处理"全量累积 vs 增量"(阿里 `incremental_output`)、`[DONE]` 有无、SSE header 要求(`X-DashScope-SSE`)。**这一层是国产接入的核心价值**。
4. **finish_reason 枚举映射**`sensitive``content_filter`,并用扩展字段保留原值,避免打破强类型客户端。
5. **非标参数透传白名单**`extra_body` 风格的旁路通道,而不是按 OpenAI schema 严格校验后丢弃。
6. **租户级模型路由表** — 火山方舟的 Endpoint ID 是账号级资源,模型别名映射不能全局共享。
7. **多地域 base_url** — 阿里按 region + workspace 分片,上游地址不能是常量。
8. **消息结构归一化** — 连续同角色消息合并(百度 v1 交替约束、Anthropic 单 system),作为通用前置处理。
---
## 10. 未覆盖 / 待验证
- **零一万物 Yi、百川 Baichuan、阶跃星辰 StepFun、商汤日日新** — 未收集。
- **火山方舟深度思考模型的 thinking 参数** — 官方 OpenAPI 页未列出,需另查。
- ~~**Kimi 的 context caching**~~ — ✅ 已查证(2026-08-13):早期的独立 cache 对象 API 已移除,现为全自动前缀缓存,见 [kimi.md](./kimi.md) §10。Kimi 的其余待验证项已转入 [kimi.md](./kimi.md) §14。
- **百度 v2 是否仍保留 messages 奇数/交替约束** — 通告未明说,**必须实测**。
- 所有标 ⚠️ 的条目 — 来自搜索结果聚合,建议用真实请求逐条验证后再固化进代码。
+129
View File
@@ -0,0 +1,129 @@
# 官方兼容层的做法(现成参考实现)
各家自己都做了"接受他家格式"的兼容层。它们踩过的坑和做出的取舍,正好是中转站的设计参考——尤其是**"忽略 vs 报错"的边界划在哪里**。
> 来源:
> - https://platform.claude.com/docs/en/api/openai-sdk
> - https://api-docs.deepseek.com/guides/anthropic_api
> - https://api-docs.deepseek.com/guides/responses_api
>
> 采集于 2026-08-12。
---
## 1. Anthropic 的 OpenAI SDK 兼容层
Base URL`https://api.anthropic.com/v1/`,用 Anthropic API key,模型名填 Claude 模型名。
官方定位明确写着:**主要用于测试和能力对比,不建议作为长期生产方案**。
### 核心行为差异
1. **`strict` 被忽略** —— 工具调用 JSON 不保证符合 schema。要严格保证得用原生 Structured Outputs。
2. **音频输入被忽略并从输入中剥离**(不报错)。
3. **Prompt caching 不支持**(原生 SDK 支持)。
4. **system/developer 消息被提升并拼接**:所有 system/developer 消息用**单个 `\n`** 连接成一条,放在对话最前面。
5. **大部分不支持的字段静默忽略,不报错。**
### 参数支持表
| 支持 | 部分支持 | 忽略 |
| --- | --- | --- |
| `model``max_tokens``max_completion_tokens``stream``stream_options``top_p``parallel_tool_calls` | `stop`(仅非空白停止序列)<br>`temperature`0~1**>1 截断为 1**<br>`n`**必须恰为 1** | `logprobs``top_logprobs``metadata``response_format``prediction``presence_penalty``frequency_penalty``seed``service_tier``audio``logit_bias``store``user``modalities``reasoning_effort` |
消息字段层面:
| 位置 | 忽略的字段 |
| --- | --- |
| 所有 role | `name` |
| user + `image_url` | `detail` |
| user | `input_audio``file` 类型 content |
| assistant | `refusal` content、`audio``refusal` |
| tools | `function.strict` |
### 响应字段
| 恒定行为 | 字段 |
| --- | --- |
| 长度恒为 1 | `choices[]` |
| 恒为空 | `usage.completion_tokens_details``usage.prompt_tokens_details``choices[].message.refusal``choices[].message.audio``logprobs``service_tier``system_fingerprint` |
### Header
支持 `x-ratelimit-*` 全套、`retry-after``request-id``authorization`
`openai-version` 恒为 `2020-10-01``openai-processing-ms` 恒为空。
### 错误
错误**格式**与 OpenAI 一致,但**错误消息内容不等价**。官方明确说明:只用于日志和调试,**不要基于错误消息文本做逻辑判断**。
### thinking 的传法
通过 `extra_body` 透传:
```python
response = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Who are you?"}],
extra_body={"thinking": {"type": "enabled", "budget_tokens": 2000}},
)
```
**OpenAI SDK 不会返回 Claude 的详细思考过程**——要拿到思考内容必须用原生 API。
---
## 2. DeepSeek 的 Anthropic 兼容层
Base URL`https://api.deepseek.com/anthropic`(配置 `ANTHROPIC_BASE_URL`)。
### 模型名映射(静默兜底)
| 传入 | 实际使用 |
| --- | --- |
| `claude-opus*` | `deepseek-v4-pro` |
| `claude-haiku*` / `claude-sonnet*` | `deepseek-v4-flash` |
| 其他任意值 | `deepseek-v4-flash` |
**未知模型名不报错**。这是个值得商榷的设计——用户拼错模型名会得到一个成功但非预期的响应。
### 支持 / 忽略
| 支持 | 忽略或不支持 |
| --- | --- |
| `model``max_tokens``system``stream``stop_sequences``tools` | `anthropic-beta` / `anthropic-version` header |
| `temperature`**0.0~2.0**,比官方 Anthropic 的 0~1 宽) | image / document content 类型 |
| `thinking`(但 **`budget_tokens` 被忽略** | `top_k` |
| `metadata.user_id`(用于限流隔离) | 其他 metadata 字段 |
### 思考参数命名不一致
DeepSeek 的 Anthropic 入口用 **`reasoning.effort`**,而官方 Anthropic API 用 `thinking` + `output_config.effort`
换句话说:**同一段客户端代码不能同时对接官方 Anthropic 和 DeepSeek 的 Anthropic 入口**。
---
## 3. DeepSeek 的 Responses API 兼容层
Base URL`https://api.deepseek.com`,目前**只支持 `deepseek-v4-flash`**。
- **"未支持的参数一律静默忽略,不报错"** —— 官方明文的设计原则,理由是不破坏既有实现。
- `store` 恒返回 `false`
- `previous_response_id``conversation` 不支持(无状态架构)。
- 上下文缓存自动进行,无手动参数。
- **image / file 输入被替换成占位文本**。这是"静默忽略"里最危险的一种:请求成功、响应看起来正常,但模型根本没看到图片。
---
## 4. 对本项目的设计启示
1. **"静默忽略"是行业默认做法**,但它把调试成本转嫁给用户。建议:
- 默认静默忽略(保持生态兼容);
- 同时通过响应 header(如 `x-gateway-dropped-params`)或可选的严格模式(`?strict=1`)暴露被丢弃的参数。
- **对会改变语义的丢弃(图片、音频、工具 strict)必须有更强的信号**,不能和 `seed` 这种无害丢弃一视同仁。
2. **模型名兜底要谨慎**。DeepSeek 的"未知名字 → flash"会掩盖拼写错误。建议白名单 + 显式别名表,未匹配则 404。
3. **错误消息不要求等价,但格式必须等价**。Anthropic 兼容层的态度是对的:格式对齐,消息内容不承诺。
4. **能力降级要分级**
- 无害(`seed``logit_bias``metadata`)→ 静默丢
- 影响质量(`temperature` clamp、`top_k` 丢弃)→ 记录日志
- 改变语义(图片被丢、`strict` 失效、`n>1` 退化)→ 报错或强提示
+181
View File
@@ -0,0 +1,181 @@
# 跨 Provider 映射表
本文件把三家的字段与语义放在一起对照,标注**哪些能无损映射、哪些必然有损**。数据来源见 [openai.md](./openai.md)、[anthropic.md](./anthropic.md)、[deepseek.md](./deepseek.md) 各自的来源列表。
---
## 1. 端点与鉴权
| | OpenAI Chat | OpenAI Responses | Anthropic Messages | DeepSeek |
| --- | --- | --- | --- | --- |
| 路径 | `/v1/chat/completions` | `/v1/responses` | `/v1/messages` | `/chat/completions` |
| 鉴权 | `Authorization: Bearer` | 同左 | **`x-api-key`** | `Authorization: Bearer` |
| 版本 header | 无 | 无 | **`anthropic-version: 2023-06-01` 必需** | 无 |
---
## 2. 请求参数
| 语义 | OpenAI Chat | OpenAI Responses | Anthropic | DeepSeek |
| --- | --- | --- | --- | --- |
| 消息列表 | `messages` | `input` | `messages` | `messages` |
| 系统提示 | `messages[role=system\|developer]`(可多条、可在任意位置) | `instructions`(顶层) | **`system`(顶层,只有一条)** | `messages[role=system]` |
| 输出上限 | `max_completion_tokens``max_tokens` 已废弃) | `max_output_tokens` | **`max_tokens`(必填)** | `max_tokens` |
| 温度 | `temperature` 0~2 | 同左 | **`temperature` 0~1** | `temperature` 0~2 |
| top-p | `top_p` | `top_p` | `top_p` | `top_p` |
| top-k | ✗ | ✗ | `top_k` | ✗ |
| 停止序列 | `stop`(≤4 | — | `stop_sequences` | `stop`(≤16 |
| 频率惩罚 | `frequency_penalty` | — | ✗ | **已废弃** |
| 存在惩罚 | `presence_penalty` | — | ✗ | **已废弃** |
| 多候选 | `n`1~128 | ✗ | ✗ | ✗ |
| JSON 模式 | `response_format` | `text.format` | `output_config.format` | `response_format`(仅 `json_object` |
| 工具 | `tools[].function.parameters` | `tools[].parameters` | **`tools[].input_schema`** | 同 OpenAI Chat |
| 工具选择 | `tool_choice`: `auto`/`required`/`none`/具体 | 同左 | `tool_choice`: `auto`/**`any`**/`none`/`tool` | 同 OpenAI Chat |
| 禁并行工具 | `parallel_tool_calls: false` | 同左 | `tool_choice.disable_parallel_tool_use: true` | `parallel_tool_calls` |
| 推理开关 | `reasoning_effort` | `reasoning.{effort,summary}` | `thinking` + `output_config.effort` | `thinking.{type,reasoning_effort}` |
| 缓存控制 | `prompt_cache_key` / `prompt_cache_options.ttl` | 同左 | `cache_control` 断点 | **无(全自动)** |
| 用户标识 | `safety_identifier``user` 已废弃) | 同左 | `metadata.user_id` | `user_id`(≤512,限字符集) |
### 必然有损的转换
- **多条 system 消息 → Anthropic**:只能合并。Anthropic 官方兼容层的做法是把所有 system/developer 消息用**单个换行 `\n`** 连接成一条,放在最前。
- **`n > 1` → 任何非 OpenAI 上游**:无法实现,只能拒绝或退化为 `n=1`
- **`frequency_penalty` / `presence_penalty` / `logit_bias` / `seed` → Anthropic 或 DeepSeek**:无对应,只能忽略。
- **`temperature` OpenAI(0~2) → Anthropic(0~1)**:需 clamp。官方兼容层的选择是 **>1 一律截为 1**(而不是线性缩放)。
- **`top_k` → OpenAI**:无对应,只能丢弃。
---
## 3. 消息与内容块
| 语义 | OpenAI Chat | Anthropic |
| --- | --- | --- |
| 纯文本 | `content: "..."``[{type:"text", text}]` | `content: "..."``[{type:"text", text}]` |
| 图片 | `{type:"image_url", image_url:{url, detail}}` | `{type:"image", source:{type:"base64"\|"url", media_type, data\|url}}` |
| 图片 detail | `detail: low/high/auto` | **无对应,丢弃** |
| PDF/文档 | `{type:"file"}` | `{type:"document", source:{...}, citations}` |
| 音频输入 | `{type:"input_audio"}` | **不支持** |
| 助手工具调用 | `assistant.tool_calls[]` | `content` 里的 `tool_use` block |
| 工具结果 | **独立 `{role:"tool", tool_call_id, content}` 消息** | **user 消息 content 里的 `tool_result` block** |
| 工具错误 | 靠 content 文本表达 | `tool_result.is_error: true` |
| 推理内容 | `reasoning` itemResponses| `thinking` / `redacted_thinking` block |
| 引用 | `annotations` | `citations`5 种 location 类型) |
### 工具 ID 三套命名
| | 调用侧 | 结果侧 |
| --- | --- | --- |
| OpenAI Chat | `tool_calls[].id` | `tool.tool_call_id` |
| OpenAI Responses | `function_call.call_id` | `function_call_output.call_id` |
| Anthropic | `tool_use.id``toolu_` 前缀) | `tool_result.tool_use_id` |
中转站需要维护 ID 映射表,不能假设上游会接受下游生成的 ID 格式。
---
## 4. 停止原因
| 语义 | OpenAI `finish_reason` | Anthropic `stop_reason` | DeepSeek `finish_reason` |
| --- | --- | --- | --- |
| 自然结束 | `stop` | `end_turn` | `stop` |
| 达到上限 | `length` | `max_tokens` | `length` |
| 命中停止序列 | `stop` | `stop_sequence` | `stop` |
| 调用工具 | `tool_calls` | `tool_use` | `tool_calls` |
| 内容过滤 | `content_filter` | `refusal`+ `stop_details.category` | `content_filter` |
| 上下文溢出 | 无(表现为 400) | `model_context_window_exceeded` | 无 |
| 服务端工具暂停 | 无 | **`pause_turn`** | 无 |
| 资源不足 | 无 | 无 | **`insufficient_system_resource`** |
**注意**`stop``stop_sequence` 在 OpenAI 侧合并成一个值,Anthropic → OpenAI 方向可无损降级;反向则丢失信息。`pause_turn` 在 OpenAI 格式下**无法表达**,中转站必须在网关内部自行完成续跑循环,不能透传给客户端。
---
## 5. Usage 统计
| 语义 | OpenAI | Anthropic | DeepSeek |
| --- | --- | --- | --- |
| 输入 token | `usage.prompt_tokens` | `usage.input_tokens` **(仅未缓存部分)** | `usage.prompt_tokens`(总数) |
| 输出 token | `usage.completion_tokens` | `usage.output_tokens` | `usage.completion_tokens` |
| 总计 | `usage.total_tokens` | 需自行相加 | `usage.total_tokens` |
| 缓存命中 | `prompt_tokens_details.cached_tokens` | `cache_read_input_tokens` | `prompt_cache_hit_tokens` |
| 缓存写入 | 无单独计数 | `cache_creation_input_tokens` | 无(`prompt_cache_miss_tokens` 是未命中数) |
| 推理 token | `completion_tokens_details.reasoning_tokens` | `output_tokens_details.thinking_tokens` | `completion_tokens_details.reasoning_tokens` |
**关键陷阱**Anthropic → OpenAI 时,
```
prompt_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
```
直接用 `input_tokens` 会严重低报(缓存命中时可能低报 99%)。
---
## 6. 流式协议映射
| | OpenAI | Anthropic |
| --- | --- | --- |
| 结构 | 无状态 chunk 序列 | **有状态**的 block 事件机 |
| 事件名 | 无(都是匿名 `data:` | 有(`event: message_start` 等) |
| 文本增量 | `choices[0].delta.content` | `content_block_delta` + `text_delta` |
| 工具增量 | `delta.tool_calls[].function.arguments`(按 `index` 累加) | `content_block_delta` + `input_json_delta.partial_json` |
| 推理增量 | Responses 有专门事件 | `thinking_delta` + `signature_delta` |
| usage | 最后一个 chunk(需 `include_usage`),`choices` 为空数组 | `message_delta.usage`**累计值** |
| 结束标记 | `data: [DONE]` | `message_stop` |
| 保活 | 无标准(实践中发注释行) | `ping` 事件 |
| 中途错误 | 无标准 | `error` 事件(200 之后仍可能发) |
### Anthropic → OpenAI 流式转换要点
1. `message_start` → 发一个只含 `role: "assistant"` 的首 chunk。
2. `content_block_start(text)` → 无对应,吞掉。
3. `text_delta``delta.content`
4. `content_block_start(tool_use)` → 发一个带 `id`/`name``arguments: ""``tool_calls[index]` chunk。
5. `input_json_delta``tool_calls[index].function.arguments` 追加。
6. `thinking_delta` → 无标准落点;常见做法是映射到 `delta.reasoning_content`(DeepSeek 风格,事实标准)。**`signature_delta` 无处可放,会丢失**——这意味着经过 OpenAI 格式往返后的对话**无法再发回 Anthropic 上游**。
7. `message_delta.stop_reason` → 最后一个 chunk 的 `finish_reason`
8. `message_stop``data: [DONE]`
9. `ping` → 丢弃或转成 SSE 注释行保活。
### OpenAI → Anthropic 流式转换要点
必须自行维护 block index 状态机:文本与工具调用要分配不同的 `index`,并在切换时补发 `content_block_stop` / `content_block_start`。OpenAI 的 chunk 里没有"块结束"信号,只能靠 `tool_calls[].index` 变化和 `finish_reason` 推断。
---
## 7. 推理内容三方对照(最不可互通的部分)
| | OpenAI Responses | Anthropic | DeepSeek |
| --- | --- | --- | --- |
| 载体 | `reasoning` item | `thinking` / `redacted_thinking` block | `reasoning_content` 字符串 |
| 可见文本是什么 | `summary`(需显式开启) | **摘要,永远不是原始 CoT** | 思考内容字符串 |
| 真实推理载体 | `encrypted_content` | **`signature`(完整思考的密文)** | 无,就是明文字段 |
| 密文是否必需 | ZDR / `store:false` 时必需 | **始终存在**,服务端解密它重建思考 | 不适用 |
| 可否修改 | 加密内容需原样回传 | **不可修改,改了直接 400** | 可自由处理 |
| 多轮是否必回传 | 有函数调用时必须 | **工具轮次内必须**;其他情况可省略但不可改 | 无工具调用时**不必**;有工具调用时**必须** |
| 关闭方式 | `reasoning.effort: "none"` / `"minimal"` | 部分模型**根本无法关闭** | `thinking.type: "disabled"` |
| 跨模型可移植 | — | **否**,绑定生成模型,换模型必须剥离 | 是 |
| 跨平台可移植 | — | **是**API/Bedrock/Vertex 三方通用 | — |
### 为什么 Anthropic 侧无法靠字段翻译打通
Anthropic 的 `thinking` 文本字段是**摘要**,真正的推理内容加密在 `signature` 里,服务端回传时**解密 `signature` 来重建思考,并不读你传的文本**(详见 [anthropic.md](./anthropic.md) §5)。
所以这条常见的转换路径是**死路**:
```
Anthropic thinking block → reasoning_content 字符串 → 存下来 → 转回 thinking block
```
转回去的东西只有摘要文本、没有 signatureAnthropic 侧要么 400,要么丢失全部推理连续性。
### 可行做法
**把 signature / encrypted_content / redacted_thinking.data 当成 opaque blob 原样搬运。** 三家的密文字段都明确声明为不可解析,中转站不需要理解它们:
1. 网关按会话 ID 存储上游返回的**原始 content block 数组**(含密文字段)。
2. 下游客户端只看到脱敏后的摘要文本(映射到 `reasoning_content` 或 Responses 的 reasoning item)。
3. 下一轮请求时,用存储的原始块重建上游请求,而不是从下游回传的文本反向构造。
需要额外注意的两点:
- **模型路由会破坏它**Anthropic thinking block 绑定生成它的模型,换模型时必须剥离。其他模型**静默忽略而不报错,但照样计输入 token**——做故障转移时不清理,用户会为无用内容付费且毫无提示。
- **signature 很长**Claude 4+ 显著变长)。存储层的列宽、日志截断、header 大小限制都可能悄悄截断它,而截断后的 signature 会在下一轮触发 400。
+404
View File
@@ -0,0 +1,404 @@
# DeepSeek API
> 来源:
> - https://api-docs.deepseek.com/api/create-chat-completion
> - https://api-docs.deepseek.com/guides/thinking_mode
> - https://api-docs.deepseek.com/guides/tool_calls
> - https://api-docs.deepseek.com/guides/json_mode
> - https://api-docs.deepseek.com/guides/kv_cache
> - https://api-docs.deepseek.com/guides/chat_prefix_completion
> - https://api-docs.deepseek.com/guides/fim_completion
> - https://api-docs.deepseek.com/guides/responses_api
> - https://api-docs.deepseek.com/guides/anthropic_api
> - https://api-docs.deepseek.com/quick_start/pricing
> - https://api-docs.deepseek.com/quick_start/error_codes
>
> 文档采集于 2026-08-12。**2026-08-13 用真实 API key 全量实测校验过一遍**,实测与文档冲突处均以实测为准并标注 ⚠️。
> 实测环境 `system_fingerprint`flash = `fp_a18b46594c_prod0820_fp8_kvcache_20260402`pro = `fp_v4pro_20260812_prod0820_fp8_kvcache_20260402`。
DeepSeek 的定位是 **OpenAI 格式兼容**,但有一批自有扩展和"看起来支持其实忽略"的参数。它同时提供三种入口格式(OpenAI / Anthropic / Responses),本身就是一个"中转站"的现成参考——**而且是一个反面参考**:实测发现它的三个入口在思考参数、图片处理、错误格式、tool_choice 上行为都不一致。
Base URL
- `https://api.deepseek.com` — OpenAI Chat Completions 格式 + Responses API 格式
- `https://api.deepseek.com/beta` — Beta 功能(prefix completion、FIM、strict tool calls
- `https://api.deepseek.com/anthropic` — Anthropic Messages 格式
实测路径别名(文档未列):
- Responses`/responses``/v1/responses` 等价
- Anthropic`/anthropic/messages``/anthropic/v1/messages` 等价
- Anthropic 入口鉴权:`x-api-key``Authorization: Bearer` **都接受**
额外端点(文档未列):`GET /user/balance``{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"...","granted_balance":"...","topped_up_balance":"..."}]}`。中转站可用它做上游健康检查与余额预警。
---
## 1. 模型与能力矩阵
`GET /models` 实测只返回两个模型:
| 模型 | 上下文 | 最大输出 |
| --- | --- | --- |
| `deepseek-v4-flash` | 1,048,576 | 393,216 |
| `deepseek-v4-pro` | 1,048,576 | 393,216 |
> 实测精确值,来自越界报错:`This model's maximum context length is 1048576 tokens`、`the valid range of max_tokens is [1, 393216]`。
⚠️ **模型名别名**`deepseek-chat``deepseek-reasoner`(旧模型名)会被**静默映射到 `deepseek-v4-flash`**,响应的 `model` 字段返回 `deepseek-v4-flash`。其他未知名字(`gpt-4``deepseek-v3`、空串等)会明确报错:`The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed X.`
> 中转站注意:用户传 `deepseek-chat` 拿到的是 v4-flash 且无任何提示,账单模型名与请求模型名对不上。
| 能力 | v4-flash | v4-pro |
| --- | --- | --- |
| Tool Calls | ✓ | ✓ |
| JSON Output(仅 `json_object` | ✓ | ✓ |
| FIM Completion | ✓ | ✓ |
| Chat Prefix Completion | ✓ | ✓ |
| Thinking Mode | ✓(默认开) | ✓(默认开) |
| Responses API | ✓ | ✓ ⚠️ |
| 内置 `web_search`(仅 Responses 入口) | ✓ | ✓ |
| Anthropic API | ✓ | ✓ |
⚠️ 文档称 Responses API "不支持 v4-pro,计划 2026-08 初支持"——**实测已支持**,`deepseek-v4-pro``/v1/responses` 正常返回。
⚠️ 文档称 FIM "仅非思考模式"且"模型用 v4-pro"——**实测 flash 和 pro 都支持 FIM**,见 §7。
价格(每 1M token):
| 模型 | 缓存命中 | 缓存未命中 | 输出 |
| --- | --- | --- | --- |
| v4-flash | $0.0028 | $0.14 | $0.28 |
| v4-pro | $0.003625 | $0.435 | $0.87 |
> 官方页面明确预告将大幅涨价,价格不要硬编码。
---
## 2. Chat Completions`POST /chat/completions`
必填:`messages``model`
| 参数 | 类型 | 默认 | 实测行为 |
| --- | --- | --- | --- |
| `thinking` | object | `{type:"enabled"}` | **DeepSeek 自有扩展**`type: enabled/disabled/adaptive` ⚠️`reasoning_effort: low/high/max` |
| `reasoning_effort` | string | — | ⚠️ **顶层 OpenAI 写法也生效**(文档未列),`"none"` 可关闭思考 |
| `max_tokens` | int | — | 范围 `[1, 393216]`,越界 400 |
| `temperature` | number | 1 | 范围 `[0, 2]`,越界 400(非静默忽略) |
| `top_p` | number | 1 | 范围 `(0, 1.0]`,越界 400 |
| `response_format` | object | `{type:"text"}` | 仅 `text` / `json_object``json_schema` 报 400 |
| `stream` | bool | false | |
| `stream_options` | object | — | `include_usage` |
| `stop` | string\|array | — | 最多 16 个,17 个报 `Stop string array too long: 17` |
| `tools` | array | — | `type` 只接受 `function``web_search` 等报 400 |
| `tool_choice` | — | `auto` | ⚠️ 思考模式下**只支持 `none`/`auto`**,见 §4 |
| `logprobs` | bool | false | ⚠️ 思考模式下返回结构不同,见下 |
| `top_logprobs` | int | — | 0 ~ 20,需 `logprobs=true` |
| `user_id` | string | — | ≤512 字符,文档称字符集 `[a-zA-Z0-9\-_]`**实测不校验**(传 `用户@1` 也 200 |
| `n` | int | 1 | **只支持 1**`n=2``Invalid n value (currently only n = 1 is supported)` |
**已废弃**`frequency_penalty``presence_penalty` — 实测传了不报错,静默忽略。
**静默忽略**(传了不报错也不生效):`seed`、OpenAI 标准的 `user`(注意与 DeepSeek 自有的 `user_id` 不是同一个参数)。
**硬报错的兼容性坑**
- `role: "developer"`OpenAI 新标准)→ 400 `unknown variant 'developer'`。中转站必须把 `developer` 降级成 `system`
- `content` 数组中的 `image_url` → 400 `unknown variant 'image_url', expected ...`。**注意这与 Anthropic / Responses 入口的静默降级行为不一致**,见 §8。
- 纯文本的 `content: [{"type":"text","text":"..."}]` 数组形式 → 正常支持。
**消息序列约束极宽松**(实测均返回 200):连续两条 `user`、以 `assistant` 结尾(无 `prefix`)、`system` 出现在中间、只有 `system` 一条——全部接受。唯一报错的是空数组 `messages: []``Empty input messages`
响应字段基本对齐 OpenAI,额外有:
- `message.reasoning_content` — 思考内容(字符串)
- `usage.prompt_cache_hit_tokens` / `usage.prompt_cache_miss_tokens`
- `usage.prompt_tokens_details.cached_tokens`(与 `prompt_cache_hit_tokens` 同值,两套字段并存)
- `usage.completion_tokens_details.reasoning_tokens` — ⚠️ **仅思考模式存在**;非思考模式下整个 `completion_tokens_details` 对象都不出现
- `finish_reason` 多一个取值:**`insufficient_system_resource`**
- ⚠️ 非流式响应的 `tool_calls[]` 里**也带 `index` 字段**(OpenAI 非流式不带)
### ⚠️ logprobs 在思考模式下结构不同
非思考模式:`logprobs.content[]`(同 OpenAI)。
思考模式:返回的是 **`logprobs.reasoning_content[]`** ——这个 key 在 OpenAI 规范里不存在(OpenAI 只有 `content``refusal`)。严格按 OpenAI schema 反序列化的客户端会拿到空的 logprobs 或直接解析失败。
---
## 3. Thinking Mode 的怪癖
### ⚠️ 三个入口的参数写法互不相同——且与官方文档记载相反
文档给出的映射表(Anthropic 用 `reasoning.effort`、Responses 用 `output_config.effort`)**实测是错的**。以"关闭思考"为探针跑的完整交叉矩阵(观察 `reasoning_content`/`thinking` block 是否出现、`prompt_tokens` 是 5 还是 84):
| 入口 | 写法 | 实测结果 |
| --- | --- | --- |
| **OpenAI** | `{"thinking":{"type":"disabled"}}` | ✅ 生效(prompt=5 |
| **OpenAI** | `{"reasoning_effort":"none"}` | ✅ 生效(prompt=5)——文档未记载 |
| **OpenAI** | `{"reasoning":{"effort":"none"}}` | ❌ 静默忽略(prompt=84,仍思考) |
| **Anthropic** | `{"thinking":{"type":"disabled"}}` | ✅ 生效(input=5 |
| **Anthropic** | `{"reasoning":{"effort":"none"}}` | ❌ 静默忽略(input=84)——**文档说这个才对,实测无效** |
| **Anthropic** | `{"reasoning_effort":"none"}` | ❌ 静默忽略(input=84 |
| **Responses** | `{"reasoning":{"effort":"none"}}` | ✅ 生效(reasoning_tokens=0 |
| **Responses** | `{"output_config":{"effort":"none"}}` | ❌ 静默忽略——**文档说这个才对,实测无效** |
| **Responses** | `{"thinking":{"type":"disabled"}}` | ❌ 静默忽略 |
**结论**Chat 与 Anthropic 入口统一用 `thinking.type`Responses 入口用 OpenAI 标准的 `reasoning.effort`。所有"不生效"的写法都是**静默忽略、不报错**——中转站若照文档实现关思考,会得到一个照常思考并照常计费的响应。
### ⚠️ `thinking.type` 有第三个取值 `adaptive`(文档完全未记载)
从 400 报错里泄露的枚举:`unknown variant 'banana', expected one of 'adaptive', 'enabled', 'disabled'`
实测 `adaptive` 会按问题难度调节思考量("hi" → 24 reasoning tokens"严格证明 √2 无理" → 262),但**不会完全关闭思考**,简单问题仍产出 `reasoning_content`
### ⚠️ 思考模式会注入约 79 token 的隐藏 system prompt,并计入你的账单
同一条 `"hi"` 请求:
| | `prompt_tokens` |
| --- | --- |
| `thinking.type = disabled` | **5** |
| `thinking.type = enabled`(默认) | **84** |
这 79 token 的差额是 DeepSeek 服务端注入的思考指令,按输入价计费,且**三个入口都一样**(Anthropic 入口 `input_tokens` 同为 84)。默认开启意味着中转站转发的每一条短请求都在为它买单。
### reasoning_effort 的实际效果存疑
`reasoning_effort` 的**非法值不报错**`"banana"` 照常返回 200),只有 `thinking.type` 是强类型枚举。`none`/`minimal`/`xhigh`/`medium` 等文档外取值也一律静默接受。
在 flash 上对同一道题各档采样 5 次的 `reasoning_tokens`
| effort | 样本 | 均值 |
| --- | --- | --- |
| `low` | 116, 80, 118, 68, 118 | 100.0 |
| `high` | 48, 71, 120, 73, 90 | 80.4 |
| `max` | 101, 56, 111, 89, 79 | 87.2 |
**方差远大于档位间差异,flash 上看不出 effort 有可观测效果。** v4-pro 上采样(low ≈ 513、high ≈ 799)倾向于正相关,但样本量不足以定论。文档所称"flash 直通、pro 上 high 与 xhigh 都映射为 high"**未能证实也未能证伪**——不要基于这个假设做路由决策。
> 例外:Responses 入口的 `reasoning.effort: "none"` 是唯一能观测到确定效果的取值(`reasoning_tokens` 精确为 0)。
### ⚠️ reasoning_content 多轮回传是硬性要求(不回传直接 400)
文档表述为"必须回传",实测是**服务端强校验**:
```
{"error":{"message":"The `reasoning_content` in the thinking mode must be passed back to the API.",
"type":"invalid_request_error","code":"invalid_request_error"}}
```
精确的触发条件(实测逐一验证):
| 场景 | 不回传 `reasoning_content` |
| --- | --- |
| 思考模式 + 历史含 `tool_calls` | **400 报错** |
| 思考模式 + 无 `tool_calls` 的普通多轮 | 正常,不报错 |
| 非思考模式 + 历史含 `tool_calls` | 正常,不报错 |
传空串 `"reasoning_content": ""` **可以过校验**——这是中转站从 Anthropic/OpenAI 格式转过来、丢失了原始思考内容时的可用降级手段(模型质量可能下降,但不会 400)。
这和 Anthropic 的规则(thinking block 必须原样、带 signature 回传)以及 OpenAI 的规则(encrypted_content / reasoning item)都不同,中转站需要按上游分别处理。
### 流式响应的两处非 OpenAI 行为
1. **`usage` 挂在最后一个正常 chunk 上**,而不是像 OpenAI 那样单独发一个 `choices: []` 的 usage-only chunk。DeepSeek 把 usage 塞进了带 `finish_reason` 的那个 chunk 里,之后直接 `data: [DONE]`。按 OpenAI 语义等待独立 usage chunk 的实现会永远拿不到 usage。
2. **delta 中未使用的字段是显式 `null` 而非省略**`{"delta":{"content":null,"reasoning_content":"We"}}` / `{"delta":{"content":"4","reasoning_content":null}}`。对 `null` 与"字段缺失"处理不同的客户端需注意。
思考内容通过 `delta.reasoning_content` 增量下发,先出完 `reasoning_content` 再出 `content`
---
## 4. Tool Calls 的怪癖
- 结构与 OpenAI 一致:`tools[].{type:"function", function:{name, description, parameters}}`,回传用 `role: "tool"` + `tool_call_id``tools[].type` 只接受 `function`
- **思考模式下 `tool_choice` 只支持 `auto``none`**(⚠️ 文档未记载):
| `tool_choice` | thinking enabled | thinking disabled |
| --- | --- | --- |
| `"auto"` | ✅ | ✅ |
| `"none"` | ✅ | ✅ |
| `"required"` | ❌ **400** | ✅ |
| `{"type":"function","function":{"name":...}}` | ❌ **400** | ✅ |
报错信息:`Thinking mode does not support this tool_choice`。由于**思考模式是默认开启的**,中转站转发一个带 `tool_choice: "required"` 的普通 OpenAI 请求会直接 400——必须同时下发 `thinking.type: "disabled"`,或把 `required` 降级为 `auto`
- **`strict` 是 Beta 功能**,文档称必须切到 `https://api.deepseek.com/beta`。实测在主 base_url 上传 `strict: true` **不报错**(静默接受并正常返回),无法从响应上区分是否生效——属于典型的静默失效。
- strict 模式支持的 schema 类型:object、string、number、integer、boolean、array、enum、anyOf、`$ref``$def`。实测传入 `pattern``minLength` 等不支持的关键字也不报错(静默接受,约束不保证生效)。
- 思考模式下的工具调用从 DeepSeek-V3.2 起支持,实测思考 + 工具调用可同时返回 `reasoning_content``tool_calls``finish_reason: "tool_calls"`
---
## 5. JSON Output 的怪癖
- 只有 `response_format: {"type": "json_object"}``json_schema` 明确报错:`This response_format type is unavailable now`(措辞暗示未来会支持,但目前中转站必须拦截或降级)。
- **必须在 system 或 user prompt 中出现 "json" 字样**,否则硬报错:
`Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'.`
中转站从其他上游格式转发时,如果用户 prompt 里没有 "json" 字样,会拿到一个用户完全无法理解的 400。
- 官方承认 API 偶尔会返回空 content,建议通过调整 prompt 措辞规避。中转站需要有重试策略。
- `max_tokens` 给小了会截断成非法 JSON。
- 与思考模式可共存,`reasoning_content` 与 JSON `content` 同时返回。
---
## 6. Context Caching 的怪癖
- **全自动,无 `cache_control` 之类的手动参数。** 与 Anthropic 的显式断点模型完全不同——不能双向映射。
- ⚠️ **实测缓存粒度为 64 token**(文档称"未说明最小可缓存粒度")。两组独立实测的 `prompt_cache_hit_tokens` 均为 64 的整数倍:3328 = 52×64、1920 = 30×64。命中量向下对齐到 64 的倍数,尾部不足 64 的部分计为 miss。
- ⚠️ **缓存即时生效**(文档称"缓存构建需要数秒")。实测第 1 次请求 miss 全量,**紧接着的第 2 次请求立即命中**,无需等待:
| 请求 | prompt_tokens | hit | miss |
| --- | --- | --- | --- |
| 第 1 次 | 3379 | 0 | 3379 |
| 第 2 次 | 3379 | 3328 | 51 |
| 第 3 次 | 3379 | 3328 | 51 |
- **公共前缀缓存已验证**:两个只有尾部一个词不同的请求(2006 / 2007 token)交替发送,双方都稳定命中共同前缀的 1920 token。
- 缓存不再使用后自动清除,通常在**数小时到数天**内。
- 统计字段:`prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`,以及 `prompt_tokens_details.cached_tokens`(同值)。Anthropic 入口映射为 `cache_read_input_tokens``cache_creation_input_tokens` **实测恒为 0**DeepSeek 没有"写入缓存"这个计费概念,官方 Anthropic 的缓存写入溢价在此不存在)。
---
## 7. Beta 功能(需 `base_url=https://api.deepseek.com/beta`
### Chat Prefix Completion
`messages` 最后一条设 `{"role": "assistant", "content": "...", "prefix": true}`,模型从该前缀继续生成。
- ⚠️ 在主 base_url 上传 `prefix` 是**明确报错**(不是静默忽略):
`prefix is only available when using beta api (set base_url="https://api.deepseek.com/beta")`
- 最后一条 message 的 role 必须是 `assistant`
- 常与 `stop` 配合,例如前缀 ` ```python\n ` + `stop=["```"]` 强制只出代码。
- **中转站注意**:这个能力无法映射到 Claude 4.6+(明确禁止 prefill),也不是 OpenAI 标准能力。
### FIM Completion
端点是 `/beta/completions`(不是 chat),参数 `prompt` + `suffix`。在主 base_url 上调用明确报错:`completions api is only available when using beta api (...)`
⚠️ 文档的三条限制实测**全部不成立**:
| 文档说法 | 实测 |
| --- | --- |
| `max_tokens` 上限 4K | **上限 393216**4096/8192/65536/393216 全部 200393217 才报错) |
| 模型用 `deepseek-v4-pro` | **flash 和 pro 都可用** |
| 仅非思考模式支持 | 传 `thinking` 参数不报错、正常返回(该参数在此端点无意义) |
其他实测:支持 legacy completions 的 `echo``logprobs`(int 型),但**两者互斥**——同时传报 `echo should not be used with logprobs`。响应是 `object: "text_completion"``choices[].text`**没有 `completion_tokens_details`**。
---
## 8. 官方兼容层(DeepSeek 自己做的中转)
### Anthropic 格式(`https://api.deepseek.com/anthropic`
模型名自动映射(实测确认):
- `claude-opus*``deepseek-v4-pro`
- `claude-haiku*` / `claude-sonnet*``deepseek-v4-flash`
- 直接传 `deepseek-v4-pro` / `deepseek-v4-flash` 也接受
- ⚠️ 其他未知名字(如 `gpt-4`**实测是报错,不是静默兜底**`The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed gpt-4.`(此处修正了文档"静默兜底到 flash"的说法)
支持:`model``max_tokens``system``temperature`**`[0, 2]`**,比官方 Anthropic 的 0~1 宽,2.5 报 400)、`stream``stop_sequences``tools``thinking``budget_tokens` 被忽略)。
流式格式规范,事件序列为 `message_start``content_block_start``ping``content_block_delta`(×N) → `content_block_stop` → …… → `message_delta``message_stop`,含 `thinking_delta` / `signature_delta` / `text_delta` 三种 delta 类型。
⚠️ **实测发现的四个兼容性缺陷**
1. **错误响应用的是 OpenAI/DeepSeek 格式,不是 Anthropic 格式。**
实际返回 `{"error":{"message":"...","type":"authentication_error","param":null,"code":"invalid_request_error"}}`
而 Anthropic SDK 期望 `{"type":"error","error":{"type":"authentication_error","message":"..."}}`
顶层缺 `"type":"error"`、多出 `param`/`code` 字段——官方 Anthropic SDK 的错误反序列化会失败或降级成通用异常。
2. **`max_tokens` 不是必填。** 官方 Anthropic API 中 `max_tokens` 必填,此处省略不报错,静默用默认值。依赖该校验的客户端会行为不一致。
3. **thinking block 的 `signature` 是伪造的——它就是本次响应的 `id`。**
```
{"id": "a7f8579c-ef83-49fb-b93c-bda7d513c07b",
"content": [{"type": "thinking", "thinking": "...",
"signature": "a7f8579c-ef83-49fb-b93c-bda7d513c07b"}]}
```
不是真正的加密签名,不具备任何校验意义,也不能与真实 Anthropic 上游互换。
4. **`tool_choice` 行为与 OpenAI 入口不一致。**
| `tool_choice` | Anthropic 入口 | 对应的 OpenAI 入口写法 |
| --- | --- | --- |
| `{"type":"auto"}` | ✅ | `"auto"` ✅ |
| `{"type":"any"}` | ⚠️ **静默降级为 auto**(实测明确要求"不要用工具"时模型确实不调工具,`stop_reason: "end_turn"` | `"required"` ❌ 思考模式下 400 |
| `{"type":"tool","name":"..."}` | ❌ 400 `Thinking mode does not support this tool_choice` | 同样 400 |
同一个语义(强制调用工具),一个入口静默失效、另一个入口硬报错。
忽略 / 不支持:`anthropic-beta` 与 `anthropic-version` header、`top_k`、大部分 metadata 字段(例外:`metadata.user_id` 支持,用于限流隔离)。
⚠️ **image content 不是"不支持",而是静默替换成占位文本**(文档表述为不支持,实测是降级)。传入 `{"type":"image","source":{...}}` 不报错,模型的思考内容里暴露了它实际收到的是 `[Unsupported Image]` 占位符,然后回答"我看不到图片"。用户拿到的是一个 HTTP 200 的错误答案。
### Responses 格式(`https://api.deepseek.com/v1/responses`
- ⚠️ **v4-flash 和 v4-pro 都已支持**(文档称仅 flash)。
- **未支持的参数一律静默忽略,不报错**,且响应里会把它们**回显成默认值**而非你传入的值——这是判断是否生效的可靠手段:
`store: true` → 回显 `false``previous_response_id: "resp_123"` → 回显 `null``conversation` 不支持。
- 思考参数用 `reasoning.effort`(见 §3),`effort: "none"` 可确定关闭思考。响应中 `reasoning` 字段会回显你传入的 effort。
- 上下文缓存自动进行,无手动参数(`prompt_cache_key` 恒 `null`)。
- ⚠️ **image 和 file 输入被替换成字面占位文本 `[Unsupported Image]`**(而不是报错)——这个行为对中转站尤其危险,用户会得到一个"成功但内容错误"的响应。与 Anthropic 入口行为一致,但与 OpenAI 入口(硬报错)不一致。
- usage 结构是 Responses 风格:`input_tokens` / `input_tokens_details.cached_tokens` / `output_tokens` / `output_tokens_details.reasoning_tokens` / `total_tokens`。
- 输出为 `output[]` 数组,思考是 `{"type":"reasoning","content":[{"type":"reasoning_text","text":"..."}],"summary":[]}`,正文是 `{"type":"message","phase":"final_answer",...}`(⚠️ `phase` 是非 OpenAI 标准字段)。
- 流式事件完整:`response.created` / `response.in_progress` / `response.output_item.added` / `response.content_part.added` / `response.reasoning_text.delta` / `response.reasoning_text.done` / `response.output_text.delta` / `response.output_text.done` / `response.content_part.done` / `response.output_item.done` / `response.completed`。
#### ⚠️ 内置 `web_search` 工具(文档完全未记载,且只有这一个入口有)
传 `"tools":[{"type":"web_search"}]` 实测**真的会执行联网搜索**,输出数组中出现:
```json
{"type":"web_search_call","id":"call_00_...","status":"completed",
"action":{"type":"search","queries":["capital of France","ws_call_id=call_00_..."]}}
```
回显的工具定义为 `{"type":"web_search","search_context_size":null,"user_location":null}`。搜索结果注入会显著抬高 `input_tokens`(实测同一问题从 84 涨到 2403)。
跨入口对比:
| 入口 | `web_search` |
| --- | --- |
| Responses | ✅ 真实执行 |
| OpenAI Chat | ❌ 400 `unknown variant 'web_search', expected 'function'` |
| Anthropic`web_search_20250305`)| ⚠️ 静默接受但不执行,模型直接凭知识作答 |
---
## 9. 错误码
| Code | 含义 | 处理 |
| --- | --- | --- |
| 400 | 请求体格式非法 | 按错误信息调整 |
| 401 | API key 错误 | 实测消息体:`Authentication Fails, Your api key: ****-bad is invalid``type: "authentication_error"` |
| 402 | **余额不足** | OpenAI/Anthropic 无此码,兼容层需决定映射到 429 还是 403 |
| 422 | 参数非法 | |
| 429 | 限流 | |
| 500 | 服务端故障 | 重试 |
| 503 | 服务器过载 | 稍后重试 |
错误响应体统一为 OpenAI 风格 `{"error":{"message","type","param","code"}}`——**包括 Anthropic 入口**(见 §8 缺陷 1)。
`type` 实测取值:`invalid_request_error`400/422)、`authentication_error`401)。注意 401 的 `code` 字段仍是 `invalid_request_error`,与 `type` 不一致,不要用 `code` 判断错误类别。
**限流**:实测 30 并发短请求全部 200,未触发 429,具体阈值未知。文档未说明限流时是否有 keep-alive 空行等连接层行为。
反序列化错误(Rust serde 风格)会泄露内部枚举,可用于探测未文档化的取值——`adaptive` 思考模式就是这么发现的:
```
Failed to deserialize the JSON body into the target type: thinking.type: unknown variant `banana`,
expected one of `adaptive`, `enabled`, `disabled` at line 1 column 100
```
---
## 10. 中转站实现要点速查
按危险程度排序的实测结论:
| # | 陷阱 | 后果 |
| --- | --- | --- |
| 1 | 思考默认开启且注入 ~79 token 隐藏 prompt | 每条请求静默多计 79 输入 token |
| 2 | 关思考的参数名三个入口各不相同,文档还写错了两个 | 照文档实现 = 关不掉、照常计费 |
| 3 | `tool_choice: required` + 默认思考模式 = 400 | 标准 OpenAI 请求直接失败 |
| 4 | 思考模式下 `reasoning_content` 不回传 = 400(仅工具场景) | 跨格式转发丢思考内容即中断;空串可降级过关 |
| 5 | image 在 Anthropic/Responses 入口被换成 `[Unsupported Image]` | HTTP 200 + 错误答案,最难排查 |
| 6 | Anthropic 入口错误体不是 Anthropic 格式 | 官方 SDK 错误解析失败 |
| 7 | Anthropic 入口 `tool_choice: any` 静默降级为 auto | 强制工具调用失效且无提示 |
| 8 | `deepseek-chat`/`deepseek-reasoner` 静默映射到 v4-flash | 账单模型名与请求不符 |
| 9 | 流式 usage 在最后一个正常 chunk,非独立 chunk | 按 OpenAI 语义等待 = 永远拿不到 usage |
| 10 | 思考模式 logprobs 返回 `logprobs.reasoning_content` | 非 OpenAI schema,解析失败 |
| 11 | `json_object` 要求 prompt 含 "json" 字面量 | 跨格式转发时用户看不懂的 400 |
| 12 | `role: "developer"` 硬报错 | 必须降级为 `system` |
| 13 | `strict` / `seed` / `user` / 非法 `reasoning_effort` 静默接受 | 用户以为生效了 |
+142
View File
@@ -0,0 +1,142 @@
# 错误、限流与超时对照
> 来源:
> - https://platform.claude.com/docs/en/api/errors
> - https://api-docs.deepseek.com/quick_start/error_codes
> - OpenAI OpenAPI spec(错误页面未单独抓取,见文末"待补")
>
> 采集于 2026-08-12。
---
## 1. HTTP 状态码对照
| 状态码 | Anthropic | DeepSeek | 说明 |
| --- | --- | --- | --- |
| 400 | `invalid_request_error` | 请求体格式非法 | Anthropic 也用它兜底其他未列出的 4xx |
| 401 | `authentication_error` | API key 错误 | Anthropic 上也可能是 key 过期/吊销,或 AWS 凭证/SigV4 问题 |
| 402 | `billing_error` | **余额不足** | OpenAI 无此码 |
| 403 | `permission_error` | — | key 无权限访问该资源 |
| 404 | `not_found_error` | — | |
| 409 | `conflict_error` | — | 资源并发修改/唯一值冲突 |
| 413 | `request_too_large` | — | 见 §2 |
| 422 | — | 参数非法 | **Anthropic/OpenAI 不用 422**DeepSeek 用 |
| 429 | `rate_limit_error` | 限流 | |
| 500 | `api_error` | 服务端故障 | |
| 503 | — | 服务器过载 | |
| 504 | `timeout_error` | — | 建议改用流式 |
| **529** | `overloaded_error` | — | **Anthropic 专有**,全平台高负载 |
### 中转站的映射决策点
- **402(余额不足)**没有 OpenAI 对应码。可选:透传 402、或映射为 429 + `insufficient_quota` 类型(OpenAI 生态的惯例是 429)。
- **529** 对 OpenAI 客户端是未知码,多数 SDK 不会自动重试。建议映射为 **503**
- **422** 对 OpenAI 客户端也不常见,建议映射为 **400**
- Anthropic 的 **429 有两种成因**:常规限流,以及**用量突增触发的加速度限制(acceleration limit**。后者的解决办法是"逐步爬坡、保持稳定用量",不是简单退避重试。
---
## 2. 请求体积上限(Anthropic
| 端点 | 上限 |
| --- | --- |
| Messages API | 32 MB |
| Token Counting API | 32 MB |
| Batch API | 256 MB |
| Files API | 500 MB |
超限返回 413。**在直连 Claude API 时,这个错误由 Cloudflare 在到达 API 服务器之前返回**——意味着响应体格式可能不是标准的 Anthropic 错误 JSON。中转站的错误解析需要能容忍非 JSON 的 413 响应。
---
## 3. 错误响应体
### Anthropic
```json
{
"type": "error",
"error": {
"type": "not_found_error",
"message": "The requested resource could not be found."
},
"request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"
}
```
`error.type` 的取值集合会随时间扩充(versioning policy 明确说明),解析器必须能处理未知 type。
### 流式中途错误(Anthropic
HTTP 已经返回 200 之后,错误以 SSE 事件形式发出:
```
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
```
**中转站必须处理这种情况**:此时已经无法改 HTTP 状态码,只能在下游流中也以某种方式表达错误。OpenAI SSE 格式没有标准的错误事件——常见做法是发一个带 `error` 字段的 data chunk,然后 `[DONE]`
---
## 4. Request ID
Anthropic 每个响应都带 `request-id` header(形如 `req_018EeWyXxfu5pfWkrYcMdjWG`),错误体里也有 `request_id` 字段。
在 AWS 上的 Claude Platform 会有**两个** ID`x-amzn-requestid`(主,CloudTrail 索引)和 `request-id`(次,Anthropic 支持工单用)。
中转站建议:生成自己的 gateway request id,并在响应 header 中同时保留上游的 ID,便于排障。
---
## 5. 超时与长请求
Anthropic 侧的明确约束:
- 官方 SDK 会**校验非流式 Messages 请求预期不超过 10 分钟**,超过则拒绝发出。
- 超过 10 分钟的请求应改用流式或 Batch API。
- 部分网络会在空闲一段时间后断开连接,导致请求超时且拿不到响应。官方建议直连集成时设置 **TCP socket keep-alive**
- SDK 默认对瞬时失败(连接错误、限流、5xx)**指数退避重试 2 次**,并遵守 `retry-after` header。
对中转站的含义:
- 网关自身的上游超时不能小于客户端预期,否则长任务永远失败。
- 网关到上游、客户端到网关,**两段都需要 keep-alive 配置**。
-`max_tokens` 的非流式请求是最容易踩超时的场景,建议在网关侧自动转成流式再聚合(Anthropic SDK 的 `get_final_message()` 就是这个思路)。
---
## 6. 限流 header
Anthropic OpenAI 兼容层确认支持全套:
```
x-ratelimit-limit-requests
x-ratelimit-limit-tokens
x-ratelimit-remaining-requests
x-ratelimit-remaining-tokens
x-ratelimit-reset-requests
x-ratelimit-reset-tokens
retry-after
```
中转站若做自己的配额管理,应该合成这套 header 返回给下游(而不是透传上游的,因为上游额度和下游用户额度不是一回事)。
---
## 7. 用户级限流隔离
| Provider | 字段 | 约束 |
| --- | --- | --- |
| OpenAI | `safety_identifier``user` 已废弃) | ≤64 字符,建议传 hash |
| OpenAI | `prompt_cache_key` | 用于提升缓存命中,与 safety_identifier 分离 |
| Anthropic | `metadata.user_id` | |
| DeepSeek | `user_id` | ≤512 字符,仅 `[a-zA-Z0-9\-_]` |
注意 DeepSeek 的字符集限制比另外两家严格——直接透传 OpenAI 的 `safety_identifier`(可能含其他字符)会被拒。
---
## 8. 待补
- **OpenAI 的错误码/错误体规范未单独收集**。`platform.openai.com/docs/guides/error-codes` 未抓取(主站 403)。需要补充:`insufficient_quota``context_length_exceeded``invalid_api_key``error.code` 取值,以及 OpenAI 错误体的 `{error: {message, type, param, code}}` 结构细节。
- DeepSeek 限流时的连接层行为(是否发 keep-alive 空行)文档未说明。
+966
View File
@@ -0,0 +1,966 @@
# Kimi API(月之暗面 Moonshot
> 来源(官方文档站 `.md` 原文,采集于 2026-08-13):
> - API 参考:[overview](https://platform.kimi.com/docs/api/overview.md)、[chat](https://platform.kimi.com/docs/api/chat.md)、[models-overview](https://platform.kimi.com/docs/api/models-overview.md)、[list-models](https://platform.kimi.com/docs/api/list-models.md)、[errors](https://platform.kimi.com/docs/api/errors.md)、[estimate](https://platform.kimi.com/docs/api/estimate.md)、[balance](https://platform.kimi.com/docs/api/balance.md)、[batch-create](https://platform.kimi.com/docs/api/batch-create.md)
> - 指南:[models](https://platform.kimi.com/docs/models.md)、[k3-quickstart](https://platform.kimi.com/docs/guide/kimi-k3-quickstart.md)、[k2.7-code-quickstart](https://platform.kimi.com/docs/guide/kimi-k2-7-code-quickstart.md)、[thinking](https://platform.kimi.com/docs/guide/use-thinking-models.md)、[reasoning-effort](https://platform.kimi.com/docs/guide/use-reasoning-effort.md)、[partial](https://platform.kimi.com/docs/guide/use-partial-mode-feature-of-kimi-api.md)、[caching](https://platform.kimi.com/docs/guide/use-context-caching-feature-of-kimi-api.md)、[tool-choice](https://platform.kimi.com/docs/guide/use-tool-choice.md)、[dynamic-tools](https://platform.kimi.com/docs/guide/use-dynamic-tool-loading.md)、[tool-calls](https://platform.kimi.com/docs/guide/use-kimi-api-to-complete-tool-calls.md)、[web-search](https://platform.kimi.com/docs/guide/use-web-search.md)、[official-tools](https://platform.kimi.com/docs/guide/use-official-tools.md)、[response_format](https://platform.kimi.com/docs/guide/response_format.md)、[streaming](https://platform.kimi.com/docs/guide/utilize-the-streaming-output-feature-of-kimi-api.md)、[vision](https://platform.kimi.com/docs/guide/use-kimi-vision-model.md)、[file-qa](https://platform.kimi.com/docs/guide/use-kimi-api-for-file-based-qa.md)、[batch](https://platform.kimi.com/docs/guide/use-batch-api.md)、[claude-code](https://platform.kimi.com/docs/guide/claude-code-kimi.md)、[troubleshooting](https://platform.kimi.com/docs/guide/troubleshooting.md)
> - 计价与限流:[limits](https://platform.kimi.com/docs/pricing/limits.md)、[chat-k3](https://platform.kimi.com/docs/pricing/chat-k3.md)
> - 完整索引:https://platform.kimi.com/docs/llms.txt OpenAPIhttps://platform.kimi.com/docs/openapi.json
>
> **2026-08-13 用真实 API key 实测校验过一遍**(约 60 次请求,覆盖两个入口)。实测与文档冲突处均以实测为准。
> 实测环境:中国站 `api.moonshot.cn`,账号档位 `msh-gid: free`Tier 03 RPM / 1 并发),流式 `system_fingerprint` = `fpv0_489ccb45`。
>
> **标注约定**:✅ 文档记载且实测确认;❌ **实测推翻文档**;🆕 文档未记载、实测发现;⚠️ 未实测或存疑。
Kimi 对外声称 **OpenAI 协议兼容**OpenAI SDK 换 `base_url` 即可用。但实测下来它是本项目已调研的几家里**"最容易在第一个请求就 400"** 的一家:
1. **新模型把采样参数写死并硬 400**——`temperature` 不是 1.0 直接报错,实测确认不是静默忽略(§3)。带默认 `temperature=0.7` 的 OpenAI 客户端在 K3 上开箱即挂。
2. **同一模型在两个入口行为相反**——OpenAI 入口对 `temperature=0.7` 报 400Anthropic 入口 200 静默忽略(§3.2)。
3. **参数集按模型分裂成四套**——合法请求体取决于 `model` 值(§3、§4)。
4. **文档与实现大面积脱节**——实测推翻了 8 条文档明确写下的规则,其中包括"K3 思考恒开不可关"这样的核心设定(§14)。**照文档写代码会同时踩到"文档说能实际不能"和"文档说不能实际能"两个方向。**
5. **它其实是两个产品**——`platform`(本文 §1–§16)和 **Kimi For Coding**`api.kimi.com/coding`,见 **§17**)是**两套独立的服务栈**:账号体系、模型名、鉴权、限流、端点、id 格式全都不同,key 互不通用。**中转站必须当作两个上游分别配置。**
---
## 1. 端点、域名与鉴权
### 1.1 双站隔离 ✅
| 产品 / 站点 | 控制台 | API base URL | Key 前缀 |
| --- | --- | --- | --- |
| platform 中国站 | `platform.kimi.com` | `https://api.moonshot.cn/v1` | `sk-` |
| platform 国际站 | `platform.kimi.ai` | `https://api.moonshot.ai/v1` | `sk-` |
| **Kimi For Coding**(另一个产品,见 §17) | — | `https://api.kimi.com/coding/v1` | **`sk-kimi-`** |
两站账号、余额、API Key 完全独立,拿错区域的 key 返回 401。中转站的上游配置必须把 key 和 base URL 绑成一对。
**Kimi For Coding 与 platform 的 key 双向不通用**(实测)——且两边的 401 文案不同,可用来判断打错了哪个上游:
```
coding key → api.moonshot.cn : {"message":"Invalid Authentication","type":"invalid_authentication_error"}
platform key → api.kimi.com/coding : {"message":"The API Key appears to be invalid or may have expired. Please verify your credentials and try again.","type":"invalid_authentication_error"}
```
⚠️ **域名分层容易记错**:文档站/控制台已迁到 `kimi.com``platform.moonshot.cn``platform.kimi.com` 301),但 **API 数据面仍是 `api.moonshot.cn` / `api.moonshot.ai`**,没有迁到 `api.kimi.com`
### 1.2 端点清单 ✅(均实测可用)
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | `/v1/chat/completions` | 对话补全 |
| GET | `/v1/models` | 模型列表 |
| POST | `/v1/tokenizers/estimate-token-count` | 官方 token 计数 |
| GET | `/v1/users/me/balance` | 余额查询 |
| POST/GET/DELETE | `/v1/files``/v1/files/{id}``/v1/files/{id}/content` | 文件管理 |
| POST/GET | `/v1/batches``/v1/batches/{id}``/v1/batches/{id}/cancel` | 批处理 |
鉴权统一 `Authorization: Bearer <api_key>` ✅。**没有智谱那种 JWT 历史包袱**,这点比多数国产厂商干净。
🆕 **非 chat 端点带业务包装层**`/v1/users/me/balance``/v1/tokenizers/estimate-token-count` 返回的**不是**裸对象,而是
```json
{"code":0,"data":{"total_tokens":87},"scode":"0x0","status":true}
```
即 HTTP 200 外面还套了一层 `code`/`scode`/`status`。而 `/v1/chat/completions``/v1/models` **没有**这层包装(直接 OpenAI 结构)。**同一个 API 里两种响应信封并存**——中转站的响应解析器不能一刀切。(对比 `china-providers.md` §0 里 MiniMax `base_resp` 那种"HTTP 200 藏业务错误",Kimi 是同一思路的半吊子版本:只在辅助端点上有。)
🆕 **未记载的调试响应头**(每个响应都带):
```
msh-request-id: 7b768543-96db-11f1-8fa0-624fe5b4e31b
msh-org-id: org-xxxxxxxx
msh-project-id: proj-xxxxxxxx
msh-uid: xxxxxxxxxxxxxxxxxxxx
msh-gid: free ← 账号档位,可用来自动探测 Tier
msh-trace-mode: on
```
`msh-request-id` 是报障时官方要的 request_id。⚠️ 但 `msh-org-id` / `msh-uid` / `msh-project-id` 是**账号标识**——中转站若原样透传响应头给下游,等于把自己的组织 ID 广播出去。**应当在响应头白名单里剥掉 `msh-*`**`msh-request-id` 可保留或改名)。
### 1.3 Anthropic 兼容入口 ✅(实测可用,但与 OpenAI 入口行为不一致)
```
ANTHROPIC_BASE_URL="https://api.moonshot.cn/anthropic"
ANTHROPIC_AUTH_TOKEN="<kimi api key>" # 注意是 AUTH_TOKEN 不是 API_KEY
```
实测结论:
| 项 | 结果 |
| --- | --- |
| `POST /anthropic/v1/messages` + `x-api-key` | ✅ 200 |
| `POST /anthropic/v1/messages` + `Authorization: Bearer` | ✅ 200**两种鉴权都接受** |
| `POST /anthropic/messages`(漏 `v1`) | ❌ 404,且**掉到网关层**(见下) |
| 不带 `anthropic-version` 头 | 🆕 200**不强制** |
| `temperature: 0.7` | 🆕 **200 静默忽略**(OpenAI 入口同样请求是 400 |
| `top_p: 0.5` | 🆕 200 静默忽略 |
| `tool_choice: {"type":"tool","name":...}` | ✅ 400 `tool_choice 'specified' is incompatible with thinking enabled`(与 OpenAI 入口一致) |
| `thinking: {"type":"enabled","budget_tokens":2000}` | ✅ 200 接受 |
| `system` + `cache_control: {"type":"ephemeral"}` | 🆕 200 接受但 `cache_creation_input_tokens: 0`,**未实际建缓存**(自动缓存机制接管,见 §10) |
| `model: "kimi-k3[1m]"` | ❌ **404**(见下) |
> **两个入口对采样参数的处理相反,但对 `tool_choice` 的限制一致**——不是"Anthropic 入口更宽松",而是**选择性地不一致**。中转站不能假设"打不通就换个入口试试"。
🆕 **Anthropic 入口的 `usage` 同时并存两套字段**
```json
"usage": {
"input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 88,
"output_tokens": 151, "output_tokens_details": {"thinking_tokens": 135},
"service_tier": "standard", "inference_geo": "not_available",
"prompt_tokens": 88, "cached_tokens": 88, "completion_tokens": 151, "total_tokens": 239
}
```
Anthropic 风格(`input_tokens`/`cache_read_input_tokens`/`output_tokens`)和 OpenAI 风格(`prompt_tokens`/`cached_tokens`/`completion_tokens`)**同时存在**,且语义不同:缓存命中时 `input_tokens` 变成 **0**(Anthropic 语义:不含缓存部分),而 `prompt_tokens` 仍是 **88**(OpenAI 语义:含缓存部分)。**中转站按哪套字段计费,账单会差一个缓存量。**
🆕 **thinking block 没有 `signature` 字段**——Anthropic 官方的 thinking block 带加密签名,Kimi 这个是纯明文 `{"type":"thinking","thinking":"..."}`。好处是不用当 opaque blob 搬运,坏处是**下游若按 Anthropic 规范校验 signature 会失败**。
**`kimi-k3[1m]` 是错的**——官方 Claude Code 配置页把 `ANTHROPIC_MODEL` 等六个变量全设成 `kimi-k3[1m]`,但实测**两个入口都 404**
```
OpenAI 入口: {"error":{"message":"Not found the model kimi-k3[1m] or Permission denied","type":"resource_not_found_error"}}
Anthropic 入口: {"error":{"type":"resource_not_found_error","message":"Not found the model kimi-k3[1m] or Permission denied"},"request_id":"...","type":"error"}
```
`[1m]` 是 Claude Code 客户端侧的上下文变体后缀,Kimi 服务端并不认。**正确的模型名是 `kimi-k3`。** 官方文档这段配置照抄会直接失败。
🆕 **三种错误格式并存**(这是中转站错误处理的隐藏坑):
```jsonc
// 1. OpenAI 入口 API 级错误
{"error":{"message":"...","type":"invalid_request_error"}}
// 2. Anthropic 入口 API 级错误 —— 多了 request_id 和顶层 type
{"error":{"type":"resource_not_found_error","message":"..."},"request_id":"...","type":"error"}
// 3. 网关级错误(路径不存在时)—— 完全不同的结构,中文消息,且回显 UA 和 URL
{"code":5,"error":"url.not_found","message":"没找到对象","method":"POST","scode":"0x5","status":false,"ua":"curl/8.7.1","url":"/anthropic/messages"}
```
第三种连 `error` 字段的类型都变了(从 object 变成 string)。**中转站解析上游错误时若假设 `error` 恒为 object,遇到网关级错误会抛类型异常。**
✅ 已知能力缺口:该端点**不支持 WebFetch**Claude Code 里报 `temporarily unavailable`)。
---
## 2. 模型与能力矩阵
### 2.1 在售模型 ✅
| 模型 | 上下文(实测 `/v1/models` | 默认 max output | 思考 | 视觉 | 视频 |
| --- | --- | --- | --- | --- | --- |
| `kimi-k3` | 1,048,576 | 131,072(上限 1,048,576 | 恒开但**可关**(§4.1 | ✓ | ✓ |
| `kimi-k2.7-code` | 262,144 | 32,768 | **不可关** | ✓ | ✓ |
| `kimi-k2.7-code-highspeed` | 262,144 | 32,768 | **不可关** | ✓ | ✓ |
| `kimi-k2.6` | 262,144 | ⚠️ 未记载 | 可关 | ✓ | ✓ |
| `kimi-k2.5` | 262,144 | ⚠️ 未记载 | 可关 | ✓ | ✓ |
| `moonshot-v1-{8k,32k,128k}` | 8192/32768/131072 | ⚠️ 未记载 | 无 | ✗ | ✗ |
| `moonshot-v1-{8k,32k,128k}-vision-preview` | 同上 | ⚠️ 未记载 | 无 | ✓ | ✗ |
| 🆕 `moonshot-v1-auto` | 131072 | — | 无 | ✗ | ✗ |
🆕 **`moonshot-v1-auto` 文档完全没提**,但 `/v1/models` 里有且可调用。它是**自动路由模型**:请求 `moonshot-v1-auto`**响应的 `model` 字段返回 `moonshot-v1-8k`**(实测两次都是)。
> 这是 DeepSeek `deepseek-chat` → `deepseek-v4-flash` 静默映射的同类问题:**请求模型名 ≠ 响应模型名 ≠ 账单模型名**。中转站若按响应 `model` 字段记账,用户请求 auto 会看到一个自己没点过的模型名;若按请求名记账,又对不上实际计费。**建议两个都记。**
`kimi-k2.7-code-highspeed` 是同模型高速档(约 180 tok/s),能力相同——可当作同一逻辑模型的 QoS 变体。
⚠️ 文档提到 moonshot-v1 系列"8 月 31 日下线"但未给年份,而 `/v1/models` 仍在返回。需持续关注。
### 2.2 已下线模型:无别名兜底 ✅(实测确认)
```
kimi-k2 → 404 Not found the model kimi-k2 or Permission denied
kimi-latest → 404 Not found the model kimi-latest or Permission denied
```
与 DeepSeek 把旧名静默映射到新模型**相反**,Kimi 是直接 404。中转站若面向存量客户需自建旧名映射表,并显式告知用户(别学 DeepSeek 静默换模型导致账单对不上)。
### 2.3 `/v1/models` 的真实返回:比文档丰富得多 🆕
文档给的示例只有 7 个字段,实测返回:
```json
{
"id": "kimi-k3", "object": "model", "created": 1786362236, "owned_by": "moonshot",
"permission": [{"created":0,"id":"","object":"","organization":"moonshot","group":"moonshot","is_blocking":false}],
"root": "", "parent": "",
"supports_image_in": true, "supports_video_in": true, "supports_reasoning": true,
"supports_dynamic_tools": true,
"think_efforts": {"support": true, "valid_efforts": ["low","high","max"], "default_effort": "max"},
"reasoning_efforts": {"support": true, "valid_efforts": ["low","high","max"], "default_effort": "max"},
"supports_thinking_type": "only",
"context_length": 1048576
}
```
- `permission[]` / `root` / `parent`**OpenAI 早期 `/models` 的遗留字段**(现已从 OpenAI 移除),Kimi 还留着,且内容全是空壳。
- 🆕 `think_efforts``reasoning_efforts` **内容完全相同的两个字段**——显然是改名过程中的兼容遗留,两个都在返回。
- 🆕 `supports_dynamic_tools`(仅 K3 为 true)、`supports_thinking_type: "only"`(仅 K3 有此字段)——**这两个字段是判断能力的最可靠来源**,比文档准。
- ❌ 文档示例里 `kimi-k2.5``context_length` 写的是 `128000`,实测是 **262144**
> 这是少见的"上游自带能力矩阵",中转站可以自动同步能力表而非硬编码。但注意 `supports_thinking_type: "only"` 的值是字符串 `"only"` 而非布尔,且**只有 K3 有这个字段**——解析时要容忍字段缺失。
---
## 3. 采样参数:新模型写死且**硬 400**(实测确认,本文件最大的坑)
### 3.1 OpenAI 入口:逐个报错 ✅
实测逐参数验证,**全部是 400,不是静默忽略**:
| 请求 | 实际响应 |
| --- | --- |
| `kimi-k3` + `temperature: 0.7` | `400 invalid temperature: only 1 is allowed for this model` |
| `kimi-k3` + `top_p: 0.5` | `400 invalid top_p: only 0.95 is allowed for this model` |
| `kimi-k3` + `n: 2` | `400 invalid n: only 1 is allowed for this model` |
| `kimi-k3` + `presence_penalty: 1` | `400 invalid presence_penalty: only 0 is allowed for this model` |
| `kimi-k2.7-code` + `temperature: 0.7` | `400 invalid temperature: only 1 is allowed for this model` |
| `moonshot-v1-8k` + `temperature: 1.5` | `400 Invalid request: temperature must not be greater than 1.000000` |
| `moonshot-v1-8k` + `temperature: 0.7` | ✅ 200 |
| 参数 | `moonshot-v1` | `kimi-k3` / `kimi-k2.7-code` |
| --- | --- | --- |
| `temperature` | 01,默认 **0** | 固定 `1.0`**传其他值 400** |
| `top_p` | 01,默认 1 | 固定 `0.95`**传其他值 400** |
| `n` | 15,默认 1 | 固定 `1`**传其他值 400** |
| `presence_penalty` / `frequency_penalty` | 22,默认 0 | 固定 `0`**传其他值 400** |
**三条独立的怪癖:**
1. **`temperature` 默认值跨代反转**moonshot-v1 默认 **0**,新模型固定 **1.0**。"同一家 API,不传 temperature 时的行为随模型跳变"。中转站若为对齐 OpenAI 而主动补默认 `temperature`,在 v1 上改变原有行为,在 K3 上直接 400。**结论:绝不给未传的参数补默认值。**
2. **`temperature` 上限是 1 而非 2**OpenAI 是 2)。
3. 🆕 **`n > 1``temperature` 存在隐藏耦合**——在 moonshot-v1 上传 `n: 3` 而不动 temperature
```
400 Invalid request: n should not be greater than 1 when temperature is less than or equal to 1e-5
```
因为 v1 的 temperature 默认是 0**要用 `n > 1` 就必须同时把 temperature 设为 > 0**。文档从未提及这个约束。中转站转发 `n>1` 请求时得连带检查 temperature,否则报出的错会让用户完全摸不着头脑。
✅ 其他约束实测确认:`stop` 数组最多 5 个——传 6 个报 `400 stop array too long. Expected an array with maximum length 5, but got an array with length 6 instead`(错误信息质量不错)。
🆕 **未知参数静默忽略**:传 `"foo_bar": 123` → 200 正常返回。即"未知字段宽松、已知字段严格"——这与多数厂商一致,但和它对已知参数的严苛形成反差。
### 3.2 Anthropic 入口:同样的参数被静默忽略 🆕
```
OpenAI 入口 kimi-k3 + temperature=0.7 → 400
Anthropic 入口 kimi-k3 + temperature=0.7 → 200(正常返回,参数被忽略)
Anthropic 入口 kimi-k3 + top_p=0.5 → 200
```
**同一个模型、同一个参数、两个入口结果相反。** 这是 DeepSeek "三入口行为不一致"的翻版(见 `deepseek.md`)。
> 中转站落地建议:为 Kimi 上游维护**按模型的参数白名单**,对新模型剥离 `temperature/top_p/n/presence_penalty/frequency_penalty` 后再转发。**这是本文件最该优先实现的一条规则**——它同时解决了两个入口的差异(剥离后两边行为一致)。
### 3.3 `max_tokens` vs `max_completion_tokens` ✅(实测厘清)
两个名字都能用。同时传时 **`max_completion_tokens` 优先**
```
{"max_tokens": 1500, "max_completion_tokens": 30} → completion_tokens: 30, finish_reason: "length"
```
文档的参数表只列 `max_completion_tokens`,正文却反复用 `max_tokens`,两者是新旧别名关系(文档未声明)。中转站两个都要能发,且注意优先级。
---
## 4. 思考模型:文档与实现脱节最严重的一节
| 模型 | 开关参数 | 能否关闭 | 实测 |
| --- | --- | --- | --- |
| `kimi-k3` | `reasoning_effort``low`/`high`/`max`,默认 `max`**和** `thinking.type` | ❌ **文档说不能,实测能** | 见 §4.1 |
| `kimi-k2.7-code` | `thinking.type` | ✅ 确实不能 | `400 invalid thinking: only type=enabled is allowed for this model` |
| `kimi-k2.6` | `thinking.type``enabled`/`disabled` | ✅ 能 | 200`reasoning_content: ""` |
| `kimi-k2.5` | `thinking.type` | ⚠️ 未测 | — |
### 4.1 ❌ K3 的思考**可以**关闭(推翻文档)
文档(K3 quickstart、thinking 指南、troubleshooting 三处)都说"K3 始终开启思考模式,不可关闭,只能调 `reasoning_effort`"。实测:
```jsonc
// 请求
{"model":"kimi-k3","messages":[{"role":"user","content":"说:好"}],"max_tokens":1500,"thinking":{"type":"disabled"}}
// 响应 200
{"choices":[{"message":{"role":"assistant","content":"好"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":21,"completion_tokens":11,"total_tokens":32}}
```
- **没有 `reasoning_content` 字段**(不是空字符串,是字段不存在)。
- **`prompt_tokens` 从 88 掉到 21**。同一条 user 消息,思考开启时 88、关闭时 21——差值约 67 token 是**思考模式注入的隐藏 system prompt**。这也解释了为什么 `estimate-token-count` 对四个汉字的消息返回 87:**官方 tokenizer 把这段隐藏 prompt 也算进去了**。
🆕 **`thinking.disabled` 优先于 `reasoning_effort`**:两个同时传(`thinking:{"type":"disabled"}` + `reasoning_effort:"max"`)→ 思考仍然关闭,`prompt_tokens: 21`。
> 对中转站的影响是正面的:**"关闭思考"这个语义在 K3 上是可表达的**,能力降级表里不必再写"无法关闭"。但因为文档明说不行,这条**属于未文档化行为,随时可能变**——建议在实现里做成可开关的特性,并在上游探测时验证。
### 4.2 ❌ `reasoning_effort` 的枚举校验形同虚设(推翻文档)
`/v1/models` 明确声明 `valid_efforts: ["low","high","max"]`,但:
```
reasoning_effort: "medium" → 200 正常思考(OpenAI 的枚举值)
reasoning_effort: "banana" → 200 正常思考(完全非法的值)
```
**任意字符串都被接受并静默忽略。** 好消息是中转站不必做 `minimal`/`medium` → `low`/`high` 的枚举映射(不会 400);坏消息是**用户传错值不会有任何反馈**,只会默默跑在默认的 `max` 档(最贵)。中转站若要给用户可预期的成本,应当自己校验枚举并拒绝非法值。
🆕 `kimi-k2.6` 传 `reasoning_effort: "low"` → 200,但仍正常思考(`reasoning_tokens: 151`)——同样是被忽略。
### 4.3 `reasoning_content` 的回传要求 ✅ 部分推翻
- 响应里思考内容在 `choices[].message.reasoning_content`**明文**,无签名)。
- ✅ 流式中 `reasoning_content` **确实先于 `content` 出现**(实测 186 个 chunk 全是 reasoning_content 后才出 content)。`content` 开始即代表思考结束,是可靠的状态机切换信号。
- ✅ `reasoning_content` 计入 `completion_tokens`,并单独报在 `completion_tokens_details.reasoning_tokens`。
- ❌ **文档说"K3/K2.7-code 多轮缺少 `reasoning_content` 会报错"——实测不报错**:
```
多轮 assistant 消息只带 content + tool_calls、不带 reasoning_content → 200 正常继续
```
中转站不必强制存档回放 `reasoning_content`(虽然存了对模型质量更好)。这条推翻降低了实现负担——**但也意味着 Preserved Thinking 的实际语义比文档描述的松**,⚠️ 建议在长对话+多步工具链场景再验一次。
### 4.4 `max_tokens` 太小导致空 `content` ✅(实测确认)
```jsonc
{"model":"kimi-k3","max_completion_tokens":30,...}
→ 200 {"message":{"content":"","reasoning_content":"The user asked..."},"finish_reason":"length"}
```
token 全烧在思考上,`content` 为空字符串。官方建议 `max_tokens >= 16000`。
> **"200 OK + 空 content"是 Kimi 的正常返回,不能当上游故障重试**——重试只会再烧一遍思考 token。应原样透传让下游看到 `finish_reason: "length"`。
---
## 5. Partial Mode(前缀续写)✅
```json
{"role": "assistant", "content": "[\"红\",", "partial": true, "name": "可选"}
```
实测 200,返回 `" \"绿\", \"蓝\"]"`——**响应不含前缀**,需客户端自己拼。
| 维度 | Kimi | DeepSeek | Anthropic |
| --- | --- | --- | --- |
| 字段名 | **`partial: true`** | `prefix: true` | 无字段,末条 assistant 即前缀 |
| 端点 | 正式端点 | 需 `/beta` | 正式端点(Claude 4.6+ 已禁用) |
**同一能力三家三个名字**——中转站 IR 里应有单一 `prefill` 标志(见 `api-design.md`),落地时翻译成对应字段。
❌ **`partial` + `response_format: json_object` 不冲突**(推翻 `chat.md` 里那条警告):
```
{"partial":true,"content":"{"} + response_format:{"type":"json_object"} → 200,正常续写出完整 JSON
```
这是实践中最常用的组合(前缀写 `{` 强制 JSON 开头),可以放心用。
⚠️ `name` 字段是 Kimi 独有语义:OpenAI 的 `name` 是"多说话人区分"Kimi 在 partial 场景当"人设锚点"。转发时不要想当然丢弃。
⚠️ 与思考叠加的陷阱(文档提示,未实测):K3 思考开启时若 `max_tokens` 太小,会在思考阶段截断导致 `content` 为空,**下一次续写等于从零重来**。
---
## 6. 工具调用
### 6.1 与 OpenAI 的差异 ✅/❌
`tools[] / tool_calls[] / role:"tool"` 结构、流式 `delta.tool_calls[].index` 累积规则均与 OpenAI 相同。差异:
1. ❌ **`role: "tool"` 消息的 `name` 字段实际可选**(推翻文档)。文档说"必须提供 `tool_call_id` 和 `name`,Kimi 才能正确匹配",实测只给 `tool_call_id` 不给 `name` → **200 正常**。中转站从 Anthropic 入口转换时不必回查函数名(Anthropic 的 `tool_result` 只有 `tool_use_id`)。
2. ✅ **`tool_call_id` 必须严格匹配**:传一个不存在的 id →
```
400 Invalid request: tool_call_id is not found
```
🆕 注意错误信息里 `tool_call_id` 和 `is not found` 之间是**两个空格**——本该填 id 值的位置是空的,是个字符串拼接 bug。**中转站不要指望从这条错误信息里解析出是哪个 id 出的问题。**
3. ✅ `finish_reason: "tool_calls"` 时 `content` 可能非空(Kimi 会解释为什么调用)。按"有 tool_calls 就忽略 content"写的下游会丢内容。
4. 🆕 **`tool_calls[].id` 不是随机串,而是 `{函数名}_{序号}`**
```json
{"index":0,"id":"get_weather_0","type":"function","function":{"name":"get_weather",...}}
```
OpenAI 是 `call_abc123...` 的不透明随机串。这个格式**可预测且与函数名耦合**。风险有两层:中转站若按 id 反查函数名会"碰巧能用",形成隐式依赖,换上游即崩;且多轮对话里**同名函数会产生重复 id**(第二轮又是 `get_weather_0`),若中转站用 id 做全局去重键会误判。**建议中转站重写 tool_call id 为自己的不透明 id 并维护映射表。**
5. ✅ `finish_reason` 枚举为 `stop` / `length` / `tool_calls`**没有国产厂商常见的 `sensitive`**——内容安全走 400 `content_filter`,不污染枚举。
### 6.2 `tool_choice` 与思考的互斥 ✅,但有一个危险的例外 🆕
| 模型 / 条件 | `required` | 具体函数 |
| --- | --- | --- |
| `kimi-k3`(思考开) | ✅ **200 正常工作** | ❌ 400 `tool_choice 'specified' is incompatible with thinking enabled` |
| `kimi-k3`(思考关) | ⚠️ 未测 | 🆕 **200,但见下** |
| `kimi-k2.7-code`(思考不可关) | ❌ 400 `tool_choice 'required' is incompatible with thinking enabled` | ❌ 400 |
| `kimi-k2.6`(思考关) | ⚠️ 未测 | ✅ 200 正常调用工具 |
| Anthropic 入口 `kimi-k3` | ⚠️ 未测 | ❌ 400(同 OpenAI 入口) |
- ✅ K3 思考开启时 `required` **可用**(文档未明说,实测正常返回 tool_calls);但 K2.7-code 的 `required` **报错**,错误信息是"与思考不兼容"。同样是"思考开启",两个模型对 `required` 的判定不同。
- 🆕 **最危险的一条**:K3 关闭思考 + `tool_choice` 指定函数 → **HTTP 200,但模型没有调用工具,而是直接编造了结果**:
```jsonc
// 请求:tool_choice 强制 get_weatherthinking disabled
// 响应 200,无 tool_callsfinish_reason: "stop"
{"content":"北京现在的天气情况如下:\n- **天气**:晴\n- **温度**28°C\n- **湿度**45%\n- **风向**:南风..."}
```
**`tool_choice` 被静默无视,模型直接幻觉出一份完整的天气数据。** 这比 400 危险得多——下游代码等着解析 `tool_calls`,拿到的却是一段看起来很真的假数据。**中转站不应把"关闭思考"作为绕过 `tool_choice` 限制的变通方案**,那样只是把显式失败变成静默错误。
> 结论:思考开启时无法强制指定函数,这个缺口**无法用参数改写安全绕过**。能力降级表建议**显式报错**而非降级。
### 6.3 动态工具加载(K3 独有)✅ 实测确认
```json
{"role": "system", "tools": [{"type":"function","function":{...}}]}
```
- 这条 system 消息**没有 `content` 字段**——OpenAI 协议里不存在的消息形状。实测 K3 上 200 正常,模型正确调用了工具。
- ✅ `kimi-k2.6` 上 → `400 Invalid request: tokenization failed`(一个极不直观的报错,实测确认)。
- 消息位置决定工具从哪轮起可见;与顶层 `tools` 可共存。
- 官方指出顶层 `tools` 不影响前缀缓存,动态工具因进了消息历史所以会影响。
> 中转站风险:这条消息**无法用 OpenAI/Anthropic 语义表达**。若做严格消息 schema 校验("system 必须有 content")会把合法请求拒掉。**IR 里要给消息保留 passthrough 扩展字段,别做白名单式裁剪。**
### 6.4 内置工具:两套并存 ✅/⚠️
**a`builtin_function` 协议**(实测确认):
```json
{"type": "builtin_function", "function": {"name": "$web_search"}}
```
实测响应:
```json
{"tool_calls":[{"id":"t-web_search-6a7d5fde","type":"builtin_function",
"function":{"name":"$web_search","arguments":"{\"search_result\":{\"search_id\":\"dda31b08...\"},\"usage\":{\"total_tokens\":6674}}"}}]}
```
- 🆕 `tool_calls[].type` 回传的是 **`builtin_function`**,不是 OpenAI 枚举里的 `function`。**下游若按 OpenAI 枚举做校验会拒掉这个响应。**
- 🆕 id 格式又是另一套:`t-web_search-{短hash}`(与普通工具的 `get_weather_0` 不同)。
- ✅ 反直觉之处确认:**模型内部已执行搜索**(`arguments` 里直接带 `search_id` 和 `usage.total_tokens: 6674`),但客户端仍要把 `arguments` **原样**塞回 `role: "tool"` 消息再发一次。即"假装自己执行了"。
- 🆕 注意首轮的 `usage.total_tokens` 只有 93,而 arguments 内嵌的 `usage.total_tokens` 是 **6674**——**搜索消耗的 token 不在响应的 usage 里**,要从 arguments 里解析。中转站按响应 usage 计费会漏掉绝大部分搜索成本。
- ✅ 计费:每次搜索按次收费,搜索结果计入下一轮 `prompt_tokens`。
**b)官方工具(Formula API)** ⚠️ 未实测 —— 另一套体系,12 个工具:`convert`、`web-search`、`rethink`、`random-choice`、`mew`、`memory`、`excel`、`date`、`base64`、`fetch`、`quickjs`、`code-runner`。走标准 `function` 协议 + Formula URI`moonshot/{tool-name}:latest`),搜索类返回 `encrypted_output``----MOONSHOT ENCRYPTED BEGIN----...`)需原样回传。
⚠️ **`web-search`(连字符,官方工具)与 `$web_search`(美元符号,builtin_function)是两个不同的东西**,文档没交代关系。已实测的是后者。
> 中转站建议:内置工具**不要跨厂商翻译**(OpenAI `web_search_preview`、Anthropic `web_search_20250305`、Kimi `$web_search` 的执行模型和计费都不同),按 opaque 透传,并在文档标明"内置工具与上游绑定"。
---
## 7. 结构化输出 ✅(文档为准,部分实测)
`response_format` 支持 `text` / `json_object` / `json_schema`。
```json
{"type": "json_schema", "json_schema": {"name": "...", "strict": true, "schema": {...}}}
```
- ✅ `json_object` 实测可用,且可与 partial 叠加(§5)。
- ⚠️ `strict: true` 走 token 级约束解码,要求 schema 符合 **MFJSMoonshot Flavored JSON Schema**——**自造方言**,与 OpenAI Structured Outputs 子集不是同一套。文档未给完整规则页。**未实测。**
- ⚠️ 文档罕见坦诚地承认支持度**按模型分层**:`kimi-k2.7-code` 最全(`anyOf`/`oneOf`/`$ref`/`additionalProperties`);`kimi-k3` 稳定;**`kimi-k2.6` 复杂 schema 不稳定——`$ref` 可能返回 Markdown 代码块、`oneOf` 可能被忽略**。
- ✅ JSON Mode 只保证生成 JSON **Object**(不会返回顶层数组/标量)。
> 建议:`json_schema` **不做跨家 schema 转换**,透传 + 记录失败率。K2.6 可能吐 Markdown 围栏意味着"保证返回合法 JSON"的承诺需要中转站自己剥围栏——那属于改写响应体,需明确取舍。
---
## 8. 流式输出:`usage` 位置**由 `include_usage` 决定** ❌(推翻文档)
文档说 usage "只出现在最后一个 chunk 的 `choices[0].usage`OpenAI SDK 的 `chunk.usage` 返回 None"。实测发现**两种模式并存,取决于是否传 `stream_options.include_usage`**
**不传 `include_usage`**Kimi 私有行为):
```jsonc
// 最后一个 chunk —— usage 嵌在 choices[0] 里,与 finish_reason 同层
data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop",
"usage":{"prompt_tokens":88,"completion_tokens":211,"total_tokens":299,
"completion_tokens_details":{"reasoning_tokens":186}}}],"system_fingerprint":"fpv0_489ccb45"}
data: [DONE]
```
**传 `stream_options: {"include_usage": true}`**(与 OpenAI 完全一致):
```jsonc
// 独立的最后一个 chunk —— choices 为空数组,usage 在顶层
data: {"id":"...","object":"chat.completion.chunk","choices":[],
"usage":{"prompt_tokens":88,"completion_tokens":182,"total_tokens":270,"cached_tokens":88,
"completion_tokens_details":{"reasoning_tokens":166},"prompt_tokens_details":{"cached_tokens":88}}}
data: [DONE]
```
> **中转站结论(比文档的推论简单得多):总是显式传 `stream_options.include_usage = true`**,就能拿到 OpenAI 标准位置的 usage,不必改写响应体。代价从"改写响应"降为"改写请求"。
>
> 两个附带差异:① 不传时**也有** usage(OpenAI 是不传就完全没有),所以"不传就没计费数据"的假设在 Kimi 上不成立;② **只有传了 `include_usage` 的那个 chunk 才带 `cached_tokens`**——不传时的 `choices[0].usage` 里没有缓存字段,会漏掉缓存计费信息。
其余流式行为 ✅ 与 OpenAI 一致:`data:` 前缀、`chat.completion.chunk`、**增量** delta(不是阿里那种全量累积)、`role` 只在首 chunk、中途有 `"delta":{}` 空 chunk、`data: [DONE]` 收尾。官方要求**以 `[DONE]` 判断结束而非 `finish_reason`**。
🆕 每个 chunk 都带 `system_fingerprint`(如 `fpv0_489ccb45`),**非流式响应里没有这个字段**——两种模式的响应字段集不一致。
### 自动断线重连 ⚠️
官方那篇《自动断线重连》**只是应用层 try/except 重试循环**(最多 100 次、间隔 1s),**没有任何协议级续传**——没有断点 offset,没有 resume id,重连就是整个请求重发。标题容易让人误以为有 resumable stream。中转站不要指望它做流式续传。
---
## 9. 多模态:**不支持 http(s) 图片 URL** ✅(实测确认)
```
{"type":"image_url","image_url":{"url":"https://statics.moonshot.cn/.../logo.png"}}
→ 400 Invalid request: unsupported image url: https://statics.moonshot.cn/.../logo.png
```
注意这个 URL 还是 Kimi 自家域名的图片,同样被拒。只接受两种形式:
- base64 data URI`data:image/png;base64,...`
- **Kimi 自有文件协议:`ms://<file_id>`**(先经 `/v1/files` 上传)
> 与 OpenAI(接受公网 URL)和 Anthropic`source.type: "url"`)的硬性差异。中转站要么替用户下载转 base64(引入出网、体积、超时、**SSRF** 风险),要么显式报错。**建议默认报错,把"自动下载转码"做成可选开关**——代下载会让中转站变成任意 URL 的抓取代理。
其他 ✅(文档,未逐条实测):
- `image_url` 既接受 `{"url": "..."}` 对象也接受**直接字符串**,比 OpenAI 宽松。
- 请求体上限 **100MB**;图片张数无硬限,受体积约束。建议图片 ≤4K、视频 ≤1080p。
- **`content` 必须是真数组,不能是序列化后的 JSON 字符串**——文档特意警告"序列化成字符串后各模型行为不可预测"。中转站做消息归一化时若曾把 content 压成字符串(OpenAI 单文本场景的常见优化),多模态消息上必须禁用。
- 视频输入(`video_url`)仅 K2.5/K2.6/K2.7-code/K3 支持——**OpenAI 协议里没有的模态**,跨家映射无对应物。
---
## 10. Context Caching:全自动 ✅,但 256 token 阈值 ❌
⚠️ **历史怪癖已消失**:Kimi 早期的显式 Cache 对象 API 已被移除,现行是**全自动前缀缓存**,无需创建或引用 cache id。(`china-providers.md` §3 原先标注的"需查证"由此作废。)
❌ **文档说"prompt tokens > 256 才能命中前缀缓存"——实测不成立**:
```
prompt_tokens: 88 → cached_tokens: 88 (命中)
prompt_tokens: 21 → cached_tokens: 21 (命中,仅 21 token
```
21 token 的请求也完整命中。实际阈值远低于 256,或该限制已取消。
🆕 **`cached_tokens` 在响应里出现两次**(同一个值):
```json
"usage": {"prompt_tokens":88, "completion_tokens":211, "total_tokens":299,
"cached_tokens": 88, // 扁平(Kimi 私有)
"prompt_tokens_details": {"cached_tokens": 88}} // 嵌套(OpenAI 兼容)
```
文档只提到扁平那个。**中转站直接用 `prompt_tokens_details.cached_tokens` 即可对齐 OpenAI,不需要做字段映射**(比文档描述的情况好)。同理输出侧有 `completion_tokens_details.reasoning_tokens`。
⚠️ 但注意**首次请求的响应里完全没有缓存字段**(未命中时字段不出现,而非返回 0)——解析时要容忍缺失。
✅ 生命周期由系统管理,TTL 未公开。✅ 计价差异极大(K3):**命中 ¥2 / 1M,未命中 ¥20 / 1M**,输出 ¥100 / 1M。
> **中转站影响(被低估的一条)**:10 倍输入价差意味着"保持前缀稳定"是硬性成本要求。任何对 prompt 前缀的改写——注入 system 提示、重排消息、规范化空白、动态插入时间戳——都会让缓存失效,成本瞬间 10 倍。**规则:对 Kimi 上游的请求,前缀必须逐字节稳定。** 同理动态工具进消息历史会影响缓存,顶层 `tools` 不会(§6.3)。
🆕 **`prompt_cache_key` 实测被接受**(200,不报错),但因为缓存本来就自动命中,无法从外部观测它是否真的分裂了缓存命名空间。⚠️ 语义仍未知,文档的 Context Caching 指南完全没提这个参数。
🆕 **Anthropic 入口的 `cache_control: {"type":"ephemeral"}` 被接受但不生效**:返回 `cache_creation_input_tokens: 0`。自动缓存机制接管了,显式缓存断点是空操作。
---
## 11. 文件、Batch 与辅助端点
### 11.1 文件问答 ✅(文档,未实测)
流程是"上传 → 抽取 → **把抽取出的文本当作 system 消息塞进 messages**",官方强调放的是**文件内容而不是 file id**。模型侧没有"引用 file id 做问答"的通道(`ms://` 只用于多模态图片/视频块)。
- 单用户最多 **1000 个文件**,单文件 **100MB**,空文件报错。
- ⚠️ **`purpose` 取值文档自相矛盾**:错误码页说"仅接受 `file-extract`"batch 创建页要求 `purpose="batch"`。至少两个合法值。
- 抽取行为:文本原样抽;图片走 OCR(**无文字则失败**);PDF 直接抽文本,纯图 PDF 走 OCR。
- 文件类 API **限时免费**。
### 11.2 Batch API ✅(文档,未实测)
- `POST /v1/batches`,字段 `input_file_id` / `endpoint`(仅 `/v1/chat/completions`/ `completion_window` / `metadata`(≤16 对,key ≤64 字符,value ≤512 字符)。
- ⚠️ `completion_window` 取值两处不一致:API 页说"最小 12h 最大 7d",指南页说 `24h`/`3d`/`7d`。
- **省 40%** 推理费。**仅支持 `kimi-k2.6` / `kimi-k2.5`,不支持 `kimi-k3`**。
- batch 请求体里**不得包含** `temperature` / `top_p` / `n` / 两个 penalty——§3 那条规则在 batch 上变成"不许出现"而非"必须等于固定值"。
- JSONL 每行需 `custom_id`(文件内唯一)/ `method`(固定 POST/ `url`(固定 `/v1/chat/completions`/ `body`,文件 ≤100MB。
- 支持多模态 batchbase64 或 `ms://`)。
### 11.3 官方 tokenizer ✅(实测)
```
POST /v1/tokenizers/estimate-token-count
{"model":"kimi-k3","messages":[{"role":"user","content":"你好世界"}]}
→ {"code":0,"data":{"total_tokens":87},"scode":"0x0","status":true}
```
🆕 **四个汉字算出 87 token**——因为它把 §4.1 那段思考模式的隐藏 system prompt(约 67 token)也算进去了,与实际 chat 请求的 `prompt_tokens: 88` 基本吻合。
> 这其实是好事:**官方 tokenizer 的口径与计费口径一致**,中转站做成本预估/限流应当用它,而不是套 tiktoken(Kimi 不是 OpenAI 词表)。但要知道它包含隐藏开销,别拿它的结果去和"用户可见文本长度"做对比,会显得离谱。代价是每次预估多一次网络往返。
### 11.4 余额 ✅(实测)
```
GET /v1/users/me/balance
→ {"code":0,"data":{"available_balance":15,"voucher_balance":15,"cash_balance":0},"scode":"0x0","status":true}
```
`cash_balance` **可为负数表示欠费**`available_balance <= 0` 时无法调用推理 API。可作上游健康检查与余额预警(对应 DeepSeek 的 `/user/balance`,路径和字段名都不同)。
❌ **文档说"K3 需最低充值 ¥10 解锁,注册代金券不解锁 K3"——不成立**:实测账号 `cash_balance: 0`、余额全是代金券,K3 调用完全正常。
---
## 12. 错误码与限流
### 12.1 错误体 ✅/🆕
OpenAI 入口:`{"error": {"message": "...", "type": "..."}}`——**没有 OpenAI 的 `code` / `param` 字段**。中转站若要输出严格 OpenAI 错误结构需补 `code: null` / `param: null`。
另有 Anthropic 入口格式和网关格式,共三种,见 §1.3。
### 12.2 错误码表 ✅(打勾项为实测触发)
| HTTP | `type` | 触发 |
| --- | --- | --- |
| 400 | `invalid_request_error` | ✅ 参数越界/固定值不符/schema 错/`tokenization failed`/`unsupported image url`/`tool_call_id is not found` |
| 400 | `content_filter` | 内容安全拦截 |
| 401 | `invalid_authentication_error` / `incorrect_api_key_error` | 鉴权头格式错 / key 无效(**含拿错区域的 key** |
| 403 | `permission_denied_error` | API 未开通 / 越权 / IP 不在组织白名单 |
| 404 | `resource_not_found_error` | ✅ 模型不存在(下线模型、`kimi-k3[1m]` |
| 429 | `engine_overloaded_error` | ✅ **上游过载**——退避重试 |
| 429 | `exceeded_current_quota_error` | **余额不足/欠费**——重试无用 |
| 429 | `rate_limit_reached_error` | ✅ **并发/RPM/TPM/TPD 超限**——降频 |
| 499 | `client_closed_request` | 客户端提前断开 |
| 500 | `server_error` / `unexpected_output` | 服务端错误 |
| 503 | `server_unavailable` | 扩缩容/维护 |
| 504 | — | **900 秒无响应**;官方建议长请求开 `stream: true` |
**429 必须按 `type` 分流** ✅——三种 429 处置完全相反:过载要退避、欠费重试纯属浪费、限流要降并发。只看状态码的通用重试逻辑在欠费时会打爆上游。实测两种 429 都遇到了,`engine_overloaded_error` 在正常低频调用下也会偶发。
🆕 **429 的错误信息泄漏账号标识**:
```
Your account org-xxxxxxxx<ak-xxxxxxxxxxxxxxxx> request reached organization max RPM: 3, please try again after 1 seconds
```
**org id 和 ak idAPI key 的标识部分)直接出现在 message 里。** 中转站若把上游错误原样透传给下游,等于把自己的账号标识和 key 标识广播给所有用户。**这是必须在错误改写层拦截的信息泄漏点**,比 §1.2 的响应头更严重(响应头容易被整体过滤,错误 message 往往被原样转发)。
其他:
- ✅ **499 是非标准状态码**nginx 系)。中转站的 HTTP 客户端和监控要能处理,别当 4xx 计入用户失败率。
- ✅ **504 的 900 秒超时**——中转站自身的上游超时必须 ≥900s,否则会在 Kimi 还在算时先断开,浪费已消耗的 token。
- ✅ 官方提醒:**OpenAI SDK 的自动重试会把 1 次请求放大成 2–3 次真实调用**,吃掉 RPM 配额。中转站应关掉 SDK 层重试,自己实现按 type 分流的重试。
### 12.3 限流响应头 🆕(文档完全未记载)
实测 429 响应头:
```
retry-after: 1
x-retry-after: 1
msh-cooldown-seconds: 1
```
**同一个信息重复了三份**(标准头 + 两个私有头),但**没有任何 `x-ratelimit-*` 头**——即**无法在请求成功时得知剩余配额**,只能等撞到 429 才知道。
> 中转站的配额管理因此只能靠本地计数 + 429 反馈,做不了"提前避让"。`retry-after` 是标准头,直接用它做退避即可。
### 12.4 限流分档 ✅
按累计充值金额定档(代金券不算):
| Tier | 累计充值 | 并发 | RPM | TPM | TPD |
| --- | --- | --- | --- | --- | --- |
| 0 | ¥0 | 1 | 3 | 500K | 1.5M |
| 1 | ¥50 | 50 | 200 | 2M | 无限 |
| 2 | ¥100 | 100 | 500 | 3M | 无限 |
| 3 | ¥500 | 200 | 5K | 3M | 无限 |
| 4 | ¥5K | 400 | 5K | 4M | 无限 |
| 5 | ¥20K | 1K | 10K | 5M | 无限 |
✅ **Tier 0 的 3 RPM 是真实且严格的**——本次实测全程必须每请求间隔 21 秒,任何并发都立刻 429。联调时若发现大量 `rate_limit_reached_error`,先看档位而不是查代码。
🆕 响应头 `msh-gid` 直接给出档位(实测为 `free`),**中转站可用它自动探测上游档位并据此设置本地并发上限**,无需人工配置。
---
## 13. 计价(K3,人民币)✅
| 项 | 单价 / 1M tokens |
| --- | --- |
| 输入(缓存命中) | ¥2 |
| 输入(缓存未命中) | ¥20 |
| 输出 | ¥100 |
- 1M = 1,000,000。无按上下文长度分档。
- **输出是命中输入的 50 倍**。加上思考默认 `max` 档、隐藏 system prompt、Preserved ThinkingK3 的实际输出 token 量显著高于同类模型——实测一句"说:好"能烧掉 **310 个 completion tokens**(其中 287 是 reasoning),而关闭思考后只要 **11 个**。
- **同一请求关不关思考差 28 倍输出成本**,这是 §4.1"K3 思考实际可关"这条发现的直接价值。
- Batch 省 40%(不支持 K3);联网搜索按次另计费,且**搜索 token 不在响应 usage 里**(§6.4)。
---
## 14. 实测推翻的文档结论(速查)
| # | 文档说法 | 实测结果 |
| --- | --- | --- |
| 1 | K3 思考恒开、不可关闭、不使用 `thinking` 参数 | ❌ `thinking:{"type":"disabled"}` 生效,`prompt_tokens` 88→21,无 `reasoning_content` |
| 2 | 流式 usage 只在 `choices[0].usage` | ❌ 传 `include_usage:true` 时在顶层 + `choices:[]`,与 OpenAI 一致 |
| 3 | `reasoning_effort` 仅接受 `low`/`high`/`max` | ❌ `"medium"`、`"banana"` 都返回 200,非法值静默忽略 |
| 4 | K3/K2.7-code 多轮缺 `reasoning_content` 会报错 | ❌ 200 正常继续 |
| 5 | `role:"tool"` 消息必须带 `name` | ❌ 不带也 200 |
| 6 | Partial Mode 与 `json_object` 冲突 | ❌ 200 正常工作 |
| 7 | 缓存需 prompt > 256 token 才命中 | ❌ 21 token 也命中 |
| 8 | K3 需最低充值 ¥10、代金券不解锁 | ❌ 纯代金券账号正常调用 K3 |
| 9 | Claude Code 用 `kimi-k3[1m]` | ❌ 两个入口都 404,正确名是 `kimi-k3` |
| 10 | `kimi-k2.5` context_length 128000(示例) | ❌ 实际 262144 |
**文档说不能、实际能**(1、3、4、5、6、7、8)意味着照文档写会**过度限制**;**文档说能、实际不能**(9)意味着照抄配置会**直接失败**。两个方向都有。
---
## 15. 仍待验证(下一轮实测)
1. **`json_schema` + `strict: true` 的 MFJS 实际约束**(§7)——本轮未测,是结构化输出的核心。
2. **K2.6 复杂 schema 吐 Markdown 围栏**的复现条件(§7)。
3. **官方工具(Formula API)与 `$web_search` 的关系**(§6.4)。
4. **`prompt_cache_key` 是否真的分裂缓存命名空间**(§10)——需要构造缓存命中/未命中对照。
5. **文件上传 `purpose` 的合法枚举全集**、Batch 端到端(§11.1、§11.2)。
6. **K3 关闭思考后 `tool_choice: "required"` 的行为**(§6.2)——指定函数已知会静默幻觉,`required` 未测。
7. **长对话 + 多步工具链下 `reasoning_content` 是否真的可省**(§4.3)——短对话已验证可省,长链路需复验。
8. **国际站 `api.moonshot.ai`** 的行为是否与国内站一致(本轮只测了国内站)。
9. **`kimi-k2.5`** 的思考开关与 max output(本轮未覆盖该模型)。
---
## 16. 中转站接入 checklist
**必须做的请求改写**
- [ ] 按模型剥离 `temperature`/`top_p`/`n`/`presence_penalty`/`frequency_penalty`(§3)——**最高优先级**,同时消除两个入口的行为差异。
- [ ] 流式请求一律补 `stream_options.include_usage = true`(§8)——用改写请求换取 OpenAI 标准的 usage 位置和缓存字段。
- [ ] thinking 参数按模型分派:K3 可用 `reasoning_effort` 或 `thinking`K2.7-code 只能 `enabled`K2.6/K2.5 用 `thinking`(§4)。
- [ ] 自行校验 `reasoning_effort` 枚举(上游不校验,非法值会静默跑最贵档)(§4.2)。
- [ ] `prefill` → `partial: true`(§5)。
- [ ] `n > 1` 时连带检查 `temperature > 0`,否则上游报的错无法理解(§3.1)。
- [ ] 模型名 `kimi-k3[1m]` 之类的方括号后缀要拒绝或剥离(§1.3)。
**必须做的响应/错误改写**
- [ ] **剥掉 `msh-org-id` / `msh-uid` / `msh-project-id` 响应头**(§1.2,账号标识泄漏)。
- [ ] **过滤 429 错误 message 里的 `org-xxx<ak-xxx>`**(§12.2,账号+key 标识泄漏)。
- [ ] 重写 `tool_calls[].id`(上游是 `{函数名}_{序号}`,多轮会重复)(§6.1)。
- [ ] 错误体补 `code`/`param` 以对齐 OpenAI;解析上游错误时容忍 `error` 字段可能是 string(网关格式)(§12.1、§1.3)。
- [ ] 非 chat 端点要剥掉 `{"code","scode","status","data"}` 业务包装(§1.2)。
- [ ] `tool_calls[].type` 可能是 `builtin_function`,不要按 OpenAI 枚举拒绝(§6.4)。
**必须保持不动的**
- [ ] prompt 前缀逐字节稳定,不注入、不重排、不规范化空白(§10,**10 倍价差**)。
- [ ] `content` 数组不要压成字符串(§9)。
- [ ] 内置工具与 `encrypted_output` 按 opaque 透传(§6.4)。
- [ ] 不给未传参数补默认值(§3.1)。
- [ ] 消息保留 passthrough 扩展字段(动态工具的 `{"role":"system","tools":[...]}` 无 content)(§6.3)。
**能力降级表**
- [ ] 思考开启时**无法强制调用指定函数**——应**显式报错**,不要靠关闭思考绕过(§6.2,会静默幻觉出假数据)。
- [ ] K2.7-code 思考不可关、`required` 也不可用(§4、§6.2)。
- [ ] 不支持 http(s) 图片 URL(§9)。
- [ ] 新模型不支持 `n > 1`(§3.1)。
- [ ] 动态工具加载仅 K3(§6.3)。
**运维配置**
- [ ] 上游超时 ≥900s(§12.2)。
- [ ] 关闭 SDK 层自动重试,按 error `type` 分流(过载退避 / 欠费停止 / 限流降并发)(§12.2)。
- [ ] 用 `retry-after` 头做退避;**没有 `x-ratelimit-*`,配额只能本地计数**(§12.3)。
- [ ] 用响应头 `msh-gid` 自动探测账号档位并设置本地并发上限(§12.4)。
- [ ] key 与区域 base URL 绑定成对(§1.1)。
- [ ] "200 OK + 空 content + `finish_reason: length`" 不触发重试(§4.4)。
- [ ] 用 `/v1/tokenizers/estimate-token-count` 做预估(口径含隐藏 system prompt,与计费一致)(§11.3)。
**Kimi For Coding 走单独一套配置**(§17):模型别名表、鉴权、限流、错误改写都不能与 platform 共用。
---
## 17. Kimi For Coding:同一家公司的另一套栈 ✅(2026-08-13 实测)
> Base URL`https://api.kimi.com/coding/v1`(根路径 `https://api.kimi.com/coding/` 返回 `{"message":"Welcome to the Kimi For Coding API!"}`
> Key 形如 `sk-kimi-...`。面向 Claude Code / 编码 Agent 的**订阅制**产品,不是 platform 的一个套餐。
**底层推理栈相同**(流式 `system_fingerprint` 两边都是 `fpv0_489ccb45`),**但接入层是完全独立的两套实现**。官方 troubleshooting 只用一句"API Platform keys ≠ Kimi Code keys"带过,实际差异远不止鉴权。
### 17.1 差异总表
| 维度 | platform`api.moonshot.cn` | Kimi For Coding`api.kimi.com/coding` |
| --- | --- | --- |
| Key 前缀 | `sk-` | **`sk-kimi-`** |
| 账号体系 | 组织/项目/余额 | **个人账号**(见 §17.4 |
| 模型名 | `kimi-k3`、`kimi-k2.7-code`、`moonshot-v1-*` | **`k3`、`k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`** |
| 模型名校验 | 严格,未知名 404 | ❌ **几乎不校验**(见 §17.3) |
| OpenAI 入口 | `/v1/chat/completions` | `/v1/chat/completions` |
| Anthropic 入口 | `/anthropic/v1/messages` | **`/v1/messages`**(与 chat 同前缀,无 `/anthropic` |
| thinking `signature` | ❌ 无 | ✅ **有真实签名** |
| Anthropic usage 字段 | 混入 OpenAI 字段(双套并存) | **纯 Anthropic 字段** |
| Anthropic 响应 id | `chatcmpl-...`(不规范) | **`msg_...`**(规范) |
| SSE 事件分隔 | `event: message_start`(有空格) | `event:message_start`**无空格** |
| `count_tokens` | ❌ 无 | ✅ `POST /v1/messages/count_tokens` |
| `estimate-token-count` | ✅ 有 | ❌ 404 |
| 余额查询 | ✅ `/v1/users/me/balance` | ❌ **404,无余额端点** |
| 账号信息端点 | ❌ 无 | 🆕 **`/v1/me`(返回手机号等 PII** |
| `tool_calls[].id` | `get_weather_0`(可预测、会重复) | **`tool_jfSEsjJ6HvGswLR2r8XaBjnc`**(随机串,规范) |
| `$web_search` 的 id | `t-web_search-{hash}` | `tool_{随机串}` |
| 响应 id | `chatcmpl-6a7d5c23...`(十六进制) | `chatcmpl-JfEbxqHjKWC5fADqUduALJfG`(大小写混合) |
| 限流 | Tier 0 = **3 RPM / 1 并发** | ✅ **并发 10 全部 200**,无可观测的 RPM 限制 |
| 调试响应头 | `msh-request-id` / `msh-org-id` / `msh-uid` / `msh-gid` | **`x-trace-id`**、`server-timing`、`server: nginx`**无 `msh-*`** |
| 网关错误 | 中文 `{"code":5,"error":"url.not_found","message":"没找到对象"}` | 统一 API 格式 `{"error":{"message":"The requested resource was not found","type":"resource_not_found_error"}}` |
| `/v1/models` 字段 | `owned_by`/`permission`/`root`/`parent` | **`display_name`/`created_at`/`type`** + 分页字段 `first_id`/`last_id`/`has_more` |
| `reasoning_effort` 默认 | `max` | **`high`**(省钱档) |
**行为一致的部分**(说明共用推理层):采样参数硬 400(`invalid temperature: only 1 is allowed for this model` 逐字相同)、`tool_choice` 指定函数与思考互斥的 400、K3 `thinking:{"type":"disabled"}` 可关(`prompt_tokens` 同样 21)、partial mode、`$web_search` 的 builtin_function 协议、流式 usage 由 `include_usage` 控制、`system_fingerprint` 值相同。
🆕 但**同一个校验的错误文案两边不同**,说明校验逻辑各写了一遍:
```
n=2 platform : invalid n: only 1 is allowed for this model
n=2 coding : Your request body contains invalid value for param n
```
### 17.2 模型列表(实测 `GET /coding/v1/models`
| id | display_name | context | 实测备注 |
| --- | --- | --- | --- |
| `kimi-for-coding` | K2.7 Coding | 262144 | **未知模型名的 fallback 目标**(§17.3.1);无隐藏 system prompt |
| `kimi-for-coding-highspeed` | K2.7 Coding Highspeed | 262144 | ⚠️ 与上一行**实测无法区分**(指纹、上限、速度全同) |
| `k3` | K3 | 1048576 | `default_effort: "high"`;带 79 token 隐藏 system prompt |
| `k3-256k` | K3-256k | 262144 | 实测是**网关层给 `k3` 加的 256K 限制**,非独立模型;`supports_video_in: false` |
四个模型全部 `supports_thinking_type: "only"`。**没有 moonshot-v1 系列**。
> ⚠️ 注意 `k3-256k` 是**同模型的短上下文 SKU**——这与 platform 用 `moonshot-v1-8k/32k/128k` 切窗口是同一思路,但命名规则又不一样。中转站的别名表要为两个上游各维护一份。
### 17.3 ❌ 最危险的差异:`model` 字段几乎不校验
实测 Kimi For Coding 对未知模型名**不报错,而是照常返回 200 并把请求的名字原样回显**:
```jsonc
{"model":"gpt-4", ...} → 200,响应 "model":"gpt-4",但内容带 reasoning_content(实际是 Kimi 在跑)
{"model":"claude-sonnet-4-5",...} → 200,响应 "model":"claude-sonnet-4-5"
{"model":"moonshot-v1-8k", ...} → 200,响应 "model":"moonshot-v1-8k"(该模型在此产品里根本不存在)
{"model":"foobar123", ...} → 200,响应 "model":"foobar123"
{"model":"", ...} → 200,响应 "model":""(空字符串也接受)
```
**唯一被拒的是带方括号的名字**,而且报错方式很离谱:
```
{"model":"k3[1m]"} → HTTP 401
{"error":{"message":"Your model id does not exist, recognized as other:k3[1m]. Please set model id as `k3`.",
"type":"invalid_authentication_error"}}
```
**模型不存在却返回 401 + `invalid_authentication_error`**——状态码和错误类型都与语义不符(platform 同样情况返回的是 404 `resource_not_found_error`)。中转站若按 401 触发"key 失效"逻辑去刷新凭据或告警,会被这个错误误导。
> **对中转站的三重后果**:
> 1. **无法用模型名做路由校验**——上游不会告诉你名字写错了,只会默默用 fallback 模型跑完并收钱。
> 2. **响应的 `model` 字段不可信**——它是请求值的回显,不是实际推理的模型。platform 那边至少 `moonshot-v1-auto` 会回显真实模型名(§2.1),这边连这个都没有。
> 3. **下游可能被误导**:用户传 `gpt-4` 拿到 200 和一个 `"model":"gpt-4"` 的响应,会以为真的调用了 GPT-4。**中转站必须自己维护白名单并拒绝未知模型名**,不能依赖上游校验。
### 17.3.1 反推:模型名确实参与路由,未知名 fallback 到 `kimi-for-coding` ✅
响应只回显请求值,无法直接观测实际模型。用**两个独立的侧信道**反推,结论一致。
**方法一:上下文上限边界。** 构造 270,015 token 的 prompt(经 `count_tokens` 校准,介于 262144 与 1048576 之间),分别打各模型名:
| 请求的 model | 结果 |
| --- | --- |
| `k3` | ✅ **200 正常返回**(真的吃下了 270K) |
| `k3-256k` | `k3-256k supports only 256K context.` |
| `kimi-for-coding` | `Invalid request: Your request exceeded model token limit: 262144 (requested: 270016)` |
| `gpt-4` | **同上,逐字相同** |
| `foobar123` | **同上,逐字相同** |
**方法二:隐藏 system prompt 的 token 指纹。** §4.1 发现思考模式会注入一段隐藏 system prompt,各模型注入量不同,可作指纹。同一句 `"说:好"`
| 请求的 model | `prompt_tokens` |
| --- | --- |
| `k3` | **89** |
| `k3-256k` | **89** |
| `kimi-for-coding` | **10** |
| `kimi-for-coding-highspeed` | **10** |
| `gpt-4` / `foobar123` / `""` / `claude-sonnet-4-5` | **10** |
两个方法交叉印证:
- ✅ **模型名确实参与路由**——`k3` 走 1M 上下文且带 79 token 的隐藏 system prompt`kimi-for-coding` 系列走 256K 且**无隐藏 prompt**。二者是两条不同的推理路径。
- ✅ **所有无法识别的模型名(含空字符串)一律 fallback 到 `kimi-for-coding` 系列****不是** `k3`。
- ✅ `k3-256k` 是**网关层对 `k3` 加的 256K 限制**,不是独立模型:它的隐藏 prompt 指纹与 `k3` 相同(89),但超限错误是一条**人工写的独立文案**(不含 `requested:` 数字),与 `kimi-for-coding` 那条模型层通用错误明显不同源。
> **中转站可直接用的结论**:用户若传了拼错的模型名,实际跑的是 K2.7 Coding(256K),而不是他以为的 K3——**上下文能力从 1M 悄悄降到 256K**,长上下文任务会在毫无提示的情况下超限失败。这使"自建模型名白名单"从建议变成必需。
⚠️ 仍无法区分的是 `kimi-for-coding` 与 `kimi-for-coding-highspeed`:两者 token 指纹相同(均为 10)、上下文上限相同(262144)、**实测输出速度也无差异**33.2 vs 34.9 tok/s,重复一轮后 highspeed 反而更慢)。官方宣称 highspeed 约 180 tok/s**实测两者都只有约 33 tok/s**——要么高速档在当前负载/订阅层级下未生效,要么二者本就是同一后端。
### 17.4 🆕 `/coding/v1/me` 返回个人身份信息(隐私暴露面)
platform 只有余额端点(返回金额),Kimi For Coding 则有一个 `/v1/me`
```jsonc
{
"user_id": "...", "global_id": "...",
"nickname": "登月者XXXX",
"avatar": "https://avatar.moonshot.cn/avatar/default/...",
"phone": {"country_code": "86", "number": "1XX****XXXX"}, // ← 部分脱敏的手机号(实测返回真实号码的首三位+后四位)
"status": "USER_STATUS_NORMAL", "region": "REGION_CN",
"user_level": 25, "user_level_name": "Allegretto",
"domain": 1, "domain_name": "DOMAIN_NEXUS",
"created_time": "...", "last_login_time": "..."
}
```
> **任何持有这个 key 的人都能读到账号主人的手机号(部分脱敏)、昵称、注册与最后登录时间。** 对中转站的含义:
> - **把 Kimi For Coding 的 key 交给第三方使用 = 交出账号主人的部分身份信息**,风险等级远高于 platform 的 key(那边最多泄漏余额)。多租户中转站尤其要注意。
> - 中转站**不应把 `/v1/me` 透传给下游**。若要做上游健康检查,用它是可行的(无余额端点可用),但响应必须裁剪,只保留 `status`。
> - 反过来,这也是**唯一能确认 coding key 是否有效**的轻量端点(没有余额接口)。
### 17.5 计费与配额:中转站看不见
- ❌ **没有余额/用量端点**`/balance`、`/usage`、`/quota`、`/subscription` 全部 404)。订阅制产品,用量只能在网页端看。
- 因此**中转站无法对 Kimi For Coding 做余额预警或成本核算**——只能自己按 usage 累加统计,且没有官方口径可对账。这与 platform(有 `/v1/users/me/balance`,可做健康检查与预警)是运维层面的实质差别。
- 限流宽松(10 并发实测无 429),但**限额规则未公开**,撞到限制时的错误类型未知(本轮未触发)。⚠️ 中转站应做好"上游可能在没有任何预警的情况下拒绝服务"的准备。
### 17.6 Anthropic 入口质量反而更高
Kimi For Coding 的 `/coding/v1/messages` 是**本次调研里最接近 Anthropic 官方规范的实现**
- ✅ 规范的事件序列:`message_start` → `content_block_start` → `content_block_delta` ×N → `content_block_stop` → `message_delta` → `message_stop`
- ✅ `msg_` 前缀的 id、纯 Anthropic 的 usage 字段、**thinking block 带 `signature`**
- ✅ 有 `POST /v1/messages/count_tokens`(返回 `{"input_tokens":9}`
- ✅ `x-api-key` 和 `Authorization: Bearer` 都接受,`anthropic-version` 头非必需
对比 platform 的 `/anthropic/v1/messages`id 是 `chatcmpl-`、usage 混两套字段、thinking 无 signature、无 count_tokens。
> **中转站若要做 Anthropic 格式的上游,Kimi For Coding 明显是更合适的一侧。**
### 17.6.1 反推:`signature` 是**装饰品**,服务端不校验 ✅
Anthropic 真机会校验 thinking 的 `signature`(篡改会报错),这是"推理内容不可跨家搬运"结论的根基(见 README §2 第 2 条)。实测 Kimi For Coding 的 signature(长达 12,946 字符,看起来很像真的):
| 多轮回传时对 thinking block 的处理 | 结果 |
| --- | --- |
| 原样回传 | 200 |
| **signature 全部替换成 `XXXX...`**(等长) | **200** |
| **完全删除 `signature` 字段** | **200** |
| **保留原 signature,但把 `thinking` 文本换成伪造内容** | **200** |
**四种情况全部正常返回,没有任何校验。**
> 这条对中转站是**减负**Kimi For Coding 的 thinking block **不必当 opaque blob 搬运**,可以自由丢弃 `signature`、截断甚至改写 thinking 内容——与 platform 那侧(明文无签名)的处理方式可以统一。
>
> 但反过来它是个**安全提示**:这个 signature 只是长得像密码学签名,实际不提供任何完整性保证。**中转站不要把它当作"上游已验证推理内容"的证据**,也不要在自己的对外 API 里模仿这种"假签名"——下游若据此信任内容,就是被误导。
>
> ⚠️ 注意方向性:这条结论**只适用于 Kimi**。转发到 Anthropic 真机时,signature 仍必须原样搬运。
### 17.7 结论:必须分开处理
**是的,必须当作两个完全独立的上游。** 具体到实现:
| 层面 | 要点 |
| --- | --- |
| 上游配置 | 独立的 base URL + key + 模型别名表;key 前缀(`sk-` vs `sk-kimi-`)可用于自动识别配错 |
| 模型路由 | **中转站自建白名单**——coding 侧不校验模型名,**错名静默 fallback 到 `kimi-for-coding`(256K),上下文能力从 1M 悄悄降级**(§17.3.1 |
| thinking | coding 侧的 `signature` 不参与校验,可自由丢弃/改写(§17.6.1);转发到 Anthropic 真机时则必须原样搬运 |
| 响应处理 | coding 侧的 `model` 字段是回显值,**不可用于记账**;platform 侧 `moonshot-v1-auto` 也有类似问题 |
| 错误处理 | coding 侧模型不存在返回 **401**,不能触发凭据刷新逻辑;两侧错误文案不同 |
| 限流 | platform 需按 Tier 严格限速(Tier 0 = 3 RPM);coding 侧宽松但规则不透明 |
| 运维 | platform 用 `/v1/users/me/balance` 做健康检查;coding 只能用 `/v1/me`**响应需裁剪,含 PII** |
| Anthropic 格式 | 优先用 coding 侧;但其 thinking `signature` 必须 opaque 搬运,platform 侧则不用 |
| 隐私 | coding key 泄漏 = 泄漏账号主人手机号等信息,**不可作为共享凭据下发** |
+225
View File
@@ -0,0 +1,225 @@
# OpenAI API
> 来源:
> - OpenAPI 规范(权威):https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml
> - https://developers.openai.com/api/reference/resources/responses/methods/create
> - https://developers.openai.com/api/docs/guides/function-calling.md
> - https://developers.openai.com/api/docs/guides/structured-outputs.md
> - https://developers.openai.com/api/docs/guides/reasoning.md
> - https://developers.openai.com/api/docs/guides/migrate-to-responses.md
>
> 采集于 2026-08-12。注意:`platform.openai.com/docs/api-reference/*` 对爬虫返回 403,文档主站已迁移到 `developers.openai.com`;页面 URL 后加 `.md` 可直接取 Markdown 版本。
OpenAI 目前有**两套并存**的文本生成接口,中转站通常两套都要支持:
- `POST /v1/chat/completions` — 老接口,生态最广,几乎所有第三方"OpenAI 兼容"服务都实现它。
- `POST /v1/responses` — 新接口,OpenAI 主推,reasoning 模型的完整能力只在这里。
---
## 1. Chat Completions
### 1.1 请求参数
必填:`model``messages`
`messages[]` 的 role 取值:`system``developer``user``assistant``tool``function`(deprecated)。
来自 `CreateChatCompletionRequest`
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `messages` | array | — | 必填,minItems 1 |
| `model` | string | — | 必填 |
| `max_completion_tokens` | int\|null | — | 输出上限,**含 reasoning tokens** |
| `max_tokens` | int\|null | — | **已 deprecated**,且与 o 系列/reasoning 模型不兼容 |
| `modalities` | array | — | 如 `["text"]` / `["text","audio"]` |
| `verbosity` | enum | — | 输出详略控制 |
| `reasoning_effort` | enum | — | 见 §3 |
| `frequency_penalty` | number | 0 | -2 ~ 2 |
| `presence_penalty` | number | 0 | -2 ~ 2 |
| `response_format` | object | `{type:"text"}` | `text` / `json_object` / `json_schema` |
| `stream` | bool | false | SSE |
| `stream_options` | object | — | `{include_usage: true}` |
| `stop` | string\|array | — | 停止序列 |
| `logit_bias` | map<int,int> | null | -100 ~ 100 |
| `logprobs` | bool | false | |
| `top_logprobs` | int | — | 0 ~ 20,需 `logprobs=true` |
| `n` | int | 1 | 1 ~ 128 |
| `seed` | int | — | **已 deprecated**Beta,不保证确定性 |
| `store` | bool | false | 是否存储用于 distillation/evals>8MB 图片会被丢弃 |
| `moderation` | object | — | 请求/输出审核配置 |
| `prediction` | object | — | Predicted Outputs |
| `audio` | object | — | `modalities:["audio"]` 时必填,含 `voice``format` |
| `web_search_options` | object | — | 内置 web 搜索 |
| `tools` | array | — | function tools 或 custom tools |
| `tool_choice` | string\|object | — | `none`/`auto`/`required`/指定函数/`allowed_tools` |
| `parallel_tool_calls` | bool | true | |
| `function_call` | — | — | **deprecated**,用 `tool_choice` |
| `functions` | array | — | **deprecated**,用 `tools`1~128 项 |
继承自 `ModelResponseProperties`Chat 与 Responses **共享**):
| 参数 | 类型 | 范围/默认 | 说明 |
| --- | --- | --- | --- |
| `temperature` | number | 0 ~ 2,默认 1 | |
| `top_p` | number | 0 ~ 1,默认 1 | 建议与 temperature 二选一 |
| `metadata` | map | — | |
| `user` | string | — | **deprecated**,被 `safety_identifier` + `prompt_cache_key` 取代 |
| `safety_identifier` | string | ≤64 字符 | 建议传用户名/邮箱的 hash |
| `prompt_cache_key` | string | — | 提升缓存命中率,取代 `user` |
| `service_tier` | enum | — | |
| `prompt_cache_retention` | enum | `in_memory` / `24h` | **deprecated**,改用 `prompt_cache_options.ttl``gpt-5.5` 及以后只支持 `24h` |
| `prompt_cache_options` | object | — | 含 `ttl` |
| `top_logprobs` | int | 0 ~ 20 | |
### 1.2 响应
```json
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1741570283,
"model": "gpt-4o-2024-08-06",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "...", "refusal": null, "annotations": []},
"logprobs": null,
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 1117,
"completion_tokens": 46,
"total_tokens": 1163,
"prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
"completion_tokens_details": {
"reasoning_tokens": 0, "audio_tokens": 0,
"accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0
}
},
"service_tier": "default",
"system_fingerprint": "fp_..."
}
```
`finish_reason` 枚举:`stop` | `length` | `tool_calls` | `content_filter` | `function_call`(deprecated)。
`system_fingerprint` 已标记 **deprecated**
`choices[].logprobs` 是 nullable 且在 schema 中被列为 **required**——即必须存在该 key,值可为 `null`。中转站生成响应时不要省略这个字段,严格的客户端会校验。
### 1.3 流式
`stream: true` 时返回 `object: "chat.completion.chunk"` 的 SSE,每个 chunk 的 `id` 相同,内容在 `choices[].delta`
关键点:
- 设置 `stream_options: {"include_usage": true}` 时,**最后一个 chunk 的 `choices` 为空数组**,只带 `usage`。这是很多客户端解析崩溃的来源。
- 流以 `data: [DONE]` 结束(非 JSON)。
- 工具调用的 `arguments` 以字符串分片下发,需按 `tool_calls[].index` 累加。
---
## 2. Responses API
`POST /v1/responses`
### 2.1 与 Chat Completions 的字段映射
| Chat Completions | Responses |
| --- | --- |
| `messages` | `input`(可为 string,也可为 Item 数组) |
| system / developer 消息 | `instructions`(顶层参数) |
| `max_completion_tokens` | `max_output_tokens` |
| `response_format` | `text.format` |
| `choices[0].message.content` | `output_text`SDK 便捷字段)/ `output[]` 里的 message item |
| `tool_calls[]`(含 `id` | `function_call` item(含 **`call_id`** |
| `tools[].function.{name,...}` | `tools[].{name,...}`(少一层嵌套) |
### 2.2 专有请求参数
| 参数 | 说明 |
| --- | --- |
| `background` | 后台执行 |
| `conversation` | 会话 ID 或 `{id}` 对象,自动把历史 item 拼到 input 前 |
| `previous_response_id` | 由 OpenAI 托管上下文;**注意仍需重发 `instructions`** |
| `store` | Responses **默认存储**Chat Completions 对新账户默认存储) |
| `include` | 请求额外输出,如 `web_search_call.action.sources``file_search_call.results` |
| `context_management` | `{type, compact_threshold}` 自动压缩上下文 |
| `reasoning` | `{effort, summary}` |
### 2.3 输出 item 类型
`output[]` 是异构数组,已知类型:
- `message``{id, role:"assistant", content:[text|refusal], status, phase?}``phase` 可为 `commentary` / `final_answer`
- `function_call``{arguments, call_id, name, status}`
- `file_search_call``{id, queries, status, results}`
- `web_search_call``{id, action, status}`
- `computer_call``{id, call_id, pending_safety_checks, action/actions, status}`
- `reasoning``{id, summary, content, encrypted_content, status}`
- `compaction``{id, encrypted_content}`
`status` 取值:`in_progress` | `completed` | `incomplete`
### 2.4 流式
事件是命名事件(如 `response.function_call_arguments.delta` / `.done`),与 Chat Completions 的匿名 chunk 完全不同结构。
未取到完整事件清单(`/api/reference/responses/streaming` 返回 404),实现前需补齐。
---
## 3. Reasoning 模型的怪癖
这是 OpenAI 侧最容易踩的一类坑:
1. **`max_tokens` 不可用**。Chat Completions 必须用 `max_completion_tokens`Responses 必须用 `max_output_tokens`。老代码直接透传 `max_tokens` 会报错。
2. **`temperature``top_p``presence_penalty` 不支持**(reasoning 模型上)。中转站若把这些参数无条件透传,请求会失败。
3. **`max_output_tokens` 包含 reasoning tokens**。官方建议实验时至少留 **25,000** tokens。给一个小的上限很可能只烧掉推理、输出为空。
4. **reasoning tokens 按 output token 计费**,占用上下文窗口,统计在 `output_tokens_details.reasoning_tokens`Chat 侧为 `completion_tokens_details.reasoning_tokens`)。
5. **`reasoning.effort` 取值随模型而变**`none` | `minimal` | `low` | `medium` | `high` | `xhigh` | `max``gpt-5.5` 默认 `medium`。不要假设所有模型接受全部取值。
6. **reasoning summary 需显式开启**`reasoning.summary`,设 `"auto"` 取最详细的可用摘要。默认不返回。
7. **无状态模式下必须回传 `encrypted_content`**。当 `store: false` 或组织启用 ZDR 时,reasoning item 携带 `encrypted_content`,多轮要把它传回去才能保留推理链。
8. **函数调用多轮时必须带回 reasoning item**:把最后一次 function call 返回的 reasoning items 与 function output 一起回传;连续多次函数调用时,需保留自上一条 user 消息以来的**所有** reasoning / function_call / function_call_output item。
---
## 4. Function calling 的怪癖
- **`strict: true` 的硬性要求**`additionalProperties` 必须为 `false`**所有** `properties` 都必须出现在 `required` 里。可选字段只能用 `"type": ["string", "null"]` 表达。
- **`strict` 在两套 API 下行为不同**Responses API 中 `strict` 默认尝试严格模式,**失败时自动回退到非严格**;Chat Completions 无此回退。
- **`tool_choice` 取值**`"auto"`(默认)| `"required"` | `"none"` | `{"type":"function","name":"x"}` | `allowed_tools`(限定子集)。
- **`parallel_tool_calls: false`** 保证一轮最多一次工具调用。
- **ID 字段名不一致**Chat Completions 是 `tool_calls[].id`Responses 是 `function_call.call_id`。跨 API 转换时这是最常见的 bug 点。
- **流式 arguments 是分片字符串**Responses 侧要累加 `response.function_call_arguments.delta` 直到 `.done`
---
## 5. Structured Outputs 的怪癖
`strict: true` 下 JSON Schema 只支持一个子集:
**支持**string、number、boolean、integer、object、array、enum、anyOf。
**不支持的关键字**`allOf``not``dependentRequired``dependentSchemas``if`/`then`/`else`
微调模型还额外不支持:字符串的 `minLength`/`maxLength`/`pattern`/`format`,数字的范围约束,数组的 `minItems`/`maxItems`
**结构上限**
- 最多 5000 个对象属性
- 最多 10 层嵌套
- 所有属性名 + 定义名 + 枚举值的总字符数 ≤ 120,000
- 枚举最多 1000 个值;超过 250 个时,单个属性的枚举值总长 ≤ 15,000 字符
- **根对象必须是 object,不能是 `anyOf`**
- 所有字段必须在 `required` 中;`additionalProperties` 必须为 `false`
**`refusal` 字段**:安全拒绝时,响应带一个**不遵守 schema** 的 `refusal` 字段。解析方必须先判断 `refusal` 再解析 JSON。
**与 `json_object` 的区别**`json_object` 只保证是合法 JSON**不保证符合 schema**。
---
## 6. 其他杂项怪癖
- `n > 1` 时按所有 choices 的生成 token 总量计费。多数第三方"OpenAI 兼容"实现只支持 `n=1`
- `seed` 已 deprecated 且从来不保证确定性。
- `store: true` 时超过 8MB 的图片输入会被静默丢弃。
- `prompt_cache_retention``prompt_cache_options.ttl` 是**两个独立且不交互**的字段:前者表达最大保留策略,后者表达最小缓存寿命。
+575
View File
@@ -0,0 +1,575 @@
# zend-token 转写方案(Transform Spec
> 状态:设计 v0.3 · 2026-08-13
> 范围:**Claude / OpenAI / DeepSeek / Kimi 四家**的双向转写实现规范。
> 与 [api-design.md](./api-design.md) 的关系:那份定义**对外契约**(端点、IR、降级协议),本份定义**转写引擎本身**——上游画像、改写管线、逐字段规则、测试口径。v0.2 的双规范模型(OaiIR / AnthIR)在此**保留不变**,本文是它的落地层。
> 依据:[anthropic.md](./anthropic.md)、[openai.md](./openai.md)、[deepseek.md](./deepseek.md)、[kimi.md](./kimi.md)(含实测)、[cross-provider-mapping.md](./cross-provider-mapping.md)、[compatibility-layers.md](./compatibility-layers.md)、[errors-and-limits.md](./errors-and-limits.md)。
---
## 0. 一页结论
四家做同一件事(多轮对话 + 工具调用 + 推理),但**它们的差异不在一个层次上**,混在一起处理必然写成一堆 if-else。本方案把差异**分成四层**,每层用不同机制消化:
| 层 | 差异性质 | 举例 | 消化机制 |
| --- | --- | --- | --- |
| **L-A 方言** | 同一语义、不同字段名/形状 | `input_schema` vs `parameters``prefix` vs `partial` | **双 IR + 桥**(§4 |
| **L-B 能力** | 上游根本没有这个能力 | Anthropic 无 `n>1`、Kimi 不收 http 图片 URL | **能力矩阵 + 降级协议**api-design §6/§7 |
| **L-C 怪癖** | 有能力但要求特定写法,否则 400 或静默失效 | Kimi K3 `temperature≠1` 硬 400、DeepSeek 关思考三入口三种写法 | **上游 Profile 驱动的改写管线**(§5、§6)← 本文核心新增 |
| **L-D 诚信度** | 上游返回的东西**不可信** | Kimi coding 模型名回显、假 signature、200+空 content | **可信度分级 + 网关自持真相**(§7、§8、§9 |
**三条贯穿全文的硬规则**(违反任意一条,转写在生产上一定出事):
1. **上游身份是三元组 `(provider, entry_family, model)`,不是 provider。** 同一家的两个入口对同一参数行为相反(Kimi `temperature`OpenAI 入口 400 / Anthropic 入口静默忽略;DeepSeek `tool_choice: any`Anthropic 入口静默降级 / OpenAI 入口 400)。任何"按 provider 分支"的代码都是错的。
2. **最小合法请求体。** 只发 Profile 白名单里的字段,**绝不为未传参数补默认值**(Kimi K3 补 `temperature=0.7` 直接 400moonshot-v1 补默认值会改变原有行为)。
3. **上游说什么都不算数。** 模型名、usage、错误状态码、signature 全都可能是装饰品。网关必须自持一份真相(白名单、账单口径、错误分类),只在能交叉验证时才采信上游。
---
## 1. 求同:四家真正共享的内核
剥掉方言后,四家**语义完全一致**的部分只有下面这些。IR 的核心字段集应当**恰好等于**这张表——多一个字段就意味着某一族要造轮子,少一个就意味着有损。
| 内核语义 | 四家一致的部分 | 落在 IR 的字段 |
| --- | --- | --- |
| 会话是 user/assistant 交替的消息序列 | ✅ 全一致 | `messages[]` / `items[]` |
| 系统指令独立于对话 | ✅ 语义一致(位置不同) | `instructions` / `system` |
| 内容是多模态块序列(文本 / 图片 / 文档) | ✅ 结构一致(字段名不同) | `Part` / `Block` |
| 工具 = JSON Schema 描述的函数 | ✅ 全一致(嵌套层数不同) | `tools[]` |
| 模型发起调用 → 客户端执行 → 结果回灌 | ✅ 全一致(载体不同) | `tool_use` / `function_call` |
| 输出上限、温度、top_p、停止序列 | ✅ 语义一致(取值域不同) | `sampling` |
| 停止原因(自然 / 截断 / 工具 / 过滤) | ✅ 四个语义一致 | `stop_reason` |
| 流式增量下发 | ✅ 都是 SSE | 流式规范层 |
| 用量计数(输入 / 输出 / 缓存 / 推理) | ✅ 概念一致(口径不同) | `usage` |
| 前缀缓存 | ✅ 四家都有 | 无字段(见 §11) |
**共识只到这里。** 下面每一项在四家之间都无法直接对齐:推理内容的载体与回传规则、结构化输出的 schema 方言、内置工具、前缀续写(prefill)、`n>1`、logprobs、`is_error`
> 设计含义:**IR 只保内核 + 各族原生扩展**,不试图为"推理""内置工具"发明统一抽象。它们走 §7 的可信度分级和 opaque 透传。
---
## 2. 存异:一张必须先看的上游身份表
我们要接的**不是 4 个上游,是 8 个入口**。逐个列出,因为每一行的合法请求体都不同:
| # | 上游身份 | Base URL | 族 | 备注 |
| --- | --- | --- | --- | --- |
| 1 | `anthropic / messages` | `api.anthropic.com/v1` | Claude | 唯一有真 signature 的上游 |
| 2 | `openai / chat` | `api.openai.com/v1` | OpenAI | 生态基准 |
| 3 | `openai / responses` | 同上 | OpenAI | reasoning 完整能力 |
| 4 | `deepseek / chat` | `api.deepseek.com` | OpenAI | `image_url` 硬 400 |
| 5 | `deepseek / anthropic` | `api.deepseek.com/anthropic` | Claude | 错误体是 OpenAI 格式;signature = 响应 id |
| 6 | `deepseek / responses` | `api.deepseek.com/v1` | OpenAI | 图片被换成 `[Unsupported Image]` |
| 7 | `kimi-platform / chat` + `/anthropic` | `api.moonshot.cn/v1``/anthropic/v1` | 两族 | 采样参数两入口行为**相反** |
| 8 | `kimi-coding / chat` + `/messages` | `api.kimi.com/coding/v1` | 两族 | **另一套账号体系**key 前缀 `sk-kimi-` |
外加 DeepSeek 的 `/beta`prefix、FIM、strict)——**同一 provider 的第四个入口**,主 base URL 上传 `prefix` 是硬 400。
### 2.1 四家的"性格"速判
这决定了转写引擎对它们的默认信任度:
| | Anthropic | OpenAI | DeepSeek | Kimi platform | Kimi coding |
| --- | --- | --- | --- | --- | --- |
| 未知参数 | 静默忽略 | 静默忽略 | 静默忽略 | 静默忽略 | 静默忽略 |
| **已知参数越界** | clamp/忽略 | 400 | **400** | **400(且是"只允许固定值"** | **400** |
| 未知模型名 | 404 | 404 | 400(但旧名静默别名) | 404 | **200 + 静默 fallback** |
| 不支持的模态 | — | — | **静默替换占位文本**2 个入口) | 400 | 400 |
| 错误体格式 | 规范 | 规范 | Anthropic 入口用 OpenAI 格式 | **三种格式并存** | 统一 |
| signature | **真密文** | `encrypted_content` 真密文 | **伪造(= 响应 id** | 无该字段 | **假的,不校验** |
| 文档可信度 | 高 | 高 | 中(3 处实测推翻) | **低(10 条实测推翻)** | 低 |
> **最危险的两格**是 DeepSeek 的"静默替换占位文本"和 Kimi coding 的"未知模型名 200"——它们产生 **HTTP 200 + 语义错误的响应**,下游无从察觉。网关必须在**请求侧**拦掉这两类情况,而不是指望上游报错。
---
## 3. 架构总图
```
┌──────────── 入口方言层 (§4) ────────────┐
POST /v1/chat/completions ─┐
POST /v1/responses ────────┼──→ 【OaiIR】────┐
POST /v1/messages ─────────┼──→ 【AnthIR】───┤
POST /v1/messages/count_tokens │
└────────────────────┼─────────────────┘
┌───────────▼───────────┐
│ 路由 + 族决策 (§6.2) │
└───────┬───────┬───────┘
同族直出 │ │ 跨族
│ ┌────▼─────┐
│ │ 跨族桥 │ ← 唯一有损点
│ │ (§4.3) │
│ └────┬─────┘
┌───────▼───────▼───────┐
│ 请求改写管线 (§6) │ ← Profile 驱动
│ R1…R9 顺序固定 │
└───────────┬───────────┘
┌─────────────────▼─────────────────┐
│ 上游 8 个入口(§2) │
└─────────────────┬─────────────────┘
┌───────────▼───────────┐
│ 响应改写管线 (§8) │
│ S1…S9 顺序固定 │
└───────────┬───────────┘
流式规范层 (§9)
```
**关键点**:改写管线是**数据驱动**的——同一份代码跑所有上游,行为差异全部来自 Profile(§5)。新增一家 provider 的正常代价应该是"写一份 Profile + 若干黄金样本",只有真正的新协议形态(比如 MiniMax 的 `base_resp`)才需要新增代码路径。
---
## 4. 方言层与跨族桥(L-A
沿用 api-design §3–§5,此处只补**四家实测暴露出的、v0.2 未覆盖的转写规则**。
### 4.1 消息归一的两条新约束
1. **`role: "developer"` 必须降级为 `system`**——DeepSeek 硬 400 `unknown variant 'developer'`。归一发生在入口层(OaiIR 本就把 system/developer 提升到 `instructions`),所以只要 §3.4 的不变量被执行,这条自动满足。**但 passthrough 路径要单独检查**。
2. **消息级扩展字段不得裁剪。** Kimi K3 的动态工具加载是一条 `{"role":"system","tools":[...]}` 且**没有 `content`** 的消息——OpenAI 协议里不存在这种形状。IR 的消息对象必须带 `ext` 字典承载它,schema 校验不能要求 `content` 必填。
### 4.2 工具调用 ID:一律重写为网关 ID
四家的 ID 形态:
| 上游 | 形态 | 问题 |
| --- | --- | --- |
| OpenAI Chat / Responses | `call_abc…` / `call_id` | 无 |
| Anthropic | `toolu_…` | 无 |
| DeepSeek | OpenAI 风格 | 非流式响应**也带 `index`**OpenAI 不带) |
| **Kimi platform** | **`{函数名}_{序号}`**,如 `get_weather_0` | **可预测、且多轮会重复** |
| Kimi platform builtin | `t-web_search-{hash}``type``builtin_function` | 打破 OpenAI 枚举 |
| Kimi coding | `tool_{随机串}` | 无 |
**规则**:网关对外一律下发自己的不透明 ID`ztk_tc_<random>`),维护 `(session, gateway_id) → upstream_id` 映射,回程还原。理由不是洁癖——Kimi platform 的重复 ID 会让"按 ID 做去重/匹配"的逻辑在多轮里静默错配。
同时:**`tool_calls[].type` 的解析必须容忍 `builtin_function`**,不能按 OpenAI 枚举拒绝。
### 4.3 跨族桥的补充规则
在 api-design §5.2/§5.3 之上追加:
| 场景 | 规则 |
| --- | --- |
| `arguments` 解析失败 | 降级 `input: {}` + L2,原文进诊断字段(**不抛异常**)——流式截断时常见 |
| A→O 的 `tool_result.is_error` | 文本前缀 `Error: ` + L2 标记(有损,无解) |
| O→A 的 `function_call` | **必须回填进前一条 assistant 消息**,不新建消息 |
| Kimi 动态工具 system 消息 | **禁止跨族**——AnthIR 无处安放,直接 L3 报错 |
| `prefill` 语义 | IR 单一 `prefill` 标志 → DeepSeek `prefix:true`(且**必须切 `/beta`**/ Kimi `partial:true` / Anthropic 4.5 及更早的末条 assistant**Claude 4.6+ 与 Mythos Preview 一律 L3 拒绝** |
| Kimi `partial``name` | Kimi 语义是"人设锚点",不是 OpenAI 的说话人区分——**转发时不要丢弃** |
---
## 5. 上游 Profile:把怪癖变成数据(L-C 的核心)
一份 Profile 描述一个**上游身份**(§2 表中的一行)。管线只读 Profile,不认 provider 名字。
### 5.1 Schema
```jsonc
{
"id": "kimi-platform/openai",
"family": "openai", // openai | anthropic —— 决定是否过桥
"base_url": "https://api.moonshot.cn/v1",
"auth": {"scheme": "bearer", "key_prefix": "sk-", "reject_prefix": ["sk-kimi-"]},
"params": {
"policy": "whitelist", // 白名单:未列出的字段一律不发
"allow": ["model","messages","tools","tool_choice","stream","stop",
"max_tokens","max_completion_tokens","response_format"],
"clamp": {"temperature": [0, 1]},
"force": {"stream_options.include_usage": true}, // 仅当 stream=true
"never_default": ["temperature","top_p","n","presence_penalty","frequency_penalty"],
"couplings": [
{"when": "n > 1", "require": "temperature > 0", "else": "reject:400"}
]
},
"model_overrides": { // 参数合法性按模型分裂
"kimi-k3": {"drop": ["temperature","top_p","n","presence_penalty","frequency_penalty"]},
"kimi-k2.7-code": {"drop": ["temperature","top_p","n","presence_penalty","frequency_penalty"],
"thinking": {"toggle": "forbidden"}},
"moonshot-v1-*": {"clamp": {"temperature": [0,1], "n": [1,5]}}
},
"thinking": {
"carrier": "reasoning_content", // reasoning_content | thinking_block | reasoning_item
"trust": "plaintext", // plaintext | fake_sig | opaque (§7.1
"toggle": {"param": "thinking.type", "off": "disabled", "on": "enabled"},
"effort": {"param": "reasoning_effort", "values": ["low","high","max"],
"upstream_validates": false}, // false = 网关自己校验枚举
"replay": "optional", // required_on_tool | optional | forbidden
"hidden_prompt_tokens": 67 // 思考开启时上游注入的隐藏 system prompt
},
"models": {
"policy": "gateway_whitelist", // 永不依赖上游校验
"reject_patterns": ["\\[.*\\]$"], // kimi-k3[1m] 之类
"echo_trustworthy": false // 响应 model 字段能否用于记账
},
"response": {
"envelope": "none | code_data | base_resp",
"strip_headers": ["msh-org-id","msh-uid","msh-project-id"],
"scrub_error_message": ["org-[a-z0-9]+<ak-[^>]+>"],
"usage_dialect": "openai | anthropic | dual",
"stream_usage_position": ["top_level","choices0","last_chunk"] // 全部探测
},
"errors": {
"shapes": ["openai","anthropic","gateway_cn"], // 解析时全部尝试
"retry_class": {"engine_overloaded_error":"backoff",
"rate_limit_reached_error":"throttle",
"exceeded_current_quota_error":"stop"}
},
"limits": {"timeout_s": 900, "rpm": 3, "concurrency": 1,
"tier_probe_header": "msh-gid", "sdk_retry": "off"}
}
```
### 5.2 四家 Profile 关键值对照
这张表是转写引擎的**实际配置内容**,逐格都有实测或文档依据:
| Profile 字段 | anthropic | openai/chat | deepseek/chat | deepseek/anthropic | kimi-platform/openai | kimi-platform/anthropic | kimi-coding/* |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `temperature` 域 | 01 | 02 | 02(越界 400 | **02**(比官方宽) | K3 系列**必须省略** | 静默忽略 | 同 platform |
| 越界处理 | clamp | 400 | 400 | 400 | **400** | 忽略 | 400 |
| `n>1` | 不支持 | 1128 | 400 | — | 400 | — | 400(文案不同) |
| 关思考写法 | `thinking.type` 按代际三选一 | `reasoning_effort` | `thinking.type` **或** 顶层 `reasoning_effort:none` | `thinking.type`**文档说的 `reasoning.effort` 无效** | `thinking.type:disabled`(**文档说不可关,实测可关**) | 同左 | 同左 |
| effort 校验 | 枚举严格 | 按模型 | **不校验**(非法值静默跑默认) | 不校验 | **不校验** | 不校验 | 默认档 `high` |
| `tool_choice` 强制 | `any` / `tool` | `required` / 指定 | **思考开启时 400** | `any` **静默降级 auto**;指定 400 | 思考开启:`required` ✅ / 指定 400 | 指定 400 | 同 platform |
| 图片输入 | base64 / url | url / base64 | **400** | **静默换占位文本** | base64 / `ms://`**http URL 400** | 同左 | 同左 |
| `strict` | 支持 | 支持 | **beta,静默失效** | — | MFJS 自造方言 | — | — |
| 流式 usage 位置 | `message_delta`**累计值** | 独立 chunk(需 `include_usage` | **挂在最后一个正常 chunk** | 规范 | **由 `include_usage` 决定两种位置** | — | 同 platform |
| 缓存 | 显式断点 ×4 | `prompt_cache_key` | 全自动,**64 token 粒度** | 同左 | 全自动(`cache_control` 空操作) | 同左 | 同左 |
| 思考隐藏 prompt | — | — | **~79 token** | ~79 | **~67 token**K3coding 侧 79 | 同左 | `kimi-for-coding` **0** |
| 超时 | 10 minSDK 校验) | — | — | — | **900 s** | 900 s | 900 s |
---
## 6. 请求改写管线(R1–R9)
**顺序固定**。每一步的输入输出都是 IR,便于单测与快照对比。
### R1 入口归一
方言 → 族 IR,执行 api-design §3.4 的不变量。此处唯一的新增是 §4.1 的两条。
### R2 模型解析
1. 查网关白名单(`(tenant, logical_model) → 上游身份 + 上游模型名`)。**未命中 404**,绝不兜底。
2.`models.reject_patterns` 拒绝 `kimi-k3[1m]` 这类客户端侧后缀(Kimi 两个入口都 404,coding 侧更离谱:**返回 401 `invalid_authentication_error`**)。
3. 记录 `requested_model`(下游传的)与 `routed_model`(我们实际发的)——两者都要进账单(§8.5)。
> 为什么必须自建白名单:Kimi coding 对任意模型名返回 200,未知名**静默 fallback 到 `kimi-for-coding`256K**,用户以为在用 K3(1M),长上下文任务会在毫无提示的情况下超限失败。DeepSeek 把 `deepseek-chat` 静默映射到 `deepseek-v4-flash`。这两类静默替换只有网关能拦。
### R3 族决策
`入口族 == Profile.family` → 直出;否则过桥。桥的产物仍是 IR。
### R4 能力校验与降级
按 api-design §7 的 L1/L2/L3 三级判定,产出 `degradation[]`。**L3 默认 400**,除非 `ztoken.allow_degradation` 放行。
四家新增的 L3 项:
| L3 项 | 触发上游 | 理由 |
| --- | --- | --- |
| 图片/文档输入 | deepseek 全入口、kimihttp URL | DeepSeek 会静默替换成 `[Unsupported Image]`,产生 200 + 错误答案 |
| 思考开启 + 强制指定函数 | kimi 全入口、deepseek | **不得靠关思考绕过**:Kimi K3 关思考 + 指定函数 → 200 但**模型幻觉出假数据**,比 400 危险得多 |
| `prefill` → Claude 4.6+ | anthropic | 上游明确禁止 |
| 动态工具 system 消息跨族 | 非 kimi 上游 | 无法表达 |
### R5 参数消毒(本管线最重要的一步)
`params.policy = whitelist` 执行,顺序:
```
1. 丢弃不在 allow model_overrides.allow 中的字段
2. 应用 model_overrides.drop ← Kimi K3 剥离全部采样参数
3. clamp 到合法域 ← O→A 的 temperature >1 截为 1(不做线性缩放)
4. 校验 couplings ← n>1 必须 temperature>0(否则 Kimi 报的错无人能懂)
5. 应用 force ← 流式一律补 stream_options.include_usage=true
6. never_default 检查:确认没有任何一步给未传的参数补了默认值
```
> 第 2 步是本项目 ROI 最高的一条规则:**对 Kimi 新模型剥离 `temperature/top_p/n/presence_penalty/frequency_penalty`**,一次同时解决"OpenAI 客户端默认 `temperature=0.7` 开箱 400"和"两个入口行为相反"两个问题——剥离后两侧行为一致。
>
> 第 6 步是**断言**,不是转换。它防止后续维护者出于"对齐 OpenAI 默认值"的好意加回默认值——那会在 Kimi 上 400、在 moonshot-v1 上悄悄改变输出。
### R6 thinking 编排
见 §7。
### R7 结构化输出与工具 schema
- `json_schema` **不做跨家 schema 翻译**。Profile 声明支持则透传,否则按能力矩阵降级(`json_object`L3)。
- DeepSeek 的 `json_object` **要求 prompt 里出现 "json" 字面量**,否则硬 400。网关的选择:**检测到缺失时报一个自解释的 400**(`"DeepSeek 要求 prompt 含 'json' 字样"`),不要自作主张往用户 prompt 里注入 —— 注入会破坏前缀缓存(§11),且改变了用户的输入。
- Kimi 的 `strict` 走自造的 MFJS 方言,OpenAI 的 strict 有硬性 schema 子集要求(`additionalProperties:false` + 全字段 `required`)。**跨家时 `strict` 一律标 L3**。
### R8 缓存前缀保护
在请求出网前做一次断言:本次请求的 `(tools, system, messages[0..k])` 序列化结果,与同会话上一次请求的对应前缀**逐字节相同**。不同则记 metric `cache_prefix_broken`。理由见 §11。
### R9 上游怪癖注入
Profile 里的最后一批动作:header(`anthropic-version``X-DashScope-SSE`)、endpoint 选择(DeepSeek `prefix`/`FIM`/`strict``/beta`)、鉴权(key 前缀与 base URL **必须成对校验**`sk-``sk-kimi-` 互换会得到两种不同文案的 401)。
---
## 7. 推理内容转写(L-D 的第一战场)
四家都有"推理",但**它们不是同一种东西**。转写规则由**可信度等级**驱动,而不是 provider。
### 7.1 三级可信度
| 级别 | 含义 | 上游 | 网关可以做什么 |
| --- | --- | --- | --- |
| **T0 明文** | 就是一段文本,无校验 | DeepSeek `reasoning_content`、Kimi platform `thinking` block**无 signature 字段** | 自由读、改、截断、丢弃 |
| **T1 假签名** | 有 `signature` 字段但**服务端不校验** | DeepSeek Anthropic 入口(signature = 响应 id)、**Kimi coding**12,946 字符的签名,实测替换成 `XXXX`、删除、伪造 thinking 文本**四种情况全部 200**) | 同 T0;**但要保留字段形状**,否则下游按 Anthropic 规范校验会失败 |
| **T2 真密文** | 密文即推理本体,改一字节即 400 | **Anthropic 官方**`signature`)、**OpenAI**`encrypted_content` | **只能 opaque 原样搬运** |
> 这个分级是本方案相对 v0.2 的关键修正。v0.2 假定"所有 Anthropic 族上游的 signature 都要当 opaque blob 搬运"——实测表明**只有 Anthropic 官方真机如此**。对 Kimi coding 和 DeepSeek,把它当 opaque 搬运会白白付出存储与信封成本。
>
> **反向的安全提示**Kimi coding 的 signature 只是"长得像密码学签名"。网关**不得**把它当作"上游已验证推理内容"的证据,也**不得**在自己的对外 API 里模仿这种假签名——下游若据此信任内容,就是被我们误导。
### 7.2 回传要求矩阵
| 上游 | 工具轮次内 | 普通多轮 | 不满足时 |
| --- | --- | --- | --- |
| Anthropic | **必须原样**(不可重排/编辑/部分丢弃,含 `redacted_thinking` | 建议全传 | 400 `blocks ... cannot be modified` |
| OpenAI | **必须带回 reasoning item**`store:false`/ZDR 时含 `encrypted_content` | 建议 | 推理链断裂 |
| DeepSeek | **必须**(缺失直接 400) | 可省 | 400;**传空串 `""` 可过校验** ← 跨族丢失内容时的降级手段 |
| Kimi | 可省(文档说必须,**实测 200 正常**) | 可省 | — |
### 7.3 四象限策略(保留 v0.2
| 入口族 | 上游族 | 策略 |
| --- | --- | --- |
| Claude | Claude | **原样透传**,不解包不重打包 |
| OpenAI | OpenAI | 原样透传 `encrypted_content` / `reasoning_content` |
| OpenAI | Claude | 摘要 → `reasoning_content`T2 时真 signature 装信封放 `encrypted_content`T0/T1 时**不装信封** |
| Claude | OpenAI | `reasoning_content``thinking.thinking`;T2 需要信封,T0/T1 直接留空 |
信封格式 `ztk1.<base64url(AES-GCM(payload))>`,只走 body **绝不走 header**Claude 4+ 的 signature 显著变长,header 截断后下一轮直接 400)。
### 7.4 三条实现硬约束
1. **切换上游模型时必须剥离 thinking block**——Anthropic thinking 与生成它的模型绑定,其他模型**静默忽略但照样计费**。故障转移路径尤其容易漏。
2. **不得按 `type == "thinking"` 过滤**——会漏掉 `redacted_thinking`,直接破坏协议。
3. **思考的隐藏成本要显式记账**——DeepSeek 思考模式注入 ~79 token、Kimi K3 ~67 token 的隐藏 system prompt,按输入价计费。Kimi K3 一句"说:好"开思考烧 310 completion tokens、关思考 11 个,**成本差 28 倍**。网关应把"思考开关"作为一等路由参数暴露给租户,并在账单里分列。
---
## 8. 响应改写管线(S1–S9)
### S1 信封剥离
- Kimi 的 `/v1/users/me/balance``/v1/tokenizers/estimate-token-count` 返回 `{"code":0,"data":{…},"scode":"0x0","status":true}`;而 `/v1/chat/completions``/v1/models` **没有**这层包装。**同一 API 两种信封并存**,解析器不能一刀切。
- 预留 `base_resp` 通道(MiniMax 式 200 内嵌错误),Profile 里用 `response.envelope` 选择。
### S2 双通道错误判定
HTTP 状态码 **+** body 业务码,两者任一为错即为错。见 §10。
### S3 隐私剥离(**上线前必须完成**)
| 泄漏点 | 动作 |
| --- | --- |
| `msh-org-id` / `msh-uid` / `msh-project-id` 响应头 | **白名单式剥离**(只放行网关自己合成的头 + `msh-request-id` 改名) |
| Kimi 429 错误 message 里的 `org-xxx<ak-xxx>` | 正则擦除后重写文案(**这是账号 + key 标识泄漏**,且错误 message 通常被原样转发,比响应头更危险) |
| Kimi 网关错误回显的 `ua` / `url` | 丢弃 |
| Kimi coding `/v1/me`(手机号、昵称、登录时间) | **绝不透传**;用作健康检查时只保留 `status` |
> 附带的运维结论:**Kimi coding 的 key 不可作为共享凭据下发给第三方**——持有 key 即可读到账号主人的部分身份信息。
### S4 ID 重写
工具调用 ID → 网关不透明 ID(§4.2)。
### S5 usage 归一
统一到 canonical usage
```jsonc
{
"input_total": int, // 完整输入,含缓存读与缓存写
"input_cache_read": int,
"input_cache_write": int,
"output_total": int, // 含 reasoning tokens
"output_reasoning": int
}
```
四家的坑:
| 陷阱 | 规则 |
| --- | --- |
| Anthropic `input_tokens` **不含缓存** | `input_total = input_tokens + cache_read + cache_creation`(直接用 `input_tokens` 缓存命中时可低报 99% |
| **Kimi Anthropic 入口双套字段并存**且语义不同(`input_tokens:0` vs `prompt_tokens:88` | Profile 标 `usage_dialect: dual`**固定取 Anthropic 语义那套再补齐**,两套混用会差一个缓存量 |
| Kimi `$web_search` 的 token **不在响应 usage 里** | 需从 `tool_calls[].function.arguments` 内嵌的 `usage.total_tokens` 解析(首轮 usage 93,实际 6674 |
| DeepSeek 非思考模式**整个 `completion_tokens_details` 不出现** | 解析容忍缺失,不要当 0 之外的异常 |
| Kimi 首次请求**无缓存字段**(不是 0,是字段不存在) | 同上 |
**网关计费以上游原始 usage 为准,不依赖任何投影结果。**
### S6 模型三元记账
记录 `requested_model` / `routed_model` / `echoed_model` 三个值。当 `echo_trustworthy: false`Kimi coding、`moonshot-v1-auto`)时,**账单与对外响应都用 `routed_model`**`echoed_model` 只进日志。
### S7 finish_reason / stop_reason 映射
按 cross-provider-mapping §4。补充:
- `pause_turn` **不对外暴露**,网关内部完成服务端工具续跑循环后再返回终态。
- DeepSeek 的 `insufficient_system_resource` → OpenAI 侧无对应,映射 `stop` + L2 warning,原值进 `ztoken.upstream_finish_reason`
- 预留 `sensitive`(智谱)→ `content_filter` 的映射通道。
### S8 族投影
IR → 入口方言。两条易漏:
- Chat 响应的 `choices[].logprobs` **必须存在**(值可为 `null`)——OpenAI spec 列为 required。
- Kimi OpenAI 入口错误体没有 `code`/`param`,对外补 `null` 以对齐严格客户端。
### S9 可观测头注入
```
X-Ztoken-Upstream: kimi-platform/openai:kimi-k3 (family=openai, bridged=false)
X-Ztoken-Degraded: temperature=dropped(L2,model-fixed), strict=ignored(L3,allowed)
X-Ztoken-Defaulted: max_tokens=4096
X-Ztoken-Request-Id: …
```
---
## 9. 流式转写
### 9.1 对外保证
1. 增量语义(上游全量累积时网关差分)。
2. 事件顺序合法(Claude 族三段式;OpenAI 族首 chunk 带 role、末尾 `[DONE]`)。
3. usage 位置固定。
4. 中途错误可表达。
### 9.2 usage 位置探测(四家四种)
| 上游 | 位置 |
| --- | --- |
| OpenAI | 独立 chunk`choices: []`(需 `include_usage` |
| DeepSeek | **挂在带 `finish_reason` 的最后一个正常 chunk 上** ← 按 OpenAI 语义等独立 chunk 会永远拿不到 |
| Kimi | 不传 `include_usage``choices[0].usage`;传了 → 顶层 + `choices:[]`**且只有传了才带 `cached_tokens`** |
| Anthropic | `message_delta.usage`,**累计值**(取末值,不累加) |
**统一策略**R5 强制补 `include_usage=true`;解析时**三个位置全部探测**,取最后一次非空。
### 9.3 跨族流式状态机
- **O→A**OpenAI chunk 无"块结束"信号,靠 `tool_calls[].index` 变化与 `finish_reason` 推断边界,在文本↔工具切换时补发 `content_block_stop` / `content_block_start`
- **A→O**`content_block_start(text)` 吞掉;`input_json_delta.partial_json` 追加到 `arguments``thinking_delta``delta.reasoning_content`**`signature_delta` 缓冲到 block 结束**,T2 时装信封随末尾 chunk 下发,T0/T1 时直接丢弃。
### 9.4 解析器必须容忍的形态
- Anthropic `fallback` block:只有 `content_block_start` + `content_block_stop`**中间没有任何 delta**。
- Anthropic `display:"omitted"`:只有一个 `signature_delta`,没有 `thinking_delta`
- 未知事件类型:**优雅忽略**Anthropic versioning policy 明确要求)。
- DeepSeek delta 中未用字段是显式 `null` 而非省略。
- Kimi 每个 chunk 带 `system_fingerprint`,非流式响应则没有。
- Kimi coding 的 SSE 事件名**没有空格**(`event:message_start`)——分隔符解析不能假设有空格。
- `data: [DONE]` 非 JSON,单独识别。
### 9.5 中途错误
已发 200 后无法改状态码:Claude 族发 `event: error`OpenAI 族发含 `error` 字段的 data chunk 再 `[DONE]`(无官方规范,属我们的事实约定,需写进对外文档)。
---
## 10. 错误与重试归一
### 10.1 上游错误体的三种形状(都要能解析)
```jsonc
{"error":{"message":"…","type":"…"}} // OpenAI 风格
{"type":"error","error":{"type":"…","message":"…"},"request_id":"…"} // Anthropic 风格
{"code":5,"error":"url.not_found","message":"没找到对象","ua":"…"} // Kimi 网关风格
```
第三种里 **`error` 从 object 变成了 string**——假设 `error` 恒为 object 的解析器会抛类型异常。413 也可能返回**非 JSON**Anthropic 侧由 Cloudflare 在到达 API 前返回)。
### 10.2 状态码归一
| 上游 | 对外 |
| --- | --- |
| DeepSeek 402 余额不足 | 429 + `insufficient_quota` |
| DeepSeek 422 | 400 |
| Anthropic 529 overloaded | 503 |
| **Kimi coding 模型不存在返回的 401** | **404 `model_not_found`** ← 绝不能触发凭据刷新逻辑 |
| MiniMax 200 + `base_resp≠0` | 真实状态码 |
### 10.3 429 必须按 type 分流(三种处置完全相反)
| type | 处置 |
| --- | --- |
| `engine_overloaded_error` | 退避重试 |
| `rate_limit_reached_error` | 降并发 |
| `exceeded_current_quota_error` | **停止**(欠费,重试纯属打爆上游) |
### 10.4 不可重试清单(会加倍烧钱或放大故障)
- **Kimi "200 OK + 空 content + `finish_reason:length`"**——这是思考烧光 token 的正常返回,重试只会再烧一遍。
- DeepSeek `json_object` 偶发空 content——可重试,但要限次并记 metric。
- 任何 `exceeded_current_quota_error` / 402。
- **关闭上游 SDK 的自动重试**——它会把 1 次请求放大成 2–3 次真实调用,直接吃掉 RPM 配额(Kimi Tier 0 只有 3 RPM)。
### 10.5 限流治理
- Kimi **没有 `x-ratelimit-*` 头**,配额只能本地计数 + 429 反馈,做不了提前避让;`retry-after` 是标准头,直接用。
-`msh-gid` 响应头**自动探测账号档位**并设置本地并发上限(Tier 0 = 3 RPM / 1 并发,实测严格)。
- 上游超时 **≥900 s**Kimi 服务端 900 s 才返回 504,网关先断会白烧已消耗的 token)。
- 对外**合成自己的 `x-ratelimit-*`**,不要透传上游的——上游额度和下游租户额度不是一回事。
---
## 11. 缓存稳定性:一条被低估的转写约束
Kimi K3 的输入价差是 **10 倍**(命中 ¥2 / 未命中 ¥20 每 1M)。DeepSeek 缓存粒度 64 token、即时生效。Anthropic 是显式断点且**改动某层会使该层及之后所有层失效**(顺序 `tools → system → messages`)。
**对转写引擎的直接约束**
| 禁止的操作 | 原因 |
| --- | --- |
| 往 prompt 里注入内容(时间戳、system 提示、"json" 字样) | 前缀变化 → 全量 miss |
| 规范化空白 / 重排消息 / 合并相邻文本块 | 同上 |
| 把 `content` 数组压成字符串 | 前缀变化,且 Kimi 多模态下明确禁止 |
| 动态改写 tools 定义顺序 | tools 是缓存第一层,失效面最大 |
**允许的**:合并连续同 role 消息(AnthIR 不变量 3)——但**必须在会话首轮就确定策略并保持一致**,不能有的轮次合有的轮次不合。
> 落地建议:R8 的前缀断言接一个 metric,任何一次 `cache_prefix_broken` 都应当能定位到是管线哪一步改写导致的。这是转写引擎唯一直接与钱挂钩的可观测指标。
---
## 12. 测试口径
转写引擎的正确性无法靠"跑通一次"证明,需要三类测试:
1. **黄金样本(golden files**:每个上游身份 × 每种消息形态(纯文本 / 多模态 / 工具轮 / 思考轮 / 流式),存请求-响应对。管线改动后做快照 diff。**四家的实测原始响应体已在 docs 里,直接作为首批样本。**
2. **往返恒等**`O→A→O``A→O→A` 的**可逆部分必须恒等**。不可逆部分(`is_error`、thinking 交错顺序、server tools)需在测试里**显式列举断言其丢失**——防止有人"顺手修好"后反而破坏了降级标记。
3. **上游契约探针**(定期跑,不进 CI 主流程):把 §5.2 表里的每一格变成一个真实请求断言。**四家的文档共有 13+ 条被实测推翻**Kimi 10 条、DeepSeek 3 条),且两个方向的错都有——文档说不能实际能、文档说能实际不能。Profile 是照实测写的,探针就是它的回归网。
---
## 13. 实现顺序
| # | 阶段 | 产出 | 备注 |
| --- | --- | --- | --- |
| 1 | 双 IR + 三入口方言层 | echo adapter 打通 | 纯逻辑,可完整单测 |
| 2 | **Profile 装载 + R5 参数消毒** | 白名单/clamp/coupling 引擎 | **优先级高于跨族桥**——它单独就能让 Kimi/DeepSeek 从"开箱 400"变成可用 |
| 3 | 能力矩阵 + 降级引擎(L1/L2/L3) | `X-Ztoken-Degraded` | 纯逻辑 |
| 4 | 第一个上游:`deepseek/chat` | 同族直出 | 最简单,验证管线骨架 |
| 5 | 跨族桥(非流式) | O↔A | 用往返恒等测试守住 |
| 6 | 流式规范层 | usage 三处探测 + 事件容错 | 最易出 bug,先写对照测试 |
| 7 | 跨族桥(流式) | 状态机 | **全项目最难**,单独设计 |
| 8 | `anthropic` adapter | thinking T2 原样透传 | 唯一真 signature 路径 |
| 9 | `kimi-platform` + `kimi-coding` | 两份独立 Profile | 含 §8 S3 隐私剥离,**上线前必须完成** |
第 5、7 步是技术核心;第 2、9 步是"能不能对外提供服务"的门槛。
**建议的 Java 模块划分**(对应当前空项目 `com.bytenya.ztoken`):
```
ztoken.ir — OaiIR / AnthIR 数据模型(不可变对象 + Jackson)
ztoken.entry — chat / responses / messages 三个方言层
ztoken.bridge — 跨族桥(非流式 + 流式状态机)
ztoken.profile — Profile 装载与校验(YAML/JSON 声明)
ztoken.pipeline — R1R9 / S1S9,每步一个可单测的 Stage
ztoken.upstream — HTTP 客户端、鉴权、超时、重试分类
ztoken.stream — SSE 编解码与规范化
ztoken.observe — 降级记录、usage 归一、计费事件
```
---
## 14. 开放问题
1. **`previous_response_id` / `conversation`**:当前 400 拒绝(网关无状态)。若要支持,建议信封化由客户端持有。
2. **L3 默认报错**是产品取向——会让"能跑就行"的客户端在我们这里失败、在别家静默成功。我认为值得(DeepSeek 的 `[Unsupported Image]` 就是反面教材),但需要产品确认。
3. **多模态代下载转码**Kimi 不收 http 图片 URL。代下载会让网关变成任意 URL 的抓取代理(SSRF 风险),建议默认报错 + 可选开关。
4. **Kimi coding 的用量核算**:该上游**没有任何余额/用量端点**,只能自行累加 usage,无官方口径可对账。多租户计费需接受这个误差。
5. **thinking 可信度 T1 的对外表达**:我们知道 Kimi coding 的 signature 是假的,是否应该在响应里向下游标注?标注会泄漏上游身份,不标注则下游可能误信。
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Wed Aug 12 21:50:11 CST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+234
View File
@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
rootProject.name = "zend-token"
@@ -0,0 +1,17 @@
package com.bytenya.ztoken;
//TIP To <b>Run</b> code, press <shortcut actionId="Run"/> or
// click the <icon src="AllIcons.Actions.Execute"/> icon in the gutter.
public class Main {
static void main() {
//TIP Press <shortcut actionId="ShowIntentionActions"/> with your caret at the highlighted text
// to see how IntelliJ IDEA suggests fixing it.
IO.println(String.format("Hello and welcome!"));
for (int i = 1; i <= 5; i++) {
//TIP Press <shortcut actionId="Debug"/> to start debugging your code. We have set one <icon src="AllIcons.Debugger.Db_set_breakpoint"/> breakpoint
// for you, but you can always add more by pressing <shortcut actionId="ToggleLineBreakpoint"/>.
IO.println("i = " + i);
}
}
}