核心结论

OpenAI 兼容(OpenAI Compatibility)有两个层次,不能混为一谈:

  1. 传输层兼容:客户端可以向 /v1/chat/completions 发送 messages: [...],代理在一次 HTTP 请求中把消息数组转发给上游。
  2. 语义层保真:每条消息的 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/JSON

cnb2api/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

因此链路是:

  1. 客户端发送标准 tools、messages;
  2. ToolForge 读取工具定义,生成 XYML 工具调用说明,注入 system message;
  3. ToolForge 把 assistant 的 tool calls 和 tool 返回值渲染成文本历史;
  4. ToolForge 删除真正的 tools / tool_choice 字段;
  5. cnb2api 只看到普通的 system/user/assistant 文本消息;
  6. CNB 模型输出 XYML 文本;
  7. 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 消息

关键映射规则(实测验证)

AnthropicOpenAI
顶层 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_schematools[].function.parameters
max_tokens / temperature / stream同名透传
stop_reason:tool_use(finish_reason=tool_calls)→ OpenAI finish_reason:"tool_calls",反向映射回 tool_use

必坑点:

  1. cache_control(ephemeral)必须剥离。Anthropic 的 system/content/tool 块常带 cache_control:{type:"ephemeral"},OpenAI 兼容供应商大多不认,直接 400。
  2. thinking 块忽略。OpenAI 无对应概念,转文本反而污染正文。
  3. system 合并成一条。部分 OpenAI 兼容中转只认一条 system 消息。
  4. SSE 合成的事件序列固定:message_start → content_block_start/delta/stop(每块 index 从 0 连续递增)→ message_delta(带 stop_reason + usage)→ message_stop。y-router 原版 index 从 1 开始是 bug,Anthropic 客户端会拒绝。
  5. tool_calls 增量是分段到达的:第一段带 id+name(arguments 为空),后续段无 id 只有 function.arguments 增量。合成时 input_json_delta 逐段透传,不能等完整 JSON。
  6. usage 在流末尾独立 chunk(choices 为空),message_start 时只能给 0/0,真实值在 message_delta 透传:OpenAI prompt_tokens/completion_tokens → Anthropic input_tokens/output_tokens。
  7. 非流式 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())。这是一个「方法覆盖自引用」的典型坑,写存储类时方法名要错开。

部署期踩过的坑(固定端点 + 转换层真实生效的验证)

  1. Claude 子进程的 settings.json env 会覆盖进程环境变量。若把任务快照(.model-config.json)里的旧 ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN 一并写入 claude 的 settings.json,任务会绕过本机代理直连旧上游——表现为「切到 openai 供应商后任务也成功」,实为假象(直连了旧的 anthropic 上游)。修复:settings 只白名单写入 ANTHROPIC_*MODEL*,端点与 key 一律由 spawn 的 env 固定注入。
  2. ANTHROPIC_BASE_URL 不能带 /v1。Claude CLI 会自动拼 /v1/messages,base 带 /claude/v1 会变成 /claude/v1/v1/messages(404)。base 应到 /claude 为止。
  3. Claude CLI 不同版本/平台发送的鉴权头不同:x-api-key 或 Authorization: Bearer,代理要两种都认。
  4. Windows 原生 claude.exe 的 Write/Edit 权限规则匹配有 bug:Read(//C:/.../**) 规则正常,但 Write(//C:/.../**) 即便写成全局 Write(//**) 也全被拒(dontAsk 下);bypassPermissions 模式则正常。Linux 上无此问题。绕法:Windows 分支用 bypassPermissions,其余平台维持 dontAsk。
  5. 验证是否真的走了代理:切到 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-114
  • cnb2api/internal/server/handler.go:223-295
  • cnb2api/internal/upstream/client.go:42-60
  • cnb2api/internal/upstream/client.go:162-184
  • cnb2api/docker/toolforge/app/fc/inject.py:58-120
  • cnb2api/docker/toolforge/app/engine/wire.py:26-56
  • cnb2api/docker/toolforge/app/engine/orchestrator.py:346-424

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps