Files
ModelRouter/internal/config/source_name_contract_test.go
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

108 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"strings"
"testing"
)
// ★ P2:源名允许字符集契约 —— 仅 `[A-Za-z0-9_-]`,**不得含 `:` 与 `/`**。
//
// 为何必须收窄(原因写在这里,因为 docs 会被改、判据不会):
//
// 模型寻址支持 `源名:模型名` / `源名/模型名` / `源名-模型名` 三种限定写法
// (WebUI 下拉框用 `源名:模型名` 作 option value:本实例 44 个模型被多个源
// 同时提供,必须能指定「从哪个源调」)。剥离源名的那一步是
// `internal/provider/splitPinned`:先做**最长源名前缀匹配**,再要求源名后
// 紧跟一个分隔符。
//
// ⇒ 若源名**自己含** `:` 或 `/`,那个字符同时是「源名的一部分」和「分隔符」,
// 限定名就成了歧义串:
//
// src="a:b" model="m" → "a:b:m" → ("a", "b:m") 还是 ("a:b", "m")?
// src="a/b" model="m" → "a/b/m" → ("a", "b/m") 还是 ("a/b", "m")?
//
// 两种切法都自圆其说,结果取决于**当前恰好有哪些源名** —— 同一客户端请求串
// 在不同部署/不同启动顺序下可能落到不同源,而且**不会报错**(splitPinned
// 会命中某一个分支然后路由到别处)。历史缺陷正是这一族:`IndexAny(model,
// "-:/")` 按第一个分隔符切,把 `channels-trae-xxx:model` 切成 src="channels"
// ⇒ 永不匹配 ⇒ `model ... is not configured`。
//
// 注意 `-` 是**允许**的:本仓渠道源就是 `channels-<平台>-<账号id>`,
// 分隔符在源名之后,不产生歧义。
func TestValidateSourceNameAllowsContractCharset(t *testing.T) {
allowed := []string{
"channels-trae-b8dd6b3df30e", // 真实渠道源形态(含两个 -)
"channels-buddy-buddy-main",
"DeepSeek",
"my_source_1",
"a",
"-leading-dash",
"trailing-dash-",
"123456",
}
for _, name := range allowed {
if err := ValidateSourceName(name); err != nil {
t.Errorf("★ 合法源名 %q 被拒: %v", name, err)
}
}
}
// 反例:`:` 与 `/` 必须**被点名拒绝**(而不是泛泛地说"非法字符")——
// 运维看到错误信息才知道这是限定名分隔符冲突,不是手滑。
func TestValidateSourceNameRejectsPinningSeparators(t *testing.T) {
cases := []string{
"a:b",
"vendor:model",
"a/b",
"openai/gpt",
"channels-trae-b8dd6b3df30e:glm-5.2", // 把"限定名"当源名写进来
"src/sub:name",
}
for _, name := range cases {
err := ValidateSourceName(name)
if err == nil {
t.Fatalf("★ 源名 %q 含限定名分隔符,必须被拒(否则 `源名:模型名` 歧义)", name)
}
msg := err.Error()
if !strings.Contains(msg, "pinning separator") {
t.Errorf("错误信息应点名「pinning separator」,实得: %v", err)
}
if !strings.Contains(msg, name) {
t.Errorf("错误信息应回显源名 %q,实得: %v", name, err)
}
}
}
// 其它非法字符同样拒(防止 `源 名`、`源名\n` 这种把日志/URL 搞乱的写法)。
func TestValidateSourceNameRejectsOtherIllegalChars(t *testing.T) {
for _, name := range []string{"has space", "tab\there", "new\nline", "中文名", "dot.name", "plus+name", "@at"} {
if err := ValidateSourceName(name); err == nil {
t.Errorf("★ 源名 %q 含非法字符,必须被拒(允许集只有 %s)", name, SourceNameAllowedPattern)
}
}
if err := ValidateSourceName(""); err == nil {
t.Error("空源名必须被拒")
}
}
// 存量兼容:启动时**只告警不报错**。
//
// 为什么不能直接报错:存量配置里可能已经有带 `:` 的源名,升级瞬间让网关
// 起不来(比"限定名歧义"更坏 —— 整个服务不可用)。契约从**新数据**开始强制
// (core.normalizeSource 硬拒),老数据给一条可读告警。
func TestSourceNameWarningIsSoftForLegacyConfig(t *testing.T) {
cfg := Config{Sources: []Source{
// 存量形态:带 `:` —— 必须能启动(不返回 error),但要被点名告警
{Name: "legacy:v1", BaseURL: "http://x", Models: []Model{{ID: "m1"}}},
}}
if err := cfg.ApplyDefaults(); err != nil {
t.Fatalf("★ 存量配置带 `:` 不得导致启动失败(会把整个网关 brick 掉): %v", err)
}
if w := SourceNameWarning("legacy:v1"); w == "" {
t.Fatal("★ 存量非法名必须给出告警文案(否则问题彻底静默)")
}
if w := SourceNameWarning("channels-trae-abc"); w != "" {
t.Fatalf("合法源名不该产生告警,实得 %q", w)
}
}