OpenAI 兼容反代的协议兼容与语义保真 (Protocol Compatibility and Semantic Fidelity)
核心结论
OpenAI 兼容(OpenAI Compatibility)有两个层次,不能混为一谈:
- 传输层兼容:客户端可以向
/v1/chat/completions发送messages: [...],代理在一次 HTTP 请求中把消息数组转发给上游。 - 语义层保真:每条消息的 role、content 结构、多模态内容、tool_calls、tool 结果等是否原样保留并由上游按原协议解释。
cnb2api 在第一层基本成立,但第二层并不成立。它不是“把每条消息逐条发送”的线性线程代理;它会把多条历史消息装在同一个上游请求的 messages 数组中。不过,在直连 cnb2api 的路径上,消息会被压平成纯文本并进行角色改写,因此不能称为完整的原生 OpenAI 兼容。
实际请求链
客户端
│ POST /v1/chat/completions
│ { model, messages: [...], tools, stream }
▼
ToolForge(可选)
│ 原生 FC 或 XYML 提示词 FC
│ 一次 HTTP 请求转发完整历史
▼
cnb2api
│ 读取 messages[],转换成内部 ChatMessage[]
│ 强制 stream=true
▼
CNB: POST https://cnb.cool/ai/chat/completions
│ Cookie: csrfkey=...
│ Csrftoken: ...
│ { model, stream:true, messages:[...] }
▼
CNB SSE → cnb2api → OpenAI 风格 SSE/JSONcnb2api/internal/server/handler.go:180-214 先反序列化整个请求,Messages 类型是切片;handler.go:230-295 将其转换为上游消息切片;internal/upstream/client.go:162-184 一次性 JSON 序列化整个 ChatRequest 并发送一个 POST。没有对每条 message 单独发请求的循环,也没有按消息顺序调用多次模型。
cnb2api 如何处理 messages
入口结构只保留了以下信息:
type chatMsg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}因此它会丢弃或不处理 OpenAI 消息中的其他字段,例如 tool_calls、tool_call_id、消息级 name 等。随后 extractChatContent 将 content 转成字符串:
- string content:原样保留;
- content 数组:只抽取
text、thinking,忽略图片等非文本部分; toolCall:变成类似(assistant called tool name with args {...})的普通文本;toolResult:变成普通文本;- image 内容:CNB 不支持,被忽略。
角色也会被重塑:
toolResult/tool→user,并加[工具执行结果]前缀;- 连续的
user消息会合并成一条; - 没有文本的 assistant →
(assistant called tools); - 某些失败占位 assistant 消息会被过滤;
- 其余
system、user、assistant角色通常保留。
所以,下面这类历史:
[
{"role":"user", "content":"查天气"},
{"role":"assistant", "content":null, "tool_calls":[...]},
{"role":"tool", "tool_call_id":"...", "content":"晴天"},
{"role":"user", "content":"谢谢"}
]不会原样到达 CNB,可能变成近似:
[
{"role":"user", "content":"查天气"},
{"role":"assistant", "content":"(assistant called tools)"},
{"role":"user", "content":"[工具执行结果] 晴天"},
{"role":"user", "content":"谢谢"}
]另外,直连 cnb2api 的 handleChat 当前没有把收到的 req.Tools 赋给 upReq.Tools;因此 README 中所说的“客户端 tools 透传”不能理解为 cnb2api 本身原生转发 tools。工具调用主要依赖前置 ToolForge 的 XYML 路径。
ToolForge 做了什么
Docker 配置中明确设置:
native_fc: false
fc_mode: auto
inject_protocol: XYML因此链路是:
- 客户端发送标准
tools、messages; - ToolForge 读取工具定义,生成 XYML 工具调用说明,注入 system message;
- ToolForge 把 assistant 的 tool calls 和 tool 返回值渲染成文本历史;
- ToolForge 删除真正的
tools/tool_choice字段; - cnb2api 只看到普通的
system/user/assistant文本消息; - CNB 模型输出 XYML 文本;
- ToolForge 解析输出,再包装成客户端期待的
tool_calls。
证据在 docker/toolforge/app/fc/inject.py:58-120 和 app/engine/wire.py:26-56。ToolForge README 也明确说明中间件“永不执行工具”,客户端收到 tool call 后自行执行,再把 tool / tool_result 历史发回来。
因此工具调用不是 CNB 原生 Function Calling,而是:
标准 tools
→ ToolForge 转成提示词协议
→ 模型输出 XML/XYML 文本
→ ToolForge 解析
→ 标准 tool_calls“线性线程”到底是什么意思
需要区分三个概念:
1. 是否一次请求发送多条 messages?
是。 客户端可以在一个 JSON 请求里发送任意数量的历史消息(受请求体、上下文窗口和代理限制影响)。代理不会为每条消息分别调用 CNB;它把整个数组作为一次模型输入。
2. CNB 是否保存服务端会话?
从该实现看,没有依赖服务端会话 ID 或历史会话状态。每次调用都重新获取/使用 CSRF 凭证,并在请求体中携带历史。对模型来说,上一轮对话之所以存在,是因为客户端下一轮重新发送了历史,而不是因为 cnb2api 或 CNB 持有一个可继续追加的线程对象。
3. 是否完整支持 OpenAI message 语义?
不是。 它支持 messages 这个外形,并且能把多轮文本上下文传给上游;但工具调用、结构化 content、多模态、精确的 tool_call 关联等会被文本化或丢失。更准确的描述是:
无服务端会话的、客户端携带完整历史的、文本优先的 OpenAI 风格网关。
与真正 OpenAI 兼容实现的对比
| 能力 | cnb2api 直连 | ToolForge + cnb2api |
|---|---|---|
一次请求发送多条 messages | 支持 | 支持 |
| 多轮文本上下文 | 基本支持 | 基本支持 |
| 上游一次 HTTP 请求 | 支持 | 支持 |
| 原样保留 content 数组 | 不支持,抽取文本 | ToolForge 先抽取/渲染文本 |
原生 tools 透传 | 当前 handler 未透传 | 否,配置为 XYML |
原生 tool_calls | 不支持 | 由 ToolForge 解析文本后模拟 |
| 工具执行 | 不执行 | 不执行,交给客户端 |
| 服务端持久化会话 | 未见实现 | 未见实现 |
| 流式响应 | 上游强制 SSE,再转格式 | 支持并转换 |
反向方向:Anthropic → OpenAI 转换(handouts 固定端点代理)
上面是「OpenAI 客户端 → 上游」的保真问题;还有完全相反的一类需求:Claude 客户端只讲 Anthropic 协议(/v1/messages),上游中转只讲 OpenAI 协议(/v1/chat/completions)。handouts-agent-server 用「固定端点 + 固定 AK + 面板映射」实现:Claude 子进程永远连本机 127.0.0.1:3180/claude/v1/messages,代理按当前映射的 activeProviderId 决定走透传(Anthropic 原生供应商)还是转换(OpenAI 兼容供应商)。
转换代码取自 y-router(luohy15/y-router,MIT,已归档),三文件二改后为纯 ESM 函数:
format-request.js:Anthropic 请求体 → OpenAI 请求体format-stream.js:OpenAI SSE 流 → Anthropic SSE 事件流format-response.js:OpenAI 非流式 JSON → Anthropic 消息
关键映射规则(实测验证)
| Anthropic | OpenAI |
|---|---|
顶层 system(数组/字符串) | 合并为一条 role:"system" 纯文本消息 |
content 里的 text 块 | 拼进 assistant/user 消息文本 |
tool_use 块 | tool_calls:[{id,type:"function",function:{name,arguments:JSON}}] |
tool_result 块(在 user 消息内) | 拆出为 role:"tool" 消息 + tool_call_id |
tools[].input_schema | tools[].function.parameters |
max_tokens / temperature / stream | 同名透传 |
stop_reason:tool_use(finish_reason=tool_calls) | → OpenAI finish_reason:"tool_calls",反向映射回 tool_use |
必坑点:
cache_control(ephemeral)必须剥离。Anthropic 的 system/content/tool 块常带cache_control:{type:"ephemeral"},OpenAI 兼容供应商大多不认,直接 400。thinking块忽略。OpenAI 无对应概念,转文本反而污染正文。- system 合并成一条。部分 OpenAI 兼容中转只认一条 system 消息。
- SSE 合成的事件序列固定:
message_start→content_block_start/delta/stop(每块 index 从 0 连续递增)→message_delta(带stop_reason+ usage)→message_stop。y-router 原版 index 从 1 开始是 bug,Anthropic 客户端会拒绝。 - tool_calls 增量是分段到达的:第一段带
id+name(arguments 为空),后续段无 id 只有function.arguments增量。合成时input_json_delta逐段透传,不能等完整 JSON。 - usage 在流末尾独立 chunk(choices 为空),
message_start时只能给 0/0,真实值在message_delta透传:OpenAIprompt_tokens/completion_tokens→ Anthropicinput_tokens/output_tokens。 - 非流式 tool_calls 的
arguments可能被截断(非合法 JSON),JSON.parse要兜底,不能抛错导致整次请求失败。
验证方法(不碰真实链路)
起一个本地 mock OpenAI 服务器(/v1/chat/completions,日志落盘收到的请求体),在面板加一个 openai 供应商指向它,再用固定端点发带工具历史的 Anthropic 请求。断言:
- mock 收到的请求:system 合并、无
cache_control、tool_use→tool_calls、tool_result→role:tool、model 被覆盖为 provider.model; - 返回体:标准 Anthropic 结构、
stop_reason:"tool_use"、usage 真值; - 流式:完整 SSE 事件序列 +
input_json_delta增量 + index 从 0 递增。
类方法同名覆盖陷阱(Node.js)
ProviderStore 里同时有私有写文件 save() 和公共 save(id, def) 时,后者覆盖前者;公共 save 内部 await this.save() 会调用自己,无参调用变成 save(undefined, undefined) 而抛「必须是对象」。现象是:内存已改、API 返回 400、GET 却能看到新值(因为抛错前 this.data 已赋值)。修复:把写文件的私有方法改名(如 persist())。这是一个「方法覆盖自引用」的典型坑,写存储类时方法名要错开。
部署期踩过的坑(固定端点 + 转换层真实生效的验证)
- Claude 子进程的 settings.json
env会覆盖进程环境变量。若把任务快照(.model-config.json)里的旧ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN一并写入 claude 的 settings.json,任务会绕过本机代理直连旧上游——表现为「切到 openai 供应商后任务也成功」,实为假象(直连了旧的 anthropic 上游)。修复:settings 只白名单写入ANTHROPIC_*MODEL*,端点与 key 一律由 spawn 的 env 固定注入。 ANTHROPIC_BASE_URL不能带/v1。Claude CLI 会自动拼/v1/messages,base 带/claude/v1会变成/claude/v1/v1/messages(404)。base 应到/claude为止。- Claude CLI 不同版本/平台发送的鉴权头不同:
x-api-key或Authorization: Bearer,代理要两种都认。 - Windows 原生 claude.exe 的 Write/Edit 权限规则匹配有 bug:
Read(//C:/.../**)规则正常,但Write(//C:/.../**)即便写成全局Write(//**)也全被拒(dontAsk 下);bypassPermissions模式则正常。Linux 上无此问题。绕法:Windows 分支用bypassPermissions,其余平台维持dontAsk。 - 验证是否真的走了代理:切到 openai 供应商后,若旧 anthropic 上游恰好余额不足,任务仍能成功就证明走的是代理(因为直连会失败)。
相关代码:
- handouts-agent-server
src/runtime.js(平台路径收敛:skillDir/claudeBin/cloudtexBin/ocrcliBin/homeDir) src/agent.js(writeTaskSettings 白名单 env、base URL 到 /claude、isWin 权限模式分支)src/llm/proxy.js(双鉴权头)、src/providers.js(ProviderStore,persist 改名)
相关代码:
- handouts-agent-server
src/llm/format-request.js/format-stream.js/format-response.js/src/llm/proxy.js src/providers.js(ProviderStore)test/convert-test.mjs(本地协议转换单测)、test/mock-openai.mjs(mock 上游)- y-router 原版:
formatRequest.ts/streamResponse.ts/formatResponse.ts
如何自己验证
可以启动代理后发送带多条历史的请求,并观察日志:
[REQ] ... msgs=N ...
[FWD] ... msgs=N ...当 N > 100 或文本超过 100000 字符时,handler 还会把原始客户端请求保存为 /tmp/cnb2api_last_req.json。需要注意,日志里的 FWD msgs 是转换清洗后的数量,不一定等于客户端原始数量。
仓库当前 Go 测试通过:
go test ./...证据范围与限制
本结论验证了仓库实现的真实请求构造和 ToolForge 的转换逻辑;没有把 cnb2api-static 当作可读的前端 JavaScript,因为该文件实际是 ELF 二进制文件。仅凭仓库代码可以确认“代理向 CNB 发送一个包含 messages 数组的请求”,但不能据此证明 CNB 服务端内部如何进一步解析或限制该数组。上下文长度、消息数量上限和 CNB 模型对不同 role 的具体权重仍需通过实际请求实验确认。
相关代码:
cnb2api/internal/server/handler.go:100-114cnb2api/internal/server/handler.go:223-295cnb2api/internal/upstream/client.go:42-60cnb2api/internal/upstream/client.go:162-184cnb2api/docker/toolforge/app/fc/inject.py:58-120cnb2api/docker/toolforge/app/engine/wire.py:26-56cnb2api/docker/toolforge/app/engine/orchestrator.py:346-424
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps