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

123 lines
4.5 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 (
"strings"
"testing"
)
// ★ P1-1:anthropic 基座必须发 `x-api-key`,不是 `Authorization: Bearer`。
//
// 真实缺陷:anthropic.lua 只有一个静态 `anthropic-version`、**没有**
// build_headers ⇒ 内核走兜底 `Authorization: Bearer <api_key>` ⇒ Anthropic
// 协议一律回 401 `invalid x-api-key`。继承该基座的渠道源(channels-zcode)
// 一有账号就会立刻暴露,与 trae 修复前完全同型(静态头 + 内核兜底 Bearer,
// 缺的正是协议要的那一个头)。
//
// 判据咬的是**真实产出表**(vm.BuildHeaders 就是内核发请求前走的那条路径),
// 不造 HTTP 假上游 —— 假上游需要 URL 覆盖/凭据 ref/路径匹配全部对齐,任一环
// 错都会让「没发请求」伪装成「头不对」(plan §3 第 4 条踩过三轮)。
func TestAnthropicBuildHeadersSendsXAPIKey(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
if !vm.HasFn("anthropic", "build_headers") {
t.Fatal("★ anthropic 基座没有 build_headers —— 内核会兜底发 " +
"Authorization: Bearer,而 Anthropic 协议要 x-api-key ⇒ 404/401")
}
hdrs, err := vm.BuildHeaders("anthropic", map[string]interface{}{
"api_key": "sk-ant-KEY-123",
"url": "https://api.anthropic.com/v1/messages",
"method": "POST",
"body": `{"model":"claude"}`,
})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
// ① 键集合:必须**恰好**是这两个。多出来的 Authorization 会让上游面对
// 两套鉴权方案(部分 Anthropic 兼容上游对歧义鉴权直接拒)。
if len(hdrs) != 2 {
t.Fatalf("★ 头集合应恰好 2 个(x-api-key + anthropic-version),实得 %d: %v",
len(hdrs), keysOf(hdrs))
}
if got := hdrs["x-api-key"]; got != "sk-ant-KEY-123" {
t.Fatalf("★ x-api-key 必须取凭据原值,实得 %q(头: %v)", got, keysOf(hdrs))
}
if got := hdrs["anthropic-version"]; got != "2023-06-01" {
t.Fatalf("anthropic-version 应为 2023-06-01,实得 %q", got)
}
if _, has := hdrs["Authorization"]; has {
t.Fatalf("★ 不得同时发 Authorization(与 x-api-key 冲突,上游视为歧义): %v", hdrs)
}
// ② 值形状:不能带 Bearer 前缀(x-api-key 的值是裸 key)
if strings.Contains(hdrs["x-api-key"], "Bearer") {
t.Fatalf("★ x-api-key 的值是**裸 key**,不能带 Bearer 前缀,实得 %q", hdrs["x-api-key"])
}
// ③ key 绝不外泄到别的头里(防止某次改动把 key 顺手塞进 UA 之类)
for k, v := range hdrs {
if k != "x-api-key" && strings.Contains(v, "sk-ant-KEY-123") {
t.Fatalf("★ 凭据只能出现在 x-api-key,泄露到了 %s=%q", k, v)
}
}
t.Logf("✓ anthropic 头: %v", keysOf(hdrs))
}
// 配对判据:没有凭据时仍要发 anthropic-version(协议头),但**不得**发空
// x-api-key —— 空值头会让上游回 `invalid x-api-key` 而不是清晰的"未配置凭据"。
func TestAnthropicBuildHeadersWithoutCredential(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
for _, meta := range []map[string]interface{}{nil, {}, {"api_key": ""}} {
hdrs, err := vm.BuildHeaders("anthropic", meta)
if err != nil {
t.Fatalf("BuildHeaders(%v): %v", meta, err)
}
if hdrs["anthropic-version"] != "2023-06-01" {
t.Fatalf("mate=%v 时仍须有 anthropic-version,实得 %v", meta, hdrs)
}
if _, has := hdrs["x-api-key"]; has {
t.Fatalf("meta=%v 时不得发空 x-api-key,实得 %v", meta, hdrs)
}
}
}
// 兜底路径:有些调用方只把 key 放在 source.meta.api_key(kimicode 同约定)。
// 基座必须与 kimicode 一致地认这第二种位置,否则换调用方就静默少头。
func TestAnthropicBuildHeadersReadsSourceMetaKey(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
hdrs, err := vm.BuildHeaders("anthropic", map[string]interface{}{
"source": map[string]interface{}{
"name": "claude-direct",
"meta": map[string]interface{}{"api_key": "KEY-FROM-META"},
},
})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
if hdrs["x-api-key"] != "KEY-FROM-META" {
t.Fatalf("★ 应读 source.meta.api_key 兜底,实得 %q(头: %v)",
hdrs["x-api-key"], keysOf(hdrs))
}
}
func keysOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}