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

38 KiB
Raw Permalink Blame History

zend-token 转写方案(Transform Spec

状态:设计 v0.3 · 2026-08-13 范围:Claude / OpenAI / DeepSeek / Kimi 四家的双向转写实现规范。 与 api-design.md 的关系:那份定义对外契约(端点、IR、降级协议),本份定义转写引擎本身——上游画像、改写管线、逐字段规则、测试口径。v0.2 的双规范模型(OaiIR / AnthIR)在此保留不变,本文是它的落地层。 依据:anthropic.mdopenai.mddeepseek.mdkimi.md(含实测)、cross-provider-mapping.mdcompatibility-layers.mderrors-and-limits.md


0. 一页结论

四家做同一件事(多轮对话 + 工具调用 + 推理),但它们的差异不在一个层次上,混在一起处理必然写成一堆 if-else。本方案把差异分成四层,每层用不同机制消化:

差异性质 举例 消化机制
L-A 方言 同一语义、不同字段名/形状 input_schema vs parametersprefix 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 temperatureOpenAI 入口 400 / Anthropic 入口静默忽略;DeepSeek tool_choice: anyAnthropic 入口静默降级 / 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 的 /betaprefix、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 风格 非流式响应也带 indexOpenAI 不带)
Kimi platform {函数名}_{序号},如 get_weather_0 可预测、且多轮会重复
Kimi platform builtin t-web_search-{hash}typebuiltin_function 打破 OpenAI 枚举
Kimi coding tool_{随机串}

规则:网关对外一律下发自己的不透明 IDztk_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 及更早的末条 assistantClaude 4.6+ 与 Mythos Preview 一律 L3 拒绝
Kimi partialname Kimi 语义是"人设锚点",不是 OpenAI 的说话人区分——转发时不要丢弃

5. 上游 Profile:把怪癖变成数据(L-C 的核心)

一份 Profile 描述一个上游身份(§2 表中的一行)。管线只读 Profile,不认 provider 名字。

5.1 Schema

{
  "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 tokenK3coding 侧 79 同左 kimi-for-coding 0
超时 10 minSDK 校验) 900 s 900 s 900 s

6. 请求改写管线(R1R9

顺序固定。每一步的输入输出都是 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-coding256K,用户以为在用 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_objectL3)。
  • 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-versionX-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)、OpenAIencrypted_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 itemstore:false/ZDR 时含 encrypted_content 建议 推理链断裂
DeepSeek 必须(缺失直接 400 可省 400传空串 "" 可过校验 ← 跨族丢失内容时的降级手段
Kimi 可省(文档说必须,实测 200 正常 可省

7.3 四象限策略(保留 v0.2

入口族 上游族 策略
Claude Claude 原样透传,不解包不重打包
OpenAI OpenAI 原样透传 encrypted_content / reasoning_content
OpenAI Claude 摘要 → reasoning_contentT2 时真 signature 装信封放 encrypted_contentT0/T1 时不装信封
Claude OpenAI reasoning_contentthinking.thinkingT2 需要信封,T0/T1 直接留空

信封格式 ztk1.<base64url(AES-GCM(payload))>,只走 body 绝不走 headerClaude 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. 响应改写管线(S1S9

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

{
  "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: falseKimi coding、moonshot-v1-auto)时,账单与对外响应都用 routed_modelechoed_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 独立 chunkchoices: [](需 include_usage
DeepSeek 挂在带 finish_reason 的最后一个正常 chunk 上 ← 按 OpenAI 语义等独立 chunk 会永远拿不到
Kimi 不传 include_usagechoices[0].usage;传了 → 顶层 + choices:[]且只有传了才带 cached_tokens
Anthropic message_delta.usage累计值(取末值,不累加)

统一策略R5 强制补 include_usage=true;解析时三个位置全部探测,取最后一次非空。

9.3 跨族流式状态机

  • O→AOpenAI chunk 无"块结束"信号,靠 tool_calls[].index 变化与 finish_reason 推断边界,在文本↔工具切换时补发 content_block_stop / content_block_start
  • A→Ocontent_block_start(text) 吞掉;input_json_delta.partial_json 追加到 argumentsthinking_deltadelta.reasoning_contentsignature_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: errorOpenAI 族发含 error 字段的 data chunk 再 [DONE](无官方规范,属我们的事实约定,需写进对外文档)。


10. 错误与重试归一

10.1 上游错误体的三种形状(都要能解析)

{"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 也可能返回非 JSONAnthropic 侧由 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 sKimi 服务端 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→OA→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 是假的,是否应该在响应里向下游标注?标注会泄漏上游身份,不标注则下游可能误信。