Files
2026-08-23 02:12:08 +08:00

26 KiB
Raw Permalink Blame History

zend-token API 设计

状态:设计草案 v0.2 · 2026-08-12 依据:openai.mdanthropic.mddeepseek.mdchina-providers.mdcross-provider-mapping.md

v0.2 变更:单一 IR → 双规范模型(§3);/v1/responses 提升为一等入口。

后续:本文的落地层是 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 messageChat 把文本放 content、工具放 tool_calls,无法表达 reasoning 与 text 的交错顺序,Responses 的异构 output[] 可以。

{
  "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 模型为基线:

{
  "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): nlogprobstop_logprobslogit_biasseedfrequency_penaltypresence_penaltyresponse_formatparallel_tool_callstools[].function.strict

响应回投影为 Chat 结构时:

  • function_call item → message.tool_calls[]call_idid
  • 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 对象(未知子字段忽略不报错):

{
  "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 无此字段,只能退化成文本前缀
thinkingtext 的交错顺序 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_degradationn
  3. server_tool_use 历史的会话切到 OpenAI 族上游 — 历史无法表达,续接必然错乱。

6. 模型路由与能力矩阵

6.1 逻辑模型名

{
  "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 能力矩阵

"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,除非显式放行
参数/能力 级别
seedlogit_biasmetadatauserstore 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 默认报错是本设计与所有现有兼容层的最大分歧。宁可让调用方显式声明"我接受图片被丢弃",也不要让他拿到一个看起来正常、实际模型根本没看到图的响应。放行方式:

"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_contentthinking.thinkingsignature 填我们的信封

两条同族路径都不进桥,这正是双规范模型的最大收益。

8.2 信封格式

ztk1.<base64url(AES-GCM(payload))>

payload = 上游原始 block/item 数组 + 元数据(provider、model、family、生成时间)。客户端视其为完全不透明ztk1. 前缀用于版本演进;解析不了的信封当作"无思考上下文"降级,不报错。

8.3 无状态优先

默认下发信封由客户端原样回传,网关不存储。与 OpenAI 的 encrypted_content、Anthropic 的 signature 同一思路,行业已验证。

风险是客户端裁剪未知字段。缓解:信封放在该族的原生位置OpenAI 族 → reasoning.encrypted_contentClaude 族 → 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_deltamessage_stopOpenAI 族保证首 chunk 带 role、末尾 data: [DONE]
  3. usage 位置固定 — Chat 下需 stream_options.include_usage 才发末尾 usage chunkchoices 为空数组);Messages 下 message_delta.usage累计值
  4. 中途错误可表达 — §10.3。

9.2 跨族流式的状态机

跨族时流式比非流式难得多,因为要在流上重建结构

O→AOpenAI 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/namearguments:"" 的 tool_calls chunk
  • input_json_delta.partial_json → 追加到 arguments
  • thinking_deltadelta.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_searchrepetition_penalty 这类无法归一的自有参数。透传字段不参与能力校验,风险由调用方承担。


10. 错误模型

10.1 双族错误格式

按入口族返回对应格式,HTTP 状态码两族一致

// 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→OA→O→A 的可逆部分必须恒等
  5. 流式规范化层(§9,含阿里全量差分)—— 最易出 bug,值得先写对照测试
  6. 跨族桥的流式路径(§9.2)—— 全项目最难的一块,状态机需单独设计
  7. Anthropic adapterthinking 原样透传路径)
  8. 国产 adapter 批量接入

第 4 步和第 6 步是这个项目的技术核心,其余都是工程量。