Files
zend-token/docs/transform-spec.md
T
2026-08-23 02:12:08 +08:00

576 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 是假的,是否应该在响应里向下游标注?标注会泄漏上游身份,不标注则下游可能误信。