P1-1 anthropic 基座补 build_headers(x-api-key + anthropic-version) 根因:基座只有静态 anthropic-version、没有 build_headers ⇒ 内核兜底发 Authorization: Bearer,而 Anthropic 协议要 x-api-key。 ★ 证据冲突(计划 P1-1 原文一半有误):zcode 的 /zcode-plan/anthropic/v1/messages 虽然**说 Anthropic 协议**,鉴权却是 Bearer <zcode_jwt>(参考 zcode-transport.ts:121-125;全仓 grep x-api-key = 0 次命中)。故 zcode 必须**显式覆盖** build_headers 回到 Bearer, 否则基座的正向修复会把它从"对"改成"错"。 内核改动:internal/provider/provider.go —— 兜底 Bearer 让位给 x-api-key, 避免同一请求带两套鉴权(部分 Anthropic 兼容上游对歧义鉴权直接 401)。 P1-2 channels-cline 的 build_headers 补 workos: 前缀 根因:参考 cline.ts 发 `Bearer workos:<jwt>`,而池里存的是第二步 register 换回的裸 token ⇒ 之前无 build_headers,内核兜底发裸 Bearer。 幂等:已带前缀(含大小写变体)不重复拼。 P1-3 channels-gemini 只确认不改 参考 gemini.ts:353-366 同样是 OAuth Bearer + 身份五头、刻意不发 x-goog-api-key ⇒ 我们现状本来就对。加判据把这个结论钉住,防下轮照 P1-1 的模子把它改坏。 P2 源名契约:仅 [A-Za-z0-9_-],禁 : 与 / 根因:: 与 / 是限定名分隔符,出现在源名里会让 `源名:模型名` 有两种都 自圆其说的切法,同一请求串在不同部署下可能路由到不同源,且**不报错**。 校验加在写入口 core.normalizeSource(硬拒,API 400);启动时只告警, 避免存量带 : 的源名让升级 brick 掉整个网关。 解析入口 splitPinned 不做校验(解析不该拒绝已有数据)。 P3 夹具形状判据(本轮最重要的一条) 根因:trae 的 Result 信封缺陷在扁平夹具下所有判据全绿,真实登录却报 「缺少 Token」—— 夹具形状错 ⇒ 判据必然虚假通过。 通用形状层:shapeFamilies 登记表,每族给 real/naive/fact/eval/assert, 断言 fact(real) 且 !fact(naive) 且 assert(eval(real))。 刻意**不比较 eval 输出形状**:trae 余额实现合法保留顶层兼容路径, 拿输出比对会把合法兼容判成错误;能区分真假的是 fact(额度在哪一层)。 另有仓库扫描:对特征明确的退化形状静态反查,需退化夹具时写 SHAPE-OK 豁免。 判据:11 个文件 / 30 用例。全部变异已验证会报红(9 处)。
16 KiB
渠道链路修复计划与现状
本文是
plan.md中「渠道链路修复计划(2026-10-17 起)」章节的可提交版本。plan.md本身被.gitignore忽略(本地工作台账),故关键计划与现状单独落在这里, 以便随代码一起进 git、供他人查阅。写作原则:只写实测到的,标注"推断"的必须写明。 每条缺陷都附可复现的观测。
记录 2026-10-17 用户报障三连修(模型不可用 / 额度异常 / 其他渠道同类) 的现状与后续计划。上接
plan.md§9(v2 契约收尾)。
1. 现状快照(2026-10-17 14:00 实测,非推断)
1.1 生产实例
| 项 | 值 |
|---|---|
| 服务 | active,NRestarts=0 |
| 二进制 sha256 | 56396f79bfc2433a(与仓库逐字节一致) |
| 插件 sha256 | c15cb4bf5d5c0f21(与仓库逐字节一致) |
| 插件 | 4 个(billing/channels/quota/usage)全部 loaded |
| 源 / 适配器 | 21 源 / 23 适配器 |
| 账号池 | 仅 trae、buddy 各有 1 个有凭据账号;其余 10 个平台账号数 0 |
1.2 各渠道缺陷矩阵(本文件写入时的实测)
自装适配器(插件在加载时经 __llmsproxy_adapters 装入):
| 适配器 | build_headers |
静态 adapter.headers |
流式转换 | 鉴权是否可能错 |
|---|---|---|---|---|
codearts |
有 | 无 | 复合 openai | 有(SDK-HMAC 自签,已实现) |
channels-trae |
有(本次新加) | 无 | SOLO 自定义(本次新写) | 已修 |
channels-gemini |
★ 缺 | 有 | 自定义 | 静态头 + Bearer,待确认 |
channels-zcode |
★ 缺 | 无 | 无(继承 anthropic 基座) | ★ 高度可疑 |
channels-cline |
★ 缺 | 无 | 套 openai | ★ 可疑(要 Bearer workos:<jwt>) |
channels-qoder |
★ 缺 | 无 | 套 openai | ★ 可疑(头由 WASM 生成) |
内置适配器里 build_headers 只有 3 个有(buddy / kimicode / opencodezen),
其余 11 个(含 anthropic 基座)靠静态头 + 内核兜底 Bearer。
★ anthropic 基座本身没有
build_headers,而 anthropic 协议实际要x-api-key而非Authorization: Bearer。channels-zcode继承它 ⇒ 一旦有账号就会立刻暴露,与 trae 修复前完全同型。
1.3 已验证可用的通道(生产实测,不是单测)
| 模型(源限定名) | 结果 |
|---|---|
channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro |
✅ 80 帧,正文 27 + 推理 299 |
channels-trae-b8dd6b3df30e:glm-5.2 |
✅ OK |
channels-buddy-buddy-main:hy4-preview |
✅ OK |
| 端点 | 结果 |
|---|---|
channels/credits/status (trae) |
✅ active:true, dailyCredit:100, todayCheckedIn:true |
channels/credits/balances (trae) |
✅ total:598.516 credits |
1.4 本轮已修(代码已部署生产,尚未提交 git)
① 网关解不开 源名:模型名 限定名(internal/provider/registry.go)
- 根因:
ResolvePinned用strings.IndexAny(model, "-:/")找第一个分隔符。 渠道源名形如channels-trae-b8dd6b3df30e(含-)⇒ 切成src="channels"⇒ 永不匹配 ⇒Resolve()返回 nil ⇒model "..." is not configured。 - 为什么必须支持:本实例 44 个模型被多源同时提供(
glm-5.2由 6 个源提供), WebUI 下拉框用源名:模型名作 option value 是有意设计。 - 修法:抽
splitPinned(),按已知源名做最长前缀匹配(源名是已知集合, 唯一可靠办法),保留旧-:/兜底。EffectiveModel同步修。 - 判据:
internal/provider/registry_pinned_name_test.go(4 用例,变异 1/1 咬住)。
② trae 适配器三连错(internal/lua/plugins/channels.lua,逐层剥开)
- 没有
build_headers⇒ 内核兜底发Bearer,而 trae 推理端点要Cloud-IDE-JWT+ 设备身份头 ⇒ 401「not able to authenticate you」。 → 与「额度不可用」是同一根因的两个出口(额度端点当轮修了、推理端点漏了)。 - header 常量值是错的:
X-App-Id抄成6eefa6c9(真实6eefa01c-1036-4c7e-9ca5-d891f63bfcd8)、app_version_code用0.1.52(真实是日期式20260811)⇒ 上游4001 binding: expr_path=app_version_code, cause=parameter type does not match binding data。 - 流式转换完全不对:TRAE 是 SOLO 自定义 SSE
(
event:output→data:{"response":"###","reasoning_content":...})。 套openai_stream_chunk⇒ 无choices⇒empty stream; 改成 OpenAI 外壳{choices:[{delta}]}仍不对 —— 内核json.Unmarshal(unified, &UnifiedChunk)解的是扁平结构,外壳字段全丢。 ⇒ 必须输出扁平{content, reasoning_content, usage, done, finish_reason}。
- 判据:
channels_trae_chat_headers_test.go(3)、channels_trae_solo_sse_test.go(2), 变异 2/2 咬住。
③ 余额恒为 0(同文件 balance.total_fn)
- 根因:读
p.credits_limit(pack 顶层)—— 该字段根本不存在。 真实路径是pack.entitlement_base_info.quota.credits_limit(参考trae-credits.ts:341)+pack.usage.credits_amount。 - 实测上游:
免费(无 credits_limit) /每月登录赠送(500) /签到奖励(100) = 600。 - 修法:读嵌套路径 + 保留顶层兼容 +
usage_summary.{total,consumed}_amount兜底。 - 判据:
channels_trae_credits_hdr_test.go(1)。 - 修后余额 600 → 实测 598.516(真实扣减)。
④ 附带:channels_trae_test.go 夹具从扁平 {"Token":...} 改为真实
{"Result":{"Token":...}} —— 原来夹具形状与生产不一致,判据必然虚假通过
(这是 ExchangeToken 缺陷藏了很久的原因)。
2. 后续计划
P0 — 提交本轮修复(必须先做)
代码已部署生产但未进 git。必须:
- 在
release/v2.0.x上提交(含 3 个源文件 + 6 个判据文件) - 打 tag
v2.0.40(这是内核 Go 改动:registry.go⇒ 真核心版本) - 回流 main(cherry-pick,不 merge)
- 复核 tag 落点 + release vs main 零差异 + 生产 sha256 一致
判据:
git tag --points-at <release HEAD>非空;git diff release/v2.0.x main为空。
P1 — 其余渠道的同类缺陷(账号 0,验证不了运行)
按"风险 × 可验证性"排序。核心限制:这 4 个平台账号数均为 0,
无法做生产实测 —— 只能靠静态对齐参考 + 装好适配器后看 __adapter_install_probe。
| 优先 | 项 | 缺陷 | 修法 | 可验证性 |
|---|---|---|---|---|
| P1-1 | anthropic 基座 |
基座无 build_headers,Anthropic 协议要 x-api-key 不是 Bearer |
给基座补 build_headers(x-api-key + anthropic-version) |
✅ 已修+已判据;运行需账号 |
| P1-1b | channels-zcode |
★ 证据冲突:zcode 的 Messages 端点虽然说 Anthropic 协议,鉴权却是 Bearer <zcode_jwt>(参考 zcode-transport.ts:121-125,全仓 x-api-key 命中 0 次)。基座补了 x-api-key 后它继承就会变错 |
zcode 显式覆盖 build_headers 发 Bearer,不用继承 |
✅ 已修+已判据;运行需账号 |
| P1-2 | channels-cline |
参考要 Bearer workos:<jwt>,我们的 token 无 workos: 前缀 |
适配器 build_headers 补前缀(幂等,已带不重复) |
✅ 已修+已判据;运行需账号 |
| P1-3 | channels-gemini |
有静态头 + Bearer ⇒ 本来就对(参考 gemini.ts:353-366 也是 OAuth Bearer,且刻意不发 x-goog-api-key) |
不改,只加判据把这个结论钉住 | ✅ 已确认+已判据 |
| P1-4 | channels-qoder |
头由 WASM 生成必须原样透传,我们拿不到 .wasm |
阻塞:二进制不在仓库任何位置 | 不可验证 |
★ P1-1 与 P1-1b 的证据冲突(本轮最重要的一条)
计划原文把 P1-1 写成「anthropic 基座没
build_headers⇒ zcode 继承就 401」。 逆向参考后两半各对一半:
断言 结论 证据 Anthropic 协议(真 · api.anthropic.com)要 x-api-key✅ 对 协议本身 zcode 的 /zcode-plan/anthropic/v1/messages也要x-api-key❌ 错 zcode-transport.ts:121-125发Authorization: Bearer ${zcode_jwt};zcode-upstream.ts:83-108的buildZcodeHeaders只有可选 Authorization;全仓grep -rn "x-api-key" src/= 0zcode 是「Anthropic 请求体形状 + Bearer 鉴权」——协议与鉴权是两个独立维度, 被这家上游拆开了。所以修复不能"把基座改成 x-api-key 就完事": 那会把 zcode 从「对」改成「错」。正确形态是基座发 x-api-key + zcode 显式覆盖成 Bearer。
附带约束:
anthropic基座同时服务生产里的其它源(/etc/llmsproxy/config.yaml里justwoker就是adapter: anthropic)。给基座补x-api-key后,内核buildHeaders的兜底 Bearer 必须让位(否则同一请求带两套鉴权), 这处改动在internal/provider/provider.go(内核改动)。
P2 — 内核对"限定名"的契约收口
已落地(详见 docs/source-name-contract.md):
| 位置 | 行为 |
|---|---|
config.ValidateSourceName / SourceNameWarning |
纯函数校验器(单一定义) |
core.normalizeSource(AddSource 入口) |
新增/修改源硬拒(API 400)—— 新数据从源头合法 |
config.ApplyDefaults(启动) |
存量带 : 的源名只告警不报错(避免升级 brick 掉整个网关) |
字符集:仅 [A-Za-z0-9_-],禁 : 与 /(限定名分隔符 ⇒ 切分歧义且不报错)。
- 允许(渠道源名 channels-<平台>-<账号id> 含 -,分隔符在源名之后不歧义)。
解析入口 internal/provider/splitPinned 不做校验(解析不该拒绝已有数据),
只做最长前缀匹配 —— 校验责任全在写入口,保持解析路径纯函数无副作用。
P3 — 判据防回归(防"夹具与生产不一致"再犯)
本轮最大的教训不是某个 bug,而是夹具形状错 ⇒ 判据虚假通过(trae 活了两轮)。
已落地,分两层(文件:internal/lua/channels_fixture_shapes_test.go 行为层 +
internal/lua/channels_fixture_shapes_general_test.go 通用形状层):
① 形状登记表 shapeFamilies —— 每族给出四项,缺一不可:
| 字段 | 含义 | 为什么必须有 |
|---|---|---|
real |
生产原文(引自实测抓包/参考注释) | 夹具的真值来源 |
naive |
错误的「方便测试版」 | 证明判据能区分真假,不是恒等式 |
fact(raw) |
对原始 JSON 的结构事实 | 判定「这段 body 是不是生产形状」 |
eval / assert |
用真实实现处理并断言行为 | 防判据自算一份解包逻辑 |
断言链:fact(real) 为真 且 fact(naive) 为假 且 assert(eval(real)) 通过。
⚠️ 刻意不比较
eval(real)与eval(naive)的输出形状:trae 余额实现 合法保留了「顶层 credits_limit」兼容兜底路径(实测两种上都返回 600), 拿输出比对会把合法兼容判成错误。真正能区分真假的是fact—— 它问的是「生产响应里额度在哪一层」。
已登记三族(基线,元判据强制不可删):
- Result 信封 —— 凭据在
Result里,顶层只有ResponseMetadata - SOLO 自定义 SSE —— 正文在顶层
response,无choices - 嵌套 quota ——
pack.entitlement_base_info.quota.credits_limit(顶层没该字段)
② 仓库扫描 TestNoDegenerateFixturesInRepo —— 扫 internal/**/*_test.go,
对特征明确的退化形状做静态反查(当前只留「额度挪到 pack 顶层」一条规则)。
需要退化夹具来测兼容路径时,必须在文件里写 // SHAPE-OK: <理由> 显式豁免。
为什么扫描只留一条规则:文本匹配对另两族必然误报 —— SOLO 判据自己会写
Fatalf("不得返回 choices…"),天然含 choices。那两族的精确钉法 是 ① 的fact判据,不是文本匹配。
本轮修复的判据清单(P1/P2/P3)
| 判据文件 | 用例数 | 钉住什么 |
|---|---|---|
internal/lua/adapter_anthropic_headers_test.go |
3 | 基座发 x-api-key+anthropic-version(恰好 2 键)、不发 Authorization、无凭据不发空头、读 source.meta.api_key 兜底 |
internal/lua/adapter_zcode_build_headers_test.go |
3 | zcode 覆盖成 Bearer、无 x-api-key;基座 vs zcode 鉴权真的不同;无凭据不发空头 |
internal/lua/channels_cline_workos_test.go |
3 | 无前缀补上 / 已有不重复 / 大小写变体不重复;无 token 不发 Bearer workos: |
internal/lua/channels_gemini_auth_check_test.go |
2 | P1-3 结论:gemini 不自带鉴权(身份五头 + 兜底 Bearer 即正确),不得发 Authorization/x-api-key/x-goog-api-key |
internal/provider/build_headers_fallback_test.go |
2 | 内核兜底让位给 x-api-key;其它适配器 Bearer 行为不变 |
internal/config/source_name_contract_test.go |
4 | 允许字符集、:// 点名拒绝、其它非法字符、存量只告警 |
internal/core/source_name_contract_test.go |
2 | 真实写入口 AddSource 硬拒且拒绝发生在写盘之前;合法渠道名照常接受 |
internal/lua/channels_fixture_shapes_test.go |
4 | 三族行为层:Result 信封 / SOLO SSE / 嵌套 quota + 元判据 |
internal/lua/channels_fixture_shapes_general_test.go |
3 | 通用形状层:fact(real)&&!fact(naive)&&assert(eval(real));仓库扫描;登记表自检 |
internal/core/adapter_install_probe_test.go |
1 | __adapter_install_probe 报告 6/6 自装适配器全绿(luac -p 对长字符串无效) |
internal/lua/adapter_zcode_harness_test.go |
—(辅助) | 兄弟目录测试台:自带 zcode 装载断言,装不上就响亮失败 |
P4 — 待用户决策
| 项 | 需要什么 |
|---|---|
| zcode 真实登录 | 用户点授权(身份块 + init-poll 流已就绪) |
| cline / qoder / gemini / codearts / minimax / lobsterai / opencode | 需各平台账号;device-poll 系列要用户去官方页输码 |
| raccoon / loomy | 需扫码 |
| qoder WASM | 用户提供 .wasm |
3. 风险与纪律(本轮新增)
luac -p对长字符串内的适配器代码无效(只看外层)⇒ 会得到假绿。 必须用__adapter_install_probe看真实装载结果。本轮我因此把 TRAE 的 SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而luac一直通过。- Python 字符串替换会静默不生效:锚文本缩进不符 ⇒ 匹配 0 次。
规矩:① 先
cp备份 ② 行号定位 ③ 写后luac -p+grep -c双确认 ④ 长字符串内的改动必须验装载。 function是 Lua 关键字:cc.function是语法错误,必须cc["function"]; 但批量替换时别把cc.function_call一起改坏(本轮踩过,导致')' expected near '_call')。- 判据要咬被测对象:本轮
channels_trae_credits_hdr_test.go第一版造 HTTP 假上游, 因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。 改成直接咬spec.headers_fn的真实产出,一轮就准。 - 只改插件不必换二进制;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本" (v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。
/tmp是 8.8G tmpfs,Go 链接失败常见于它满(no space left on device)。 元凶通常是/tmp/gocache(可安全清)。
4. 账务实况(必须记住)
| 事实 | 值 |
|---|---|
| 内核 Go 代码自 v2.0.20 起的全部改动 | 仅 internal/lua/adapter_prelude.go、internal/provider/provider.go、internal/provider/registry.go |
| 只改插件却发了核心 tag 的版本 | v2.0.21–v2.0.39 中的 18 个 |
| 当前最新 tag | v2.0.39(091f790),落点正确 |
| main | 56ea269(与 release 内容零差异) |
| 未提交的改动 | 本轮三个修复(见 §1.4),已部署生产 |
⇒ 版本号语义教训:以后"改插件不打核心 tag",插件自身维护版本; 内核版本只在 Go 代码变更时递增。