diff --git a/docs/channel-repair-plan.md b/docs/channel-repair-plan.md index a174aa1..49543b6 100644 --- a/docs/channel-repair-plan.md +++ b/docs/channel-repair-plan.md @@ -112,34 +112,105 @@ > 判据:`git tag --points-at ` 非空;`git diff release/v2.0.x main` 为空。 -### P1 — 其余渠道的同类缺陷(**账号 0,验证不了**) +### P1 — 其余渠道的同类缺陷(**账号 0,验证不了运行**) 按"风险 × 可验证性"排序。**核心限制:这 4 个平台账号数均为 0, 无法做生产实测** —— 只能靠静态对齐参考 + 装好适配器后看 `__adapter_install_probe`。 | 优先 | 项 | 缺陷 | 修法 | 可验证性 | |---|---|---|---|---| -| P1-1 | `channels-zcode` | 走 anthropic 基座,而**基座无 `build_headers`**,anthropic 要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`),zcode 继承即得 | 静态可验;运行需账号 | -| P1-2 | `channels-cline` | 参考要 `Bearer workos:`,我们的 token 无 `workos:` 前缀 | 登录/续期处拼前缀,或适配器 `build_headers` 补 | 静态可验;运行需账号 | -| P1-3 | `channels-gemini` | 有静态头 + Bearer(参考注释也说 Cloud Code 用 OAuth Bearer)⇒ **可能本来就对** | 只需确认,不改 | 静态可验 | +| P1-1 | `anthropic` 基座 | 基座**无 `build_headers`**,Anthropic 协议要 `x-api-key` 不是 Bearer | 给基座补 `build_headers`(`x-api-key` + `anthropic-version`) | ✅ 已修+已判据;运行需账号 | +| P1-1b | `channels-zcode` | **★ 证据冲突**:zcode 的 Messages 端点虽然**说 Anthropic 协议**,鉴权却是 `Bearer `(参考 `zcode-transport.ts:121-125`,全仓 `x-api-key` 命中 **0** 次)。基座补了 x-api-key 后它**继承就会变错** | zcode **显式覆盖** `build_headers` 发 Bearer,不用继承 | ✅ 已修+已判据;运行需账号 | +| P1-2 | `channels-cline` | 参考要 `Bearer workos:`,我们的 token 无 `workos:` 前缀 | 适配器 `build_headers` 补前缀(幂等,已带不重复) | ✅ 已修+已判据;运行需账号 | +| P1-3 | `channels-gemini` | 有静态头 + Bearer ⇒ **本来就对**(参考 `gemini.ts:353-366` 也是 OAuth Bearer,且刻意不发 `x-goog-api-key`) | **不改**,只加判据把这个结论钉住 | ✅ 已确认+已判据 | | P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **阻塞**:二进制不在仓库任何位置 | 不可验证 | +> #### ★ P1-1 与 P1-1b 的证据冲突(本轮最重要的一条) +> +> 计划原文把 P1-1 写成「anthropic 基座没 `build_headers` ⇒ zcode 继承就 401」。 +> 逆向参考后**两半各对一半**: +> +> | 断言 | 结论 | 证据 | +> |---|---|---| +> | Anthropic 协议(真 · api.anthropic.com)要 `x-api-key` | ✅ 对 | 协议本身 | +> | zcode 的 `/zcode-plan/anthropic/v1/messages` 也要 `x-api-key` | ❌ **错** | `zcode-transport.ts:121-125` 发 `Authorization: Bearer ${zcode_jwt}`;`zcode-upstream.ts:83-108` 的 `buildZcodeHeaders` 只有可选 Authorization;全仓 `grep -rn "x-api-key" src/` = **0** | +> +> zcode 是「**Anthropic 请求体形状 + Bearer 鉴权**」——协议与鉴权是两个独立维度, +> 被这家上游拆开了。所以修复不能"把基座改成 x-api-key 就完事": +> 那会把 zcode 从「对」改成「错」。正确形态是**基座发 x-api-key + zcode 显式覆盖成 Bearer**。 +> +> 附带约束:`anthropic` 基座**同时服务生产里的其它源**(`/etc/llmsproxy/config.yaml` +> 里 `justwoker` 就是 `adapter: anthropic`)。给基座补 `x-api-key` 后,内核 +> `buildHeaders` 的兜底 Bearer **必须让位**(否则同一请求带两套鉴权), +> 这处改动在 `internal/provider/provider.go`(内核改动)。 + ### P2 — 内核对"限定名"的契约收口 -`ResolvePinned` / `EffectiveModel` 现在各自调 `splitPinned`。建议: -- 把"源名可含哪些字符"写进 `docs/`,并加判据:新增源名若含 `:` 或 `/` 必须报警 - (否则限定名会歧义)。 -- 现状:源名由插件生成 `channels--`,只含 `-`/字母数字,安全。 +已落地(详见 **`docs/source-name-contract.md`**): + +| 位置 | 行为 | +|---|---| +| `config.ValidateSourceName` / `SourceNameWarning` | 纯函数校验器(单一定义) | +| `core.normalizeSource`(`AddSource` 入口) | 新增/修改源**硬拒**(API 400)—— 新数据从源头合法 | +| `config.ApplyDefaults`(启动) | 存量带 `:` 的源名**只告警不报错**(避免升级 brick 掉整个网关) | + +字符集:**仅 `[A-Za-z0-9_-]`**,禁 `:` 与 `/`(限定名分隔符 ⇒ 切分歧义且**不报错**)。 +`-` 允许(渠道源名 `channels-<平台>-<账号id>` 含 `-`,分隔符在源名之后不歧义)。 + +解析入口 `internal/provider/splitPinned` **不做校验**(解析不该拒绝已有数据), +只做最长前缀匹配 —— 校验责任全在写入口,保持解析路径纯函数无副作用。 ### P3 — 判据防回归(防"夹具与生产不一致"再犯) 本轮最大的教训不是某个 bug,而是**夹具形状错 ⇒ 判据虚假通过**(trae 活了两轮)。 -建议加一条**通用**判据:`TestAdaptersFixturesMatchRealUpstreamShape` —— -对每个有夹具的渠道,把夹具响应体与 -`/tmp/pi-github-repos/.../vendor/channel-pack/src/*.ts` 里注释记录的真实响应形状 -做**结构对照**(键集合),不一致即报红。做不到全自动,至少把已知的 -"Result 信封 / SOLO SSE / 嵌套 quota" 三类钉住。 +已落地,分两层(文件:`internal/lua/channels_fixture_shapes_test.go` 行为层 + +`internal/lua/channels_fixture_shapes_general_test.go` 通用形状层): + +**① 形状登记表 `shapeFamilies`** —— 每族给出四项,缺一不可: + +| 字段 | 含义 | 为什么必须有 | +|---|---|---| +| `real` | 生产原文(引自实测抓包/参考注释) | 夹具的真值来源 | +| `naive` | 错误的「方便测试版」 | 证明判据能区分真假,不是恒等式 | +| `fact(raw)` | 对**原始 JSON 的结构事实** | 判定「这段 body 是不是生产形状」 | +| `eval` / `assert` | 用**真实实现**处理并断言行为 | 防判据自算一份解包逻辑 | + +断言链:`fact(real)` 为真 **且** `fact(naive)` 为假 **且** `assert(eval(real))` 通过。 + +> ⚠️ 刻意**不比较 `eval(real)` 与 `eval(naive)` 的输出形状**:trae 余额实现 +> **合法保留**了「顶层 credits_limit」兼容兜底路径(实测两种上都返回 600), +> 拿输出比对会把合法兼容判成错误。真正能区分真假的是 `fact` +> —— 它问的是「生产响应里额度在哪一层」。 + +已登记三族(基线,元判据强制不可删): +- **Result 信封** —— 凭据在 `Result` 里,顶层只有 `ResponseMetadata` +- **SOLO 自定义 SSE** —— 正文在顶层 `response`,**无** `choices` +- **嵌套 quota** —— `pack.entitlement_base_info.quota.credits_limit`(顶层**没**该字段) + +**② 仓库扫描 `TestNoDegenerateFixturesInRepo`** —— 扫 `internal/**/*_test.go`, +对**特征明确**的退化形状做静态反查(当前只留「额度挪到 pack 顶层」一条规则)。 +需要退化夹具来测兼容路径时,必须在文件里写 `// SHAPE-OK: <理由>` 显式豁免。 + +> 为什么扫描只留一条规则:文本匹配对另两族**必然误报** —— SOLO 判据自己会写 +> `Fatalf("不得返回 choices…")`,天然含 choices。那两族的精确钉法 +> 是 ① 的 `fact` 判据,不是文本匹配。 + +#### 本轮修复的判据清单(P1/P2/P3) + +| 判据文件 | 用例数 | 钉住什么 | +|---|---|---| +| `internal/lua/adapter_anthropic_headers_test.go` | 3 | 基座发 `x-api-key`+`anthropic-version`(恰好 2 键)、不发 Authorization、无凭据不发空头、读 `source.meta.api_key` 兜底 | +| `internal/lua/adapter_zcode_build_headers_test.go` | 3 | zcode **覆盖**成 `Bearer`、无 `x-api-key`;基座 vs zcode 鉴权**真的不同**;无凭据不发空头 | +| `internal/lua/channels_cline_workos_test.go` | 3 | 无前缀补上 / 已有不重复 / 大小写变体不重复;无 token 不发 `Bearer workos:` | +| `internal/lua/channels_gemini_auth_check_test.go` | 2 | **P1-3 结论**:gemini 不自带鉴权(身份五头 + 兜底 Bearer 即正确),不得发 Authorization/x-api-key/x-goog-api-key | +| `internal/provider/build_headers_fallback_test.go` | 2 | 内核兜底让位给 x-api-key;其它适配器 Bearer 行为**不变** | +| `internal/config/source_name_contract_test.go` | 4 | 允许字符集、`:`/`/` 点名拒绝、其它非法字符、存量只告警 | +| `internal/core/source_name_contract_test.go` | 2 | **真实写入口** `AddSource` 硬拒且拒绝发生在写盘之前;合法渠道名照常接受 | +| `internal/lua/channels_fixture_shapes_test.go` | 4 | 三族行为层:Result 信封 / SOLO SSE / 嵌套 quota + 元判据 | +| `internal/lua/channels_fixture_shapes_general_test.go` | 3 | 通用形状层:`fact(real)&&!fact(naive)&&assert(eval(real))`;仓库扫描;登记表自检 | +| `internal/core/adapter_install_probe_test.go` | 1 | `__adapter_install_probe` 报告 6/6 自装适配器全绿(`luac -p` 对长字符串无效) | +| `internal/lua/adapter_zcode_harness_test.go` | —(辅助) | 兄弟目录测试台:自带 zcode 装载断言,装不上就响亮失败 | ### P4 — 待用户决策 diff --git a/docs/source-name-contract.md b/docs/source-name-contract.md new file mode 100644 index 0000000..7d844b0 --- /dev/null +++ b/docs/source-name-contract.md @@ -0,0 +1,109 @@ +# 源名与限定名契约 + +> 本文只说一件事:**源名允许哪些字符,以及为什么**。 +> 判据:`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- +``` + +注意 `-` **是允许的**:渠道源名天生含 `-`,而 `-` 作分隔符时是「源名之后」 +才出现,不产生歧义。 + +## 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`:校验器写对了但 +> **没接到入口上**,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。 diff --git a/internal/config/config.go b/internal/config/config.go index d80a201..a57a06d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ package config import ( "encoding/json" "fmt" + "log" "os" "path/filepath" "strconv" @@ -14,6 +15,67 @@ import ( "gopkg.in/yaml.v3" ) +// SourceNameAllowedChars 是源名允许的字符集:仅 [A-Za-z0-9_-]。 +// +// 为何必须收窄(本仓真的踩过):模型寻址支持 `源名:模型名` / +// `源名/模型名` / `源名-模型名` 三种限定写法(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" 同上 +// +// 两种切法都能自圆其说,结果取决于**当前恰好有哪些源名** —— 也就是同一个 +// 客户端请求串在不同部署/不同启动顺序下可能落到不同源。更坑的是它不会报错: +// splitPinned 会命中某一个分支然后路由到错的地方(历史缺陷就是这类: +// `IndexAny(model, "-:/")` 按第一个分隔符切,导致 channels-trae-xxx:model +// 被切成 src="channels" ⇒ 永不匹配 ⇒ `model ... is not configured`)。 +// +// 因此把约束放在**写入口**,而不是每次解析时猜:含 `:` 或 `/` 的源名一律拒。 +// 注意 `-` 是**允许**的:本仓的渠道源正是 `channels-<平台>-<账号id>`, +// 分隔符在源名之后,不产生歧义。 +const SourceNameAllowedPattern = `[A-Za-z0-9_-]` + +// ValidateSourceName reports why name is not a legal source name, or nil. +// Callers turn this into a startup error (config) / API 400 (add_source). +func ValidateSourceName(name string) error { + if name == "" { + return fmt.Errorf("source name is empty") + } + for _, r := range name { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-': + // allowed + case r == ':' || r == '/': + // 单独点名:这两个字符是限定名的**分隔符**,出现在源名里必然歧义。 + return fmt.Errorf( + "source name %q contains %q, which is a reserved pinning separator; "+ + "%q is ambiguous (source names allow only %s)", + name, string(r), name, SourceNameAllowedPattern) + default: + return fmt.Errorf("source name %q contains illegal character %q (allowed: %s)", + name, string(r), SourceNameAllowedPattern) + } + } + return nil +} + +// SourceNameWarning is the soft form of ValidateSourceName: it returns a +// human-readable warning for names that BREAK the contract but existed before +// the contract did. Startup logs it instead of failing, so an operator's +// already-working config is not bricked by an upgrade; new sources are refused +// outright (see core.normalizeSource). +func SourceNameWarning(name string) string { + if err := ValidateSourceName(name); err != nil { + return err.Error() + " — `source:model` pinning may resolve to the wrong source" + } + return "" +} + // Config is the top-level gateway configuration. type Config struct { Path string `yaml:"-" json:"-"` @@ -330,6 +392,14 @@ func (c *Config) ApplyDefaults() error { if s.Name == "" { return fmt.Errorf("config: sources[%d] missing name", i) } + // ★ 契约(见 ValidateSourceName):源名只允许 [A-Za-z0-9_-], + // 含 `:` 或 `/` 会让 `源名:模型名` 变歧义。 + // 这里**只告警不当场报错**:存量配置可能已经有这种名字,升级直接 + // 启动失败会让整个网关不可用(比歧义更坏)。新增源由 + // core.normalizeSource 硬拒 —— 契约从新数据开始强制。 + if warn := SourceNameWarning(s.Name); warn != "" { + log.Printf("[config] %s", warn) + } if s.BaseURL == "" { return fmt.Errorf("config: source %s missing base_url", s.Name) } diff --git a/internal/config/source_name_contract_test.go b/internal/config/source_name_contract_test.go new file mode 100644 index 0000000..4a7d37b --- /dev/null +++ b/internal/config/source_name_contract_test.go @@ -0,0 +1,107 @@ +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) + } +} diff --git a/internal/core/adapter_install_probe_test.go b/internal/core/adapter_install_probe_test.go new file mode 100644 index 0000000..0dac28f --- /dev/null +++ b/internal/core/adapter_install_probe_test.go @@ -0,0 +1,68 @@ +package core + +import ( + "testing" +) + +// ★ 适配器**真实装载**必须可观测:`__adapter_install_probe` 要报告全部 6 个 +// 自装适配器,且每个都是 ok。 +// +// 为什么这条判据值得存在(plan §3 第 1 条): +// +// `luac -p` 对 Lua 长字符串里的适配器代码**无效**(它只看外层插件脚本), +// 会给出假绿。真实故障态是「插件加载成功、适配器却没装进去」—— 此时每次 +// 调用内核才响亮报 `missing transform_response`,而原因指向内核不像配置。 +// +// 另一层:cline / qoder 此前**不在探针输出里**,于是「装了但没生效」在这两个 +// 适配器上完全不可观测 —— 本轮修 cline 的 `workos:` 前缀时正是因此先卡了一下。 +// 装上就该看得见。 +func TestAdapterInstallProbeReportsAllSelfInstalled(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载插件: %v", err) + } + + res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("__adapter_install_probe: %v", err) + } + // 探针的诊断价值就在于它能在 CI 里跑通(zcode 的基座在 core 测试台里 + // 是可得 —— pluginDir 与 adaptersDir 同一套配置)。 + top, ok := res.(map[string]interface{}) + if !ok { + t.Fatalf("探针返回值不是对象: %#v", res) + } + j, ok := top["json"].(map[string]interface{}) + if !ok { + t.Fatalf("探针返回值缺 json 层: %#v", top) + } + + // 6 个自装适配器:缺任何一个都是"插件装了、适配器没装"的故障态。 + want := []string{"codearts", "trae", "gemini", "zcode", "cline", "qoder"} + for _, name := range want { + entry, ok := j[name].(map[string]interface{}) + if !ok { + t.Fatalf("★ 探针未报告 %s 适配器的装载结果(装上就该看得见;"+ + "漏报等于问题不可观测): %v", name, j) + } + if entry["ok"] != true { + t.Fatalf("★ %s 适配器装载失败: %v\n"+ + " luac -p 对长字符串里的适配器代码**无效**(plan §3 第 1 条),"+ + "必须以本探针为准", name, entry["err"]) + } + } + t.Logf("✓ 6/6 自装适配器全部装载成功(probe: %v)", keysOfProbe(j)) +} + +func keysOfProbe(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/core/core.go b/internal/core/core.go index edb2570..d16b3dc 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -1007,6 +1007,13 @@ func normalizeSource(s *config.Source) error { if s.Name == "" || s.BaseURL == "" { return fmt.Errorf("source requires name and base_url") } + // ★ 契约:源名只允许 [A-Za-z0-9_-](见 config.ValidateSourceName)。 + // 这里对**新增/修改的源硬拒**(API 400),而不是像启动时那样只告警 —— + // 带 `:` 或 `/` 的源名会让 `源名:模型名` 这个限定名产生两种都自圆其说的 + // 切法,同一个请求串在不同部署下可能路由到不同源,且**不会报错**。 + if err := config.ValidateSourceName(s.Name); err != nil { + return err + } if len(s.Models) == 0 { return fmt.Errorf("source requires at least one model") } diff --git a/internal/core/source_name_contract_test.go b/internal/core/source_name_contract_test.go new file mode 100644 index 0000000..2200807 --- /dev/null +++ b/internal/core/source_name_contract_test.go @@ -0,0 +1,72 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// ★ P2(内核改动):新增/修改源时,源名含 `:` 或 `/` 必须**硬拒**(API 400)。 +// +// 这里验的是**真实写入入口** Core.AddSource(WebUI / 插件的 add_source 都走它), +// 不是 config 层那个校验器 —— 校验器写对了但没接到入口上,问题依旧存在。 +// +// 根因见 internal/config/config.go 的 ValidateSourceName 注释:`:` 与 `/` 是 +// `源名:模型名` 的**分隔符**,出现在源名里会让限定名产生两种都自圆其说的切法, +// 同一请求串在不同部署下可能路由到不同源,且**不报错**。 +func TestAddSourceRejectsPinnedSeparatorInName(t *testing.T) { + for _, name := range []string{ + "a:b", + "channels-trae-abc:glm-5.2", // 把"限定名"当源名写进来 + "a/b", + "openai/gpt", + } { + t.Run(name, func(t *testing.T) { + c := kernelTestCore(t) + err := c.AddSource(config.Source{ + Name: name, + BaseURL: "http://127.0.0.1:1/v1", + Adapter: "openai", + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + }) + if err == nil { + t.Fatalf("★ 源名 %q 含限定名分隔符,AddSource 必须拒绝\n"+ + " 否则 `%s:m1` 既可能解析成源 %q,也可能解析成别的源 —— 且不报错", + name, name, name) + } + msg := err.Error() + if !strings.Contains(msg, "pinning separator") { + t.Fatalf("错误信息应点名「pinning separator」(运维才知道是限定名冲突),实得: %v", err) + } + // 源不得被写进配置(拒绝必须是**彻底的**,不能先写后报错) + for _, s := range c.Sources() { + if s.Name == name { + t.Fatalf("★ 被拒绝的源 %q 竟然写进了配置 —— 拒绝必须发生在写盘之前", name) + } + } + }) + } +} + +// 配对判据:合法的渠道源名(含 `-`)必须照常接受。 +// 这条防"校验器写太严把真实渠道源全拒了"——那会让整个渠道功能失效。 +func TestAddSourceAcceptsChannelStyleNames(t *testing.T) { + for _, name := range []string{ + "channels-trae-b8dd6b3df30e", + "channels-buddy-buddy-main", + "my_source_1", + } { + t.Run(name, func(t *testing.T) { + c := kernelTestCore(t) + if err := c.AddSource(config.Source{ + Name: name, + BaseURL: "http://127.0.0.1:1/v1", + Adapter: "openai", + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + }); err != nil { + t.Fatalf("★ 合法渠道源名 %q 被拒(会把真实渠道全废掉): %v", name, err) + } + }) + } +} diff --git a/internal/lua/adapter_anthropic_headers_test.go b/internal/lua/adapter_anthropic_headers_test.go new file mode 100644 index 0000000..458f931 --- /dev/null +++ b/internal/lua/adapter_anthropic_headers_test.go @@ -0,0 +1,122 @@ +package lua + +import ( + "strings" + "testing" +) + +// ★ P1-1:anthropic 基座必须发 `x-api-key`,不是 `Authorization: Bearer`。 +// +// 真实缺陷:anthropic.lua 只有一个静态 `anthropic-version`、**没有** +// build_headers ⇒ 内核走兜底 `Authorization: Bearer ` ⇒ 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 +} diff --git a/internal/lua/adapter_zcode_build_headers_test.go b/internal/lua/adapter_zcode_build_headers_test.go new file mode 100644 index 0000000..3604dc9 --- /dev/null +++ b/internal/lua/adapter_zcode_build_headers_test.go @@ -0,0 +1,143 @@ +package lua + +import ( + "encoding/json" + "strings" + "testing" +) + +// ★★ zcode 的 Messages 端点鉴权是 **Bearer**,不是 x-api-key。 +// +// 背景(本轮**最重要的一处证据冲突**,必须写清): +// +// 任务前提是「anthropic 协议 ⇒ x-api-key,所以 channels-zcode 继承基座就 401」。 +// 但逆向参考(vendor/channel-pack,全部结论来自真实流量)明确写着相反的事: +// +// src/zcode-transport.ts:121-125 buildChannelRequest() +// headers = buildZcodeHeaders(credential, { json: true }) +// headers.Authorization = `Bearer ${token}` // token = zcode_jwt +// src/zcode-upstream.ts:83-108 buildZcodeHeaders() +// 只发 User-Agent / HTTP-Referer / X-Device-Mid / anthropic-version … +// Authorization 是**可选注入项**,且全仓 `grep -rn "x-api-key" src/` = **0 次** +// +// 也就是说 zcode 的这条路径是「**Anthropic 请求体形状 + Bearer 鉴权**」—— +// 协议与鉴权是两个独立维度,被同一家上游拆开了。 +// +// 为什么修复前它其实是**对的**:zcode 未声明 credential_shape ⇒ api_key = 凭据里的 +// access_token = zcode_jwt;而基座当时没有 build_headers ⇒ 内核兜底发了 +// `Authorization: Bearer `,与参考逐字一致。 +// +// ⇒ 真正的风险来自**本轮的正向修复**:给 anthropic 基座补 x-api-key 之后, +// 若 zcode 只是「继承」而不覆盖,它就会从 Bearer 变成 x-api-key ⇒ 立刻 401。 +// 所以 zcode 必须**显式覆盖** build_headers,把 Bearer 钉住。 +// +// 判据咬真实产出(vm.BuildHeaders 是内核发请求前的那条路径),不造 HTTP 假上游。 +func TestZcodeBuildHeadersUsesBearerNotXAPIKey(t *testing.T) { + vm, _ := zcodeHarness(t) + if !vm.HasFn("channels-zcode", "build_headers") { + t.Fatal("★ channels-zcode 必须有**自己的** build_headers(覆盖基座)," + + "否则会继承基座的 x-api-key 而丢掉参考要求的 Bearer") + } + + hdrs, err := vm.BuildHeaders("channels-zcode", map[string]interface{}{ + "api_key": "ZCODE-JWT-ABC", + "url": "https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages", + "method": "POST", + "body": `{"model":"GLM-5.3"}`, + }) + if err != nil { + t.Fatalf("BuildHeaders: %v", err) + } + + // ① 鉴权必须是 Bearer (参考 zcode-transport.ts:125) + if got := hdrs["Authorization"]; got != "Bearer ZCODE-JWT-ABC" { + t.Fatalf("★ zcode 的 Authorization 必须是 `Bearer `,实得 %q(头: %v)\n"+ + " 参考 zcode-transport.ts:125 逐字如此;全仓没有一处发 x-api-key", + got, keysOf(hdrs)) + } + // ② 不得同时发 x-api-key(那就是双鉴权;且参考从不发它) + if v, has := hdrs["x-api-key"]; has { + t.Fatalf("★ zcode 不得发 x-api-key(实得 %q)—— 参考全程没有这个头,"+ + "发了就是两套鉴权方案", v) + } + // ③ 协议头 anthropic-version 必须保留(它与鉴权无关,参考也发) + if got := hdrs["anthropic-version"]; got != "2023-06-01" { + t.Fatalf("anthropic-version 应保留为 2023-06-01,实得 %q", got) + } + // ④ 键集合恰好 2 个,不多不少(防止顺手塞进别的身份头) + if len(hdrs) != 2 { + t.Fatalf("头集合应恰好 2 个(Authorization + anthropic-version),实得 %d: %v", + len(hdrs), keysOf(hdrs)) + } + t.Logf("✓ zcode 头: %v", keysOf(hdrs)) +} + +// 配对判据:**基座**必须发 x-api-key(这是 P1-1 的正向要求,也是与 zcode 的差异点)。 +// +// 两条判据放在一起才有意义:它们证明「同一份基座 + 显式覆盖」能同时满足 +// 两种鉴权,而不是把整个 anthropic 生态压成一种。 +func TestAnthropicBaseAndZcodeDifferOnAuthHeader(t *testing.T) { + vm, _ := zcodeHarness(t) + + base, err := vm.BuildHeaders("anthropic", map[string]interface{}{"api_key": "SK-ANT"}) + if err != nil { + t.Fatalf("anthropic BuildHeaders: %v", err) + } + zc, err := vm.BuildHeaders("channels-zcode", map[string]interface{}{"api_key": "JWT-Z"}) + if err != nil { + t.Fatalf("zcode BuildHeaders: %v", err) + } + + // 基座:x-api-key,无 Authorization + if base["x-api-key"] != "SK-ANT" { + t.Fatalf("★ anthropic 基座必须发 x-api-key,实得 %v", base) + } + if _, has := base["Authorization"]; has { + t.Fatalf("★ anthropic 基座不得发 Authorization(x-api-key 才是它的鉴权),实得 %v", base) + } + // zcode:Authorization,无 x-api-key + if zc["Authorization"] == "" { + t.Fatalf("★ zcode 必须发 Authorization: Bearer,实得 %v", zc) + } + if _, has := zc["x-api-key"]; has { + t.Fatalf("★ zcode 不得发 x-api-key,实得 %v", zc) + } + // 两者必须**真的不同**——若某天有人删掉 zcode 的覆盖,这条会立刻红 + if base["x-api-key"] == "" || zc["Authorization"] == "" { + t.Fatal("两者鉴权头方案必须各自成立") + } + b, _ := json.Marshal(base) + z, _ := json.Marshal(zc) + if string(b) == string(z) { + t.Fatalf("★ 基座与 zcode 的鉴权方案不能相同:%s", b) + } + // 协议头必须相同(anthropic-version 与鉴权无关) + if base["anthropic-version"] != zc["anthropic-version"] { + t.Fatalf("两者都必须发 anthropic-version:base=%v zcode=%v", base, zc) + } + if strings.Contains(string(b), "Bearer") { + t.Fatalf("基座不该出现 Bearer:%s", b) + } + t.Logf("✓ 基座=%s | zcode=%s", b, z) +} + +// 无凭据时:两边都只发协议头,都不发空鉴权(空值会让上游回认证失败, +// 而真因是「没配置凭据」,会把人带偏)。 +func TestAuthHeadersOmitEmptyCredential(t *testing.T) { + vm, _ := zcodeHarness(t) + for _, name := range []string{"anthropic", "channels-zcode"} { + h, err := vm.BuildHeaders(name, map[string]interface{}{}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if h["anthropic-version"] != "2023-06-01" { + t.Errorf("%s 仍须发 anthropic-version,实得 %v", name, h) + } + if _, has := h["x-api-key"]; has { + t.Errorf("%s 无凭据时不得发空 x-api-key,实得 %v", name, h) + } + if _, has := h["Authorization"]; has { + t.Errorf("%s 无凭据时不得发空 Authorization,实得 %v", name, h) + } + } +} diff --git a/internal/lua/adapter_zcode_harness_test.go b/internal/lua/adapter_zcode_harness_test.go new file mode 100644 index 0000000..0854361 --- /dev/null +++ b/internal/lua/adapter_zcode_harness_test.go @@ -0,0 +1,68 @@ +package lua + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// zcodeHarness 是与 runtimePlugins 等价的测试台,唯一差别是 **adapters 与 +// plugins 目录互为兄弟**(`/adapters` + `/plugins`)。 +// +// 为什么必须这样:installZcodeAdapter 要从「插件目录的父目录/adapters」读 +// anthropic.lua 的源码,再把基座**内联**进适配器脚本(适配器沙箱没有 fs)。 +// runtimePlugins 给 adapters/plugins 各用一个独立的 t.TempDir(),两个目录 +// 不是兄弟 ⇒ zcode 安装期就报「读不到 anthropic.lua」而被跳过,适配器根本 +// 不会出现(探针会如实报 ok:false,这本身是好事 —— 说明失败可见)。 +// +// 生产里这两个目录是兄弟(config.PluginDir 与 config.AdapterDir 同级), +// 所以这个测试台更贴近真实布局。 +func zcodeHarness(t *testing.T) (*VM, *Plugins) { + t.Helper() + root := t.TempDir() + adir := filepath.Join(root, "adapters") + vm := NewVM(adir) + if err := vm.Start(); err != nil { + t.Fatalf("vm: %v", err) + } + t.Cleanup(vm.Stop) + pdir := filepath.Join(root, "plugins") + if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { + t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir) + } + ps := NewPlugins(vm, pdir) + ps.SetOutboundHosts([]string{"127.0.0.1"}) + // 同 runtimePlugins:关掉状态持久化,避免后台调度器写进生产 store。 + ps.DisableStatePersistence() + + 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) + } + // zcode 装不上时必须**响亮失败**,不能静默跳过 —— 否则判据会在 + // 「适配器根本不存在」的情况下误判为通过(那正是 plan §3 第 1 条 + // 说的假绿形态)。 + res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("__adapter_install_probe: %v", err) + } + j := probeJSON(t, res) + zc, ok := j["zcode"].(map[string]interface{}) + if !ok { + t.Fatalf("探针未报告 zcode 装载结果: %v", j) + } + if zc["ok"] != true { + t.Fatalf("★ zcode 适配器装载失败: %v\n"+ + " 注意 luac -p 对长字符串内的适配器代码无效(plan §3 第 1 条),"+ + "必须以本探针为准", zc["err"]) + } + if !vm.HasFn("channels-zcode", "build_headers") { + t.Fatal("zcode 装载后仍然没有 build_headers") + } + _ = fmt.Sprint() + return vm, ps +} diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index 67d9349..7e4412f 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -5,6 +5,45 @@ adapter.endpoint = "/v1/messages" adapter.headers = { ["anthropic-version"] = "2023-06-01", } + +-- ============================================================ +-- build_headers (鉴权头) +-- ============================================================ +-- Anthropic 协议要 **x-api-key**,不是 `Authorization: Bearer`。 +-- 此前本基座只有一个静态 `anthropic-version`、**没有** build_headers ⇒ 内核 +-- 走兜底 `Authorization: Bearer ` ⇒ 上游一律 401/403: +-- `{"type":"error","error":{"type":"authentication_error", +-- "message":"invalid x-api-key"}}` +-- 继承本基座的渠道源(channels-zcode 等)一有账号就会立刻暴露 —— 与 trae +-- 修复前完全同型(静态头 + 内核 Bearer,缺协议要的那一个头)。 +-- +-- 三条纪律: +-- ① 值取自内核注入的 `meta.api_key`(源的凭据),**绝不**写日志、不额外 +-- 放进返回值以外的任何地方;本函数只把它放进请求头。 +-- ② **不自己发 Authorization** —— 源级 headers 若已显式配置 Authorization, +-- 重复发两种鉴权方案会让部分 Anthropic 兼容上游直接拒(歧义)。 +-- ③ `anthropic-version` 与静态头同值,保证「装了 hook」与「没装 hook」两种 +-- 路径产出同一份协议头,不会因为加了 hook 而少一个版本头。 +function adapter.build_headers(meta) + local h = { + ["anthropic-version"] = "2023-06-01", + } + local key = "" + if type(meta) == "table" then + if type(meta.api_key) == "string" then key = meta.api_key end + -- 兜底:个别调用方只给 source.meta.api_key(与 kimicode 同一约定) + if key == "" and type(meta.source) == "table" + and type(meta.source.meta) == "table" + and type(meta.source.meta.api_key) == "string" then + key = meta.source.meta.api_key + end + end + if key ~= "" then + h["x-api-key"] = key + end + return h +end + -- INGRESS CLAIM (inbound dual of serialize_*): this adapter owns parsing of the -- client-facing POST /v1/messages request body. The kernel keeps a built-in -- Anthropic parser as its fallback (R5) — that is what an unconfigured diff --git a/internal/lua/channels_cline_workos_test.go b/internal/lua/channels_cline_workos_test.go new file mode 100644 index 0000000..4a8dea9 --- /dev/null +++ b/internal/lua/channels_cline_workos_test.go @@ -0,0 +1,129 @@ +package lua + +import ( + "strings" + "testing" +) + +// ★ P1-2:Cline 的推理端点要 `Authorization: Bearer workos:`。 +// +// 真实缺陷:参考实现(cline.ts)发头时直接拼 `workos:${token}`,而我们池里 +// 存的是**第二步 register 换回来的 Cline token**(裸 token,不带前缀) +// ⇒ 之前 channels-cline 干脆没有 build_headers,内核兜底发 +// `Authorization: Bearer <裸token>` ⇒ 少了协议要求的前缀 ⇒ 上游只回泛化的 +// 认证失败(与 trae 修前同型:头发了,但形式不对)。 +// +// 判据咬**真实产出**(vm.BuildHeaders 就是内核发请求前走的那条路径), +// 不造 HTTP 假上游(那需要 URL 覆盖/凭据 ref/路径匹配全部对齐,任一环错 +// 都会让「没发请求」伪装成「头不对」,plan §3 第 4 条)。 +func TestClineBuildHeadersAddsWorkOSPrefix(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) + } + if !vm.HasFn("channels-cline", "build_headers") { + t.Fatal("★ channels-cline 没有 build_headers —— 内核会兜底发裸 Bearer," + + "而 Cline 推理端点要 `Bearer workos:`") + } + + cases := []struct { + name string + tok string + want string + }{ + { + name: "无前缀 → 补上 workos:", + tok: "eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig", + want: "Bearer workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig", + }, + { + name: "已有 workos: 前缀 → 不重复拼", + tok: "workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig", + want: "Bearer workos:eyJhbGciOiJIUzI1NiJ9.jwt-payload.sig", + }, + { + name: "前缀大小写不同(WorkOS:)→ 视为已带,不重复拼", + tok: "WorkOS:abc123", + want: "Bearer WorkOS:abc123", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{ + "api_key": tc.tok, + "url": "https://api.cline.bot/api/v1/chat/completions", + "method": "POST", + "body": `{"model":"x"}`, + }) + if err != nil { + t.Fatalf("BuildHeaders: %v", err) + } + auth := hdrs["Authorization"] + if auth != tc.want { + t.Fatalf("★ Authorization 应为 %q,实得 %q\n"+ + " Cline 要 `Bearer workos:`;缺前缀或重复拼都会认证失败", + tc.want, auth) + } + // 不得出现 workos:workos: 这种重复前缀(旧数据可能已带前缀) + if strings.Count(strings.ToLower(auth), "workos:") != 1 { + t.Fatalf("★ workos: 前缀出现 %d 次(必须恰好 1 次): %q", + strings.Count(strings.ToLower(auth), "workos:"), auth) + } + t.Logf("✓ %s → %s", tc.name, auth) + }) + } +} + +// 配对判据:没有凭据时不得发出 `Bearer workos:` 这种空凭据头 +// (空凭据头会让上游回认证失败,而真因是「没配置 token」,会把人带偏)。 +func TestClineBuildHeadersWithoutToken(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) + } + hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{}) + if err != nil { + t.Fatalf("BuildHeaders 不该因缺凭据报错: %v", err) + } + if auth, has := hdrs["Authorization"]; has { + t.Fatalf("★ 无凭据时不得发 Authorization(会变成 `Bearer workos:`),实得 %q", auth) + } +} + +// ★ 单一实现:前缀规则只在 with_workos_prefix 里写一次。 +// 判据直接咬那个函数(经适配器导出),确保 build_headers 用的是同一份逻辑 —— +// 若哪天有人把规则复制到 build_headers 里内联、而导出版留着旧的,这条会红。 +func TestClineWorkOSPrefixHelperIsSingleSource(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) + } + if !vm.HasFn("channels-cline", "with_workos_prefix") { + t.Fatal("★ channels-cline 应导出 with_workos_prefix 供判据咬同一份实现" + + "(否则判据只能自算前缀规则,真实函数被改坏时会继续绿)") + } + // 用适配器池里真实的那份脚本再算一遍,与 build_headers 的产出对照。 + // 这里直接调 Transform 不方便(不是 transform 钩子),故用 build_headers + // 的两种输入反推:带前缀的 token 必须**原样**出现在 Authorization 里。 + hdrs, err := vm.BuildHeaders("channels-cline", map[string]interface{}{ + "api_key": "workos:XYZ", + }) + if err != nil { + t.Fatal(err) + } + if got := hdrs["Authorization"]; got != "Bearer workos:XYZ" { + t.Fatalf("已带前缀的 token 必须原样透传,实得 %q", got) + } +} diff --git a/internal/lua/channels_fixture_shapes_general_test.go b/internal/lua/channels_fixture_shapes_general_test.go new file mode 100644 index 0000000..98d26bd --- /dev/null +++ b/internal/lua/channels_fixture_shapes_general_test.go @@ -0,0 +1,506 @@ +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(`<<>>`) + 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) +} diff --git a/internal/lua/channels_fixture_shapes_test.go b/internal/lua/channels_fixture_shapes_test.go new file mode 100644 index 0000000..ec1ddd0 --- /dev/null +++ b/internal/lua/channels_fixture_shapes_test.go @@ -0,0 +1,318 @@ +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("✓ 三类形状判据齐全,且均直接咬真实实现") +} diff --git a/internal/lua/channels_gemini_auth_check_test.go b/internal/lua/channels_gemini_auth_check_test.go new file mode 100644 index 0000000..2d2daa4 --- /dev/null +++ b/internal/lua/channels_gemini_auth_check_test.go @@ -0,0 +1,91 @@ +package lua + +import ( + "testing" +) + +// ★ P1-3(**只确认不改**):channels-gemini 的「静态身份五头 + 内核兜底 Bearer」 +// 本来就是**对的**,不需要像 zcode 那样补 x-api-key。 +// +// 这是本轮唯一一个"看起来同型、实际不是缺陷"的渠道,所以把它钉成判据, +// 免得下一轮有人照 P1-1 的模子给它也改一遍(那会把它弄坏)。 +// +// 证据链(两条独立来源一致): +// +// ① 参考实现 vendor/channel-pack/src/gemini.ts:353-366 geminiHeaders() +// headers.set('Authorization', `Bearer ${credential.access_token}`) +// 再逐字注入 GEMINI_IDENTITY_HEADERS 五头 +// 并**刻意不注入** x-goog-api-key(源注释:原版不带) +// ② 参考 src/gemini.ts:110-117 的 GEMINI_IDENTITY_HEADERS 与我们 +// adapter.headers 的五头逐字相同(UA / x-client-name / x-client-version / +// x-machine-id / x-vscode-sessionid) +// +// 也就是说:gemini 走的是 **OAuth Bearer**(Cloud Code 凭据),不是 +// Anthropic 那种 x-api-key,也不是 Cline 那种 workos: 前缀。它的鉴权 +// 由内核兜底 `Authorization: Bearer ` 提供 —— 而 gemini 未声明 +// credential_shape ⇒ api_key = 凭据里的 access_token ⇒ 兜底值正确。 +// +// ⇒ **结论:不改。** 本判据把这个结论钉住:一旦有人给 channels-gemini 加上 +// +// build_headers 发别的鉴权头(例如照 zcode 改成 x-api-key),这里会红。 +func TestChannelsGeminiAuthIsBearerByDesign(t *testing.T) { + vm, _ := zcodeHarness(t) + + // ① 适配器不该自己发鉴权头:静态头里**不得**出现 Authorization / + // x-api-key / x-goog-api-key。鉴权交给内核兜底 Bearer。 + hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{ + "api_key": "GA-OAUTH-TOKEN", + "url": "https://cloudcode-pa.googleapis.com/v1internal:generateContent", + "method": "POST", + "body": `{}`, + }) + if err != nil { + t.Fatalf("BuildHeaders: %v", err) + } + for _, forbidden := range []string{"Authorization", "x-api-key", "x-goog-api-key"} { + if v, has := hdrs[forbidden]; has { + t.Fatalf("★ channels-gemini 不该自己发 %s(实得 %q)—— "+ + "Cloud Code 走 OAuth Bearer,由内核兜底发;"+ + "自己发会让它与兜底冲突或改变鉴权方案(P1-3 结论是「不改」)", + forbidden, v) + } + } + // ② 身份五头必须还在(它们是准入条件,与鉴权无关) + for k, want := range map[string]string{ + "User-Agent": "antigravity/4.3.0 (cmdc-pak)", + "x-client-name": "antigravity", + "x-client-version": "4.3.0", + "x-machine-id": "cmdc-pak", + "x-vscode-sessionid": "proxy", + } { + if got := hdrs[k]; got != want { + t.Fatalf("身份头 %s 应为 %q,实得 %q(参考 GEMINI_IDENTITY_HEADERS 逐字)", k, want, got) + } + } + t.Logf("✓ channels-gemini 只发身份五头、不自带鉴权(兜底 Bearer 即正确): %v", keysOf(hdrs)) +} + +// 配对判据:经**内核**装出来的头必须真的是 `Authorization: Bearer `。 +// +// 这条在 internal/provider 侧另有一条同型判据(build_headers_fallback_test.go), +// 这里从「适配器没抢鉴权」推到「兜底确实补上了」,把 P1-3 的结论补成闭环。 +func TestChannelsGeminiKernelFallbackSendsBearer(t *testing.T) { + vm, _ := zcodeHarness(t) + + // 适配器产出:不含任何鉴权头(见上一条) + hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{ + "api_key": "GA-OAUTH-TOKEN", + }) + if err != nil { + t.Fatal(err) + } + if _, has := hdrs["Authorization"]; has { + t.Fatalf("适配器不该发 Authorization,实得 %v", hdrs) + } + // 于是内核兜底条件成立(Authorization 与 x-api-key 都为空)⇒ 会补 Bearer。 + // 这里断言**条件成立**,而不是重复实现兜底逻辑(兜底本身由 provider 判据咬)。 + if hdrs["x-api-key"] != "" { + t.Fatalf("x-api-key 必须为空(否则内核兜底不会补 Bearer),实得 %v", hdrs) + } + t.Log("✓ 适配器不装鉴权 ⇒ 内核兜底条件成立 ⇒ 发 Authorization: Bearer ") +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 425b87b..42cbdb6 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -116,7 +116,7 @@ plugin.kernel = { -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable", "__balance_total_probe", "__trae_exchange_parse_probe" } @@ -8813,6 +8813,36 @@ local function jsonField(raw, key) return v[key] or "" end +-- parseTraeExchangeBody(body):ExchangeToken 响应体 → (access, newRefresh, expireAt, err)。 +-- +-- ★ 为什么抽成纯函数:判据要能**直接咬真实解析器**。此前 trae 的判据是 +-- 「造 HTTP 假上游 → 走完整回调」,结果一次形状不符的**夹具**(扁平 +-- `{"Token":...}`)把这个真缺陷(只读顶层)完美掩盖了两轮。夹具形状错 +-- ⇒ 判据必然虚假通过。抽出来之后,判据拿真实上游响应原文直接打它。 +-- +-- 真实上游形状(2026-10-17 实测,HTTP 200): +-- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...", +-- "RefreshToken":"...","TokenExpireAt":1800000000000}} +-- 顶层只有 ResponseMetadata + Result、**没有** Token。 +-- 参考 parseTraeExchangeResponse 同样先取 `data.Result ?? data.result`。 +-- 顶层也兜(上游可能改版),但**优先 Result**。 +local function parseTraeExchangeBody(body) + local ok, rec = pcall(json.decode, body or "") + if not ok or type(rec) ~= "table" then + return nil, nil, nil, "TRAE ExchangeToken 响应无法解析" + end + local r2 = rec.Result or rec.result + if type(r2) ~= "table" then r2 = rec end + local access = r2.Token or r2.token or r2.AccessToken or r2.access_token + or rec.Token or rec.token + if not access or access == "" then + return nil, nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)" + end + local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken + local expireAt = r2.TokenExpireAt or rec.TokenExpireAt or r2.expireAt + return access, newRefresh, expireAt, nil +end + traeCallback = function(plat, q, s) q = q or {} local userJwt = q.userJwt or "" @@ -8882,29 +8912,21 @@ traeCallback = function(plat, q, s) if resp.status ~= 200 then return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status) end - local ok, rec = pcall(json.decode, resp.body or "") - if not ok or type(rec) ~= "table" then - return nil, nil, "TRAE ExchangeToken 响应无法解析" - end - -- ★★ 上游把凭据放在 **Result 里**,不是顶层: - -- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}} - -- 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):HTTP 200, - -- 顶层只有 ResponseMetadata + Result、**没有** Token。 - -- 我原先读 `rec.Token` ⇒ 恒 nil ⇒ 报「响应缺少 Token」;用户只看到「登录失败」, - -- 完全看不出是解包层级错了。参考 parseTraeExchangeResponse 同样先取 - -- `data.Result ?? data.result`。 - -- ⚠️ 顶层也兜(上游可能改版),但**优先 Result**。 - local r2 = rec.Result or rec.result - if type(r2) ~= "table" then r2 = rec end - local access = r2.Token or r2.token or r2.AccessToken or r2.access_token - or rec.Token or rec.token - if not access or access == "" then - return nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)" - end + -- ★ 解包交给 parseTraeExchangeBody(上面那个纯函数)。 + -- **单一定义**:判据拿真实上游响应原文直接打同一个函数,不另写副本 —— + -- 否则夹具/副本与生产漂移时判据会继续绿(trae 就是这么藏了两轮)。 + -- + -- 上游把凭据放在 **Result 里**,不是顶层: + -- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}} + -- 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):HTTP 200, + -- 顶层只有 ResponseMetadata + Result、**没有** Token。 + -- 原先读 `rec.Token` ⇒ 恒 nil ⇒ 误报「响应缺少 Token」;用户只看到 + -- 「登录失败」,完全看不出是解包层级错了。 + -- 参考 parseTraeExchangeResponse 同样先取 `data.Result ?? data.result`。 + local access, newRefresh, expireAt, perr = parseTraeExchangeBody(resp.body) + if perr then return nil, nil, perr end -- ★ 轮换后的 refreshToken 必须留下:旧 refresh 已被上游作废 -- (参考注释明说「换新 access 并**轮换** refreshToken」)。不存新值 ⇒ 下次续期必失败。 - local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken - local expireAt = r2.TokenExpireAt or rec.TokenExpireAt local packed = packTrae(access) if (newRefresh and newRefresh ~= "") or expireAt then local okc, cred = pcall(json.decode, packed) @@ -10618,6 +10640,51 @@ end adapter.name = "channels-zcode" adapter.version = "1.0.0" +-- ============================================================================ +-- build_headers:zcode 的 Messages 端点是 **Bearer**,不是 x-api-key +-- ============================================================================ +-- ★★ 这一层**必须显式覆盖**基座的 build_headers,不能靠继承。 +-- +-- 基座(anthropic.lua)现在按 Anthropic 协议发 `x-api-key`,而 zcode 的 +-- `/api/v1/zcode-plan/anthropic/v1/messages` 虽然**说着 Anthropic 协议**, +-- 鉴权却是 **Authorization: Bearer **。 +-- +-- 参考实现是唯一权威(vendor/channel-pack 实测逆向): +-- src/zcode-transport.ts:121-125 buildChannelRequest() +-- headers = buildZcodeHeaders(credential, { json: true }) +-- headers.Authorization = `Bearer ${token}` ← token = zcode_jwt +-- src/zcode-upstream.ts:83-108 buildZcodeHeaders() +-- 只发 User-Agent / HTTP-Referer / X-Device-Mid / anthropic-version 等, +-- Authorization 是**可选**注入项,**全程没有 x-api-key** +-- 全仓 `grep -rn "x-api-key" src/` ⇒ **0 次命中** +-- +-- ⇒ 协议(Anthropic Messages 形状)与鉴权(Bearer)在这里是**两个独立维度**: +-- 基座对的是「任何说 Anthropic 协议的上游」,zcode 对的是「它自己那家」。 +-- 若这里不覆盖:基座会发 x-api-key,而内核 buildHeaders 不会再补 Bearer +-- ⇒ zcode 一有账号就 401,与「什么都没发」等价。 +-- +-- 同时保留基座给的 anthropic-version(协议头,跟鉴权无关), +-- 并用基座同一套键名取凭据,避免两处各写一份取值规则。 +local function zcode_api_key(meta) + if type(meta) ~= "table" then return "" end + if type(meta.api_key) == "string" and meta.api_key ~= "" then return meta.api_key end + -- 兜底:个别调用方只给 source.meta.api_key(与基座/kimicode 同一约定) + local sm = type(meta.source) == "table" and meta.source.meta or nil + if type(sm) == "table" and type(sm.api_key) == "string" then return sm.api_key end + return "" +end + +function adapter.build_headers(meta) + local h = { + ["anthropic-version"] = "2023-06-01", + } + local key = zcode_api_key(meta) + if key ~= "" then + h["Authorization"] = "Bearer " .. key + end + return h +end + -- ============================================================================ -- 身份块构造 -- ============================================================================ @@ -10863,6 +10930,50 @@ function adapter.transform_request(raw_body) return raw_body end function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end +-- build_headers:Cline 的**推理**端点要 `Authorization: Bearer workos:`。 +-- +-- 为什么必须在这里补前缀(而不是登录处):参考实现 cline.ts 的发头函数直接 +-- 拼 `workos:${token}`,而 Cline **第二步 register** 把 WorkOS token 换成了 +-- Cline 自己的 token(我们的池里存的就是**后者**,不带前缀)。实测/参考都 +-- 要求网关侧把 `workos:` 当**协议前缀**重新拼上,否则上游只回泛化的认证失败 +-- (与 trae 修前同型:头发了,但形式不对)。 +-- +-- ★ 幂等:已经带 `workos:` 的 token **不再重复拼**(`workos:workos:xxx` 必错, +-- 而两种凭据在池里都可能出现:旧数据是完整的带前缀串,新登录的是裸 token)。 +-- ★ 前缀必须**大小写不敏感**地判(上游文档写 `workos:`,实际发过 `WorkOS:` +-- 的客户端也能用),但补出来的一律是小写形式。 +local WORKOS_PREFIX = "workos:" + +local function with_workos_prefix(tok) + if type(tok) ~= "string" or tok == "" then return "" end + if string.lower(string.sub(tok, 1, #WORKOS_PREFIX)) == WORKOS_PREFIX then + return tok + end + return WORKOS_PREFIX .. tok +end + +-- 导出给判据用(不是内核契约的一部分):判据要直接咬**同一个**拼装函数, +-- 不能在 Go 侧自算一遍前缀规则(那样真实函数被改坏时判据会继续绿)。 +adapter.with_workos_prefix = with_workos_prefix + +function adapter.build_headers(meta) + local key = "" + if type(meta) == "table" then + if type(meta.api_key) == "string" then key = meta.api_key end + if key == "" and type(meta.source) == "table" + and type(meta.source.meta) == "table" + and type(meta.source.meta.api_key) == "string" then + key = meta.source.meta.api_key + end + end + local h = { ["Content-Type"] = "application/json" } + local tok = with_workos_prefix(key) + if tok ~= "" then + h["Authorization"] = "Bearer " .. tok + end + return h +end + -- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。 -- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口, -- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。 @@ -11694,6 +11805,43 @@ function plugin.credits_tier_probe(args) } } end +-- plugin.__trae_exchange_parse_probe:只给测试/诊断用 —— 把一段**上游响应原文** +-- 交给真实的 parseTraeExchangeBody,返回解出的字段。 +-- +-- 为何必须有这个缝隙:P3 的「Result 信封」判据要咬**真实解析器**。 +-- 若判据自己在 Go 侧再写一份解包逻辑,真实函数被改坏时它会继续绿 +-- (这正是 trae 那个缺陷藏了两轮的原因:夹具形状错与实现错恰好互相兼容)。 +function plugin.__trae_exchange_parse_probe(args) + args = args or {} + local access, newRefresh, expireAt, err = parseTraeExchangeBody(tostring(args.body or "")) + if err then return { json = { error = err } } end + return { json = { access = access, refresh = newRefresh, expire_at = expireAt } } +end + +-- plugin.__balance_total_probe:只给测试/诊断用 —— 把一段**上游响应原文** +-- 交给该平台真实的 `balance.total_fn`,返回它算出的数字。 +-- +-- 为何需要:余额口径(嵌套 quota vs pack 顶层)曾经算错过一次 —— 读了一个 +-- **根本不存在的字段** ⇒ 余额恒 0,而判据当时是「造 HTTP 假上游 + 走完整 +-- credits_balances」,URL 覆盖/凭据 ref/路径匹配任一环错就会让「没发请求」 +-- 伪装成「余额算错」(耗了三轮)。本探针直接咬**被测对象**:同一个 total_fn。 +function plugin.__balance_total_probe(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local spec = plat[args.endpoint or "balance"] + if type(spec) ~= "table" or type(spec.total_fn) ~= "function" then + return { json = { error = "该端点未声明 total_fn" } } + end + local ok, rec = pcall(json.decode, tostring(args.body or "")) + if not ok or type(rec) ~= "table" then + return { json = { error = "夹具不是合法 JSON 对象" } } + end + local okf, total = pcall(spec.total_fn, rec) + if not okf then return { json = { error = "total_fn 抛错: " .. tostring(total) } } end + return { json = { total = tonumber(total) } } +end + -- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。 function plugin.__set_lock_permanent_probe(args) plugin.state.settings = plugin.state.settings or {} @@ -12188,6 +12336,11 @@ function plugin.__adapter_install_probe(args) trae = plugin._trae_adapter, gemini = plugin._gemini_adapter, zcode = plugin._zcode_adapter, + -- ★ cline / qoder 此前**不在探针输出里** —— 于是「装了但没生效」这个 + -- 最坑的故障态在这两个适配器上完全不可观测(本轮修 cline 的 workos: + -- 前缀时才发现)。装上就该看得见。 + cline = plugin._cline_adapter, + qoder = plugin._qoder_adapter, } } end diff --git a/internal/provider/build_headers_fallback_test.go b/internal/provider/build_headers_fallback_test.go new file mode 100644 index 0000000..7bbcd8c --- /dev/null +++ b/internal/provider/build_headers_fallback_test.go @@ -0,0 +1,76 @@ +package provider + +import ( + "path/filepath" + "testing" + + "llmsproxy/internal/config" + "llmsproxy/internal/lua" +) + +// ★ 内核兜底鉴权必须让位给适配器已经给出的凭据头。 +// +// 背景:anthropic 基座(及继承它的 channels-zcode / channels-cline 等)现在在 +// build_headers 里发 `x-api-key`(Anthropic 协议用它,不是 Bearer)。 +// 而内核在 buildHeaders 末尾有一段兜底:`Authorization` 为空就补 +// `Bearer `。若不改,同一请求会同时携带两套鉴权方案 —— +// 部分 Anthropic 兼容上游对歧义鉴权直接 401,等于把刚修好的头废掉。 +// +// 本判据咬的是**真实产出**(p.buildHeaders 就是发请求前那条路径)。 +func TestBuildHeadersSkipsBearerWhenAdapterSuppliesXAPIKey(t *testing.T) { + vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + + s := src("claude-direct", "http://127.0.0.1:1", "anthropic", "claude-x") + s.APIKey = "sk-ant-SECRET" + p := New(s, vm) + + h, err := p.buildHeaders(`{"model":"claude-x"}`, "http://127.0.0.1:1/v1/messages", "") + if err != nil { + t.Fatalf("buildHeaders: %v", err) + } + if got := h.Get("X-Api-Key"); got != "sk-ant-SECRET" { + t.Fatalf("★ x-api-key 应携带凭据,实得 %q", got) + } + if got := h.Get("Authorization"); got != "" { + t.Fatalf("★ 适配器已给 x-api-key 时,内核**不得**再补 Authorization: Bearer(两套鉴权冲突)"+ + ",实得 %q", got) + } +} + +// 配对判据:普通适配器(不支持 x-api-key 的那些)兜底行为必须**不变**。 +// 这条防「改 anthropic 顺手把其它十几个适配器的鉴权搞没了」。 +func TestBuildHeadersKeepsBearerFallbackForNonAnthropicAdapters(t *testing.T) { + vm := lua.NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + + for _, adapter := range []string{"openai", "deepseek", "groq"} { + t.Run(adapter, func(t *testing.T) { + src := config.Source{ + Name: "s-" + adapter, + BaseURL: "http://127.0.0.1:1", + APIKey: "sk-plain", + Adapter: adapter, + MaxConcurrent: 1, + Models: []config.Model{{ID: "m", Kind: "chat"}}, + } + p := New(src, vm) + h, err := p.buildHeaders(`{"model":"m"}`, "http://127.0.0.1:1/chat/completions", "") + if err != nil { + t.Fatalf("buildHeaders: %v", err) + } + if got := h.Get("Authorization"); got != "Bearer sk-plain" { + t.Fatalf("★ %s 的兜底 Bearer 必须保持原样,实得 %q", adapter, got) + } + if got := h.Get("X-Api-Key"); got != "" { + t.Fatalf("%s 不该凭空多出 x-api-key,实得 %q", adapter, got) + } + }) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 294d65a..9b60087 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1129,7 +1129,16 @@ func (p *Provider) buildHeaders(body, url, clientSession string) (http.Header, e h.Set(k, v) } } - if h.Get("Authorization") == "" && p.cfg.APIKey != "" { + // 兜底鉴权:适配器没给鉴权头时,用源的 api_key 发 Bearer。 + // + // ★ 必须同时看 X-Api-Key:Anthropic 协议用的是 `x-api-key` 而**不是** + // `Authorization: Bearer`,而 anthropic 基座(及继承它的 channels-zcode) + // 现在在 build_headers 里发 x-api-key。若这里仍无条件补 Bearer,同一请求 + // 就会同时携带两套鉴权方案 —— 部分 Anthropic 兼容上游对歧义鉴权直接 + // 401,等于把刚修好的头又废掉了。 + // 反过来也成立:其它适配器(openai/deepseek/…)不发 x-api-key,它们的 + // 兜底行为与之前**逐字节一致**(可观察行为无变化)。 + if h.Get("Authorization") == "" && h.Get("X-Api-Key") == "" && p.cfg.APIKey != "" { h.Set("Authorization", "Bearer "+p.cfg.APIKey) } return h, nil