mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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 处)。
110 lines
5.6 KiB
Markdown
110 lines
5.6 KiB
Markdown
# 源名与限定名契约
|
||
|
||
> 本文只说一件事:**源名允许哪些字符,以及为什么**。
|
||
> 判据:`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`:校验器写对了但
|
||
> **没接到入口上**,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。
|