Files
ModelRouter/docs/source-name-contract.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

5.6 KiB
Raw Blame History

源名与限定名契约

本文只说一件事:源名允许哪些字符,以及为什么。 判据:internal/config/source_name_contract_test.go、internal/core/source_name_contract_test.go。

1. 契约

项 值
允许字符集 仅 [A-Za-z0-9_-]
禁止 : 与 /(限定名分隔符,见下)
其它禁止 空白、换行、.、+、@、非 ASCII 等一切不在允许集内的字符
长度 非空(上限未强制,但超长源名无实际用途)

合法的真实形态(本仓渠道源):

channels-trae-b8dd6b3df30e
channels-buddy-buddy-main
channels-zcode-<accountId>

注意 - 是允许的:渠道源名天生含 -,而 - 作分隔符时是「源名之后」 才出现,不产生歧义。

2. 为什么禁 : 与 /

2.1 背景:限定名是有意设计,不是兼容尾巴

网关支持 源名:模型名 这种限定名来指定「从哪个源调用」:

channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro

这不是历史包袱 —— 生产实例里 44 个模型被多个源同时提供 (glm-5.2 由 6 个源提供),WebUI 的模型下拉框用 源名:模型名 作 option value。没有限定名,用户就无法指定源。

: / / / - 三种分隔符都支持(历史写了三种,都保留)。

2.2 根因:源名含分隔符 ⇒ 限定名有两种都自圆其说的切法

剥离源名的那一步是 internal/provider/splitPinned:先按已知源名做最长 前缀匹配,命中后要求源名之后紧跟一个分隔符。

若源名自己含 : 或 /,那个字符同时是「源名的一部分」和「分隔符」:

源名 模型 限定名 可能的切法
a:b m a:b:m ("a", "b:m") 或 ("a:b", "m")
a/b m a/b/m ("a", "b/m") 或 ("a/b", "m")

两种切法都能自圆其说,结果取决于当前恰好存在哪些源名 —— 也就是 同一个客户端请求串,在不同部署、不同启动顺序下可能落到不同的源。

更糟的是它不会报错:splitPinned 会命中某一个分支,然后路由到别处。 历史上这一族缺陷就非常难查:

IndexAny(model, "-:/") 按第一个分隔符切,把 channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro 切成 src="channels"、 model="trae-b8dd6b3df30e:..." ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil ⇒ 客户端看到 model "channels-trae-xxx:..." is not configured。 用户只看到「模型不能用」,完全看不出是字符串切分错了。

所以约束必须放在写入口,而不是每次解析时去猜。

3. 校验加在哪(三个位置,职责不同)

位置 函数 行为 为什么在这儿
写入口(硬拒) core.normalizeSource(internal/core/core.go) AddSource 返回 error(API 400) WebUI 新增/修改源、插件 add_source 都走这里。新数据必须从源头就是合法的
启动(只告警) config.ApplyDefaults(internal/config/config.go) log.Printf 一行,不返回 error 存量配置可能已有带 : 的源名。升级瞬间让网关起不来比「限定名歧义」更坏 —— 整个服务不可用
校验器本体 config.ValidateSourceName / config.SourceNameWarning 纯函数 单一定义,上面两处都调它,不会漂移

解析入口(不在上面三处,但需要知道) :internal/provider/registry.go 的 splitPinned 是限定名的解析入口, Resolve / ResolvePinned / EffectiveModel 三个都用它。 它不做校验(解析不该拒绝已有数据),只做最长前缀匹配 + 分隔符判定。 校验的责任全在写入口 —— 这样解析路径保持纯函数、无副作用。

4. 存量数据怎么办

启动时告警、不阻断:

[config] source name "legacy:v1" contains ":", which is a reserved pinning
separator; "legacy:v1" is ambiguous (source names allow only [A-Za-z0-9_-])
  — `source:model` pinning may resolve to the wrong source

运维看到告警后可以:

  • 改名(推荐,WebUI 里编辑即可 —— 改名后 channels-* 源会在下次账号同步时重建)
  • 或者继续用,但不要对这种源使用 源名:模型名 限定名(用「模型 + 源」的 显式槽位,即 AUTO 链里的 (model, source) 对,那条路径不经过 splitPinned)

5. 判据覆盖

判据 文件 钉住什么
TestValidateSourceNameAllowsContractCharset internal/config/source_name_contract_test.go 合法集(含真实渠道源形态、含 -)不被误拒
TestValidateSourceNameRejectsPinningSeparators 同上 : / / 被点名拒绝(错误文案含 pinning separator + 回显源名)
TestValidateSourceNameRejectsOtherIllegalChars 同上 空白/换行/./+/@/非 ASCII/空串
TestSourceNameWarningIsSoftForLegacyConfig 同上 存量带 : 能启动(不 brick),且有告警文案
TestAddSourceRejectsPinnedSeparatorInName internal/core/source_name_contract_test.go 真实写入入口 Core.AddSource 硬拒,且拒绝发生在写盘之前
TestAddSourceAcceptsChannelStyleNames 同上 真实渠道源名照常接受(防校验器写太严把渠道全废掉)

写入口那条特意在 internal/core 而不是 internal/config:校验器写对了但 没接到入口上,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。