Files
ModelRouter/internal/lua/channels_fixture_shapes_general_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

507 lines
19 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 lua
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// ============================================================================
// P3(通用版):把「夹具形状」本身变成判据,而不是逐个渠道写三条用例。
// ============================================================================
//
// 为什么这条比「三个具体用例」重要(plan §3 第 1 条 / §1.4 ④):
//
// 本轮最大的教训**不是某个 bug**,而是「夹具形状错 ⇒ 判据必然虚假通过」。
// trae ExchangeToken 的「只读顶层、不认 Result 信封」在两个版本的扁平夹具
// `{"Token":...}` 下**全部判据都是绿的**,真实登录却报「缺少 Token」。
//
// ⇒ 判据写得再严,只要喂进去的形状不是生产形状,它就毫无意义。
// 所以必须钉住的不是「行为」,而是「**喂进去的形状**」。
//
// 本文件做两层(`channels_fixture_shapes_test.go` 的三条具体用例仍在,负责
// 行为断言;这里负责**形状本身**的守门):
//
// ① shapeFamilies 登记表:每族给出
// real —— 生产原文(引自实测抓包/参考实现注释)
// naive —— 错误的「方便测试版」
// fact —— 对原始 JSON 的**结构事实**(real 必须满足,naive 必须不满足)
// eval —— 用**真实实现**处理它
// 断言:fact(real) && !fact(naive) && eval(real) 符合该族契约。
// 注意**不比较 eval 的输出形状**:多族实现刻意保留了兼容兜底路径
// (例如 trae 余额同时认顶层与嵌套),拿输出比对会把「合法兼容」判成错误。
// 真正能区分真假的判据是 fact —— 它说的是「生产响应长什么样」。
//
// ② 仓库扫描:对**特征明确**的退化形状做静态反查(只用于不会误报的那一族:
// 把额度挪到 pack 顶层的 `{"credits_limit":`)。其余族的精确钉法是 ①,
// 不靠文本匹配 —— 文本匹配对「Fatalf 文案里提到 choices」这类必然误报。
// shapeFamily 一类真实上游形状。
type shapeFamily struct {
// name 族名(判据名里会带上它,便于定位)。
name string
// why 为什么这族形状会被「方便的夹具」写坏(写进失败信息)。
why string
// real 生产原文。
real string
// naive 错误的「方便版」。必须**不满足** fact。
naive string
// fact 结构事实:true 表示「这段 body 是生产形状」。
// 只用它做形状判定,不用它替代 eval。
fact func(raw map[string]interface{}) bool
// eval 用真实实现处理一段 body。
eval func(body string) (map[string]interface{}, error)
// assert 断言 eval(real) 的结果符合该族契约(行为层)。
assert func(t *testing.T, obs map[string]interface{})
}
// shapeFamilies 是形状契约的**唯一登记处**。
//
// 新增渠道若出现「信封 / 自定义 SSE / 嵌套字段」这类容易被夹具写坏的形状,
// 必须在这里加一行 —— 元判据会检查三类基线族都在,且每行都真的调了被测实现。
func shapeFamilies(t *testing.T) []shapeFamily {
t.Helper()
return []shapeFamily{
{
// ① Result 信封:凭据在 **Result 里**,顶层只有 ResponseMetadata。
name: "Result 信封",
why: "顶层看起来「也」该有 Token,于是夹具顺手写成扁平 {\"Token\":...};" +
"实现只读顶层时判据全绿 —— 真实缺陷就是这么藏了两轮",
real: `{"ResponseMetadata":{"RequestId":"req-1","Action":"ExchangeToken"},` +
`"Result":{"ClientID":"cid","Token":"ACCESS-FROM-RESULT",` +
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`,
naive: `{"ResponseMetadata":{"RequestId":"req-1"},` +
`"Token":"ACCESS-FROM-RESULT"}`,
fact: func(raw map[string]interface{}) bool {
// 生产形状的判据:Result 是对象且其中有 Token,**顶层没有** Token。
r, ok := raw["Result"].(map[string]interface{})
if !ok {
return false
}
if _, has := r["Token"]; !has {
return false
}
_, topHas := raw["Token"]
return !topHas
},
eval: func(body string) (map[string]interface{}, error) {
_, ps := shapesProbeVM(t)
out, err := ps.Call("channels", "__trae_exchange_parse_probe",
map[string]interface{}{"body": body})
if err != nil {
return nil, err
}
return probeJSON(t, out), nil
},
assert: func(t *testing.T, obs map[string]interface{}) {
if obs["error"] != nil {
t.Fatalf("★ 生产形状解析失败: %v —— 是否只读顶层、没认 Result 信封?", obs["error"])
}
if got, _ := obs["access"].(string); got != "ACCESS-FROM-RESULT" {
t.Fatalf("★ access 必须来自 Result.Token,实得 %q", got)
}
if got, _ := obs["refresh"].(string); got != "RT-ROTATED" {
t.Fatalf("★ 轮换后的 refresh 必须来自 Result.RefreshToken,实得 %q", got)
}
},
},
{
// ② SOLO 自定义 SSE:扁平帧,正文在 **response**,**没有 choices**。
name: "SOLO 自定义 SSE",
why: "内核按 UnifiedChunk 解**扁平**结构;夹具若照 OpenAI 写成 " +
"{choices:[{delta}]},适配器返回外壳也会「看起来对」,而真机是 91 帧全空",
real: `{"response":"### 分析","reasoning_content":null,"tool_calls":null}`,
naive: `{"choices":[{"delta":{"content":"### 分析"}}]}`,
fact: func(raw map[string]interface{}) bool {
// 生产形状:正文在顶层 response,且**没有** choices。
if _, has := raw["response"]; !has {
return false
}
_, hasChoices := raw["choices"]
return !hasChoices
},
eval: func(body string) (map[string]interface{}, error) {
vm, _ := shapesProbeVM(t)
out, err := vm.Transform("channels-trae", "transform_stream_chunk", body)
if err != nil {
return nil, err
}
var ck map[string]interface{}
if err := json.Unmarshal([]byte(out), &ck); err != nil {
// 适配器对非 SOLO 帧返回 "" 是合法行为,不是夹具问题。
return map[string]interface{}{"raw": out}, nil
}
return ck, nil
},
assert: func(t *testing.T, obs map[string]interface{}) {
if _, has := obs["choices"]; has {
t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析: %v", obs)
}
if got, _ := obs["content"].(string); got != "### 分析" {
t.Fatalf("★ SOLO 正文必须摊平到顶层 content,实得 %q(%v)", got, obs)
}
},
},
{
// ③ 嵌套 quota:额度在 **entitlement_base_info.quota.credits_limit**。
name: "嵌套 quota",
why: "pack 顶层**没有** credits_limit(该字段根本不存在);" +
"夹具若照想象的形状写成 pack.credits_limit,实现读错字段也全绿。" +
"注意实现**刻意**保留了顶层兼容路径,所以「输出形状」不能当判据 ——" +
"能区分真假的是「生产响应里额度在哪一层」",
real: `{"user_entitlement_pack_list":[` +
`{"pack_type":"free","entitlement_base_info":{"quota":{"enable_free":true}}},` +
`{"pack_type":"monthly","entitlement_base_info":{"quota":{"credits_limit":500}},"usage":{}},` +
`{"pack_type":"checkin","entitlement_base_info":{"quota":{"credits_limit":100}},` +
` "usage":{"credits_amount":0}}],` +
`"usage_summary":{"total_amount":600,"consumed_amount":0}}`,
// 退化版:额度直接挂 pack 顶层(= 旧实现假设的形状,真实上游不发)。
naive: `{"user_entitlement_pack_list":[{"credits_limit":500},{"credits_limit":100}]}`,
fact: func(raw map[string]interface{}) bool {
list, ok := raw["user_entitlement_pack_list"].([]interface{})
if !ok || len(list) == 0 {
return false
}
nested := 0
for _, it := range list {
e, ok := it.(map[string]interface{})
if !ok {
return false
}
if _, top := e["credits_limit"]; top {
return false // 顶层有 credits_limit ⇒ 不是生产形状
}
base, ok := e["entitlement_base_info"].(map[string]interface{})
if !ok {
continue
}
q, ok := base["quota"].(map[string]interface{})
if !ok {
continue
}
if _, ok := q["credits_limit"]; ok {
nested++
}
}
return nested > 0
},
eval: func(body string) (map[string]interface{}, error) {
_, ps := shapesProbeVM(t)
out, err := ps.Call("channels", "__balance_total_probe",
map[string]interface{}{"platform": "trae", "body": body})
if err != nil {
return nil, err
}
return probeJSON(t, out), nil
},
assert: func(t *testing.T, obs map[string]interface{}) {
if obs["error"] != nil {
t.Fatalf("★ 生产形状解析失败: %v", obs["error"])
}
if got, _ := obs["total"].(float64); got != 600 {
t.Fatalf("★ 余额应为 500+100=600(嵌套 entitlement_base_info.quota.credits_limit),"+
"实得 %v —— 若为 0,检查是否又去读 pack 顶层的 credits_limit(该字段不存在)", got)
}
},
},
}
}
// TestRealUpstreamShapesArePinned 是 P3 的**通用**判据。
//
// 每族三重断言:
// 1. fact(real) —— 生产原文**确实是**该族形状(防止有人把「生产原文」写错)
// 2. !fact(naive) —— 退化形状**确实不是**该族形状(证明两者可区分)
// 3. assert(eval(real)) —— 真实实现能吃下生产形状
//
// 第 1、2 条是关键:它们证明「判据在解结构」,而不是「碰巧对任何输入都成功」。
func TestRealUpstreamShapesArePinned(t *testing.T) {
fams := shapeFamilies(t)
if len(fams) < 3 {
t.Fatalf("★ 形状契约至少要覆盖 3 族(Result 信封 / SOLO SSE / 嵌套 quota),实得 %d", len(fams))
}
for _, f := range fams {
t.Run(f.name, func(t *testing.T) {
if f.real == "" || f.naive == "" || f.fact == nil || f.eval == nil || f.assert == nil {
t.Fatalf("族 %q 登记不完整(real/naive/fact/eval/assert 全必填)—— "+
"只钉生产形状无法证明判据真的在解结构", f.name)
}
// ① 生产原文必须真的满足该族的结构事实
realRaw := mustJSONObject(t, f.real, f.name, "real")
if !f.fact(realRaw) {
t.Fatalf("★ 族 %q:登记的「生产原文」不满足结构事实 —— "+
"要么原文抄错了,要么 fact 写错了(两者都会让判据失去意义):\n %s", f.name, f.real)
}
// ② 退化形状必须不满足(证明 fact 能区分,不是恒真)
naiveRaw := mustJSONObject(t, f.naive, f.name, "naive")
if f.fact(naiveRaw) {
t.Fatalf("★ 族 %q:退化形状也满足结构事实 ⇒ fact 是恒真式,"+
"它区分不了真假夹具(这正是「夹具形状错⇒判据虚假通过」的机制):\n %s",
f.name, f.naive)
}
// ③ 真实实现必须能吃下生产形状,且行为符合契约
obs, err := f.eval(f.real)
if err != nil {
t.Fatalf("%s: 生产形状求值失败: %v", f.name, err)
}
f.assert(t, obs)
// ④ 退化形状**不得**被当成同一族的成功解析:要么报错,要么明确
// 走了兼容路径(此时 fact 已经证明它不是生产形状)。
// 这里只记录,不做断言 —— 兼容路径是刻意的(见 why)。
naiveObs, naiveErr := f.eval(f.naive)
t.Logf("✓ %s: real→%v | naive→(err=%v, obs=%v)", f.name, obs, naiveErr, naiveObs)
})
}
}
func mustJSONObject(t *testing.T, s, fam, which string) map[string]interface{} {
t.Helper()
var v map[string]interface{}
if err := json.Unmarshal([]byte(s), &v); err != nil {
t.Fatalf("族 %q 的 %s 形状不是合法 JSON 对象: %v\n %s", fam, which, err, s)
}
return v
}
// TestShapeFamilyTableIsHonest 元判据:登记表本身不许糊弄。
//
// ① 三类基线族必须在(Result 信封 / SOLO SSE / 嵌套 quota);
// ② 每族的 eval 必须真的打到被测实现 —— 给一段垃圾 body 时它必须有反应
// (报错,或产出与生产形状不同的观测)。恒等函数会在这里被抓住。
func TestShapeFamilyTableIsHonest(t *testing.T) {
fams := shapeFamilies(t)
want := []string{"Result 信封", "SOLO 自定义 SSE", "嵌套 quota"}
got := map[string]bool{}
for _, f := range fams {
got[f.name] = true
}
for _, w := range want {
if !got[w] {
t.Fatalf("★ 形状表缺基线族 %q —— 三类真实形状必须持续被钉住(plan §1.4 ④)", w)
}
}
for _, f := range fams {
realObs, err := f.eval(f.real)
if err != nil {
t.Fatalf("%s: 生产形状求值失败: %v", f.name, err)
}
junkObs, junkErr := f.eval(`<<<not json at all>>>`)
if junkErr == nil && samePrimitives(realObs, junkObs) {
t.Fatalf("★ 族 %q 的 eval 对垃圾输入与生产输入返回相同观测 ⇒ "+
"它不是真的在调被测实现(恒等判据毫无意义)", f.name)
}
}
t.Logf("✓ 形状表 %d 族,基线齐全且 eval 真的打到被测实现", len(fams))
}
// samePrimitives 只比较"标量键值都相同",用于检测恒等实现。
func samePrimitives(a, b map[string]interface{}) bool {
if len(a) != len(b) {
return false
}
for k, av := range a {
bv, ok := b[k]
if !ok {
return false
}
switch x := av.(type) {
case string:
if y, ok := bv.(string); !ok || x != y {
return false
}
case float64:
if y, ok := bv.(float64); !ok || x != y {
return false
}
case bool:
if y, ok := bv.(bool); !ok || x != y {
return false
}
case nil:
if bv != nil {
return false
}
default:
return false
}
}
return true
}
// ---------------------------------------------------------------------------
// ② 仓库扫描:只对**特征明确**的退化形状做静态反查
// ---------------------------------------------------------------------------
//
// 为什么只留一条规则:文本匹配对另外两族**必然误报** ——
// · SOLO:判据自己会写 `t.Fatalf("不得返回 choices …")`,天然含 choices;
// · Result:真夹具里 `"Result":{…,"Token":…}` 与顶层 `"Token":` 是同一行。
// 那两族的精确钉法是上面的 fact 判据(对原始 JSON 结构判定),不是文本。
//
// 剩下这一条规则的特征是**不会误报**的:把额度写到 pack 顶层必然写成
// `{"credits_limit":`(花括号紧跟键名),而生产形状一定写成
// `"quota":{"credits_limit":`(键名前是 `"quota":`)。
// degenerateFixturePattern 一条"退化夹具"特征。
type degenerateFixturePattern struct {
name string
trigger string // 文件里出现它 ⇒ 该文件在测这个渠道
forbidden string // 出现它 ⇒ 疑似把生产形状改成了方便版
why string
}
var degenerateFixturePatterns = []degenerateFixturePattern{
{
name: "trae 余额(嵌套 quota)",
trigger: "user_entitlement_pack_list",
forbidden: `{"credits_limit":`,
why: "生产把额度放在 entitlement_base_info.quota.credits_limit(pack 顶层" +
"**没有**该字段,实测上游从不发);`{\"credits_limit\":` 是把额度挪到顶层的" +
"退化形状,会让「读错层级」这个缺陷继续绿(余额恒 0 就是这么来的)",
},
}
// TestNoDegenerateFixturesInRepo 扫 internal/**/*_test.go,任何"可疑退化夹具"
// 都必须带 `// SHAPE-OK: <理由>` 显式豁免。
//
// 为什么扫描而不是只钉三条用例:三条用例只能钉住**它们自己**用的夹具;
// 下一次有人在**别的**测试里写一个退化夹具,那三条照样绿。这条扫描对着
// 整个仓库,是「防再犯」的那一半。
func TestNoDegenerateFixturesInRepo(t *testing.T) {
root, err := repoInternalDir()
if err != nil {
t.Fatalf("定位 internal/ 失败: %v", err)
}
var files []string
err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(p, "_test.go") {
files = append(files, p)
}
return nil
})
if err != nil {
t.Fatalf("遍历 internal/: %v", err)
}
if len(files) == 0 {
t.Fatal("没扫到任何 _test.go —— 扫描失效(这本身比告警更糟)")
}
var hits []string
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
continue
}
rel, _ := filepath.Rel(root, f)
// 本文件自身在描述规则(含特征字面量),跳过。
if strings.HasSuffix(f, "channels_fixture_shapes_general_test.go") {
continue
}
code := stripGoComments(string(b))
for _, pat := range degenerateFixturePatterns {
if !strings.Contains(code, pat.trigger) {
continue
}
if !strings.Contains(code, pat.forbidden) {
continue
}
if strings.Contains(string(b), "SHAPE-OK:") {
continue // 显式豁免,理由写在文件里
}
hits = append(hits, rel+" ["+pat.name+"] 含 "+pat.forbidden)
}
}
if len(hits) > 0 {
t.Fatalf("★ 疑似退化夹具(形状与生产不一致 ⇒ 判据必然虚假通过):\n %s\n\n"+
"若这**确实**是在测兼容/兜底路径,请在文件里加一行\n"+
" // SHAPE-OK: <为什么这个退化形状是刻意的>\n"+
"历史代价:trae 的 Result 信封缺陷就是这样藏了两轮(plan §1.4 ④)。\n"+
"规则理由:\n%s", strings.Join(hits, "\n "), whyList())
}
t.Logf("✓ 扫描 %d 个 _test.go,无未豁免的退化夹具(%d 条特征规则)",
len(files), len(degenerateFixturePatterns))
}
func whyList() string {
var b strings.Builder
for _, p := range degenerateFixturePatterns {
b.WriteString(" · " + p.name + ": " + p.why + "\n")
}
return b.String()
}
// stripGoComments 去掉 `//` 行注释与 `/* */` 块注释。
//
// 只做够用的一层:不解析字符串字面量里的 `//`(例如 URL "https://…")。
// 略微保守(可能少剥一点)比误剥安全 —— 误剥会让扫描漏报,少剥只会多报,
// 而多报可以由 SHAPE-OK 显式豁免。
func stripGoComments(s string) string {
var b strings.Builder
b.Grow(len(s))
lines := strings.Split(s, "\n")
inBlock := false
for _, line := range lines {
if inBlock {
if i := strings.Index(line, "*/"); i >= 0 {
inBlock = false
line = line[i+2:]
} else {
b.WriteString("\n")
continue
}
}
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
b.WriteString("\n")
continue
}
if i := strings.Index(line, "/*"); i >= 0 && !strings.Contains(line[:i], `"`) {
if j := strings.Index(line[i:], "*/"); j >= 0 {
line = line[:i] + line[i+j+2:]
} else {
line = line[:i]
inBlock = true
}
}
b.WriteString(line)
b.WriteString("\n")
}
return b.String()
}
// repoInternalDir 从测试工作目录(internal/lua)向上找 internal/。
func repoInternalDir() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
for d := wd; d != "/" && d != ""; d = filepath.Dir(d) {
if filepath.Base(d) == "internal" {
return d, nil
}
}
return filepath.Dir(wd), nil
}
// shapesProbeVM 提供形状判据共用的插件 VM(插件自装适配器需要它)。
//
// 用 zcodeHarness 而不是 runtimePlugins:后者给 adapters/plugins 各一个独立
// 临时目录,而 installZcodeAdapter 要求两者是兄弟目录(见 zcodeHarness 注释)。
func shapesProbeVM(t *testing.T) (*VM, *Plugins) {
t.Helper()
return zcodeHarness(t)
}