mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
73 Commits
fix/channe
...
v2.0.28
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d2c27c430 | |||
| 0f83fc048a | |||
| 1f29be43db | |||
| 8109acf7cd | |||
| 7c82c7ff6b | |||
| 1f18643d1f | |||
| e6fa2a678f | |||
| b871c0c713 | |||
| 8b318a8507 | |||
| 69da3c9b92 | |||
| 1be34557dd | |||
| 9e5fec08b8 | |||
| 68446fb6fe | |||
| 7e0a428c23 | |||
| 7a8d97001c | |||
| 850920155a | |||
| eaac0ef6b3 | |||
| 226493b5ae | |||
| d3a0eb6311 | |||
| cbad61a3ea | |||
| 2a2067284d | |||
| 742e2b5ed3 | |||
| 81b3661336 | |||
| 9521a35a03 | |||
| 252e3486bd | |||
| 0c56392d29 | |||
| f813ec714f | |||
| 7a6e03692d | |||
| 420379784b | |||
| 92c240cc25 | |||
| 5720e60fc9 | |||
| 42ec552c6d | |||
| 67e2ba070f | |||
| 29c87ce015 | |||
| 583976f7da | |||
| a020247627 | |||
| af8b93bd7c | |||
| 3819b00ded | |||
| 10ad4421f9 | |||
| f55d514e85 | |||
| faa3be5c37 | |||
| 8e2d384a7b | |||
| ff9b460eba | |||
| 1f54143956 | |||
| a7a0c6adee | |||
| 741edfea98 | |||
| ed1e250452 | |||
| fe9d5c6054 | |||
| 3e94047889 | |||
| 4a90099fb2 | |||
| 59229990ac | |||
| 6238265502 | |||
| 4bdde9cd61 | |||
| 70c24ba158 | |||
| c1733feeaa | |||
| 5b433b3266 | |||
| d1b7d94043 | |||
| 1586a051da | |||
| 41ff6d1be3 | |||
| 6fe6014d92 | |||
| 5ab047e48b | |||
| ec5e6dd57c | |||
| 705e079285 | |||
| 8949405746 | |||
| d86514c474 | |||
| d4ae2a1d13 | |||
| 652a035d47 | |||
| 6bec260513 | |||
| ed954b77bd | |||
| cb3ea25586 | |||
| 0232b9cd24 | |||
| a07449d32e | |||
| 84a9fccd9a |
@ -1,333 +0,0 @@
|
||||
# 渠道链路修复计划与现状
|
||||
|
||||
> 本文是 `plan.md` 中「渠道链路修复计划(2026-10-17 起)」章节的**可提交版本**。
|
||||
> `plan.md` 本身被 `.gitignore` 忽略(本地工作台账),故关键计划与现状单独落在这里,
|
||||
> 以便随代码一起进 git、供他人查阅。
|
||||
>
|
||||
> **写作原则:只写实测到的,标注"推断"的必须写明。** 每条缺陷都附可复现的观测。
|
||||
|
||||
> 记录 **2026-10-17 用户报障三连修**(模型不可用 / 额度异常 / 其他渠道同类)
|
||||
> 的现状与后续计划。上接 `plan.md` §9(v2 契约收尾)。
|
||||
|
||||
## 1. 现状快照(2026-10-17 14:00 实测,非推断)
|
||||
|
||||
### 1.1 生产实例
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务 | `active`,NRestarts=0 |
|
||||
| 二进制 sha256 | `56396f79bfc2433a`(与仓库逐字节一致) |
|
||||
| 插件 sha256 | `c15cb4bf5d5c0f21`(与仓库逐字节一致) |
|
||||
| 插件 | 4 个(billing/channels/quota/usage)**全部 loaded** |
|
||||
| 源 / 适配器 | 21 源 / 23 适配器 |
|
||||
| 账号池 | **仅 trae、buddy 各有 1 个有凭据账号**;其余 10 个平台**账号数 0** |
|
||||
|
||||
### 1.2 各渠道缺陷矩阵(本文件写入时的实测)
|
||||
|
||||
自装适配器(插件在加载时经 `__llmsproxy_adapters` 装入):
|
||||
|
||||
| 适配器 | `build_headers` | 静态 `adapter.headers` | 流式转换 | 鉴权是否可能错 |
|
||||
|---|---|---|---|---|
|
||||
| `codearts` | 有 | 无 | 复合 openai | 有(SDK-HMAC 自签,已实现) |
|
||||
| `channels-trae` | **有(本次新加)** | 无 | **SOLO 自定义(本次新写)** | 已修 |
|
||||
| `channels-gemini` | **★ 缺** | 有 | 自定义 | 静态头 + Bearer,**待确认** |
|
||||
| `channels-zcode` | **★ 缺** | 无 | 无(继承 anthropic 基座) | **★ 高度可疑** |
|
||||
| `channels-cline` | **★ 缺** | 无 | 套 openai | **★ 可疑**(要 `Bearer workos:<jwt>`) |
|
||||
| `channels-qoder` | **★ 缺** | 无 | 套 openai | **★ 可疑**(头由 WASM 生成) |
|
||||
|
||||
内置适配器里 `build_headers` 只有 3 个有(buddy / kimicode / opencodezen),
|
||||
其余 11 个(含 **anthropic 基座**)靠静态头 + 内核兜底 `Bearer`。
|
||||
|
||||
> **★ anthropic 基座本身没有 `build_headers`**,而 anthropic 协议实际要
|
||||
> `x-api-key` 而非 `Authorization: Bearer`。`channels-zcode` 继承它 ⇒
|
||||
> 一旦有账号就会立刻暴露,与 trae 修复前完全同型。
|
||||
|
||||
### 1.3 已验证可用的通道(生产实测,不是单测)
|
||||
|
||||
| 模型(源限定名) | 结果 |
|
||||
|---|---|
|
||||
| `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` | ✅ 80 帧,正文 27 + 推理 299 |
|
||||
| `channels-trae-b8dd6b3df30e:glm-5.2` | ✅ OK |
|
||||
| `channels-buddy-buddy-main:hy4-preview` | ✅ OK |
|
||||
|
||||
| 端点 | 结果 |
|
||||
|---|---|
|
||||
| `channels/credits/status` (trae) | ✅ `active:true, dailyCredit:100, todayCheckedIn:true` |
|
||||
| `channels/credits/balances` (trae) | ✅ `total:598.516 credits` |
|
||||
|
||||
### 1.4 本轮已修(代码已部署生产,**尚未提交 git**)
|
||||
|
||||
**① 网关解不开 `源名:模型名` 限定名**(`internal/provider/registry.go`)
|
||||
|
||||
- 根因:`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
|
||||
渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**)⇒ 切成 `src="channels"`
|
||||
⇒ 永不匹配 ⇒ `Resolve()` 返回 nil ⇒ `model "..." is not configured`。
|
||||
- 为什么必须支持:本实例 **44 个模型被多源同时提供**(`glm-5.2` 由 6 个源提供),
|
||||
WebUI 下拉框用 `源名:模型名` 作 option value 是**有意设计**。
|
||||
- 修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
|
||||
唯一可靠办法),保留旧 `-:/` 兜底。`EffectiveModel` 同步修。
|
||||
- 判据:`internal/provider/registry_pinned_name_test.go`(4 用例,变异 1/1 咬住)。
|
||||
|
||||
**② trae 适配器三连错**(`internal/lua/plugins/channels.lua`,逐层剥开)
|
||||
|
||||
1. **没有 `build_headers`** ⇒ 内核兜底发 `Bearer`,而 trae 推理端点要
|
||||
`Cloud-IDE-JWT` + 设备身份头 ⇒ 401「not able to authenticate you」。
|
||||
→ 与「额度不可用」是**同一根因的两个出口**(额度端点当轮修了、推理端点漏了)。
|
||||
2. **header 常量值是错的**:`X-App-Id` 抄成 `6eefa6c9`(真实
|
||||
`6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`)、`app_version_code` 用 `0.1.52`
|
||||
(真实是**日期式** `20260811`)⇒ 上游 `4001 binding: expr_path=app_version_code,
|
||||
cause=parameter type does not match binding data`。
|
||||
3. **流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**
|
||||
(`event:output` → `data:{"response":"###","reasoning_content":...}`)。
|
||||
套 `openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`;
|
||||
改成 OpenAI 外壳 `{choices:[{delta}]}` **仍不对** —— 内核
|
||||
`json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全丢。
|
||||
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。
|
||||
- 判据:`channels_trae_chat_headers_test.go`(3)、`channels_trae_solo_sse_test.go`(2),
|
||||
变异 2/2 咬住。
|
||||
|
||||
**③ 余额恒为 0**(同文件 `balance.total_fn`)
|
||||
|
||||
- 根因:读 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
|
||||
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
|
||||
(参考 `trae-credits.ts:341`)+ `pack.usage.credits_amount`。
|
||||
- 实测上游:`免费`(无 credits_limit) / `每月登录赠送`(500) / `签到奖励`(100) = **600**。
|
||||
- 修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
|
||||
- 判据:`channels_trae_credits_hdr_test.go`(1)。
|
||||
- 修后余额 **600 → 实测 598.516**(真实扣减)。
|
||||
|
||||
**④ 附带**:`channels_trae_test.go` 夹具从**扁平** `{"Token":...}` 改为真实
|
||||
`{"Result":{"Token":...}}` —— 原来夹具形状与生产不一致,**判据必然虚假通过**
|
||||
(这是 ExchangeToken 缺陷藏了很久的原因)。
|
||||
|
||||
## 2. 后续计划
|
||||
|
||||
### P0 — 提交本轮修复(必须先做)
|
||||
|
||||
代码已部署生产但**未进 git**。必须:
|
||||
1. 在 `release/v2.0.x` 上提交(含 3 个源文件 + 6 个判据文件)
|
||||
2. 打 tag `v2.0.40`(**这是内核 Go 改动**:`registry.go` ⇒ 真核心版本)
|
||||
3. 回流 main(cherry-pick,不 merge)
|
||||
4. 复核 tag 落点 + release vs main 零差异 + 生产 sha256 一致
|
||||
|
||||
> 判据:`git tag --points-at <release HEAD>` 非空;`git diff release/v2.0.x main` 为空。
|
||||
|
||||
### P1 — 其余渠道的同类缺陷(**账号 0,验证不了运行**)
|
||||
|
||||
按"风险 × 可验证性"排序。**核心限制:这 4 个平台账号数均为 0,
|
||||
无法做生产实测** —— 只能靠静态对齐参考 + 装好适配器后看 `__adapter_install_probe`。
|
||||
|
||||
| 优先 | 项 | 缺陷 | 修法 | 可验证性 |
|
||||
|---|---|---|---|---|
|
||||
| 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` | **仍阻塞**:二进制不在我们仓库,且本仓**没有 wasm 运行时入口**。结论与接入方案见下节与 `docs/qoder-wasm-integration.md` | 本机账号 0 ⇒ 运行时不可验证 |
|
||||
|
||||
> #### ★ 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`(内核改动)。
|
||||
|
||||
> #### ★ P1-4 的结论:qoder 为什么卡住(事实陈述,**未修复**)
|
||||
>
|
||||
> 本段只记录查证结果,不代表任何修复已完成。
|
||||
>
|
||||
> **① 二进制客观存在,但不在我们仓库里。**
|
||||
>
|
||||
> | 项 | 值 |
|
||||
> |---|---|
|
||||
> | 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
|
||||
> | 大小 | `298606` 字节(291.6 KiB) |
|
||||
> | sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
|
||||
> | 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
|
||||
> | 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖(`NOTICE.md` 记明来自 MIT 许可的上游 channel-pack,**不随本仓分发**) |
|
||||
>
|
||||
> ⇒ 我们仓库 `git ls-files | grep -i wasm` = **0 条**。任何「我们直接调用它」的实现都是
|
||||
> 对**别人安装目录**的硬依赖,换机/升级插件即失效。这是 P1-4 的第一层阻塞。
|
||||
>
|
||||
> **② 它是 wasm-bindgen 产物,为 JS 宿主生成,不是 self-contained wasm。**
|
||||
>
|
||||
> 证据(解析二进制 section 得到,非推断):
|
||||
> - 只有 **1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
|
||||
> - import 名带 wasm-bindgen 的哈希与 `__wbindgen_*` ABI 前缀
|
||||
> (`__wbindgen_object_drop_ref` / `__wbindgen_object_clone_ref` /
|
||||
> `__wbindgen_cast_0000000000000001|2` / `__wbg_static_accessor_GLOBAL_THIS_*`);
|
||||
> - 导出含 `__wbindgen_add_to_stack_pointer` / `__wbindgen_export1..4`、`memory`。
|
||||
>
|
||||
> 结论:它**不是**「喂 WASI 就能跑」的模块,而是要求宿主**自己实现一套 JS 值语义**
|
||||
> (对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
|
||||
> wasm-bindgen 的标准做法是「随 wasm 一起生成 12 KB 的 JS glue」,而该目录里
|
||||
> **没有** `qoder_auth_wasm_bg.js` —— 上游改为手写了一份等价 glue
|
||||
> (`vendor/channel-pack/src/qoder-wasm.ts`,706 行,含 31 个 import 的逐个实现)。
|
||||
> 这是 P1-4 的第二层阻塞:**代价在宿主侧,不在 wasm 侧**。
|
||||
>
|
||||
> **③ 31 个 import 按用途分四类。**
|
||||
>
|
||||
> | 类 | 数量 | 代表 | 纯 Go 宿主能否实现 |
|
||||
> |---|---|---|---|
|
||||
> | A. bindgen 对象表 ABI | 5 | `__wbindgen_object_drop_ref` / `object_clone_ref` / `cast_..._1` / `cast_..._2` | 能(Go 侧自建 `[]any` 索引表) |
|
||||
> | B. 字节缓冲原语 | 6 | `__wbg_new_with_length` / `length` / `subarray` / `set` / `prototypesetcall` / `new_...`(Map) | 能(`[]byte` + `map[string]any`) |
|
||||
> | C. 随机源与时间 | 4 | `getRandomValues` **两个方向相反的变体** / `randomFillSync` / `now` | 能(`crypto/rand`、`time.Now`),**但两个变体必须区分**:一个写 wasm 内存,一个调 JS crypto 对象 |
|
||||
> | D. JS/Node 环境探针与调用 | 16 | `crypto` / `msCrypto` / `process` / `versions` / `node` / `require` / `call` / `globalThis` / `self` / `window` / `throw` / `Error` / `is_object|string|function|undefined` | 部分能:`self`/`window` 按 JS 惯例返 `0`;`process`/`node`/`require` 返回 `undefined` 即可 —— **前提是从未被调用** |
|
||||
>
|
||||
> **④ 34 个 export 按用途分四类。**
|
||||
>
|
||||
> | 类 | 数量 | 成员(去 bindgen plumbing) |
|
||||
> |---|---|---|
|
||||
> | 1. 鉴权字段生成 | 1 | `generate_runtime_auth_fields` |
|
||||
> | 2. 请求构造 / 加解密(**红线所在**) | 13 | `qodercontext_new` · `qodercontext_prepareInferRequest` · `qodercontext_prepareRequest` · `qodercontext_refreshAuthFields` · `qodercontext_get_external_providers_access` · `requestresult_url|body|headers|headerCount` · `decrypt_server_response` · `profile_encrypt` · 2 个 `__wbg_*_free` |
|
||||
> | 3. 本地存储 / 缓存 / 凭据加解密 | 8 | `credential_storage_encrypt|decrypt` · `model_cache_encrypt|decrypt` · `profileencryptor_new|encryptChunk|header` · `__wbg_profileencryptor_free` |
|
||||
> | 4. 静态常量 / HTTPDNS / 环境探测 | 6 | `get_httpdns_config|account_id|secret_key` · `build_httpdns_url` · `get_external_providers_feature_gate_fallback` · `get_profile_key_fingerprint` |
|
||||
> | plumbing | 6 | `memory` · `__wbindgen_export1..4` · `__wbindgen_add_to_stack_pointer` |
|
||||
>
|
||||
> 只有第 2 类与 qoder 的**推理鉴权**直接相关(`prepareInferRequest` 产出的
|
||||
> `Authorization` 是 `Bearer COSY.<载荷>.<签名>`,**必须原样透传**);
|
||||
> 第 1 类是它的前置(`encrypt_user_info` / `key`);第 3、4 类在推理路径上不需要。
|
||||
>
|
||||
> **⑤ 我们仓库当前没有 wasm 运行时入口 —— 这是 P1-4 卡住的直接原因。**
|
||||
>
|
||||
> | 查证项 | 命令 | 结果 |
|
||||
> |---|---|---|
|
||||
> | Go 侧 wasm 运行时依赖 | `grep -rniE 'wazero|wasmtime|wasmer|tetratelabs' --include=*.go --include=*.mod --include=*.sum .` | **0 命中** |
|
||||
> | `go.mod` 直接依赖 | `cat go.mod` | 仅 `aarzilli/golua` + `yaml.v3` |
|
||||
> | `go.sum` 中 wasm 相关 | `grep -ci wasm go.sum` | **0** |
|
||||
> | Lua 侧 wasm 能力 | `grep -rniE 'wasm' internal/lua/` | 仅 **注释**(`plugins/channels.lua:141` 明写「WASM 签名(Qoder) 这类无法在当前 Lua 环境完成」) |
|
||||
> | 插件 VM 外呼能力 | `internal/lua/plugin_http.go` | 只有受限 HTTP(host 白名单 + 禁重定向 + 4 MiB 上限),**无任意代码执行/无 wasm** |
|
||||
>
|
||||
> 即:现状是「**Go 内核无 wasm 依赖、Lua 插件无 wasm 能力**」,而 qoder 的
|
||||
> 加密端点头必须由该 wasm 生成。我们目前走的是**明文端点**
|
||||
> `api2-v2.qoder.sh` + `/model/v1/chat/completions`(见
|
||||
> `internal/core/channels_upstream_gaps_test.go` 的判据),因此**不需要** wasm ——
|
||||
> 代价是该端点只认一小撮通用模型名,拿不到目录里 `qmodel_*` 系列的完整能力。
|
||||
>
|
||||
> **⑥ 本机不可验证。** qoder 账号数 **0**(`plan.md` §1.1 快照),
|
||||
> 因此「签名头是否被上游接受」这一层**无法做运行时验证**。
|
||||
> 能验证的只有静态层:wasm 可实例化、能被驱动产出请求头(见方案文档的实测节),
|
||||
> **但「判据通过」≠「功能可用」**。
|
||||
>
|
||||
> 可执行的最小路径方案与实测记录另见 **`docs/qoder-wasm-integration.md`**。
|
||||
|
||||
### P2 — 内核对"限定名"的契约收口
|
||||
|
||||
已落地(详见 **`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 活了两轮)。
|
||||
|
||||
已落地,分两层(文件:`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 — 待用户决策
|
||||
|
||||
| 项 | 需要什么 |
|
||||
|---|---|
|
||||
| zcode 真实登录 | 用户点授权(身份块 + init-poll 流已就绪) |
|
||||
| cline / qoder / gemini / codearts / minimax / lobsterai / opencode | 需各平台账号;`device-poll` 系列要用户去官方页输码 |
|
||||
| raccoon / loomy | 需扫码 |
|
||||
| qoder WASM | 用户提供 `.wasm` |
|
||||
|
||||
## 3. 风险与纪律(本轮新增)
|
||||
|
||||
1. **`luac -p` 对长字符串内的适配器代码无效**(只看外层)⇒ 会得到假绿。
|
||||
**必须**用 `__adapter_install_probe` 看真实装载结果。本轮我因此把 TRAE 的
|
||||
SSE 代码误塞进 CODEARTS 适配器,导致 codearts 装载失败而 `luac` 一直通过。
|
||||
2. **Python 字符串替换会静默不生效**:锚文本缩进不符 ⇒ 匹配 0 次。
|
||||
规矩:① 先 `cp` 备份 ② **行号定位** ③ 写后 `luac -p` + `grep -c` 双确认
|
||||
④ 长字符串内的改动必须验装载。
|
||||
3. **`function` 是 Lua 关键字**:`cc.function` 是语法错误,必须 `cc["function"]`;
|
||||
但批量替换时别把 `cc.function_call` 一起改坏(本轮踩过,导致
|
||||
`')' expected near '_call'`)。
|
||||
4. **判据要咬被测对象**:本轮 `channels_trae_credits_hdr_test.go` 第一版造 HTTP 假上游,
|
||||
因 URL 覆盖/凭据 ref/路径匹配任一环错,"没发请求"伪装成"头不对"(耗了三轮)。
|
||||
改成直接咬 `spec.headers_fn` 的真实产出,一轮就准。
|
||||
5. **只改插件不必换二进制**;改内核 Go 必须换。混着发会把"插件改动"记成"核心版本"
|
||||
(v2.0.21–v2.0.39 有 18 个 tag 是这么来的,见 §4)。
|
||||
6. **`/tmp` 是 8.8G tmpfs**,Go 链接失败常见于它满(`no space left on device`)。
|
||||
元凶通常是 `/tmp/gocache`(可安全清)。
|
||||
|
||||
## 4. 账务实况(必须记住)
|
||||
|
||||
| 事实 | 值 |
|
||||
|---|---|
|
||||
| 内核 Go 代码自 v2.0.20 起的**全部**改动 | 仅 `internal/lua/adapter_prelude.go`、`internal/provider/provider.go`、`internal/provider/registry.go` |
|
||||
| 只改插件却发了核心 tag 的版本 | v2.0.21–v2.0.39 中的 **18 个** |
|
||||
| 当前最新 tag | `v2.0.39`(`091f790`),落点正确 |
|
||||
| main | `56ea269`(与 release 内容零差异) |
|
||||
| **未提交的改动** | 本轮三个修复(见 §1.4),已部署生产 |
|
||||
|
||||
> ⇒ **版本号语义教训**:以后"改插件不打核心 tag",插件自身维护版本;
|
||||
> 内核版本只在 Go 代码变更时递增。
|
||||
@ -1,404 +0,0 @@
|
||||
# Qoder WASM 接入评估(`qoder-auth-wasm.wasm`)
|
||||
|
||||
> **状态:未实现(not implemented)。**
|
||||
> 本文给出**已实测的可行性结论**与**可执行的接入方案**,但我们仓库里
|
||||
> **没有任何 wasm 运行时、也没有任何代码加载这个 wasm**。
|
||||
> 本文写于 `fix/channel-auth-headers` 分支,仅文档 + 评测工具,不动内核。
|
||||
|
||||
> **判据 ≠ 功能可用。** 本文所有「PASS」都是**本机静态/离线**结论:
|
||||
> qoder 账号数 **0**,所以「签名头是否被 Qoder 上游接受」**没有做过运行时验证**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
| 问题 | 结论 | 依据 |
|
||||
|---|---|---|
|
||||
| wasm 能不能被加载? | **能**,且**不需要 JS 引擎** —— 纯 Go(wazero)即可实例化 | 31 个 import 全部由 Go 侧桩提供,实例化阶段真实调用 **0** 次 |
|
||||
| 能不能真正跑出鉴权头? | **能**,且**已实测做到** | `prepareInferRequest` 产出 20 个头,含 `Authorization: Bearer COSY.<载荷>.<签名>`,body 为 60 字节加密串 |
|
||||
| 31 个 import 要全部实现吗? | **不用。实际路径只触碰 18 个**,其中 5 个是 node/JS 环境探针(`process`/`node`/`require`/`versions`/`call`),**本路径一次都没调用** | 逐 import 计数(§4 表) |
|
||||
| 最小可行接入是什么? | **新增一个 `build_headers` 后端(wazero)**,由适配器把 `meta` 交给它,拿回 headers 原样透传 | §5 |
|
||||
| 本轮能做进 `fix/` 分支吗? | **不能**。见 §7 改动面与风险 —— 这属于主分支级改动 | §7 |
|
||||
| 风险最高的是什么? | **不是** wasm 调用,而是**二进制来源**(不在我们仓库,也不可再分发)与**账号数为 0 无法端到端验证** | §6 |
|
||||
| 性能可以接受吗? | **可以**。实测 ≈ **2.2 ms/请求**,线性内存 ≈ **1.25 MiB**,实例可复用 | §8 |
|
||||
|
||||
> ⚠️ **一条红线**:qoder 加密端点要求 `Authorization` **原样透传**。
|
||||
> 任何「适配器里再兜底一次 `Bearer <api_key>`」的写法都会覆盖签名,得到
|
||||
> `403 Signature invalid`。这必须在接入时由代码结构保证,不能只靠注释
|
||||
> (见 §5.4)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标物:abs 路径 / 大小 / sha256
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
|
||||
| 大小 | `298606` 字节(291.6 KiB) |
|
||||
| sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
|
||||
| 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
|
||||
| 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖;`NOTICE.md` 注明来自 MIT 许可的上游 channel-pack,**不随本仓分发** |
|
||||
| 本仓引用 | `git ls-files \| grep -i wasm` = **0 条** |
|
||||
|
||||
同一目录下的相关文件(**都不在我们仓库里**):
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `pack.js`(2.8 MB) | 插件运行时实际加载的单文件 bundle |
|
||||
| `src/qoder-wasm.ts`(706 行) | **手写的 wasm-bindgen glue** —— 31 个 import 的逐个实现 + ABI 助手。本文的 Go 实现即照它翻译 |
|
||||
| `NOTICE.md` / `LICENSE` | 出处与许可(MIT) |
|
||||
|
||||
> `qoder_auth_wasm_bg.js`(wasm-bindgen 本该生成的那份 glue)在该目录中
|
||||
> **不存在** —— 上游改用手写的 `qoder-wasm.ts` 替代。这一点决定了接入代价
|
||||
> 落在**宿主侧**,而这正好是可以由 Go 承担的部分。
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么它是「wasm-bindgen 产物,给 JS 用」
|
||||
|
||||
解析二进制各 section(不是推断):
|
||||
|
||||
- **只有 1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
|
||||
- import 名同时带有 wasm-bindgen 哈希后缀(`__wbg_set_08463b1df38a7e29`)与
|
||||
`__wbindgen_*` ABI 前缀;
|
||||
- 导出含 ABI plumbing:`memory`、`__wbindgen_add_to_stack_pointer`、`__wbindgen_export1..4`;
|
||||
- 返回多值靠**写栈槽**传递(`ptr/len/valIdx/isErr`),这是 wasm-bindgen 的约定。
|
||||
|
||||
⇒ 它**不是**「喂 WASI 就能跑」的模块。它要求宿主实现 `JS 值语义`
|
||||
(对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
|
||||
`--export-all` 之类参数改不了这一点:ABI 就写在 import 表里。
|
||||
|
||||
---
|
||||
|
||||
## 4. 31 import / 34 export 的四类用途(附实测触碰次数)
|
||||
|
||||
### 4.1 imports(31 个,全部来自 `./qoder_auth_wasm_bg.js`)
|
||||
|
||||
| 类 | # | 成员 | 纯 Go 可否 | **实测触碰** |
|
||||
|---|---|---|---|---|
|
||||
| **A. bindgen 对象表 ABI** | 4 | `__wbindgen_object_drop_ref`、`object_clone_ref`、`cast_0000000000000001`、`cast_0000000000000002` | ✅ 自建 `[]any` 索引表 | 3(`cast_..2`×40、`clone`×2、`drop`×65) |
|
||||
| **B. 字节缓冲 / Map** | 6 | `new_with_length`、`length`、`subarray`、`set`、`prototypesetcall`、`new_99cabae`(Map) | ✅ `[]byte` + `map` | 6(全部触碰) |
|
||||
| **C. 随机源与时间** | 4 | `getRandomValues` **两个方向相反的变体**、`randomFillSync`、`now` | ✅ `crypto/rand`、`time.Now` | 3(`randomFillSync` 未被调用) |
|
||||
| **D. JS/Node 环境探针** | 17 | `crypto`、`msCrypto`、`process`、`versions`、`node`、`require`、`call`、`globalThis`、`self`、`window`、`throw`、`Error`、`is_object`、`is_string`、`is_function`、`is_undefined` | 部分:`self`/`window` 返 `0`;`process`/`node` 等**从不被调用**即可 | 4(`crypto`×1、`globalThis`×1、`self`×1、`window`×1、`is_object`×1、`is_undefined`×1) |
|
||||
|
||||
**实测合计:31 个中 13 个「从未被调用」。** 未触达的 13 个:
|
||||
|
||||
```
|
||||
__wbg_process_44c7a14e11e9f69e __wbg_versions_276b2795b1c6a219
|
||||
__wbg_node_84ea875411254db1 __wbg_require_b4edbdcf3e2a1ef0
|
||||
__wbg_msCrypto_bd5a034af96bcba6 __wbg_randomFillSync_6c25eac9869eb53c
|
||||
__wbg_call_d578befcc3145dee __wbg_static_accessor_GLOBAL_f2e0f995a21329ff
|
||||
__wbg___wbindgen_throw_81fc77679af83bc6 __wbg_Error_2e59b1b37a9a34c3
|
||||
__wbg___wbindgen_is_string_b29b5c5a8065ba1a
|
||||
__wbg___wbindgen_is_function_49868bde5eb1e745
|
||||
__wbindgen_cast_0000000000000001
|
||||
```
|
||||
|
||||
> 这是**本路径**的观测(`generate_runtime_auth_fields` + `qodercontext_new`
|
||||
> + `prepareInferRequest`)。错误分支会用到 `throw`/`Error`,所以实现里
|
||||
> 仍应给它们一个真实语义,而不是留空。
|
||||
|
||||
### 4.2 exports(34 个)
|
||||
|
||||
| 类 | # | 成员 | 与本接入的关系 |
|
||||
|---|---|---|---|
|
||||
| **1. 鉴权字段生成** | 1 | `generate_runtime_auth_fields` | **必需**(`encrypt_user_info` / `key` 是 `qodercontext_new` 的前置) |
|
||||
| **2. 请求构造 / 加解密** | 13 | `qodercontext_new`、`qodercontext_prepareInferRequest`、`qodercontext_prepareRequest`、`qodercontext_refreshAuthFields`、`qodercontext_get_external_providers_access`、`requestresult_url\|body\|headers\|headerCount`、`decrypt_server_response`、`profile_encrypt`、`__wbg_qodercontext_free`、`__wbg_requestresult_free` | **核心**:本接入只需其中 6 个 |
|
||||
| **3. 本地存储 / 缓存 / 凭据加解密** | 8 | `credential_storage_encrypt\|decrypt`、`model_cache_encrypt\|decrypt`、`profileencryptor_new\|encryptChunk\|header`、`__wbg_profileencryptor_free` | **不需要**(推理路径不涉及;仅「离线读本机目录缓存」会用到) |
|
||||
| **4. 静态常量 / HTTPDNS / 环境探测** | 6 | `get_httpdns_config\|account_id\|secret_key`、`build_httpdns_url`、`get_external_providers_feature_gate_fallback`、`get_profile_key_fingerprint` | **不需要**(可换成我们自己的常量) |
|
||||
| plumbing | 6 | `memory`、`__wbindgen_export1..4`、`__wbindgen_add_to_stack_pointer` | 必需 |
|
||||
|
||||
> 即:**34 个 export 里,接入只需要 1 + 6 + plumbing**。
|
||||
> 其余 27 个不影响「能不能发出一条被签名的 qoder 请求」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 最小可行接入方案(设计,未实现)
|
||||
|
||||
### 5.1 依赖选型
|
||||
|
||||
| 候选 | 结论 | 理由 |
|
||||
|---|---|---|
|
||||
| **`github.com/tetratelabs/wazero` v1.9.0** | ✅ **推荐** | 纯 Go、`CGO_ENABLED=0` 可交叉编译、**零传递依赖**(`go list -m all` 只有它自己);`go 1.22` 下限,本仓 `go 1.25` 无冲突 |
|
||||
| `wasmtime-go` | ❌ | 需要 CGO + 预编译动态库 ⇒ 破坏 `go build` 的单二进制分发与 Windows 交叉编译 |
|
||||
| `wasmer-go` | ❌ | 同上,且维护活跃度低 |
|
||||
| 自己写解释器 | ❌ | 不值当 |
|
||||
| 用 Node 子进程跑上游 `pack.js` | ⚠️ 备选 | 现有 node 可跑,但把「一个 Go 单二进制网关」变成「依赖宿主 node + 一份 2.8 MB 外部 bundle」,运维形态倒退 |
|
||||
|
||||
体积实测(`CGO_ENABLED=0`,空 `main`):
|
||||
|
||||
| 构建 | 大小 |
|
||||
|---|---|
|
||||
| 空程序 | 1,895,775 B |
|
||||
| `+ wazero`(仅引用 `NewRuntime`) | 3,106,358 B |
|
||||
| 增量 | **≈ +1.16 MiB** |
|
||||
|
||||
> 交叉编译已验证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0` 出 3,050,496 B,
|
||||
> 与现有 `gui-win` 流程兼容。
|
||||
|
||||
### 5.2 二进制从哪来(**必须先解决**)
|
||||
|
||||
wasm **不入库**会带来三个问题:换机失效、插件升级后 sha256 漂移、离线构建不安全。
|
||||
三条可选路径,推荐 ①:
|
||||
|
||||
| 方案 | 做法 | 代价 |
|
||||
|---|---|---|
|
||||
| **① 运维侧提供,启动时校验**(推荐) | 新增配置项 `qoder_wasm_path`;启动/首次使用时读文件,**校验 sha256 必须等于** `6419471e…55b43d`,不符则拒绝启用并报错 | 需要运维放一份文件;但**不引入许可问题**,且校验把「用错版本」变成显式失败 |
|
||||
| ② 我们仓库 `go:embed` | 把 wasm 放进 `internal/qoderwasm/assets/` 并 embed | ✅ 单二进制、无外部依赖;❌ **许可与再分发**需先确认(上游 MIT,但需保留 NOTICE),且 291 KiB 进 git |
|
||||
| ③ 运行时从上游插件目录读(我们现在的路径) | 直接读 `/opt/dsh-plugins/...` | ❌ 对别人安装目录硬依赖,**不建议进主干** |
|
||||
|
||||
> **本轮不选任何一条** —— 选型本身就是需要主管决策的事项(涉及再分发与
|
||||
> 运维约定),不该由一条 fix 分支暗定。
|
||||
|
||||
### 5.3 接入点:放在哪
|
||||
|
||||
现架构事实(`grep` 实测):
|
||||
|
||||
- 适配器是 **Lua**(`internal/lua/adapters/*.lua` + 插件自装 `channels-qoder`);
|
||||
- `channels-qoder` 目前**没有 `build_headers`**,靠静态头 + 内核兜底 `Bearer`;
|
||||
- 内核统一入口是 `Provider.buildHeaders`(`internal/provider/provider.go:1104`)
|
||||
→ `VM.BuildHeaders`(`internal/lua/vm.go:1077`)→ Lua `adapter.build_headers(meta)`;
|
||||
- **`go.mod` 里没有任何 wasm 运行时**;`internal/` 下 wasm 相关命中**全部是注释**;
|
||||
- Lua 侧**没有** wasm 能力(`channels.lua:141` 自己写明「无法在当前 Lua 环境完成」)。
|
||||
|
||||
⇒ 既然桥接面是 `build_headers(meta) → map[string]string`,**最干净的接法是在
|
||||
这个桥后面加一个 Go 侧后端**,而不是把 wasm 塞进 Lua:
|
||||
|
||||
```
|
||||
Provider.buildHeaders (internal/provider/provider.go)
|
||||
└─ VM.BuildHeaders (internal/lua/vm.go)
|
||||
└─ Lua adapter.build_headers(meta) ← channels-qoder 适配器
|
||||
└─ __llmsproxy_kernel("qoder.wasm_headers", meta) ← 新增内核动作
|
||||
└─ internal/qoderwasm.PrepareHeaders(meta) ← 新增包(wazero)
|
||||
└─ qoder-auth-wasm.wasm
|
||||
```
|
||||
|
||||
**为什么走 `__llmsproxy_kernel` 而不是新增一个 Lua 全局**:
|
||||
`internal/lua/plugin_bridges.go:146` 已有 `registerKernelBridge`,它是
|
||||
**按插件声明授权**的(插件在 `plugin.kernel_actions` 里列白名单)。
|
||||
新增一个 Lua 全局等于给**所有**插件开能力;走 kernel action 则保持「谁声明
|
||||
谁获得」。这与现有 `plugin_http.go` 的收窄思路一致。
|
||||
|
||||
**为什么不在 provider 里直接 if-else 厂商名**:本仓的既有约束是
|
||||
「内核零厂商名」(见 `channels.lua:1017` 的注释与 P2 段)。wasm 调用应作为
|
||||
一个**通用内核能力**暴露(`wasm.headers`),由插件把 qoder 语义喂给它。
|
||||
|
||||
### 5.4 输入 / 输出边界
|
||||
|
||||
```go
|
||||
// internal/qoderwasm(新包,示例形参;未实现)
|
||||
type Credentials struct {
|
||||
UID string `json:"uid"`
|
||||
SecurityOauthToken string `json:"security_oauth_token"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrganizationTags []string `json:"organization_tags"`
|
||||
DataPolicyAgreed bool `json:"data_policy_agreed"`
|
||||
MachineID string `json:"machine_id"` // 必填:缺失会 AES-GCM 失败
|
||||
ClientVersion string `json:"client_version"` // 默认 "1.1.49"
|
||||
}
|
||||
|
||||
type Metadata struct { // 写入 QoderContext
|
||||
ClientType string `json:"client_type"`
|
||||
BusinessProduct string `json:"business_product"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Scene string `json:"scene"`
|
||||
}
|
||||
|
||||
type InferRequest struct {
|
||||
Host string // ⚠️ 必须 api2.qoder.sh 系,不能是 api2-v2
|
||||
Body json.RawMessage // 明文 payload(由插件构造)
|
||||
ModelKey string // 目录 key,如 qfmodel
|
||||
Source string
|
||||
}
|
||||
|
||||
// PrepareHeaders 返回**必须原样透传**的头集合。
|
||||
func PrepareHeaders(ctx context.Context, c Credentials, m Metadata, r InferRequest) (map[string]string, error)
|
||||
```
|
||||
|
||||
**输出实测形态**(20 个头):
|
||||
|
||||
```
|
||||
Accept text/event-stream Authorization Bearer COSY.<载荷>.<签名>(len=209)
|
||||
Cache-Control no-cache Connection keep-alive
|
||||
Content-Type application/json Cosy-Business-Product qoder
|
||||
Cosy-Business-Type default Cosy-ClientType 10
|
||||
Cosy-Data-Policy disagree Cosy-Date 1792279699
|
||||
Cosy-Key <modelKey> Cosy-MachineId <machineId>
|
||||
Cosy-MachineToken <machineId> Cosy-MachineType 5
|
||||
Cosy-Scene chat Cosy-User <uid>
|
||||
Cosy-Version 1.1.49 Login-Version v2
|
||||
X-Model-Key <modelKey> X-Model-Source system
|
||||
```
|
||||
|
||||
> 其中只有 `Authorization` / `Cosy-Key` / `Cosy-MachineId` / `Cosy-User` 等是
|
||||
> **动态**的;其余来自 `Metadata`/常量。注意 `Cosy-Date` 并非当前时间
|
||||
> (实测 `1792279699`,是 WASM 内部计算),**不要用 Go 时间覆盖**。
|
||||
|
||||
**红线(结构保证,不是注释)**:`Provider.buildHeaders`
|
||||
(`provider.go:1141`)当前逻辑是
|
||||
`if h.Get("Authorization")=="" && h.Get("X-Api-Key")=="" { 补 Bearer }`。
|
||||
qoder 拿到签名 `Authorization` 后自然跳过兜底 —— 这个**已有**行为正好安全,
|
||||
但新接入**必须有一个判据钉住**:签名头存在时兜底 Bearer 不得生效。
|
||||
|
||||
### 5.5 31 个 import 的 Go 实现映射表
|
||||
|
||||
| import | Go 实现 | 备注 |
|
||||
|---|---|---|
|
||||
| `__wbindgen_object_drop_ref` | **no-op(不释放槽位)** | ⚠️ 实测该模块会 `drop` 一个 Map 之后**继续用同一索引** `set`。wasm-bindgen 新版把 drop 退化为空操作(表交给 JS GC)。按「释放槽位」实现会得到 `set on undefined` |
|
||||
| `__wbindgen_object_clone_ref` | `push(obj[idx])` | |
|
||||
| `__wbindgen_cast_..._1` | wasm slice → `[]byte` | 本路径未触碰 |
|
||||
| `__wbindgen_cast_..._2` | wasm str → Go `string` | 最高频(40 次):字符串返回靠它 |
|
||||
| `__wbg_new_with_length` | `make([]byte, n)` | |
|
||||
| `__wbg_length` | `len(buf)` | |
|
||||
| `__wbg_subarray` | `buf[lo:hi]`(**共享底层数组**,不要 copy) | |
|
||||
| `__wbg_set` | 两种形态:`[]byte.set(src, off)` / `Map.set(k,v)` | 全靠 `arg0` 的动态类型分派 |
|
||||
| `__wbg_prototypesetcall` | 直接写 wasm 内存 | |
|
||||
| `__wbg_new_99cabae` | `new Map()` → 有序 `{keys,vals}` | `requestresult_headers` 的容器 |
|
||||
| **`__wbg_getRandomValues_d49329ff89a07af1`** | `crypto/rand` 写 **wasm 内存** `(ptr,len)` | ⚠️ 与下面那个**签名方向相反** |
|
||||
| **`__wbg_getRandomValues_c44a50d8cfdaebeb`** | 对 **crypto 对象** `(objIdx, bufIdx)` 填随机 | ⚠️ 写反 ⇒ Rust `unreachable` panic |
|
||||
| `__wbg_randomFillSync` | `crypto/rand` 填 `[]byte` | 本路径未触碰 |
|
||||
| `__wbg_now` | `float64(time.Now().UnixMilli())` | 返回 **f64** |
|
||||
| `__wbg_crypto` / `__wbg_msCrypto` | 返回一个「crypto 对象」标记值 | 只为喂 `getRandomValues` 变体 B |
|
||||
| `__wbg_static_accessor_GLOBAL_THIS` / `GLOBAL` | 返回任意非 0 索引 | |
|
||||
| `__wbg_static_accessor_SELF` / `WINDOW` | **返回 0** | 服务端无 window/self,wasm-bindgen 约定 0 = 缺席 |
|
||||
| `__wbg___wbindgen_is_object` | 类型判断(`[]byte`/`Map`/crypto) | |
|
||||
| `__wbg___wbindgen_is_string` / `is_function` / `is_undefined` | 类型判断 | 后两个本路径未触碰 |
|
||||
| `__wbg___wbindgen_throw` | 记日志(或抛 panic 转 error) | 错误分支 |
|
||||
| `__wbg_Error` | `fmt.Errorf(读出的字符串)` | 错误分支 |
|
||||
| `__wbg_process` / `versions` / `node` / `require` / `call` | 返回 `undefined` / 0 | **实测从未被调用**;但若被调用说明换了 wasm 版本,应告警 |
|
||||
| `__wbindgen_add_to_stack_pointer` / `export1..4` | 由 wasm 自己提供(是 **export**,不是 import) | |
|
||||
|
||||
### 5.6 调用顺序与 ABI 陷阱(都实测过)
|
||||
|
||||
1. `generate_runtime_auth_fields(userJSON)` → `{encrypt_user_info, key}`(各 **172** 字符)
|
||||
2. `qodercontext_new(sp, machineId, len, version, len, userInfo, len, clientMeta, len)`
|
||||
- 返回布局是 **`ptr/errIdx/isErr`**(与字符串类的 `ptr/len/valIdx/isErr` **不同**,混用会得到 `null pointer passed to rust`)
|
||||
3. `qodercontext_prepareInferRequest(sp, ctxPtr, host, len, body, len, key, len, source, len)`
|
||||
- 同样是 `ptr/errIdx/isErr`
|
||||
4. `requestresult_headers(resPtr)` → Map
|
||||
5. `requestresult_url(sp, resPtr)` / `requestresult_body(sp, resPtr)`
|
||||
- ⚠️ **参数顺序是 `(栈指针, ptr)`**,与直觉相反
|
||||
|
||||
另外两条实测纪律:
|
||||
|
||||
- ⚠️ `machineId` 是**必填**:漏传得到 `AES-GCM decrypt failed: aead::Error`,
|
||||
看起来像「密文损坏」,实际是缺参数。
|
||||
- ⚠️ `host` 必须 `api2.qoder.sh` 系;传 `api2-v2.qoder.sh`(公开 OpenAI 兼容端点)
|
||||
会 **404**。
|
||||
|
||||
### 5.7 并发模型
|
||||
|
||||
- **wasm 实例无状态可复用**,实测 `-repeat 50` 全部 PASS;
|
||||
- 但上游注释明确 `QoderContext` **不是线程安全的**,且「一个账号一个实例」;
|
||||
- ⇒ 设计为 **每个账号一个 `QoderContext` + 一把互斥锁**(复用现有 pool 模式),
|
||||
或按 `(uid, machineId)` 做 key 的 `sync.Map`。**不要**单实例全局共享。
|
||||
- 兜底:`objectHeap` 是**只增不回收**的(见 §5.5 drop_ref),长跑需设
|
||||
`qodercontext_free` + 定期重建实例,否则对象表单调增长。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险点
|
||||
|
||||
| # | 风险 | 级别 | 说明 / 缓解 |
|
||||
|---|---|---|---|
|
||||
| R1 | **二进制不在我们仓库,也不可再分发** | 🔴 高 | 需先定 §5.2 的三种来源方案之一;`go:embed` 需法务/许可确认 |
|
||||
| R2 | **账号数 0 ⇒ 端到端不可验证** | 🔴 高 | 本机能证明「产出了签名头」,**不能**证明「上游接受」。必须拿到 1 个 qoder 账号才能做真实验收 |
|
||||
| R3 | wasm 版本漂移 | 🟠 中 | 上游换版本 ⇒ sha256 变,import 表可能变(尤其那 13 个未触碰项)。**必须**启动时校验 sha256 并在版本不符时显式拒绝 |
|
||||
| R4 | `drop_ref` 语义与 wasm-bindgen 版本相关 | 🟠 中 | 本次实测必须 no-op;换 wasm 后需重跑 `tools/qoder-wasm-probe` 复验,否则会退化成 `set on undefined` |
|
||||
| R5 | 对象表只增不回收 ⇒ 内存缓涨 | 🟠 中 | 每个 `QoderContext` 定期重建;`requestresult_*` 用后 `__wbg_requestresult_free` |
|
||||
| R6 | 加密端点意味着**响应也是密文** | 🟠 中 | 需 `decrypt_server_response` 解包,且要处理 SSE 逐帧解密(上游走 `Encode=1`)。这是**第二条**未实现链路,工作量不小 |
|
||||
| R7 | 端点语义与现有明文路径**并存** | 🟡 低-中 | `channels_upstream_gaps_test.go` 现有判据钉住「走明文端点且无信封解包」。接入加密端点必须**同时**改那条判据,否则两套逻辑互相打脸 |
|
||||
| R8 | 引入 wazero 抬升内核版本 | 🟡 低 | 按 §4 的版本纪律,新增 Go 依赖 = 核心版本递增(不是 patch) |
|
||||
| R9 | 体积 +1.16 MiB | 🟡 低 | 桌面端/服务端均可接受,但需确认 `packaging/` 的体积校验阈值 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 预计改动面(为什么不该放进 `fix/` 分支)
|
||||
|
||||
| 层 | 文件(预计) | 性质 |
|
||||
|---|---|---|
|
||||
| 新依赖 | `go.mod` / `go.sum`(wazero) | **内核级** |
|
||||
| 新包 | `internal/qoderwasm/{glue.go,abi.go,context.go,headers.go}` + 测试 | 新代码 ≈ 600–900 行(glue 706 行 TS 的 Go 翻译) |
|
||||
| 内核桥 | `internal/lua/plugin_bridges.go`(新增 kernel action)、`internal/lua/kernel_api.go` 白名单 | **内核级** |
|
||||
| 插件 | `internal/lua/plugins/channels.lua`(`channels-qoder` 增加 `build_headers` + 密文响应解包) | 插件级 |
|
||||
| 判据 | `internal/core/channels_upstream_gaps_test.go`(**必须改**,见 R7)、新增 adapter 判据 | 判据级 |
|
||||
| 部署 | `packaging/`(wasm 放置 + 校验)、`config`(`qoder_wasm_path`) | 运维级 |
|
||||
| 文档 | 本文 + `docs/channel-repair-plan.md` P1-4 段 | 文档级 |
|
||||
|
||||
**结论:这是一个跨 7 个层、含新内核依赖、且需要账号才能验收的主分支级改动。**
|
||||
一条 `fix/channel-auth-headers` 分支承担它会把「已修好的三条头缺陷」
|
||||
一起拖成不可发布状态。⇒ 本轮**不做**,只交付本文 + 可复现工具。
|
||||
|
||||
### 建议的推进顺序(若主管决定做)
|
||||
|
||||
1. **先决策 §5.2**(wasm 从哪来)—— 这是唯一的前置阻塞。
|
||||
2. `internal/qoderwasm` 落地 + 用 `tools/qoder-wasm-probe` 的用例做单测
|
||||
(**离线可测**:不需要账号,断言头集合形状与 sha256 校验)。
|
||||
3. 内核桥 `wasm.headers` + 插件 `build_headers` 接线,**只走明文端点**先跑通
|
||||
头生成链路(不切端点)。
|
||||
4. 拿到 1 个真实账号后,验证「签名头被接受」→ 再决定是否切加密端点。
|
||||
5. 切端点那一轮再处理 R6(响应/SSE 解密)与 R7(判据改写)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实测记录(可复现)
|
||||
|
||||
工具:`tools/qoder-wasm-probe`(**独立 module**,主仓 `go.mod` 不含 wazero)。
|
||||
|
||||
```bash
|
||||
cd tools/qoder-wasm-probe
|
||||
go run . -wasm /opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm
|
||||
# 加 -json 出结构化结果;-repeat N 测实例复用;-strict 让未实现 import 被调用即失败
|
||||
```
|
||||
|
||||
本次输出(2026-10-18,go1.27.1 linux/amd64,wazero v1.9.0):
|
||||
|
||||
```
|
||||
wasm : .../qoder-auth-wasm.wasm
|
||||
size : 298606 bytes
|
||||
imports : 31 (all from "./qoder_auth_wasm_bg.js")
|
||||
exports : 33
|
||||
instantiate: true calls-at-instantiate=0
|
||||
|
||||
-- LEVEL 2: generate_runtime_auth_fields --
|
||||
encrypt_user_info <string len=172>
|
||||
key <string len=172>
|
||||
|
||||
-- LEVEL 3: qodercontext_new + prepareInferRequest --
|
||||
url = api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1
|
||||
body = 60 bytes (encrypted, not logged)
|
||||
hdr Authorization Bearer COSY.eyJ2ZXJzaW9u…<len=209>
|
||||
...(共 20 个头)
|
||||
|
||||
-- IMPORT USAGE (18 of 31 touched) --
|
||||
RESULT: PASS
|
||||
```
|
||||
|
||||
量化(`-repeat 50 -json`):
|
||||
|
||||
| 指标 | 值 |
|
||||
|---|---|
|
||||
| 50 次 `prepareInferRequest` 端到端 | ≈ 108 ms ⇒ **≈ 2.2 ms/请求** |
|
||||
| wasm 线性内存(跑完后) | 20 页 = **1.25 MiB** |
|
||||
| 未触碰 import | **13 / 31** |
|
||||
| 实例复用 | 50 次全部 PASS(无状态泄漏迹象) |
|
||||
|
||||
> **再次声明:以上全部是离线静态/单机结论。**
|
||||
> 「签名头被 qoder 上游接受」**未验证** —— 本机 qoder 账号数 **0**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 与现有实现的冲突(必须在动手前处理)
|
||||
|
||||
`internal/core/channels_upstream_gaps_test.go` 当前把**明文端点**写成了判据:
|
||||
|
||||
> 「qoder 的 chat 端点变了 —— 若改成加密端点 `agent_chat_generation`,
|
||||
> 则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名」
|
||||
|
||||
这条判据是对的(它防的是「只改一半」)。接入 wasm 时**它必须同步演进**,
|
||||
否则会出现「新代码已走加密端点、旧判据仍断言没有信封解包」的自相矛盾。
|
||||
|
||||
> 注意该判据注释里的一处**过时事实**:它写「该二进制在参考仓库里也不存在」。
|
||||
> 实测:文件**存在**于 `vendor/channel-pack/qoder-auth-wasm.wasm`
|
||||
> (sha256 `6419471e…`),只是**不在我们仓库**。接入时应顺手更正这句,
|
||||
> 但**不要**为了「改注释」而放宽判据。
|
||||
@ -1,109 +0,0 @@
|
||||
# 源名与限定名契约
|
||||
|
||||
> 本文只说一件事:**源名允许哪些字符,以及为什么**。
|
||||
> 判据:`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,7 +5,6 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@ -15,67 +14,6 @@ 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:"-"`
|
||||
@ -392,14 +330,6 @@ 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)
|
||||
}
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
|
||||
//
|
||||
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
|
||||
//
|
||||
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
|
||||
// codewise-default-model-v2 / hunyuan-image-alpha / auto
|
||||
//
|
||||
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
|
||||
// 极易被误判成"这个模型坏了"。
|
||||
//
|
||||
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
|
||||
// 且都是实测依据:
|
||||
//
|
||||
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
|
||||
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
|
||||
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
|
||||
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
|
||||
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
|
||||
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
|
||||
for _, rule := range []string{
|
||||
`id == "auto" or id == "default"`, // ①
|
||||
`id:sub(1, 4) == "nes-"`, // ②
|
||||
`id:sub(1, 11) == "completion-"`, // ②
|
||||
`id:sub(1, 9) == "codewise-"`, // ②
|
||||
`m.supportsExtra == true`, // ②
|
||||
`mo > 0 and mo <= 256`, // ③
|
||||
`t == "text-to-image"`, // ④
|
||||
} {
|
||||
if !strings.Contains(s, rule) {
|
||||
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
|
||||
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
|
||||
"删任何一条都会把不可调用模型放回选择器)", rule)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
|
||||
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
|
||||
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
|
||||
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
|
||||
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
|
||||
// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。
|
||||
//
|
||||
// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回
|
||||
// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加
|
||||
// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。
|
||||
// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。
|
||||
//
|
||||
// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。
|
||||
func TestBuddyCatalogDropsKnownUncallable(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
if !strings.Contains(s, "local NOT_CALLABLE = {") {
|
||||
t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)")
|
||||
}
|
||||
if !strings.Contains(s, `["hy4-preview-x"] = true`) {
|
||||
t.Error("hy4-preview-x 实测恒 11102,必须排除")
|
||||
}
|
||||
// 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后
|
||||
if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") {
|
||||
t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)")
|
||||
}
|
||||
t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸")
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
|
||||
//
|
||||
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
|
||||
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
|
||||
//
|
||||
// https://copilot.tencent.com/v2/chat/completions/chat/completions
|
||||
//
|
||||
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
|
||||
//
|
||||
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
|
||||
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
|
||||
//
|
||||
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
|
||||
//
|
||||
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
|
||||
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
|
||||
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
|
||||
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
|
||||
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
|
||||
//
|
||||
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
|
||||
// 路径必须在 `endpoint` 里。
|
||||
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
|
||||
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
|
||||
|
||||
found := 0
|
||||
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
|
||||
url := m[1]
|
||||
found++
|
||||
for _, b := range banned {
|
||||
if strings.Contains(url, b) {
|
||||
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
|
||||
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
|
||||
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
|
||||
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
|
||||
url, url, b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("没扫到任何 base_url 声明(判据失效)")
|
||||
}
|
||||
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
|
||||
}
|
||||
|
||||
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
|
||||
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
|
||||
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
|
||||
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
|
||||
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
|
||||
m := declRe.FindStringSubmatch(bad)
|
||||
if m == nil {
|
||||
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
|
||||
}
|
||||
if !strings.Contains(m[1], "/chat/completions") {
|
||||
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
|
||||
}
|
||||
t.Log("✓ 判据对坏数据有判别力")
|
||||
}
|
||||
@ -1,190 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。
|
||||
//
|
||||
// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务
|
||||
// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
|
||||
// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮,
|
||||
// **真正的用户动作**(如「账号删除」)被挤到看不见。
|
||||
//
|
||||
// 三个结构性修法,每个都在源码里可断言:
|
||||
//
|
||||
// ① logEvent 支持 level(task/warn/error),前端默认折叠 task
|
||||
// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误)
|
||||
// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1
|
||||
func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 签名必须收 level 与 platform(三参)
|
||||
if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) {
|
||||
t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达")
|
||||
}
|
||||
// ② 写入的条目必须带上这两个字段
|
||||
for _, field := range []string{"level = level or", "platform = platform"} {
|
||||
if !strings.Contains(s, field) {
|
||||
t.Errorf("日志条目必须写入 %s", field)
|
||||
}
|
||||
}
|
||||
// ③ 同文折叠:紧邻同文时只加计数
|
||||
if !strings.Contains(s, "top.n = (top.n or 1) + 1") {
|
||||
t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)")
|
||||
}
|
||||
// ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前,
|
||||
// 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。
|
||||
if !strings.Contains(s, "local top = log[1]") {
|
||||
t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱")
|
||||
}
|
||||
t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠")
|
||||
}
|
||||
|
||||
// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。
|
||||
//
|
||||
// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" ——
|
||||
// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。
|
||||
func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 这些是「每次跑都成功」的常规回音 —— 必须 task 级
|
||||
routine := []string{
|
||||
`logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`,
|
||||
`logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`,
|
||||
`logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`,
|
||||
}
|
||||
for _, r := range routine {
|
||||
if !strings.Contains(s, r) {
|
||||
t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r)
|
||||
}
|
||||
}
|
||||
// 这些是异常 —— 必须 warn(不能被一起折叠掉)
|
||||
for _, pat := range []string{
|
||||
`logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
|
||||
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`,
|
||||
`logEvent("凭据续期失败 " .. #failed .. " 个:`,
|
||||
} {
|
||||
if !strings.Contains(s, pat) {
|
||||
t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat)
|
||||
}
|
||||
}
|
||||
// 「已同步源」是周期重同步的常规回音 —— 降级为 task
|
||||
if !strings.Contains(s, `logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)`) {
|
||||
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属")
|
||||
}
|
||||
t.Log("✓ 周期噪音已降级,异常保持 warn")
|
||||
}
|
||||
|
||||
// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。
|
||||
func TestChannelsLogPanelHasFilters(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属)
|
||||
// 判据:日志卡片的 DOM 不是 ch-detail 的子元素。
|
||||
// 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过
|
||||
// ch-detail 的闭合(即两者是兄弟而非父子)。
|
||||
iDetail := strings.Index(s, `id="ch-detail"`)
|
||||
iLogCard := strings.Index(s, `id="ch-log-card"`)
|
||||
if iDetail < 0 || iLogCard < 0 {
|
||||
t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)")
|
||||
}
|
||||
if iLogCard < iDetail {
|
||||
t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)")
|
||||
}
|
||||
// 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数
|
||||
seg := s[iDetail:iLogCard]
|
||||
depth := 0
|
||||
for _, m := range regexp.MustCompile(`</?div`).FindAllString(seg, -1) {
|
||||
if strings.HasPrefix(m, "</") {
|
||||
depth--
|
||||
} else {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth)
|
||||
}
|
||||
|
||||
// ② 三个过滤维度
|
||||
for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} {
|
||||
if !strings.Contains(s, id) {
|
||||
t.Errorf("缺少日志视图控件 %s", id)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, "function chLogRender()") {
|
||||
t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)")
|
||||
}
|
||||
// ③ 按渠道过滤 + 切渠道重渲染
|
||||
if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") {
|
||||
t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)")
|
||||
}
|
||||
if !strings.Contains(s, "chLogRender();\n };\n") {
|
||||
t.Error("chSelect 切换渠道后必须重渲染日志")
|
||||
}
|
||||
t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备")
|
||||
}
|
||||
|
||||
// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。
|
||||
//
|
||||
// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成
|
||||
//
|
||||
// `能力增强不<E5BCBA>`
|
||||
//
|
||||
// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节
|
||||
// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。
|
||||
func TestLogTruncationIsUTF8Safe(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 必须存在按字符截断的助手
|
||||
if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") {
|
||||
t.Fatal("必须实现 plugin.utf8Clip(按字符截断)")
|
||||
}
|
||||
// ② 中文文案上的截断不得再用裸 sub()
|
||||
for _, bad := range []string{
|
||||
`tostring(cres):sub(1, 120)`,
|
||||
`tostring(cerr):sub(1, 120)`,
|
||||
`table.concat(failed, "; "):sub(1, 300)`,
|
||||
} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad)
|
||||
}
|
||||
}
|
||||
// ③ 三处必须已换成 utf8Clip
|
||||
for _, want := range []string{
|
||||
"plugin.utf8Clip(cres, 120)",
|
||||
"plugin.utf8Clip(cerr, 120)",
|
||||
`plugin.utf8Clip(table.concat(failed, "; "), 300)`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("缺少 UTF-8 安全截断调用:%s", want)
|
||||
}
|
||||
}
|
||||
// ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短)
|
||||
if !strings.Contains(s, "if #s <= maxBytes then return s end") {
|
||||
t.Error("utf8Clip 必须对未超长的输入原样返回")
|
||||
}
|
||||
// ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节
|
||||
if !strings.Contains(s, "elseif b >= 0xC0 then") {
|
||||
t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)")
|
||||
}
|
||||
t.Log("✓ 截断已 UTF-8 安全")
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。
|
||||
//
|
||||
// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**,
|
||||
// 往顶部加**哪怕 1 个**顶层 local 就报
|
||||
//
|
||||
// main function has more than 200 local variables
|
||||
//
|
||||
// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。
|
||||
//
|
||||
// ⚠️ 判据为什么用 luajit 而不是 luac:
|
||||
//
|
||||
// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出
|
||||
// **不同的**结论。用错工具会得到假绿。
|
||||
//
|
||||
// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」:
|
||||
//
|
||||
// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件
|
||||
// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的,
|
||||
// 也正是我们真正需要的性质。
|
||||
func TestChannelsLuaHasLocalHeadroom(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := exec.LookPath("luajit"); err != nil {
|
||||
t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)")
|
||||
}
|
||||
|
||||
const headroom = 10
|
||||
for _, n := range []int{1, headroom} {
|
||||
probe := strings.Repeat("local __headroom = 1\n", n) + src
|
||||
cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin")
|
||||
cmd.Stdin = strings.NewReader(probe)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+
|
||||
"下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+
|
||||
"修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom)
|
||||
}
|
||||
|
||||
// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又
|
||||
// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。
|
||||
func TestChannelsConstantsLiveInPluginK(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
if !strings.Contains(s, "plugin.K = {") {
|
||||
t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)")
|
||||
}
|
||||
// 抽样:这些曾占用 local 槽位的纯常量必须已挂表
|
||||
for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} {
|
||||
if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 应定义在 plugin.K 表内", n)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) {
|
||||
t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 纯常量已集中在 plugin.K")
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 孤儿源必须能被识别、且**只删该删的**。
|
||||
//
|
||||
// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除,
|
||||
// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404),
|
||||
// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。
|
||||
//
|
||||
// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。
|
||||
func TestChannelsReapOrphanSources(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)
|
||||
}
|
||||
// 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权,
|
||||
// 否则 add_source 会被拒 ⇒ 造不出孤儿源。
|
||||
if err := ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
|
||||
t.Fatalf("授权: %v", err)
|
||||
}
|
||||
|
||||
// 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。
|
||||
cleanup := func() interface{} {
|
||||
r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{
|
||||
"dry_run": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reap_orphan_sources: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
cleanup()
|
||||
|
||||
// 造出**真实形态**:走 account_add 登记账号(它会注册源),
|
||||
// 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态
|
||||
// (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。
|
||||
// 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖,
|
||||
// 而我们要的恰恰是"源留下了"这个状态。
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %v", j["error"])
|
||||
}
|
||||
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
|
||||
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
|
||||
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
|
||||
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
|
||||
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add(2): %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("account_add(2) 报错: %v", j["error"])
|
||||
}
|
||||
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": "my-handmade-source", "base_url": "http://127.0.0.1:1",
|
||||
"adapter": "openai", "models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册手工源: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
// ★ 必须看 json 里的 error:内核把失败放在返回体里,
|
||||
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
|
||||
t.Fatalf("注册手工源报错: %v", j["error"])
|
||||
}
|
||||
// 摘账号(保留源)
|
||||
for _, id := range []string{"deadbeef1234", "dead-acct-1"} {
|
||||
if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
t.Fatalf("摘账号 %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ① dry_run(默认)只报告、不删
|
||||
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
orphans, _ := j["orphans"].([]interface{})
|
||||
seen := map[string]bool{}
|
||||
for _, o := range orphans {
|
||||
if s, _ := o.(string); s != "" {
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+
|
||||
" 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+
|
||||
" 实得 %v", want, seen)
|
||||
}
|
||||
}
|
||||
if seen["my-handmade-source"] {
|
||||
t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)")
|
||||
}
|
||||
if j["dry_run"] != true {
|
||||
t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"])
|
||||
}
|
||||
if j["removed"].(float64) != 0 {
|
||||
t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"])
|
||||
}
|
||||
|
||||
// ② 确认 dry_run 真的没删(看源列表)
|
||||
after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := sourceNames(jsonOf(t, after))
|
||||
if !names["channels-buddy-deadbeef1234"] {
|
||||
t.Fatalf("dry_run 竟真删了!源列表: %v", names)
|
||||
}
|
||||
// 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿)
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names)
|
||||
}
|
||||
|
||||
// ③ 真删:孤儿消失、手工源存活
|
||||
res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j = jsonOf(t, res)
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("清理应成功,实得 %v", j)
|
||||
}
|
||||
after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = sourceNames(jsonOf(t, after))
|
||||
for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if names[dead] {
|
||||
t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names)
|
||||
}
|
||||
}
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("★ 误删了运维手工源: %v", names)
|
||||
}
|
||||
t.Logf("✓ 孤儿源已清、手工源存活")
|
||||
}
|
||||
|
||||
// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。
|
||||
func sourceNames(j map[string]interface{}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if arr, ok := j["sources"].([]interface{}); ok {
|
||||
for _, x := range arr {
|
||||
if m, ok := x.(map[string]interface{}); ok {
|
||||
if s, ok := m["name"].(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -48,13 +48,6 @@ func TestAccountDeleteRemovesItsSource(t *testing.T) {
|
||||
if !strings.Contains(joined, "remove_source") {
|
||||
t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined)
|
||||
}
|
||||
// ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。
|
||||
// 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** ——
|
||||
// 只接第一个返回值就会把原因丢掉(我第一版正是如此)。
|
||||
if !strings.Contains(joined, "not granted") {
|
||||
t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+
|
||||
"否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined)
|
||||
}
|
||||
t.Logf("✓ 撤源失败已可见: %s", joined)
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**推理请求**必须用 Cloud-IDE-JWT 头族,不是 Bearer。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// Doubao-Seed-2.1-Pro / seed-code-pro-0430 / glm-5-turbo 全部
|
||||
// 401「We're sorry, but we are not able to authenticate you」
|
||||
// 因为 channels-trae 适配器**只有 4 个 transform、没有 build_headers**
|
||||
// ⇒ 内核兜底发 `Authorization: Bearer <api_key>`。
|
||||
//
|
||||
// 参考 trae.ts 端点表:对话也是 Cloud-IDE-JWT(traeSOLOHeaders)。
|
||||
//
|
||||
// ★ 这与「额度不可用」是**同一根因的两个出口**:额度端点(checkin_status /
|
||||
//
|
||||
// balance)漏了头、已修;推理端点(本判据)也漏了,但当时没一起修。
|
||||
// ⇒ 教训:修「某一族请求的鉴权」时必须把所有**该族的出口**列出来逐个对齐。
|
||||
func TestTraeChatHeadersUseCloudIDEJWT(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)
|
||||
}
|
||||
|
||||
// 走真实的 BuildHeaders(内核调用适配器的那条路径)
|
||||
hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{
|
||||
"api_key": "ACCESS-TOKEN-X",
|
||||
"source": map[string]interface{}{
|
||||
"name": "channels-trae-test",
|
||||
"meta": map[string]interface{}{
|
||||
"uid": "3428695215186675",
|
||||
"device_id": "dev-abc",
|
||||
"machine_id": "mach-xyz",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders: %v", err)
|
||||
}
|
||||
auth := hdrs["Authorization"]
|
||||
if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("★ 推理请求鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+
|
||||
" 用 Bearer 会得到 401「not able to authenticate you」,"+
|
||||
"表现为「channel 注册的模型全都不能用」", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "ACCESS-TOKEN-X") {
|
||||
t.Fatalf("Authorization 应携带 access_token,实得 %q", auth)
|
||||
}
|
||||
// 设备身份头必须来自 meta(适配器沙箱读不到凭据)
|
||||
for k, want := range map[string]string{
|
||||
"X-Uid": "3428695215186675",
|
||||
"X-Device-Id": "dev-abc",
|
||||
"X-Machine-Id": "mach-xyz",
|
||||
} {
|
||||
if got := hdrs[k]; got != want {
|
||||
t.Fatalf("★ %s 应为 %q(来自源 meta),实得 %q", k, want, got)
|
||||
}
|
||||
}
|
||||
// 参考 traeSOLOHeaders 的成对 token 头
|
||||
for _, k := range []string{"X-Cloudide-Token", "X-Ide-Token"} {
|
||||
if hdrs[k] != "ACCESS-TOKEN-X" {
|
||||
t.Fatalf("%s 应与 access_token 相同,实得 %q", k, hdrs[k])
|
||||
}
|
||||
}
|
||||
t.Logf("✓ trae 推理头: %s(X-Uid=%s)", auth, hdrs["X-Uid"])
|
||||
}
|
||||
|
||||
// 配对判据:源 meta 缺失时不得崩、也要给出 Cloud-IDE-JWT(只是没有设备头)。
|
||||
func TestTraeChatHeadersTolerateMissingMeta(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.Fatal(err)
|
||||
}
|
||||
hdrs, err := c.VM().BuildHeaders("channels-trae", map[string]interface{}{
|
||||
"api_key": "T",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildHeaders 不该因缺 meta 报错: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hdrs["Authorization"], "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("即使没有 meta 也必须是 Cloud-IDE-JWT,实得 %q", hdrs["Authorization"])
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 源必须携带设备身份(meta),否则适配器拿不到 uid。
|
||||
func TestTraeSourceCarriesDeviceIdentityInMeta(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
if !strings.Contains(s, "source_meta_from_credential") {
|
||||
t.Fatal("trae 平台应声明 source_meta_from_credential=true —— " +
|
||||
"否则源的 meta 为空、适配器无法派生 X-Uid / X-Device-Id")
|
||||
}
|
||||
if !strings.Contains(s, "src.meta") {
|
||||
t.Fatal("syncAccountSource 应把设备身份写进 src.meta")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = lua.HandlerRequest{}
|
||||
@ -1,71 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ TRAE 的**签到状态 / 积分余额**必须用 Cloud-IDE-JWT + 设备身份头,
|
||||
// 不能用默认的 `Authorization: Bearer`。
|
||||
//
|
||||
// 真实缺陷(用户报「显示额度不可用」,2026-10-17):
|
||||
//
|
||||
// 同一账号**签到领取成功**(claim_fn 走 traceCheckinHeaders),
|
||||
// 但 credits/status 返回
|
||||
// "We're sorry, but we are not able to authenticate you..."
|
||||
// 因为 checkin_status / balance 既没 auth 也没 headers_fn
|
||||
// ⇒ fetchAccountJSON 退回默认 Bearer。
|
||||
//
|
||||
// 参考 trae.ts 端点表逐行写明这三个端点都是 Cloud-IDE-JWT。
|
||||
//
|
||||
// ⚠️ 只加 `auth = "cloud-ide-jwt"` **没用**:该字段只被 claim_fn 的绑定循环
|
||||
//
|
||||
// 读取(line ~9033),fetchAccountJSON 只认 headers_fn —— 第一版我就只加了
|
||||
// 声明,等于没改。
|
||||
//
|
||||
// ⚠️ 为什么不用 httptest 假上游断言:那需要 set_endpoints 改 URL + 凭据落到
|
||||
//
|
||||
// 池账号 ref + 路径匹配全部对齐,任一环节错都会让"没发请求"看起来像
|
||||
// "头不对"(我在这上面耗了三轮,全是夹具问题)。判据直接咬**被测对象**:
|
||||
// spec.headers_fn 的产出。
|
||||
func TestTraeCreditsEndpointsUseCloudIDEJWT(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)
|
||||
}
|
||||
|
||||
// 通过真实函数取 trae 的两个 spec 的 headers_fn 产出。
|
||||
// 凭据带 uid ⇒ 设备身份头由 uid 确定性派生。
|
||||
out, err := ps.Call("channels", "__cred_headers_probe", map[string]interface{}{
|
||||
"platform": "trae",
|
||||
"credential": `{"access_token":"ACCESS-X","uid":"3428695215186675",` +
|
||||
`"device_id":"d1","machine_id":"m1"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("__cred_headers_probe: %v", err)
|
||||
}
|
||||
j := jsonOf(t, out)
|
||||
for _, which := range []string{"checkin_status", "balance"} {
|
||||
h, ok := j[which].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("探针应回报 %s 的头,实得 %v", which, j)
|
||||
}
|
||||
auth, _ := h["Authorization"].(string)
|
||||
if !strings.HasPrefix(auth, "Cloud-IDE-JWT ") {
|
||||
t.Fatalf("★ %s 的鉴权必须是 Cloud-IDE-JWT,实得 %q\n"+
|
||||
" 用默认 Bearer 会得到「not able to authenticate you」,"+
|
||||
"而同一账号的签到领取却是成功的 —— 这个矛盾就是缺陷", which, auth)
|
||||
}
|
||||
for _, k := range []string{"X-Device-Id", "X-Market-User-Id", "X-User-Region"} {
|
||||
if v, _ := h[k].(string); v == "" {
|
||||
t.Fatalf("★ %s 缺 %s —— 参考实测简化头会失败", which, k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ %s: %s (device=%v)", which, auth, h["X-Device-Id"])
|
||||
}
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE ExchangeToken 的凭据在 **Result 里**,不在顶层。
|
||||
//
|
||||
// 真实缺陷(用户报「trae 登录还是不行」,2026-10-17):
|
||||
//
|
||||
// 会话落定为 state=failed,message = "TRAE ExchangeToken 响应缺少 Token"
|
||||
// 而实测上游 HTTP 200,body 是:
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
// 代码读 `rec.Token` ⇒ 恒 nil ⇒ 误报「缺少 Token」。
|
||||
// 参考 parseTraeExchangeResponse 明确先取 `data.Result ?? data.result`。
|
||||
//
|
||||
// ★★ 为什么此前所有 trae 判据都是绿的(这个缺陷藏了很久的原因):
|
||||
//
|
||||
// **测试夹具返回的是扁平 `{"Token":...}`** —— 与生产形状不一致,
|
||||
// 于是「只读顶层」这个错误在夹具里恰好是对的。夹具形状错 = 判据必然虚假通过。
|
||||
// 已把夹具改成真实 Result 形状(channels_trae_test.go)。
|
||||
//
|
||||
// 本判据直接用**两种形状**分别打一次,确保实现同时认 Result 与顶层兜底。
|
||||
func TestTraeExchangeReadsResultEnvelope(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "生产形状:凭据在 Result 里",
|
||||
body: `{"ResponseMetadata":{"RequestId":"x"},"Result":{"Token":"ACCESS-FROM-RESULT",` +
|
||||
`"RefreshToken":"RT-ROTATED","TokenExpireAt":1800000000000}}`,
|
||||
want: "ACCESS-FROM-RESULT",
|
||||
},
|
||||
{
|
||||
name: "兜底:凭据在顶层(上游若改版)",
|
||||
body: `{"Token":"ACCESS-FROM-TOP","RefreshToken":"RT-TOP"}`,
|
||||
want: "ACCESS-FROM-TOP",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 覆盖夹具的响应体为这个形状
|
||||
atomic.StoreInt32(&h.exchOK, 1)
|
||||
h.overrideExchangeBody(tc.body)
|
||||
|
||||
// ★ 必须走**池路径**(UI 真实流程):先建占位账号再带 account_id
|
||||
// 发登录。beginAndCallback 不带 account_id ⇒ 凭据落到
|
||||
// `channels:trae`(无池账号),也就无法回读 —— 我第一版就这么写,
|
||||
// 判据报"池里应有账号,实得 0",与真因(形状)无关。
|
||||
aid, nonce := h.addAccountAndStartLogin("trae")
|
||||
status, body := h.invokeCallback(map[string]string{
|
||||
"state": nonce, "refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("回调应成功:status=%d body=%s", status, body)
|
||||
}
|
||||
if strings.Contains(body, "缺少 Token") {
|
||||
t.Fatalf("★ 又没解出 Token(形状=%s)—— 检查是否只读了顶层", tc.name)
|
||||
}
|
||||
// 凭据必须真的落进 secrets,且新 refresh 被轮换保存。
|
||||
// ⚠️ 账号 id 是**随机生成**的(account_add 用 newToken():sub(1,12)),
|
||||
// 不能硬编码 —— 用 account_list 拿真实 id。
|
||||
cred := h.credentialFor(aid)
|
||||
if !strings.Contains(cred, tc.want) {
|
||||
t.Fatalf("落盘凭据里应有 access token %q,实得 %q", tc.want, cred)
|
||||
}
|
||||
if !strings.Contains(cred, "refresh_token") {
|
||||
t.Fatalf("★ 轮换后的 refreshToken 必须保存 —— 旧 refresh 已被上游作废,"+
|
||||
"不存新值会让下次续期必然失败。实得 %q", cred)
|
||||
}
|
||||
t.Logf("✓ %s → access=%s", tc.name, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// overrideExchangeBody 让夹具的 ExchangeToken 返回指定 body。
|
||||
func (h *traeHarness) overrideExchangeBody(body string) {
|
||||
h.t.Helper()
|
||||
h.exchBody = body
|
||||
}
|
||||
|
||||
// credentialFor 读回某账号落盘的凭据原文。
|
||||
//
|
||||
// 用 Plugins.GetSecret(内核级读取)而不是 __read_cred_probe:
|
||||
// 探针那版我传错过入参(它吃 ref),返回 len:0,让判据看起来像"凭据没落盘"。
|
||||
// GetSecret 是 TestTraeCallbackPoolPath 已在用的正规入口。
|
||||
// addAccountAndStartLogin 建占位账号并带 account_id 发起登录,返回 (账号id, nonce)。
|
||||
// 这是 UI 真实流程(见 TestTraeCallbackPoolPath)。
|
||||
func (h *traeHarness) addAccountAndStartLogin(platform string) (string, string) {
|
||||
h.t.Helper()
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":%q}`, platform),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid, _ := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":%q,"account_id":%q}`, platform, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
nonce, _ := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
|
||||
return aid, nonce
|
||||
}
|
||||
|
||||
func (h *traeHarness) credentialFor(accountID string) string {
|
||||
h.t.Helper()
|
||||
v, ok, err := h.ps.GetSecret("channels", "channels:trae:"+accountID)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
@ -1,130 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ TRAE 的流式转换必须把 **SOLO 自定义 SSE** 摊平成内核要的 **UnifiedChunk**。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// ① 我原先 `return __prelude.openai_stream_chunk(raw_chunk)` —— SOLO 帧形如
|
||||
// {"response":"###","reasoning_content":null,"tool_calls":null}
|
||||
// 没有 `choices` 字段 ⇒ 每帧都解析不出 ⇒ 上游 HTTP 200 + 118 正常帧,
|
||||
// 客户端却得到 `empty stream`。
|
||||
// ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对:
|
||||
// 内核 `json.Unmarshal(unified, &UnifiedChunk)` 直接解成**扁平结构**,
|
||||
// 外壳字段全被丢弃 ⇒ 91 帧全是空 content。
|
||||
//
|
||||
// ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
|
||||
//
|
||||
// 因此判别帧类型只能靠内容特征。
|
||||
func TestTraeSoloSSEToUnifiedChunk(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.Fatal(err)
|
||||
}
|
||||
|
||||
// ① 正文帧 → content(**扁平**,不是 choices[].delta)
|
||||
out, err := c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"Hello","reasoning_content":null,"tool_calls":null}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform: %v", err)
|
||||
}
|
||||
var ck map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("产出不是 JSON: %q", out)
|
||||
}
|
||||
if got, _ := ck["content"].(string); got != "Hello" {
|
||||
t.Fatalf("★ 正文必须摊平到顶层 content,实得 %q(原始产出 %s)\n"+
|
||||
" 返回 {choices:[{delta:...}]} 外壳会被内核丢弃", got, out)
|
||||
}
|
||||
if _, has := ck["choices"]; has {
|
||||
t.Fatalf("★ 不得返回 OpenAI 外壳(choices)—— 内核按 UnifiedChunk 解析,外壳会被忽略: %s", out)
|
||||
}
|
||||
|
||||
// ② 推理帧 → reasoning_content
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"","reasoning_content":"We","tool_calls":null}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("推理帧产出不是 JSON: %q", out)
|
||||
}
|
||||
if got, _ := ck["reasoning_content"].(string); got != "We" {
|
||||
t.Fatalf("★ 推理必须进 reasoning_content,实得 %q(%s)", got, out)
|
||||
}
|
||||
|
||||
// ③ done 帧
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk", `{"finish_reason":"stop"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("done 帧产出不是 JSON: %q", out)
|
||||
}
|
||||
if ck["done"] != true {
|
||||
t.Fatalf("★ done 帧必须 done=true,实得 %s", out)
|
||||
}
|
||||
if ck["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop,实得 %s", out)
|
||||
}
|
||||
|
||||
// ④ usage 帧
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &ck); err != nil {
|
||||
t.Fatalf("usage 帧产出不是 JSON: %q", out)
|
||||
}
|
||||
u, _ := ck["usage"].(map[string]interface{})
|
||||
if u == nil || u["total"] != 15.0 {
|
||||
t.Fatalf("★ usage 必须摊平为 {prompt,completion,total},实得 %s", out)
|
||||
}
|
||||
|
||||
// ⑤ 工具调用:function_call → function 归一化
|
||||
out, err = c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"response":"","reasoning_content":null,"tool_calls":[{"function_call":{"name":"f","arguments":"{}"}}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, "function_call") {
|
||||
t.Fatalf("★ SOLO 的 function_call 必须归一化为 function,实得 %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"function"`) {
|
||||
t.Fatalf("工具调用缺 function 字段: %s", out)
|
||||
}
|
||||
t.Logf("✓ SOLO SSE 全部摊平正确")
|
||||
}
|
||||
|
||||
// 配对判据:无关帧不得产出垃圾。
|
||||
func TestTraeSoloSSEDropsUnknownFrames(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.Fatal(err)
|
||||
}
|
||||
// metadata 帧(上游确实会发,且无正文)
|
||||
out, err := c.VM().Transform("channels-trae", "transform_stream_chunk",
|
||||
`{"model":"","session_id":"abc","prompt_completion_id":0}`)
|
||||
if err != nil {
|
||||
t.Fatalf("metadata 帧不该报错: %v", err)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("无正文的 metadata 帧应产出空串(不制造垃圾帧),实得 %q", out)
|
||||
}
|
||||
}
|
||||
@ -16,12 +16,11 @@ import (
|
||||
// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken;
|
||||
// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。
|
||||
type traeHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
exchHit int32
|
||||
exchOK int32 // 1=成功 0=失败(atomic)
|
||||
exchBody string // 非空时覆盖夹具的响应体(供形状判据用)
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
exchHit int32
|
||||
exchOK int32 // 1=成功 0=失败(atomic)
|
||||
}
|
||||
|
||||
func newTraeHarness(t *testing.T) *traeHarness {
|
||||
@ -46,23 +45,7 @@ func newTraeHarness(t *testing.T) *traeHarness {
|
||||
t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
// ★★ 必须是**真实上游的形状**:凭据在 `Result` 里,不在顶层。
|
||||
//
|
||||
// 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):
|
||||
// {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"...",
|
||||
// "TokenExpireAt":...,"RefreshExpireAt":...}}
|
||||
//
|
||||
// ⚠️ 这条夹具原先返回**扁平** `{"Token":...}` —— 于是「代码只读顶层」
|
||||
// 这个真缺陷被夹具**完美掩盖**:所有 trae 判据都绿,真实登录却报
|
||||
// 「响应缺少 Token」。夹具形状与生产不一致 = 判据必然虚假通过。
|
||||
if h.exchBody != "" {
|
||||
fmt.Fprint(w, h.exchBody)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, `{"ResponseMetadata":{"RequestId":"r%d"},`+
|
||||
`"Result":{"ClientID":"en1oxy7wnw8j9n","Token":"ACCESS-%d",`+
|
||||
`"RefreshToken":"RT-NEW-%d","TokenExpireAt":1800000000000,`+
|
||||
`"TokenExpireDuration":1209600000,"RefreshExpireAt":1807755929432}}`, n, n, n)
|
||||
fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n)
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
|
||||
@ -27,12 +27,8 @@ func TestUpstreamGapsAreDocumented(t *testing.T) {
|
||||
s := string(src)
|
||||
|
||||
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
|
||||
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
|
||||
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
|
||||
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
|
||||
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
|
||||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
|
||||
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH
|
||||
if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") {
|
||||
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
|
||||
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
|
||||
"否则响应会被当成畸形数据")
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。
|
||||
func TestUtf8ClipBehaviour(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.Fatal(err)
|
||||
}
|
||||
|
||||
s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表"
|
||||
for _, n := range []int{10, 40, 119, 120, 121, 400} {
|
||||
out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := jsonOf(t, out)["out"].(string)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got)
|
||||
}
|
||||
if n < len(s) && len(got) > n {
|
||||
t.Errorf("n=%d 结果超出字节上限: %d", n, len(got))
|
||||
}
|
||||
if n >= len(s) && got != s {
|
||||
t.Errorf("n=%d 未超长时应原样返回", n)
|
||||
}
|
||||
t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got))
|
||||
}
|
||||
}
|
||||
@ -252,16 +252,14 @@ func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。
|
||||
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
|
||||
//
|
||||
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
|
||||
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
|
||||
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
|
||||
//
|
||||
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
|
||||
//
|
||||
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
|
||||
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
|
||||
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
|
||||
// transform_request 必须返回错误而不是原样透传。
|
||||
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
|
||||
dir := t.TempDir()
|
||||
@ -280,26 +278,21 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic
|
||||
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{
|
||||
"adapters_dir": filepath.Join(dir, "adapters-no-base"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
vm := c.VM()
|
||||
if vm == nil {
|
||||
t.Fatal("VM 不可用")
|
||||
}
|
||||
j := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
zc, ok := j["zcode_reinstall"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j)
|
||||
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
|
||||
// 但调用必须报错
|
||||
out, err := vm.Transform("channels-zcode", "transform_request",
|
||||
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err == nil {
|
||||
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
|
||||
}
|
||||
if zc["ok"] == true {
|
||||
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
|
||||
if !strings.Contains(err.Error(), "基座") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
|
||||
}
|
||||
msg, _ := zc["err"].(string)
|
||||
if !strings.Contains(msg, "anthropic") {
|
||||
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
|
||||
}
|
||||
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
|
||||
t.Logf("✓ 基座缺失时响亮失败: %v", err)
|
||||
}
|
||||
|
||||
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
|
||||
|
||||
@ -1007,13 +1007,6 @@ 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")
|
||||
}
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,122 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadBuddyAdapter 装载内置 buddy 适配器。
|
||||
func loadBuddyAdapter(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/buddy.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("buddy", string(src)); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ buddy 适配器:两个厂商要求,各有实测依据。
|
||||
//
|
||||
// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150)
|
||||
// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0)
|
||||
func TestBuddyAdapterEffortAndHeaders(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
|
||||
// ① glm-5.1 → 补 medium
|
||||
out, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &b); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if b["reasoning_effort"] != "medium" {
|
||||
t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ② 其它模型**不补**(避免把单模型要求扩成全平台行为)
|
||||
out2, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b2 map[string]interface{}
|
||||
json.Unmarshal([]byte(out2), &b2)
|
||||
if b2["reasoning_effort"] != nil {
|
||||
t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ③ 调用方已给档位时**尊重它**
|
||||
out3, _ := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`)
|
||||
var b3 map[string]interface{}
|
||||
json.Unmarshal([]byte(out3), &b3)
|
||||
if b3["reasoning_effort"] != "high" {
|
||||
t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ④ 头:Accept 必须是 SSE
|
||||
h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h["Accept"] != "text/event-stream" {
|
||||
t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"])
|
||||
}
|
||||
for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} {
|
||||
if h[k] == "" {
|
||||
t.Errorf("缺少 buddy 身份头 %s", k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"])
|
||||
}
|
||||
|
||||
// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。
|
||||
func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}`
|
||||
out, err := vm.Transform("buddy", "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) {
|
||||
t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归护栏:适配器**必须**定义全部四个转换函数。
|
||||
//
|
||||
// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码",
|
||||
// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统),
|
||||
// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request,
|
||||
// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文,
|
||||
// 网关却记 completion_tokens=0、客户端看到**空回复**。
|
||||
// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底
|
||||
// "看起来正常" ⇒ 症状指向错误方向。
|
||||
//
|
||||
// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话"
|
||||
//
|
||||
// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。
|
||||
func TestBuddyAdapterDefinesAllTransforms(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} {
|
||||
if !vm.HasFn("buddy", fn) {
|
||||
t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 四个转换函数齐备")
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadZenVM 装载 opencodezen 适配器(照 adapter_opencodezen_test.go 的方式)。
|
||||
func loadZenVM(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ assistant 回合的 reasoning_content **必须回传**给 opencode 上游。
|
||||
//
|
||||
// 真实缺陷(生产实测,2026-10-16):
|
||||
//
|
||||
// provider localzen: api error 400: Upstream request failed: [invalid_request_error]
|
||||
// The `reasoning_content` in the thinking mode must be passed back to the API.
|
||||
//
|
||||
// 根因:transform_request 里写了 `msg.reasoning_content = nil`,理由是
|
||||
// "Zen 免费池不接受回传"。该假设**方向反了** —— 上游要的就是它。
|
||||
//
|
||||
// ★★ 这个缺陷极难归因,因为失败是**交替**出现的:
|
||||
// 只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启)
|
||||
// 时,回放那段历史才会被拒;普通回合照常 200。审计里表现为成串 502 紧随一个
|
||||
// 成功的长上下文请求(实测 prompt_tokens=139330)之后。
|
||||
//
|
||||
// 判据必须**咬住「被剥掉」这个具体行为**,而不是"请求成功" —— 后者在
|
||||
// 非 thinking 回合下永远为真,测不出这个缺陷。
|
||||
func TestOpenCodeZenKeepsAssistantReasoningContent(t *testing.T) {
|
||||
vm := loadZenVM(t)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": "deepseek-v4.1-flash-free",
|
||||
"stream": true,
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "user", "content": "1+1=?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "2",
|
||||
// 这段正是 agent 回放历史时会带上的东西
|
||||
"reasoning_content": "一加一等于二。",
|
||||
},
|
||||
{"role": "user", "content": "再加一"},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := vm.Transform("opencodezen", "transform_request", string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 解析出站体,找 assistant 回合
|
||||
var sent struct {
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &sent); err != nil {
|
||||
t.Fatalf("出站体不是合法 JSON: %v\n%s", err, out)
|
||||
}
|
||||
var found bool
|
||||
for _, m := range sent.Messages {
|
||||
if m["role"] != "assistant" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
rc, _ := m["reasoning_content"].(string)
|
||||
if rc == "" {
|
||||
t.Fatalf("assistant 回合的 reasoning_content 被剥掉了 ⇒ 上游会回 "+
|
||||
"`The reasoning_content in the thinking mode must be passed back to the API`\n"+
|
||||
"出站 messages: %s", out)
|
||||
}
|
||||
if rc != "一加一等于二。" {
|
||||
t.Errorf("reasoning_content 被改写:%q", rc)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("没找到 assistant 回合(判据失效)\n%s", out)
|
||||
}
|
||||
t.Log("✓ assistant 的 reasoning_content 已保留")
|
||||
}
|
||||
|
||||
// ★ 反向:**空的** reasoning_content 应被丢掉(不要把空串发给上游)。
|
||||
// 与上一条配对 —— 缺了这条,"改成无条件保留"这类过度修正不会被发现。
|
||||
func TestOpenCodeZenDropsEmptyReasoningContent(t *testing.T) {
|
||||
vm := loadZenVM(t)
|
||||
|
||||
raw := `{"model":"m","stream":true,"messages":[
|
||||
{"role":"assistant","content":"hi","reasoning_content":""}
|
||||
]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out, `"reasoning_content":""`) {
|
||||
t.Errorf("空的 reasoning_content 不该发给上游(多余字段,部分上游会挑),实得: %s", out)
|
||||
}
|
||||
t.Log("✓ 空 reasoning_content 已丢弃")
|
||||
}
|
||||
@ -343,39 +343,6 @@ function __prelude.openai_error(status, body)
|
||||
return resp.detail
|
||||
end
|
||||
|
||||
-- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。
|
||||
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
|
||||
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。
|
||||
local msg = nil
|
||||
if type(resp.error_msg) == "string" and resp.error_msg ~= "" then
|
||||
msg = resp.error_msg
|
||||
elseif type(resp.msg) == "string" and resp.msg ~= "" then
|
||||
msg = resp.msg
|
||||
end
|
||||
-- 腾讯系(buddy/workbuddy)用 msg + displayMsg.{zh,en},且**没有** message。
|
||||
-- 参考 buddy-adapter.ts:576 明确:本地化文案最可读,排在**最前**。
|
||||
local localized = nil
|
||||
if type(resp.displayMsg) == "table" then
|
||||
local dm = resp.displayMsg
|
||||
if type(dm.zh) == "string" and dm.zh ~= "" then localized = dm.zh
|
||||
elseif type(dm.en) == "string" and dm.en ~= "" then localized = dm.en end
|
||||
end
|
||||
if msg then
|
||||
local code = nil
|
||||
if type(resp.error_code) == "string" and resp.error_code ~= "" then
|
||||
code = resp.error_code
|
||||
elseif resp.code ~= nil and type(resp.code) ~= "string" then
|
||||
code = tostring(resp.code)
|
||||
end
|
||||
local detail = code and (code .. ": " .. msg) or msg
|
||||
if localized then return localized .. " " .. detail end
|
||||
return detail
|
||||
end
|
||||
if localized then return localized end
|
||||
if type(resp.error_code) == "string" and resp.error_code ~= "" then
|
||||
return resp.error_code
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
@ -1,99 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 上游错误体抽取必须覆盖**网关与腾讯系**形状 —— 这两类实测都漏过。
|
||||
//
|
||||
// 发现路径(2026-10-16 读生产日志):
|
||||
//
|
||||
// unhandled upstream error body (adapter "openai" lacks transform_error):
|
||||
// status=404 body={"error_msg":"404 Route Not Found"}
|
||||
// unhandled upstream error body (adapter "buddy" lacks transform_error):
|
||||
// status=400 body={"code":11101,"msg":"Non-stream chat request is not supported"}
|
||||
//
|
||||
// 两者都是 `{"error_msg":...}` / `{"msg":...}` —— 旧实现只认
|
||||
// error/message/detail,于是返回 nil ⇒ 客户端只看到
|
||||
// `api error 404: unknown error`,而 body 里明明有可用文案。
|
||||
//
|
||||
// ⚠️ 那两个适配器**都定义了** transform_error(openai.lua:139、buddy.lua:166),
|
||||
// 内核却报 "lacks" ⇒ 一句会把排查带偏的诊断(已另行修正内核文案)。
|
||||
func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
// 用**仓库里的真实** openai 适配器(含共享前导),不是自造桩:
|
||||
// 判据必须走真实实现,否则"补了形状"这件事根本没被验。
|
||||
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
|
||||
t.Fatalf("装载 openai 适配器: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name, body, want string
|
||||
}{
|
||||
{"APISIX error_msg",
|
||||
`{"error_msg":"404 Route Not Found"}`, "404 Route Not Found"},
|
||||
{"腾讯 msg(buddy 11101)",
|
||||
`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`,
|
||||
"11101: Non-stream chat request is currently not supported"},
|
||||
{"error_code + error_msg",
|
||||
`{"error_code":"APIG.0301","error_msg":"verify ak sk signature fail"}`,
|
||||
"APIG.0301: verify ak sk signature fail"},
|
||||
{"腾讯 displayMsg 本地化(最可读,排最前)",
|
||||
`{"code":11101,"msg":"Non-stream chat request is currently not supported",` +
|
||||
`"displayMsg":{"en":"Invalid request. Please retry","zh":"请求内容不合法,请重试"}}`,
|
||||
"请求内容不合法,请重试 11101: Non-stream chat request is currently not supported"},
|
||||
{"仅 displayMsg",
|
||||
`{"displayMsg":{"zh":"只有本地化文案"}}`, "只有本地化文案"},
|
||||
{"仅 error_code",
|
||||
`{"error_code":"InferHub.4291.200"}`, "InferHub.4291.200"},
|
||||
// 回归:原有的 6 种形状不得丢
|
||||
{"OpenAI 信封", `{"error":{"message":"boom"}}`, "boom"},
|
||||
{"裸字符串 error", `{"error":"boom"}`, "boom"},
|
||||
{"扁平 code/message", `{"message":"boom"}`, "boom"},
|
||||
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
|
||||
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
|
||||
{"裸文本", "plain failure line\nmore", "plain failure line"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok, err := vm.TransformError("openai", 400, c.body)
|
||||
if err != nil {
|
||||
t.Fatalf("TransformError: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("★ 抽不出消息(返回 ok=false)⇒ 客户端只会看到 "+
|
||||
"'unknown error',而 body 里明明有:%s", c.body)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("抽出的文案不对\n 实得: %q\n 期望: %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 配对判据:真正**没有** transform_error 的适配器仍应返回 ok=false
|
||||
// (不能为了让上面那条通过就把"抽不出"和"没这个钩子"混为一谈)。
|
||||
func TestPreludeErrorExtractionStillReportsUnknownShapes(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
|
||||
t.Fatalf("装载 openai 适配器: %v", err)
|
||||
}
|
||||
// 一个确实认不出的 JSON(没有 message/error/detail/msg/error_msg)
|
||||
got, ok, err := vm.TransformError("openai", 400, `{"weird":true}`)
|
||||
if err != nil {
|
||||
t.Fatalf("TransformError: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("认不出的形状不该硬凑文案,实得 %q", got)
|
||||
}
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。
|
||||
//
|
||||
// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、
|
||||
// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个
|
||||
// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。
|
||||
// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
|
||||
//
|
||||
// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换,
|
||||
//
|
||||
// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」,
|
||||
// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」——
|
||||
// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。
|
||||
// 安装接线由 internal/core 的端到端判据覆盖:
|
||||
// TestZcodeAdapterInjectsOfficialIdentityBlocks
|
||||
// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活)
|
||||
// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错)
|
||||
// 这三条**会**咬住"内联空基座"这个变异(已实测)。
|
||||
// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。
|
||||
func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) {
|
||||
psrc, _ := os.ReadFile("plugins/channels.lua")
|
||||
s := string(psrc)
|
||||
diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
|
||||
ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
|
||||
asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
full := "local Z = (function()\n" + diag + "\nend)()\n" + ad
|
||||
// ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。
|
||||
// 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。
|
||||
if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") {
|
||||
full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc)))
|
||||
} else {
|
||||
t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" +
|
||||
"适配器沙箱里读不到文件)")
|
||||
}
|
||||
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("channels-zcode", full); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
want := []string{"transform_request", "transform_response", "transform_stream_chunk",
|
||||
"transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble",
|
||||
"serialize_response", "deserialize_request"}
|
||||
var lack []string
|
||||
for _, f := range want {
|
||||
if !vm.HasFn("channels-zcode", f) {
|
||||
lack = append(lack, f)
|
||||
}
|
||||
}
|
||||
if len(lack) > 0 {
|
||||
t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+
|
||||
"每次调用都会报 'missing <fn>' ⇒ 渠道完全不可用", len(lack), lack)
|
||||
}
|
||||
// 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应
|
||||
out, err := vm.Transform("channels-zcode", "transform_response",
|
||||
`{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_response 调用失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "hi") {
|
||||
t.Errorf("基座 transform_response 未产出内容: %s", out)
|
||||
}
|
||||
t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out)
|
||||
}
|
||||
|
||||
func quoteLua(s string) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('"')
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
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,45 +5,6 @@ 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
|
||||
|
||||
@ -1,201 +0,0 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "buddy"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
|
||||
-- to the Anthropic Messages API internally, so they inherit Anthropic's
|
||||
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
|
||||
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
|
||||
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
|
||||
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
|
||||
-- is replayed to every other source, so one such id kills every Claude slot at
|
||||
-- once and an AUTO request falls through all tiers.
|
||||
--
|
||||
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
|
||||
-- matching tool_call_id are rewritten identically within one request. A
|
||||
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
|
||||
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
|
||||
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
|
||||
-- well-behaved traffic is byte-identical to before.
|
||||
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
|
||||
local TOOL_ID_MAX = 64
|
||||
|
||||
local function safe_tool_id(id)
|
||||
if type(id) ~= "string" or id == "" then return id end
|
||||
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
|
||||
if clean == id and #clean <= TOOL_ID_MAX then
|
||||
return clean
|
||||
end
|
||||
local digest = string.sub(sha256_hex(id), 1, 8)
|
||||
local keep = TOOL_ID_MAX - #digest - 1
|
||||
if #clean > keep then clean = string.sub(clean, 1, keep) end
|
||||
return clean .. "_" .. digest
|
||||
end
|
||||
|
||||
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
|
||||
--
|
||||
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
|
||||
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
|
||||
local NEED_EFFORT = {
|
||||
["glm-5.1"] = "medium",
|
||||
}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- The gateway's private control fields are GONE from the body (v2): the
|
||||
-- neutral `thinking` field carries that intent and each adapter maps it in
|
||||
-- its own protocol terms. These upstreams have no thinking switch at all,
|
||||
-- so the field is simply dropped here — which is what the nil-ing below
|
||||
-- used to do, except the field being dropped is no longer the kernel's
|
||||
-- private one.
|
||||
req.thinking = nil
|
||||
req.extra_body = nil
|
||||
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
|
||||
-- 否则上游回 400:
|
||||
-- {"code":11150,"msg":"the reasoning effort value is not supported
|
||||
-- by the current model"}
|
||||
-- 实测(2026-10-16,逐字对照实验):
|
||||
-- glm-5.1 裸(无该字段) → 400 11150 ✗
|
||||
-- glm-5.1 + reasoning_effort:medium → 200 ✓
|
||||
-- glm-5.1 空串 → 400 11150 ✗
|
||||
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
|
||||
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
|
||||
-- glm-5.1 **独有**的要求。
|
||||
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
|
||||
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
|
||||
-- 所有模型。
|
||||
if NEED_EFFORT[req.model or ""] and
|
||||
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
|
||||
req.reasoning_effort == nil then
|
||||
req.reasoning_effort = NEED_EFFORT[req.model]
|
||||
end
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
if msg.tool_call_id ~= nil then
|
||||
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
|
||||
end
|
||||
if type(msg.tool_calls) == "table" then
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- usage-only chunk is not a content/finish signal; the gateway
|
||||
-- emits its own terminal stop chunk and merges this usage.
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- Sanitize on the way OUT too: an id this upstream happily minted (it
|
||||
-- does not validate them) becomes a landmine once the client replays
|
||||
-- it to a Claude upstream. Only the first fragment of a streamed call
|
||||
-- carries an id; later argument fragments have none and are untouched.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
|
||||
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
|
||||
--
|
||||
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
|
||||
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
|
||||
-- "模型不回答",极易误判成模型坏了或额度不足。
|
||||
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
|
||||
--
|
||||
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
|
||||
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
|
||||
-- 是产品码(codebuddy)。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Accept"] = "text/event-stream",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
|
||||
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
|
||||
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. meta.api_key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -11,14 +11,8 @@ adapter.endpoint = "/chat/completions"
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。
|
||||
-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于
|
||||
-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** ——
|
||||
-- 剥掉恰恰会引发
|
||||
-- The `reasoning_content` in the thinking mode must be passed back to the API
|
||||
-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」
|
||||
-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。
|
||||
-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。
|
||||
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
|
||||
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
|
||||
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
|
||||
-- 否则 thinking 模式直接 400。
|
||||
--
|
||||
@ -225,50 +219,26 @@ function adapter.transform_request(raw_body)
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- ★ 必须**保留** reasoning_content(不再剥离)。
|
||||
--
|
||||
-- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池
|
||||
-- 不接受回传」。**生产实测证伪**(2026-10-16):
|
||||
-- provider localzen: api error 400: Upstream request failed:
|
||||
-- [invalid_request_error] The `reasoning_content` in the thinking
|
||||
-- mode must be passed back to the API.
|
||||
-- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** ——
|
||||
-- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、
|
||||
-- reasoning_effort 开启、长上下文)时,回放该历史才会被拒;
|
||||
-- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。
|
||||
-- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的
|
||||
-- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。
|
||||
--
|
||||
-- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器
|
||||
-- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
|
||||
-- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
|
||||
-- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。
|
||||
--
|
||||
-- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠
|
||||
-- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」
|
||||
-- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),
|
||||
-- 需要时缺了就是硬 400。
|
||||
if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
|
||||
msg.reasoning_content = nil
|
||||
local drop = false
|
||||
if type(msg.content) == "table" then
|
||||
local parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
|
||||
-- multimodal part not supported by zen
|
||||
-- multimodal part not supported by zen
|
||||
else
|
||||
table.insert(parts, part)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,506 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@ -1,318 +0,0 @@
|
||||
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("✓ 三类形状判据齐全,且均直接咬真实实现")
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
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>")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,76 +0,0 @@
|
||||
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,16 +1129,7 @@ func (p *Provider) buildHeaders(body, url, clientSession string) (http.Header, e
|
||||
h.Set(k, v)
|
||||
}
|
||||
}
|
||||
// 兜底鉴权:适配器没给鉴权头时,用源的 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 != "" {
|
||||
if h.Get("Authorization") == "" && p.cfg.APIKey != "" {
|
||||
h.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
}
|
||||
return h, nil
|
||||
@ -1468,18 +1459,8 @@ func (p *Provider) apiErrReason(status int, raw string) string {
|
||||
return fmt.Sprintf("api error %d: %s", status, types.OneLine(trimmed, 200))
|
||||
}
|
||||
}
|
||||
// ⚠️ 措辞必须**区分两种原因**(实测教训):适配器**有** transform_error 钩子
|
||||
// 但它返回 nil(不认识的错误体)是**常见**情况,而"真的没这个钩子"是另一回事。
|
||||
// 原先一律说 "lacks transform_error" —— 我照着这句话去适配器里找那个函数,
|
||||
// 查了几轮才发现函数明明在、只是抽不出消息。
|
||||
// 这里直接问内核要事实,不再猜(判据也据此断言)。
|
||||
if p.vm.HasFn(p.adapter, "transform_error") {
|
||||
log.Printf("[provider] unhandled upstream error body (adapter %q: transform_error returned no reason for this shape): status=%d body=%.300s",
|
||||
p.adapter, status, types.OneLine(raw, 300))
|
||||
} else {
|
||||
log.Printf("[provider] unhandled upstream error body (adapter %q defines no transform_error): status=%d body=%.300s",
|
||||
p.adapter, status, types.OneLine(raw, 300))
|
||||
}
|
||||
log.Printf("[provider] unhandled upstream error body (adapter %q lacks transform_error): status=%d body=%.300s",
|
||||
p.adapter, status, types.OneLine(raw, 300))
|
||||
return fmt.Sprintf("api error %d: unknown error", status)
|
||||
}
|
||||
|
||||
|
||||
@ -117,17 +117,13 @@ func (r *Registry) Resolve(model string) []*Provider {
|
||||
// pinning prefix and returns the bare model id when that source serves it;
|
||||
// otherwise it returns the input unchanged.
|
||||
func (r *Registry) EffectiveModel(model string) string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
sep := strings.IndexAny(model, "-:/")
|
||||
if sep < 1 || sep == len(model)-1 {
|
||||
return model
|
||||
}
|
||||
src, m := model[:sep], model[sep+1:]
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
// 与 ResolvePinned 同一个拆分器:源名可含 `-`,不能用第一个分隔符切。
|
||||
src, m, ok := splitPinned(model, r.providers)
|
||||
if !ok {
|
||||
return model
|
||||
}
|
||||
for _, p := range r.providers {
|
||||
if strings.EqualFold(p.Name(), src) && p.ModelByID(m) != nil {
|
||||
return m
|
||||
@ -139,16 +135,13 @@ func (r *Registry) EffectiveModel(model string) string {
|
||||
// ResolvePinned resolves "source-model" / "source:model" / "source/model" to
|
||||
// the exact source, or nil if the source does not serve that model.
|
||||
func (r *Registry) ResolvePinned(model string) *Provider {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
sep := strings.IndexAny(model, "-:/")
|
||||
if sep < 1 || sep == len(model)-1 {
|
||||
return nil
|
||||
}
|
||||
src, m := model[:sep], model[sep+1:]
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
src, m, ok := splitPinned(model, r.providers)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, p := range r.providers {
|
||||
if !strings.EqualFold(p.Name(), src) {
|
||||
continue
|
||||
@ -160,54 +153,6 @@ func (r *Registry) ResolvePinned(model string) *Provider {
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitPinned 把 "源名 + 分隔符 + 模型名" 拆开。
|
||||
//
|
||||
// ★★ 不能用 `IndexAny(model, "-:/")` 找**第一个**分隔符。
|
||||
// 源名本身常常含 `-`(本仓的渠道源是 `channels-<平台>-<账号id>`),
|
||||
// 于是 `channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 会被切成
|
||||
//
|
||||
// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
|
||||
//
|
||||
// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil ⇒ 客户端看到
|
||||
// `model "channels-trae-xxx:Doubao-Seed-2.1-Pro" is not configured`。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
// WebUI 的模型下拉框用 `源名:模型名` 作为 option value(这是**有意**的:
|
||||
// 44 个模型被多个源同时提供,必须能指定从哪个源调用),而网关解不开它。
|
||||
//
|
||||
// 修法:**按已知源名做最长前缀匹配**,命中的那个源名才是真正的分隔点。
|
||||
// 这样既支持含 `-` 的源名,也保留 "src:model" / "src/model" 等旧写法。
|
||||
func splitPinned(model string, providers []*Provider) (src, m string, ok bool) {
|
||||
// ① 最长源名前缀匹配(唯一可靠的办法:源名是已知集合)
|
||||
best := ""
|
||||
for _, p := range providers {
|
||||
n := p.Name()
|
||||
if n == "" || len(n) >= len(model) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(model), strings.ToLower(n)) {
|
||||
continue
|
||||
}
|
||||
// 源名之后必须紧跟一个分隔符
|
||||
if c := model[len(n)]; c != ':' && c != '/' && c != '-' {
|
||||
continue
|
||||
}
|
||||
if len(n) > len(best) {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return best, model[len(best)+1:], true
|
||||
}
|
||||
// ② 兜底:旧行为(第一个分隔符)。仅当没有任何源名匹配时才走到这里,
|
||||
// 保持对非源名形态(例如第三方模型名自带 ":" 的)的兼容。
|
||||
sep := strings.IndexAny(model, "-:/")
|
||||
if sep < 1 || sep == len(model)-1 {
|
||||
return "", "", false
|
||||
}
|
||||
return model[:sep], model[sep+1:], true
|
||||
}
|
||||
|
||||
// ProviderForModel returns the provider owning the model id (nil if unknown).
|
||||
// When the same model exists on multiple sources, returns the first (source
|
||||
// config order). Callers that need a specific source use ProviderForSlot.
|
||||
@ -260,10 +205,10 @@ type ModelKindInfo struct {
|
||||
|
||||
// ModelStatus is a web-UI friendly snapshot per source.
|
||||
type SourceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
Name string `json:"name"`
|
||||
Adapter string `json:"adapter"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Models []string `json:"models"`
|
||||
// ModelKinds parallels Models: Models[i] is ModelKinds[i].ID.
|
||||
//
|
||||
// It exists because a flat []string cannot answer "is gpt-image-2
|
||||
@ -272,15 +217,15 @@ type SourceStatus struct {
|
||||
// callers that only need names; this is purely additive and omitted when
|
||||
// empty, so an older UI keeps working.
|
||||
ModelKinds []ModelKindInfo `json:"model_kinds,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
LiveAvailable bool `json:"live_available"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastChecked int64 `json:"last_checked,omitempty"`
|
||||
FailCount int `json:"fail_count,omitempty"`
|
||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||
Permanent bool `json:"permanent,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
Healthy bool `json:"healthy"`
|
||||
MaxConcurrent int `json:"max_concurrent"`
|
||||
LiveAvailable bool `json:"live_available"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastChecked int64 `json:"last_checked,omitempty"`
|
||||
FailCount int `json:"fail_count,omitempty"`
|
||||
BackoffUntil int64 `json:"backoff_until,omitempty"`
|
||||
Permanent bool `json:"permanent,omitempty"`
|
||||
// RecentOK / RecentErr count real gateway requests served by this source
|
||||
// within the last 300s (drives the status column so a source that is
|
||||
// actually serving traffic can never be shown as down just because a
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
package provider
|
||||
|
||||
import "testing"
|
||||
|
||||
// ★ `源名:模型名` 这种限定写法,源名本身**含 `-`** 时必须仍能解开。
|
||||
//
|
||||
// 真实缺陷(用户报「无法使用 channel 注册的模型」,2026-10-17):
|
||||
//
|
||||
// WebUI 的模型下拉框用 `源名:模型名` 作为 option value —— 这是**有意**的:
|
||||
// 本实例有 44 个模型被多个源同时提供(glm-5.2 被 6 个源提供),
|
||||
// 用户必须能指定「从哪个源调用」。
|
||||
//
|
||||
// 但 ResolvePinned 用 `IndexAny(model, "-:/")` 找**第一个**分隔符,
|
||||
// 而渠道源名形如 `channels-trae-b8dd6b3df30e`(含 `-`)⇒ 被切成
|
||||
//
|
||||
// src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
|
||||
//
|
||||
// ⇒ 源名永不匹配 ⇒ Resolve() 返回 nil
|
||||
// ⇒ 客户端看到 `model "channels-trae-xxx:..." is not configured`。
|
||||
func TestResolvePinnedWithHyphenatedSourceName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sources map[string][]string // 源名 → 模型
|
||||
input string
|
||||
wantSrc string
|
||||
wantMdl string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "渠道源名含两个 -(真实形态)",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"Doubao-Seed-2.1-Pro", "glm-5.2"},
|
||||
"channels-buddy-buddy-main": {"hy4-preview", "glm-5.2"},
|
||||
},
|
||||
input: "channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro",
|
||||
wantSrc: "channels-trae-b8dd6b3df30e",
|
||||
wantMdl: "Doubao-Seed-2.1-Pro",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "同名模型跨源:必须能选中第二个源",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"glm-5.2"},
|
||||
"channels-buddy-buddy-main": {"glm-5.2"},
|
||||
},
|
||||
input: "channels-buddy-buddy-main:glm-5.2",
|
||||
wantSrc: "channels-buddy-buddy-main",
|
||||
wantMdl: "glm-5.2",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "斜杠写法仍要支持",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"glm-5.2"},
|
||||
},
|
||||
input: "channels-trae-b8dd6b3df30e/glm-5.2",
|
||||
wantSrc: "channels-trae-b8dd6b3df30e",
|
||||
wantMdl: "glm-5.2",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// 自带 ":" 的第三方模型名,且**没有**任何源名与之匹配 ⇒ 走旧兜底。
|
||||
// ⚠️ 不能拿 "our-free-model_cohere/..." 当例子:本实例真有一个叫
|
||||
// `our-free-model_cohere` 的源,而仓库里还有叫 `our` 的源 ——
|
||||
// 最长前缀匹配会正确命中 `our`,那个例子的预期是我写错了。
|
||||
name: "无源名匹配时走旧兜底",
|
||||
sources: map[string][]string{
|
||||
"channels-trae-b8dd6b3df30e": {"glm-5.2"},
|
||||
},
|
||||
input: "vendor-lib/thing:free",
|
||||
wantSrc: "vendor",
|
||||
wantMdl: "lib/thing:free",
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var ps []*Provider
|
||||
for name, models := range c.sources {
|
||||
ps = append(ps, newTestProvider(t, src(name, "http://127.0.0.1:1", "openai", models...)))
|
||||
}
|
||||
src, m, ok := splitPinned(c.input, ps)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok 应为 %v,实得 %v(输入 %q)", c.wantOK, ok, c.input)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if src != c.wantSrc {
|
||||
t.Fatalf("★ 源名解析错:期望 %q,实得 %q\n"+
|
||||
" 输入 %q —— 源名含 `-` 时不能按第一个分隔符切", c.wantSrc, src, c.input)
|
||||
}
|
||||
if m != c.wantMdl {
|
||||
t.Fatalf("模型名解析错:期望 %q,实得 %q", c.wantMdl, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
module qoder-wasm-probe
|
||||
|
||||
// 独立 module(不是主 module 的一部分):主仓 go.mod **不引入** wazero。
|
||||
// 本目录只用于**评测/取证**,`go build ./...` 与 `go test ./...` 在根目录
|
||||
// 会跳过嵌套 module,因此不会给内核带来任何依赖。
|
||||
go 1.25
|
||||
|
||||
require github.com/tetratelabs/wazero v1.9.0
|
||||
@ -1,2 +0,0 @@
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
@ -1,678 +0,0 @@
|
||||
// Command qoder-wasm-probe 评测 `qoder-auth-wasm.wasm` 能否被**纯 Go** wasm
|
||||
// 运行时(wazero,无 JS 引擎)驱动,并给出它实际需要宿主提供哪些能力。
|
||||
//
|
||||
// 这是**取证/评测工具**,不是生产代码:它不注册进网关,也不被内核 import。
|
||||
// 主仓的 go.mod **不包含** wazero —— 本目录是独立 module。
|
||||
//
|
||||
// 为什么需要它:qoder 的加密推理端点头由该 wasm 生成,
|
||||
// 而 wasm 是 wasm-bindgen 产物(31 个 import 全部来自一个 JS glue 模块)。
|
||||
// "能不能实例化" 与 "能不能真正跑出鉴权头" 是两件事 —— 上游参考甚至没有
|
||||
// 提供那份 JS glue(手写了 706 行替代),所以必须实测,不能推断。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run . -wasm /path/to/qoder-auth-wasm.wasm [-json]
|
||||
//
|
||||
// 退出码:0 = 三级证据全部通过;1 = 任一环节失败(详见输出)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const importModule = "./qoder_auth_wasm_bg.js"
|
||||
|
||||
type probe struct {
|
||||
ctx context.Context
|
||||
rt wazero.Runtime
|
||||
mod api.Module
|
||||
hits map[string]int
|
||||
order []string
|
||||
warns []string
|
||||
strict bool
|
||||
repeat int
|
||||
}
|
||||
|
||||
// ---- JS-ish 值模型 -------------------------------------------------------
|
||||
//
|
||||
// wasm-bindgen 的 ABI 把 JS 值当作整数索引传进导出表。纯 Go 没有这张表,
|
||||
// 宿主必须自己模拟。qoder 模块实际用到的形状只有三种:
|
||||
// 1. Uint8Array —— 字节缓冲
|
||||
// 2. Map —— requestresult_headers 的产物
|
||||
// 3. crypto 对象 —— 只用来喂 getRandomValues(见 C 类 import)
|
||||
|
||||
type (
|
||||
undefined struct{}
|
||||
u8arr []byte
|
||||
jsMap struct{ keys, vals []string }
|
||||
cryptoObj struct{}
|
||||
)
|
||||
|
||||
// wasm-bindgen 把索引 0..1027 保留给 undefined/null/true/false 等哨兵。
|
||||
const sentinelN = 1028
|
||||
|
||||
func newHeap() []interface{} {
|
||||
o := make([]interface{}, sentinelN, 4096)
|
||||
for i := range o {
|
||||
o[i] = undefined{}
|
||||
}
|
||||
o[1024], o[1025], o[1026], o[1027] = undefined{}, nil, true, false
|
||||
return o
|
||||
}
|
||||
|
||||
func main() {
|
||||
wasmPath := flag.String("wasm", "",
|
||||
"qoder-auth-wasm.wasm 路径(必填)")
|
||||
asJSON := flag.Bool("json", false, "以 JSON 输出结果")
|
||||
repeat := flag.Int("repeat", 1, "重复调用 prepareInferRequest 的次数(测实例复用)")
|
||||
strict := flag.Bool("strict", false,
|
||||
"任何未实现的 import 被调用即失败(默认只告警)")
|
||||
flag.Parse()
|
||||
if *wasmPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "-wasm is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
p := &probe{ctx: context.Background(), hits: map[string]int{}, strict: *strict, repeat: *repeat}
|
||||
res := p.run(*wasmPath)
|
||||
|
||||
if *asJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(res)
|
||||
} else {
|
||||
p.report(res)
|
||||
}
|
||||
if !res.OK() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type result struct {
|
||||
WasmPath string `json:"wasm_path"`
|
||||
WasmSize int `json:"wasm_size"`
|
||||
Imports int `json:"imports"`
|
||||
Exports int `json:"exports"`
|
||||
Instantiated bool `json:"instantiated"`
|
||||
CallsAtInstall int `json:"calls_at_instantiate"`
|
||||
AuthFields map[string]any `json:"generate_runtime_auth_fields"`
|
||||
AuthFieldLens map[string]int `json:"auth_field_lengths"`
|
||||
AuthErr string `json:"auth_error,omitempty"`
|
||||
InferURL string `json:"infer_url,omitempty"`
|
||||
InferBodyLen int `json:"infer_body_len"`
|
||||
InferHeaders map[string]string `json:"infer_headers,omitempty"`
|
||||
InferErr string `json:"infer_error,omitempty"`
|
||||
ImportHits map[string]int `json:"import_hits"`
|
||||
NeverCalled []string `json:"imports_never_called"`
|
||||
UnimplementedHit []string `json:"unimplemented_imports_hit,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
// MemPages 是跑完三级调用后 wasm 线性内存的页数(1 页 = 64 KiB)。
|
||||
MemPages uint32 `json:"mem_pages"`
|
||||
Repeat int `json:"repeat"`
|
||||
ElapsedMS float64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func (r result) OK() bool {
|
||||
return r.Instantiated && r.AuthErr == "" && r.InferErr == ""
|
||||
}
|
||||
|
||||
func (p *probe) heap() []byte { b, _ := p.mod.Memory().Read(0, p.mod.Memory().Size()); return b }
|
||||
|
||||
func (p *probe) readStr(ptr, ln uint32) string {
|
||||
if b, ok := p.mod.Memory().Read(ptr, ln); ok {
|
||||
return string(b)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *probe) warn(format string, a ...any) {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
p.warns = append(p.warns, msg)
|
||||
fmt.Fprintln(os.Stderr, "warn: "+msg)
|
||||
}
|
||||
|
||||
func (p *probe) run(path string) result {
|
||||
res := result{WasmPath: path, ImportHits: map[string]int{}}
|
||||
t0 := time.Now()
|
||||
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
res.InferErr = err.Error()
|
||||
return res
|
||||
}
|
||||
res.WasmSize = len(b)
|
||||
|
||||
p.rt = wazero.NewRuntime(p.ctx)
|
||||
defer p.rt.Close(p.ctx)
|
||||
|
||||
cm, err := p.rt.CompileModule(p.ctx, b)
|
||||
if err != nil {
|
||||
res.InferErr = "compile: " + err.Error()
|
||||
return res
|
||||
}
|
||||
imports := cm.ImportedFunctions()
|
||||
res.Imports = len(imports)
|
||||
res.Exports = len(cm.ExportedFunctions())
|
||||
|
||||
// ---- 宿主模块:31 个 import 的纯 Go 实现 ----
|
||||
host := p.rt.NewHostModuleBuilder(importModule)
|
||||
for _, f := range imports {
|
||||
_, name, _ := f.Import()
|
||||
params, results := f.ParamTypes(), f.ResultTypes()
|
||||
host.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(p.impl(name)), params, results).
|
||||
Export(name)
|
||||
}
|
||||
if _, err := host.Instantiate(p.ctx); err != nil {
|
||||
res.InferErr = "host module: " + err.Error()
|
||||
return res
|
||||
}
|
||||
|
||||
p.mod, err = p.rt.InstantiateModule(p.ctx, cm, wazero.NewModuleConfig().WithName("qoder"))
|
||||
if err != nil {
|
||||
res.InferErr = "instantiate: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.Instantiated = true
|
||||
res.CallsAtInstall = len(p.order) // 实例化阶段真实调用次数
|
||||
|
||||
p.level2(&res)
|
||||
if res.AuthErr == "" {
|
||||
for i := 0; i < p.repeat; i++ {
|
||||
p.level3(&res)
|
||||
if res.InferErr != "" {
|
||||
res.InferErr = fmt.Sprintf("iteration %d/%d: %s", i+1, p.repeat, res.InferErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.ElapsedMS = float64(time.Since(t0).Microseconds()) / 1000
|
||||
if mem := p.mod.Memory(); mem != nil {
|
||||
res.MemPages = mem.Size() / 65536
|
||||
}
|
||||
res.ImportHits = p.hits
|
||||
res.NeverCalled = p.neverCalled(imports)
|
||||
res.Warnings = p.warns
|
||||
return res
|
||||
}
|
||||
|
||||
// ---- 三级证据 ------------------------------------------------------------
|
||||
|
||||
// level2: generate_runtime_auth_fields —— 第一次真实调用。
|
||||
func (p *probe) level2(res *result) {
|
||||
payload := `{"uid":"probe-uid","security_oauth_token":"probe-token",` +
|
||||
`"organization_id":"","organization_tags":[],"data_policy_agreed":false}`
|
||||
ptr, ln := p.writeString(payload)
|
||||
raw, err := p.callString("generate_runtime_auth_fields", uint64(ptr), uint64(ln))
|
||||
if err != nil {
|
||||
res.AuthErr = err.Error()
|
||||
return
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
res.AuthErr = "non-JSON output: " + err.Error()
|
||||
return
|
||||
}
|
||||
res.AuthFields = map[string]any{}
|
||||
res.AuthFieldLens = map[string]int{}
|
||||
for k, v := range fields {
|
||||
// 只暴露类型与长度:字段本身是敏感材料,不该进日志。
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
res.AuthFields[k] = fmt.Sprintf("<string len=%d>", len(s))
|
||||
res.AuthFieldLens[k] = len(s)
|
||||
default:
|
||||
res.AuthFields[k] = fmt.Sprintf("<%T>", v)
|
||||
res.AuthFieldLens[k] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// level3: qodercontext_new + prepareInferRequest —— 产出真实加密请求。
|
||||
func (p *probe) level3(res *result) {
|
||||
userInfo := `{"uid":"probe-uid","encrypt_user_info":"probe","key":"probe",` +
|
||||
`"organization_id":"","organization_tags":[],"data_policy_agreed":false}`
|
||||
meta := `{"client_type":"10","business_product":"qoder",` +
|
||||
`"business_type":"default","scene":"chat"}`
|
||||
|
||||
mp, ml := p.writeString("probe-machine-uuid")
|
||||
vp, vl := p.writeString("1.1.49")
|
||||
up, ul := p.writeString(userInfo)
|
||||
tp, tl := p.writeString(meta)
|
||||
|
||||
sp := p.stackPtr()
|
||||
_, err := p.export("qodercontext_new").Call(p.ctx, uint64(sp),
|
||||
uint64(mp), uint64(ml), uint64(vp), uint64(vl),
|
||||
uint64(up), uint64(ul), uint64(tp), uint64(tl))
|
||||
v := p.dv()
|
||||
ctxPtr := int32(v.u32(sp))
|
||||
isErr := int32(v.u32(sp + 8))
|
||||
errIdx := int32(v.u32(sp + 4))
|
||||
p.stackFree()
|
||||
if err != nil || isErr != 0 {
|
||||
res.InferErr = fmt.Sprintf("qodercontext_new err=%v isErr=%d obj=%v",
|
||||
err, isErr, p.getObj(uint32(errIdx)))
|
||||
return
|
||||
}
|
||||
|
||||
hp, hl := p.writeString("api2.qoder.sh")
|
||||
bp, bl := p.writeString(`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||
kp, kl := p.writeString("qfmodel")
|
||||
skp, skl := p.writeString("system")
|
||||
|
||||
sp2 := p.stackPtr()
|
||||
_, err = p.export("qodercontext_prepareInferRequest").Call(p.ctx, uint64(sp2),
|
||||
uint64(ctxPtr), uint64(hp), uint64(hl), uint64(bp), uint64(bl),
|
||||
uint64(kp), uint64(kl), uint64(skp), uint64(skl))
|
||||
v2 := p.dv()
|
||||
resPtr := int32(v2.u32(sp2))
|
||||
rErr := int32(v2.u32(sp2 + 8))
|
||||
rErrIdx := int32(v2.u32(sp2 + 4))
|
||||
p.stackFree()
|
||||
if err != nil || rErr != 0 {
|
||||
res.InferErr = fmt.Sprintf("prepareInferRequest err=%v isErr=%d obj=%v",
|
||||
err, rErr, p.getObj(uint32(rErrIdx)))
|
||||
return
|
||||
}
|
||||
|
||||
hdrIdx, _ := p.export("requestresult_headers").Call(p.ctx, uint64(uint32(resPtr)))
|
||||
switch hm := p.getObj(uint32(hdrIdx[0])).(type) {
|
||||
case jsMap:
|
||||
res.InferHeaders = map[string]string{}
|
||||
for i := range hm.keys {
|
||||
k, val := hm.keys[i], hm.vals[i]
|
||||
// Authorization 是 WASM 产出的签名头:只报形状,不回显载荷。
|
||||
if len(val) > 24 {
|
||||
res.InferHeaders[k] = val[:24] + fmt.Sprintf("…<len=%d>", len(val))
|
||||
} else {
|
||||
res.InferHeaders[k] = val
|
||||
}
|
||||
}
|
||||
default:
|
||||
res.InferErr = fmt.Sprintf("requestresult_headers -> %T (expected jsMap)", hm)
|
||||
}
|
||||
|
||||
res.InferURL = p.resultString(resPtr, "requestresult_url")
|
||||
body := p.resultString(resPtr, "requestresult_body")
|
||||
res.InferBodyLen = len(body)
|
||||
res.Repeat = p.repeat
|
||||
}
|
||||
|
||||
func (p *probe) report(r result) {
|
||||
fmt.Printf("wasm : %s\n", r.WasmPath)
|
||||
fmt.Printf("size : %d bytes\n", r.WasmSize)
|
||||
fmt.Printf("imports : %d (all from %q)\n", r.Imports, importModule)
|
||||
fmt.Printf("exports : %d\n", r.Exports)
|
||||
fmt.Printf("instantiate: %v calls-at-instantiate=%d\n", r.Instantiated, r.CallsAtInstall)
|
||||
|
||||
fmt.Println("\n-- LEVEL 2: generate_runtime_auth_fields --")
|
||||
if r.AuthErr != "" {
|
||||
fmt.Println(" FAILED:", r.AuthErr)
|
||||
} else {
|
||||
ks := make([]string, 0, len(r.AuthFields))
|
||||
for k := range r.AuthFields {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
for _, k := range ks {
|
||||
fmt.Printf(" %-20s %s\n", k, r.AuthFields[k])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n-- LEVEL 3: qodercontext_new + prepareInferRequest --")
|
||||
if r.InferErr != "" {
|
||||
fmt.Println(" FAILED:", r.InferErr)
|
||||
} else {
|
||||
fmt.Printf(" url = %s\n", r.InferURL)
|
||||
fmt.Printf(" body = %d bytes (encrypted, not logged)\n", r.InferBodyLen)
|
||||
for _, k := range sortedKeys(r.InferHeaders) {
|
||||
fmt.Printf(" hdr %-20s %s\n", k, r.InferHeaders[k])
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n-- IMPORT USAGE (%d of %d touched) --\n", len(r.ImportHits), r.Imports)
|
||||
for _, k := range sortedKeys(r.ImportHits) {
|
||||
fmt.Printf(" %-50s %d\n", k, r.ImportHits[k])
|
||||
}
|
||||
fmt.Printf(" never called: %d\n", len(r.NeverCalled))
|
||||
for _, n := range r.NeverCalled {
|
||||
fmt.Printf(" %s\n", n)
|
||||
}
|
||||
if len(r.Warnings) > 0 {
|
||||
fmt.Printf("\n-- WARNINGS (%d) --\n", len(r.Warnings))
|
||||
for _, w := range r.Warnings {
|
||||
fmt.Printf(" %s\n", w)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nRESULT: %s\n", map[bool]string{true: "PASS", false: "FAIL"}[r.OK()])
|
||||
}
|
||||
|
||||
// ---- wasm-bindgen ABI 助手(照 upstream src/qoder-wasm.ts 的语义)---------
|
||||
|
||||
func (p *probe) export(n string) api.Function {
|
||||
f := p.mod.ExportedFunction(n)
|
||||
if f == nil {
|
||||
panic("missing export " + n)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (p *probe) dv() dataView {
|
||||
b, _ := p.mod.Memory().Read(0, p.mod.Memory().Size())
|
||||
return dataView(b)
|
||||
}
|
||||
|
||||
type dataView []byte
|
||||
|
||||
func (d dataView) u32(off uint32) uint32 { return binary.LittleEndian.Uint32(d[off:]) }
|
||||
|
||||
func (p *probe) stackPtr() uint32 {
|
||||
out, _ := p.export("__wbindgen_add_to_stack_pointer").Call(p.ctx, api.EncodeI32(-16))
|
||||
return uint32(api.DecodeI32(out[0]))
|
||||
}
|
||||
|
||||
func (p *probe) stackFree() {
|
||||
p.export("__wbindgen_add_to_stack_pointer").Call(p.ctx, api.EncodeI32(16))
|
||||
}
|
||||
|
||||
func (p *probe) writeString(s string) (uint32, uint32) {
|
||||
b := []byte(s)
|
||||
out, err := p.export("__wbindgen_export2").Call(p.ctx, uint64(len(b)), 1)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr := uint32(out[0])
|
||||
p.mod.Memory().Write(ptr, b)
|
||||
return ptr, uint32(len(b))
|
||||
}
|
||||
|
||||
// callString 对应「返回值布局 = ptr/len/valIdx/isErr」那一类函数。
|
||||
func (p *probe) callString(name string, args ...uint64) (string, error) {
|
||||
sp := p.stackPtr()
|
||||
defer p.stackFree()
|
||||
if _, err := p.export(name).Call(p.ctx, append([]uint64{uint64(sp)}, args...)...); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
v := p.dv()
|
||||
ptr, ln := int32(v.u32(sp)), int32(v.u32(sp+4))
|
||||
if isErr := int32(v.u32(sp + 12)); isErr != 0 {
|
||||
return "", fmt.Errorf("%s: error idx=%d (%v)", name, int32(v.u32(sp+8)), p.getObj(v.u32(sp+8)))
|
||||
}
|
||||
return p.readStr(uint32(ptr), uint32(ln)), nil
|
||||
}
|
||||
|
||||
// resultString 对应 `requestresult_url|body`:参数顺序是 (栈指针, ptr)。
|
||||
func (p *probe) resultString(result int32, name string) string {
|
||||
sp := p.stackPtr()
|
||||
defer p.stackFree()
|
||||
if _, err := p.export(name).Call(p.ctx, uint64(sp), uint64(uint32(result))); err != nil {
|
||||
p.warn("%s: %v", name, err)
|
||||
return ""
|
||||
}
|
||||
v := p.dv()
|
||||
ptr, ln := int32(v.u32(sp)), int32(v.u32(sp+4))
|
||||
if ptr == 0 {
|
||||
return ""
|
||||
}
|
||||
return p.readStr(uint32(ptr), uint32(ln))
|
||||
}
|
||||
|
||||
// ---- 对象堆 --------------------------------------------------------------
|
||||
|
||||
var objects = newHeap()
|
||||
|
||||
func pushObj(v any) uint32 {
|
||||
objects = append(objects, v)
|
||||
return uint32(len(objects) - 1)
|
||||
}
|
||||
|
||||
func (p *probe) getObj(i uint32) any {
|
||||
if int(i) < len(objects) {
|
||||
return objects[i]
|
||||
}
|
||||
return undefined{}
|
||||
}
|
||||
|
||||
var dbg = os.Getenv("PROBE_DBG") != ""
|
||||
|
||||
func (p *probe) takeObj(i uint32) {
|
||||
if dbg && i >= sentinelN {
|
||||
fmt.Printf("[dbg] drop %d (was %T)\n", i, p.getObj(i))
|
||||
}
|
||||
if i >= sentinelN && int(i) < len(objects) {
|
||||
objects[i] = undefined{}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *probe) asBytes(i uint32) u8arr {
|
||||
switch v := p.getObj(i).(type) {
|
||||
case u8arr:
|
||||
return v
|
||||
case []byte:
|
||||
return u8arr(v)
|
||||
}
|
||||
p.warn("heap[%d] is %T, expected Uint8Array", i, p.getObj(i))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 31 个 import 的纯 Go 实现 -------------------------------------------
|
||||
//
|
||||
// 分四类(与 docs/qoder-wasm-integration.md 的接口映射表对应):
|
||||
// A bindgen 对象表 · B 字节缓冲 · C 随机/时间 · D JS 环境探针
|
||||
|
||||
func (p *probe) impl(name string) func(context.Context, api.Module, []uint64) {
|
||||
mark := func(n string) {
|
||||
p.hits[n]++
|
||||
if len(p.order) < 4096 {
|
||||
p.order = append(p.order, n)
|
||||
}
|
||||
}
|
||||
wrap := func(f func(s []uint64)) func(context.Context, api.Module, []uint64) {
|
||||
return func(_ context.Context, _ api.Module, s []uint64) {
|
||||
mark(name)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
p.warn("host func %s panicked: %v", name, r)
|
||||
}
|
||||
}()
|
||||
f(s)
|
||||
}
|
||||
}
|
||||
// 未在 auth 路径上验证过的 import:被调用就是新信息,必须记下来。
|
||||
unimpl := func(what string) func(context.Context, api.Module, []uint64) {
|
||||
return wrap(func(s []uint64) {
|
||||
p.warn("import %s reached (%s) -- not exercised by the auth path", name, what)
|
||||
if p.strict {
|
||||
panic("unimplemented import reached: " + name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
switch name {
|
||||
// -- A: bindgen 对象表 ------------------------------------------------
|
||||
case "__wbindgen_object_drop_ref":
|
||||
// ⚠️ 必须**不释放**槽位:实测该模块会 drop 一个 Map 之后继续用同一索引
|
||||
// 调 set(日志见 docs)。wasm-bindgen 新版把 drop 退化为空操作
|
||||
// (externref 表交给 JS GC),因此这里保持 no-op —— 代价是索引只增不回收。
|
||||
return wrap(func(s []uint64) {})
|
||||
case "__wbindgen_object_clone_ref":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(p.getObj(uint32(s[0])))) })
|
||||
case "__wbindgen_cast_0000000000000001": // wasm slice -> Uint8Array
|
||||
return wrap(func(s []uint64) {
|
||||
a, e := uint32(s[0]), uint32(s[1])
|
||||
dst := make(u8arr, e)
|
||||
copy(dst, p.heap()[a:a+e])
|
||||
s[0] = uint64(pushObj(dst))
|
||||
})
|
||||
case "__wbindgen_cast_0000000000000002": // wasm str -> JS string
|
||||
return wrap(func(s []uint64) {
|
||||
s[0] = uint64(pushObj(p.readStr(uint32(s[0]), uint32(s[1]))))
|
||||
})
|
||||
|
||||
// -- B: 字节缓冲 ------------------------------------------------------
|
||||
case "__wbg_new_with_length_9cedd08484b73942":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(make(u8arr, uint32(s[0])))) })
|
||||
case "__wbg_length_0c32cb8543c8e4c8":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(len(p.asBytes(uint32(s[0])))) })
|
||||
case "__wbg_subarray_0f98d3fb634508ad":
|
||||
return wrap(func(s []uint64) {
|
||||
b := p.asBytes(uint32(s[0]))
|
||||
lo, hi := uint32(s[1]), uint32(s[2])
|
||||
if hi > uint32(len(b)) {
|
||||
hi = uint32(len(b))
|
||||
}
|
||||
if lo > hi {
|
||||
lo = hi
|
||||
}
|
||||
s[0] = uint64(pushObj(b[lo:hi]))
|
||||
})
|
||||
case "__wbg_set_08463b1df38a7e29":
|
||||
// 两种形态:Uint8Array.set(src, off) 与 Map.set()(headers 集合)。
|
||||
return wrap(func(s []uint64) {
|
||||
switch dst := p.getObj(uint32(s[0])).(type) {
|
||||
case jsMap:
|
||||
k, ok := p.getObj(uint32(s[1])).(string)
|
||||
if !ok {
|
||||
k = fmt.Sprint(p.getObj(uint32(s[1])))
|
||||
}
|
||||
dst.keys = append(dst.keys, k)
|
||||
dst.vals = append(dst.vals, fmt.Sprint(p.getObj(uint32(s[2]))))
|
||||
objects[s[0]] = dst
|
||||
case u8arr:
|
||||
src := p.asBytes(uint32(s[1]))
|
||||
off := int(uint32(s[2]))
|
||||
if off > len(dst) {
|
||||
return
|
||||
}
|
||||
copy(dst[off:], src)
|
||||
default:
|
||||
if dbg {
|
||||
fmt.Printf("[dbg] set arg0=%d type=%T arg2=%d\n", s[0], p.getObj(uint32(s[0])), s[2])
|
||||
}
|
||||
p.warn("set on unsupported %T", p.getObj(uint32(s[0])))
|
||||
}
|
||||
})
|
||||
case "__wbg_prototypesetcall_3e05eb9545565046":
|
||||
return wrap(func(s []uint64) {
|
||||
a, e := uint32(s[0]), uint32(s[1])
|
||||
copy(p.heap()[a:a+e], p.asBytes(uint32(s[2])))
|
||||
})
|
||||
case "__wbg_new_99cabae501c0a8a0": // new Map()
|
||||
return wrap(func(s []uint64) {
|
||||
idx := pushObj(jsMap{})
|
||||
if dbg {
|
||||
fmt.Printf("[dbg] newMap -> %d\n", idx)
|
||||
}
|
||||
s[0] = uint64(idx)
|
||||
})
|
||||
|
||||
// -- C: 随机源与时间 --------------------------------------------------
|
||||
//
|
||||
// ⚠️ 两个 getRandomValues 的签名**方向相反**(upstream 注释里踩过的坑):
|
||||
// _d49329ff89a07af1 = (wasmPtr, len) -> 直接写 wasm 内存
|
||||
// _c44a50d8cfdaebeb = (cryptoObjIdx, u8arrIdx) -> 调 JS 对象的方法
|
||||
// 写反会得到 Rust panic `unreachable`。
|
||||
case "__wbg_getRandomValues_d49329ff89a07af1":
|
||||
return wrap(func(s []uint64) {
|
||||
b := make([]byte, uint32(s[1]))
|
||||
rand.Read(b)
|
||||
p.mod.Memory().Write(uint32(s[0]), b)
|
||||
})
|
||||
case "__wbg_getRandomValues_c44a50d8cfdaebeb":
|
||||
return wrap(func(s []uint64) {
|
||||
if _, ok := p.getObj(uint32(s[0])).(cryptoObj); !ok {
|
||||
p.warn("getRandomValues(B): arg0 is %T, want cryptoObj", p.getObj(uint32(s[0])))
|
||||
}
|
||||
if b := p.asBytes(uint32(s[1])); b != nil {
|
||||
rand.Read(b)
|
||||
}
|
||||
})
|
||||
case "__wbg_randomFillSync_6c25eac9869eb53c":
|
||||
return wrap(func(s []uint64) {
|
||||
if b := p.asBytes(uint32(s[1])); b != nil {
|
||||
rand.Read(b)
|
||||
}
|
||||
})
|
||||
case "__wbg_now_88621c9c9a4f3ffc":
|
||||
return wrap(func(s []uint64) {
|
||||
s[0] = math.Float64bits(float64(time.Now().UnixMilli()))
|
||||
})
|
||||
|
||||
// -- D: JS / Node 环境探针 --------------------------------------------
|
||||
case "__wbg_crypto_38df2bab126b63dc", "__wbg_msCrypto_bd5a034af96bcba6":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(cryptoObj{})) })
|
||||
case "__wbg_static_accessor_GLOBAL_THIS_a1248013d790bf5f",
|
||||
"__wbg_static_accessor_GLOBAL_f2e0f995a21329ff":
|
||||
return wrap(func(s []uint64) { s[0] = uint64(pushObj(undefined{})) })
|
||||
case "__wbg_static_accessor_SELF_24f78b6d23f286ea",
|
||||
"__wbg_static_accessor_WINDOW_59fd959c540fe405":
|
||||
// 服务端没有 window/self:wasm-bindgen 约定用 0 表示缺席。
|
||||
return wrap(func(s []uint64) { s[0] = 0 })
|
||||
case "__wbg___wbindgen_is_object_40c5a80572e8f9d3":
|
||||
return wrap(func(s []uint64) {
|
||||
switch p.getObj(uint32(s[0])).(type) {
|
||||
case u8arr, jsMap, cryptoObj:
|
||||
s[0] = 1
|
||||
}
|
||||
})
|
||||
case "__wbg___wbindgen_is_string_b29b5c5a8065ba1a":
|
||||
return wrap(func(s []uint64) {
|
||||
if _, ok := p.getObj(uint32(s[0])).(string); ok {
|
||||
s[0] = 1
|
||||
}
|
||||
})
|
||||
case "__wbg___wbindgen_is_undefined_c0cca72b82b86f4d":
|
||||
return wrap(func(s []uint64) {
|
||||
if _, ok := p.getObj(uint32(s[0])).(undefined); ok {
|
||||
s[0] = 1
|
||||
}
|
||||
})
|
||||
case "__wbg___wbindgen_throw_81fc77679af83bc6":
|
||||
return wrap(func(s []uint64) {
|
||||
p.warn("wasm throw: %s", p.readStr(uint32(s[0]), uint32(s[1])))
|
||||
})
|
||||
case "__wbg_Error_2e59b1b37a9a34c3":
|
||||
return wrap(func(s []uint64) {
|
||||
s[0] = uint64(pushObj(fmt.Errorf("%s", p.readStr(uint32(s[0]), uint32(s[1])))))
|
||||
})
|
||||
case "__wbg_process_44c7a14e11e9f69e", "__wbg_versions_276b2795b1c6a219",
|
||||
"__wbg_node_84ea875411254db1", "__wbg_require_b4edbdcf3e2a1ef0",
|
||||
"__wbg_call_d578befcc3145dee", "__wbg___wbindgen_is_function_49868bde5eb1e745":
|
||||
return unimpl("node/JS 环境探针")
|
||||
}
|
||||
return unimpl("未知")
|
||||
}
|
||||
|
||||
func (p *probe) neverCalled(imports []api.FunctionDefinition) []string {
|
||||
var out []string
|
||||
for _, f := range imports {
|
||||
_, name, _ := f.Import()
|
||||
if p.hits[name] == 0 {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
Reference in New Issue
Block a user