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

319 lines
13 KiB
Go
Raw 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"
"strings"
"testing"
)
// ★★ P3:夹具形状防回归 —— 钉住三类**真实上游形状**。
//
// 本轮最大的教训不是某个 bug,而是「**夹具形状错 ⇒ 判据必然虚假通过**」:
// trae 的 ExchangeToken 缺陷(只读顶层、不认 `Result` 信封)在两个版本的
// 扁平夹具 `{"Token":...}` 下**全部判据都是绿的**,真实登录却报「缺少 Token」。
// 也就是说:判据写得再严,只要它喂进去的形状不是生产形状,它就毫无意义。
//
// 因此这里不只钉行为,还要钉**形状**本身,且钉在**真实的解析器/转换器**上
// (经插件探针调用同一份实现),而不是在 Go 侧重写一遍解包逻辑 ——
// 自算版会在真实函数被改坏时继续绿(plan §3 第 4 条踩过三轮)。
//
// 三类形状(依据 docs/channel-repair-plan.md §1.4 ④ 与 §3 第 1 条):
// ① Result 信封 —— trae ExchangeToken:凭据在 `Result` 里,顶层没有
// ② SOLO 自定义 SSE —— trae 流式:`{"response":...}` 扁平帧,无 choices
// ③ 嵌套 quota —— trae 余额:`pack.entitlement_base_info.quota.credits_limit`
//
// 任一形状被"改回方便测试的样子"(扁平/OpenAI 外壳/顶层字段),本判据立刻报红。
// TestFixtureShapeResultEnvelope ①:Result 信封必须被真实解析器认出来。
//
// 真实上游(2026-10-17 实测,HTTP 200):
//
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
//
// 顶层**只有** ResponseMetadata + Result、没有 Token。若夹具写成扁平
// `{"Token":...}`,实现里「只读顶层」的错误会被完美掩盖 —— 这正是它藏了两轮的原因。
func TestFixtureShapeResultEnvelope(t *testing.T) {
_, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
// 真实形状:凭据只在 Result 里
real := `{"ResponseMetadata":{"RequestId":"req-1","Action":"ExchangeToken"},` +
`"Result":{"ClientID":"cid","Token":"ACCESS-FROM-RESULT",` +
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000,` +
`"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`
out, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
"body": real,
})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
j := probeJSON(t, out)
if j["error"] != nil {
t.Fatalf("★ 真实 Result 信封解析失败: %v —— 检查是否只读顶层", j["error"])
}
if got, _ := j["access"].(string); got != "ACCESS-FROM-RESULT" {
t.Fatalf("★ access 必须来自 Result.Token,实得 %q", got)
}
if got, _ := j["refresh"].(string); got != "RT-ROTATED" {
t.Fatalf("★ 轮换后的 refresh 必须来自 Result.RefreshToken,实得 %q", got)
}
// 反例:把这个形状的 Result 剥掉(= 旧的扁平夹具),解析器必须**报错**而不是
// 悄悄返回空 —— 否则"缺 Token"永远被误报成成功。
flat := `{"ResponseMetadata":{"RequestId":"req-1"},"Token":"SHOULD-NOT-RESOLVE"}`
out2, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
"body": flat,
})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
j2 := probeJSON(t, out2)
// 注意:顶层 Token 是**兼容兜底**(上游可能改版),所以这里允许成功;
// 但要钉住「Result 缺失时不会崩、也不会把 ResponseMetadata 当 Result」。
if j2["error"] == nil {
if got, _ := j2["access"].(string); got != "SHOULD-NOT-RESOLVE" {
t.Fatalf("顶层兜底路径应取顶层 Token,实得 %q", got)
}
}
// 真正必须报错的形状:既无 Result.Token 也无顶层 Token
bad := `{"ResponseMetadata":{"RequestId":"req-1"},"Result":{"ClientID":"cid"}}`
out3, err := ps.Call("channels", "__trae_exchange_parse_probe", map[string]interface{}{
"body": bad,
})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
j3 := probeJSON(t, out3)
if j3["error"] == nil {
t.Fatalf("★ 无 Token 的响应必须报错(否则「缺少 Token」会被误判成成功): %v", j3)
}
t.Logf("✓ Result 信封: access=%v refresh=%v;缺 Token 报错=%v",
j["access"], j["refresh"], j3["error"])
}
// TestFixtureShapeSoloSSE ②:trae 的 SOLO 自定义 SSE 必须被摊平成扁平 UnifiedChunk。
//
// 真实上游帧(参考 trae.ts SOLO):
//
// event: output
// data: {"response":"###","reasoning_content":null,"tool_calls":null}
//
// 内核只把 `data:` 行交给适配器(event 行在 provider.go 丢弃),且
// `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构** ——
// 返回 OpenAI 外壳 `{choices:[{delta}]}` 会被整体丢弃(91 帧全空)。
func TestFixtureShapeSoloSSE(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
// 真实正文帧(注意:`reasoning_content` / `tool_calls` 显式 null,不是缺失)
frames := []struct {
name string
frame string
wantKey string
wantText string
}{
{
name: "正文帧",
frame: `{"response":"### 分析","reasoning_content":null,"tool_calls":null}`,
wantKey: "content",
wantText: "### 分析",
},
{
name: "推理帧",
frame: `{"response":"","reasoning_content":"We need","tool_calls":null}`,
wantKey: "reasoning_content",
wantText: "We need",
},
}
for _, f := range frames {
out, err := vm.Transform("channels-trae", "transform_stream_chunk", f.frame)
if err != nil {
t.Fatalf("%s: transform 失败: %v", f.name, err)
}
var ck map[string]interface{}
if err := json.Unmarshal([]byte(out), &ck); err != nil {
t.Fatalf("%s: 产出不是 JSON: %q", f.name, out)
}
// ★ 形状一:必须是**扁平**(顶层 content),不能是 OpenAI 外壳
if _, has := ck["choices"]; has {
t.Fatalf("★ %s 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,"+
"外壳字段会被全部丢弃: %s", f.name, out)
}
got, _ := ck[f.wantKey].(string)
if got != f.wantText {
t.Fatalf("★ %s 应摊平到顶层 %s=%q,实得 %q(原始产出 %s)",
f.name, f.wantKey, f.wantText, got, out)
}
}
t.Logf("✓ SOLO SSE 扁平形状正确(content / reasoning_content 均在顶层)")
}
// TestFixtureShapeNestedQuota ③:trae 余额在**两层嵌套**里,不在 pack 顶层。
//
// 真实上游(2026-10-17 实测):
//
// pack[0] 免费 → entitlement_base_info.quota 无 credits_limit
// pack[1] 每月登录赠送 → entitlement_base_info.quota.credits_limit = 500
// pack[2] 签到奖励 → entitlement_base_info.quota.credits_limit = 100
// usage_summary: {total_amount:600, consumed_amount:0}
//
// 旧实现读 `pack.credits_limit`(**根本不存在的字段**)⇒ 余额恒 0,
// 而账号里其实有 600 —— 用户看到「额度显示异常」。
func TestFixtureShapeNestedQuota(t *testing.T) {
_, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
// 真实形状:额度在 entitlement_base_info.quota 里(含一个无额度的免费包)
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}}`
out, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
"platform": "trae",
"body": real,
})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
j := probeJSON(t, out)
if j["error"] != nil {
t.Fatalf("★ 嵌套 quota 解析失败: %v", j["error"])
}
if got, _ := j["total"].(float64); got != 600 {
t.Fatalf("★ 余额应为 500+100=600(嵌套 entitlement_base_info.quota.credits_limit),"+
"实得 %v —— 若为 0,检查是否又去读了 pack 顶层的 credits_limit(该字段不存在)",
j["total"])
}
// 反例:把额度放到**pack 顶层**(= 旧实现假设的形状)。真实上游不这么发,
// 但实现保留了兼容路径 —— 这里钉住的是「嵌套路径是权威」,同时确认
// 顶层兼容不会把数字算错(不会重复累加)。
//
// SHAPE-OK: 这段**刻意**用退化形状(额度挂 pack 顶层),因为它要测的正是
// 兼容兵底路径。生产形状由上面的嵌套夹具铉住;本文件里“生产/退化”两种
// 形状成对出现,才是“实现能区分两者”的证据。
// (若删掉这句,TestNoDegenerateFixturesInRepo 会报红 —— 这是故意的:
// 它强迫每一个退化夹具显式声明自己是刻意的。)
topLevel := `{"user_entitlement_pack_list":[{"credits_limit":50}]}`
out2, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
"platform": "trae",
"body": topLevel,
})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
j2 := probeJSON(t, out2)
if j2["error"] != nil {
t.Fatalf("顶层兼容路径不该报错: %v", j2["error"])
}
if got, _ := j2["total"].(float64); got != 50 {
t.Fatalf("顶层兼容路径应算 50,实得 %v", j2["total"])
}
// 已用额度必须扣减(usage.credits_amount 也是嵌套的)
consumed := `{"user_entitlement_pack_list":[` +
`{"entitlement_base_info":{"quota":{"credits_limit":500}},` +
` "usage":{"credits_amount":120}}]}`
out3, err := ps.Call("channels", "__balance_total_probe", map[string]interface{}{
"platform": "trae",
"body": consumed,
})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
j3 := probeJSON(t, out3)
if got, _ := j3["total"].(float64); got != 380 {
t.Fatalf("★ 余额应为 500-120=380(usage.credits_amount 是嵌套的),实得 %v", j3["total"])
}
t.Logf("✓ 嵌套 quota: 600 / 扣减后 380 / 顶层兼容 50")
}
// probeJSON 取探针返回的 {json:{...}} 那层。
func probeJSON(t *testing.T, got interface{}) map[string]interface{} {
t.Helper()
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("探针返回值不是对象: %#v", got)
}
j, ok := m["json"].(map[string]interface{})
if !ok {
t.Fatalf("探针返回值缺 json 层: %#v", m)
}
return j
}
// TestFixtureShapeGuardCoversAllThree 元判据:上三条**真的存在且真的咬到了
// 被测对象**。这是防「有人删掉形状判据」或「把判据改成不调真实解析器」的兜底。
//
// 它读自己的源码文本(同目录下),断言:
// ① 三个形状判据函数都在;
// ② 它们**都调用了**插件探针(而不是自己另写一份解包逻辑 ——
// 那样真实函数被改坏时判据会继续绿,这正是 trae 藏了两轮的原因)。
func TestFixtureShapeGuardCoversAllThree(t *testing.T) {
self, err := os.ReadFile("channels_fixture_shapes_test.go")
if err != nil {
t.Fatalf("读不到自身源码(元判据靠它验证三条形状判据是否还在): %v", err)
}
s := string(self)
// ① 三个形状判据必须在
for _, fn := range []string{
"TestFixtureShapeResultEnvelope",
"TestFixtureShapeSoloSSE",
"TestFixtureShapeNestedQuota",
} {
if !strings.Contains(s, "func "+fn+"(") {
t.Fatalf("★ 形状判据 %s 不见了 —— 三类真实形状必须持续被钉住", fn)
}
}
// ② 三条判据必须真的调用探针/真实适配器(不能退化成读源码字符串断言)
// Result 信封 / 嵌套 quota → 探针(同一个真实解析器)
// SOLO SSE → 已装载的 channels-trae 适配器
for _, anchor := range []string{
`__trae_exchange_parse_probe`, // ① Result 信封
`__balance_total_probe`, // ③ 嵌套 quota
`vm.Transform("channels-trae", "transform_stream_chunk"`, // ② SOLO SSE
} {
if !strings.Contains(s, anchor) {
t.Fatalf("★ 形状判据不再直接咬真实实现(缺 %s)—— "+
"自算版会在真实函数被改坏时继续绿", anchor)
}
}
// ③ 探针必须真的在插件里导出(否则上面两条会在运行期变成"探针不存在")
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
for _, probe := range []string{"__trae_exchange_parse_probe", "__balance_total_probe"} {
if !strings.Contains(string(src), probe) {
t.Fatalf("★ 插件缺探针 %s —— 形状判据将无从咬真实解析器", probe)
}
}
t.Logf("✓ 三类形状判据齐全,且均直接咬真实实现")
}