Files
ModelRouter/docs/channel-repair-plan.md
JianFeeeee 4174310421 fix(channels): 渠道鉴权头三修 + 源名契约 + 夹具形状判据
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 处)。
2026-10-18 00:38:49 +08:00

16 KiB
Raw Blame History

渠道链路修复计划与现状

本文是 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,逐层剥开)

  1. 没有 build_headers ⇒ 内核兜底发 Bearer,而 trae 推理端点要 Cloud-IDE-JWT + 设备身份头 ⇒ 401「not able to authenticate you」。 → 与「额度不可用」是同一根因的两个出口(额度端点当轮修了、推理端点漏了)。
  2. 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。
  3. 流式转换完全不对: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。必须:

  1. 在 release/v2.0.x 上提交(含 3 个源文件 + 6 个判据文件)
  2. 打 tag v2.0.40(这是内核 Go 改动:registry.go ⇒ 真核心版本)
  3. 回流 main(cherry-pick,不 merge)
  4. 复核 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/ = 0

zcode 是「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. 风险与纪律(本轮新增)

  1. luac -p 对长字符串内的适配器代码无效(只看外层)⇒ 会得到假绿。 必须用 __adapter_install_probe 看真实装载结果。本轮我因此把 TRAE 的 SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而 luac 一直通过。
  2. Python 字符串替换会静默不生效:锚文本缩进不符 ⇒ 匹配 0 次。 规矩:① 先 cp 备份 ② 行号定位 ③ 写后 luac -p + grep -c 双确认 ④ 长字符串内的改动必须验装载。
  3. function 是 Lua 关键字:cc.function 是语法错误,必须 cc["function"]; 但批量替换时别把 cc.function_call 一起改坏(本轮踩过,导致 ')' expected near '_call')。
  4. 判据要咬被测对象:本轮 channels_trae_credits_hdr_test.go 第一版造 HTTP 假上游, 因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。 改成直接咬 spec.headers_fn 的真实产出,一轮就准。
  5. 只改插件不必换二进制;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本" (v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。
  6. /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 代码变更时递增。