mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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 处)。
This commit is contained in:
@ -112,34 +112,105 @@
|
||||
|
||||
> 判据:`git tag --points-at <release HEAD>` 非空;`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:<jwt>`,我们的 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_jwt>`(参考 `zcode-transport.ts:121-125`,全仓 `x-api-key` 命中 **0** 次)。基座补了 x-api-key 后它**继承就会变错** | zcode **显式覆盖** `build_headers` 发 Bearer,不用继承 | ✅ 已修+已判据;运行需账号 |
|
||||
| P1-2 | `channels-cline` | 参考要 `Bearer workos:<jwt>`,我们的 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-<platform>-<accountId>`,只含 `-`/字母数字,安全。
|
||||
已落地(详见 **`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 — 待用户决策
|
||||
|
||||
|
||||
109
docs/source-name-contract.md
Normal file
109
docs/source-name-contract.md
Normal file
@ -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-<accountId>
|
||||
```
|
||||
|
||||
注意 `-` **是允许的**:渠道源名天生含 `-`,而 `-` 作分隔符时是「源名之后」
|
||||
才出现,不产生歧义。
|
||||
|
||||
## 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`:校验器写对了但
|
||||
> **没接到入口上**,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。
|
||||
@ -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)
|
||||
}
|
||||
|
||||
107
internal/config/source_name_contract_test.go
Normal file
107
internal/config/source_name_contract_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
68
internal/core/adapter_install_probe_test.go
Normal file
68
internal/core/adapter_install_probe_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
72
internal/core/source_name_contract_test.go
Normal file
72
internal/core/source_name_contract_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
122
internal/lua/adapter_anthropic_headers_test.go
Normal file
122
internal/lua/adapter_anthropic_headers_test.go
Normal file
@ -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 <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
|
||||
}
|
||||
143
internal/lua/adapter_zcode_build_headers_test.go
Normal file
143
internal/lua/adapter_zcode_build_headers_test.go
Normal file
@ -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 <zcode_jwt>`,与参考逐字一致。
|
||||
//
|
||||
// ⇒ 真正的风险来自**本轮的正向修复**:给 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_jwt>(参考 zcode-transport.ts:125)
|
||||
if got := hdrs["Authorization"]; got != "Bearer ZCODE-JWT-ABC" {
|
||||
t.Fatalf("★ zcode 的 Authorization 必须是 `Bearer <zcode_jwt>`,实得 %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)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/lua/adapter_zcode_harness_test.go
Normal file
68
internal/lua/adapter_zcode_harness_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// zcodeHarness 是与 runtimePlugins 等价的测试台,唯一差别是 **adapters 与
|
||||
// plugins 目录互为兄弟**(`<tmp>/adapters` + `<tmp>/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
|
||||
}
|
||||
@ -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 <api_key>` ⇒ 上游一律 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
|
||||
|
||||
129
internal/lua/channels_cline_workos_test.go
Normal file
129
internal/lua/channels_cline_workos_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ P1-2:Cline 的推理端点要 `Authorization: Bearer workos:<jwt>`。
|
||||
//
|
||||
// 真实缺陷:参考实现(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:<jwt>`")
|
||||
}
|
||||
|
||||
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:<jwt>`;缺前缀或重复拼都会认证失败",
|
||||
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)
|
||||
}
|
||||
}
|
||||
506
internal/lua/channels_fixture_shapes_general_test.go
Normal file
506
internal/lua/channels_fixture_shapes_general_test.go
Normal file
@ -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(`<<<not json at all>>>`)
|
||||
if junkErr == nil && samePrimitives(realObs, junkObs) {
|
||||
t.Fatalf("★ 族 %q 的 eval 对垃圾输入与生产输入返回相同观测 ⇒ "+
|
||||
"它不是真的在调被测实现(恒等判据毫无意义)", f.name)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 形状表 %d 族,基线齐全且 eval 真的打到被测实现", len(fams))
|
||||
}
|
||||
|
||||
// samePrimitives 只比较"标量键值都相同",用于检测恒等实现。
|
||||
func samePrimitives(a, b map[string]interface{}) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, av := range a {
|
||||
bv, ok := b[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch x := av.(type) {
|
||||
case string:
|
||||
if y, ok := bv.(string); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case float64:
|
||||
if y, ok := bv.(float64); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case bool:
|
||||
if y, ok := bv.(bool); !ok || x != y {
|
||||
return false
|
||||
}
|
||||
case nil:
|
||||
if bv != nil {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ② 仓库扫描:只对**特征明确**的退化形状做静态反查
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 为什么只留一条规则:文本匹配对另外两族**必然误报** ——
|
||||
// · SOLO:判据自己会写 `t.Fatalf("不得返回 choices …")`,天然含 choices;
|
||||
// · Result:真夹具里 `"Result":{…,"Token":…}` 与顶层 `"Token":` 是同一行。
|
||||
// 那两族的精确钉法是上面的 fact 判据(对原始 JSON 结构判定),不是文本。
|
||||
//
|
||||
// 剩下这一条规则的特征是**不会误报**的:把额度写到 pack 顶层必然写成
|
||||
// `{"credits_limit":`(花括号紧跟键名),而生产形状一定写成
|
||||
// `"quota":{"credits_limit":`(键名前是 `"quota":`)。
|
||||
|
||||
// degenerateFixturePattern 一条"退化夹具"特征。
|
||||
type degenerateFixturePattern struct {
|
||||
name string
|
||||
trigger string // 文件里出现它 ⇒ 该文件在测这个渠道
|
||||
forbidden string // 出现它 ⇒ 疑似把生产形状改成了方便版
|
||||
why string
|
||||
}
|
||||
|
||||
var degenerateFixturePatterns = []degenerateFixturePattern{
|
||||
{
|
||||
name: "trae 余额(嵌套 quota)",
|
||||
trigger: "user_entitlement_pack_list",
|
||||
forbidden: `{"credits_limit":`,
|
||||
why: "生产把额度放在 entitlement_base_info.quota.credits_limit(pack 顶层" +
|
||||
"**没有**该字段,实测上游从不发);`{\"credits_limit\":` 是把额度挪到顶层的" +
|
||||
"退化形状,会让「读错层级」这个缺陷继续绿(余额恒 0 就是这么来的)",
|
||||
},
|
||||
}
|
||||
|
||||
// TestNoDegenerateFixturesInRepo 扫 internal/**/*_test.go,任何"可疑退化夹具"
|
||||
// 都必须带 `// SHAPE-OK: <理由>` 显式豁免。
|
||||
//
|
||||
// 为什么扫描而不是只钉三条用例:三条用例只能钉住**它们自己**用的夹具;
|
||||
// 下一次有人在**别的**测试里写一个退化夹具,那三条照样绿。这条扫描对着
|
||||
// 整个仓库,是「防再犯」的那一半。
|
||||
func TestNoDegenerateFixturesInRepo(t *testing.T) {
|
||||
root, err := repoInternalDir()
|
||||
if err != nil {
|
||||
t.Fatalf("定位 internal/ 失败: %v", err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
err = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(p, "_test.go") {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历 internal/: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("没扫到任何 _test.go —— 扫描失效(这本身比告警更糟)")
|
||||
}
|
||||
|
||||
var hits []string
|
||||
for _, f := range files {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(root, f)
|
||||
// 本文件自身在描述规则(含特征字面量),跳过。
|
||||
if strings.HasSuffix(f, "channels_fixture_shapes_general_test.go") {
|
||||
continue
|
||||
}
|
||||
code := stripGoComments(string(b))
|
||||
for _, pat := range degenerateFixturePatterns {
|
||||
if !strings.Contains(code, pat.trigger) {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(code, pat.forbidden) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(b), "SHAPE-OK:") {
|
||||
continue // 显式豁免,理由写在文件里
|
||||
}
|
||||
hits = append(hits, rel+" ["+pat.name+"] 含 "+pat.forbidden)
|
||||
}
|
||||
}
|
||||
if len(hits) > 0 {
|
||||
t.Fatalf("★ 疑似退化夹具(形状与生产不一致 ⇒ 判据必然虚假通过):\n %s\n\n"+
|
||||
"若这**确实**是在测兼容/兜底路径,请在文件里加一行\n"+
|
||||
" // SHAPE-OK: <为什么这个退化形状是刻意的>\n"+
|
||||
"历史代价:trae 的 Result 信封缺陷就是这样藏了两轮(plan §1.4 ④)。\n"+
|
||||
"规则理由:\n%s", strings.Join(hits, "\n "), whyList())
|
||||
}
|
||||
t.Logf("✓ 扫描 %d 个 _test.go,无未豁免的退化夹具(%d 条特征规则)",
|
||||
len(files), len(degenerateFixturePatterns))
|
||||
}
|
||||
|
||||
func whyList() string {
|
||||
var b strings.Builder
|
||||
for _, p := range degenerateFixturePatterns {
|
||||
b.WriteString(" · " + p.name + ": " + p.why + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stripGoComments 去掉 `//` 行注释与 `/* */` 块注释。
|
||||
//
|
||||
// 只做够用的一层:不解析字符串字面量里的 `//`(例如 URL "https://…")。
|
||||
// 略微保守(可能少剥一点)比误剥安全 —— 误剥会让扫描漏报,少剥只会多报,
|
||||
// 而多报可以由 SHAPE-OK 显式豁免。
|
||||
func stripGoComments(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
lines := strings.Split(s, "\n")
|
||||
inBlock := false
|
||||
for _, line := range lines {
|
||||
if inBlock {
|
||||
if i := strings.Index(line, "*/"); i >= 0 {
|
||||
inBlock = false
|
||||
line = line[i+2:]
|
||||
} else {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
b.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, "/*"); i >= 0 && !strings.Contains(line[:i], `"`) {
|
||||
if j := strings.Index(line[i:], "*/"); j >= 0 {
|
||||
line = line[:i] + line[i+j+2:]
|
||||
} else {
|
||||
line = line[:i]
|
||||
inBlock = true
|
||||
}
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// repoInternalDir 从测试工作目录(internal/lua)向上找 internal/。
|
||||
func repoInternalDir() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for d := wd; d != "/" && d != ""; d = filepath.Dir(d) {
|
||||
if filepath.Base(d) == "internal" {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
return filepath.Dir(wd), nil
|
||||
}
|
||||
|
||||
// shapesProbeVM 提供形状判据共用的插件 VM(插件自装适配器需要它)。
|
||||
//
|
||||
// 用 zcodeHarness 而不是 runtimePlugins:后者给 adapters/plugins 各一个独立
|
||||
// 临时目录,而 installZcodeAdapter 要求两者是兄弟目录(见 zcodeHarness 注释)。
|
||||
func shapesProbeVM(t *testing.T) (*VM, *Plugins) {
|
||||
t.Helper()
|
||||
return zcodeHarness(t)
|
||||
}
|
||||
318
internal/lua/channels_fixture_shapes_test.go
Normal file
318
internal/lua/channels_fixture_shapes_test.go
Normal file
@ -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("✓ 三类形状判据齐全,且均直接咬真实实现")
|
||||
}
|
||||
91
internal/lua/channels_gemini_auth_check_test.go
Normal file
91
internal/lua/channels_gemini_auth_check_test.go
Normal file
@ -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 <api_key>` 提供 —— 而 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 <token>`。
|
||||
//
|
||||
// 这条在 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 <access_token>")
|
||||
}
|
||||
@ -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 <zcode_jwt>**。
|
||||
--
|
||||
-- 参考实现是唯一权威(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:<jwt>`。
|
||||
--
|
||||
-- 为什么必须在这里补前缀(而不是登录处):参考实现 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
|
||||
|
||||
|
||||
76
internal/provider/build_headers_fallback_test.go
Normal file
76
internal/provider/build_headers_fallback_test.go
Normal file
@ -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 <api_key>`。若不改,同一请求会同时携带两套鉴权方案 ——
|
||||
// 部分 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user