Compare commits

..

71 Commits

Author SHA1 Message Date
1f29be43db feat(channels): credits_pool_reason —— 区分「真的用尽」与「查不到」
## 为什么需要(参考同款修复,用户报障 2026-09-29)
把两者混成一句"额度都已用尽"会让**一次网络抖动伪装成钱花完了** —— 用户于是
去解锁永久积分、或白等到明天,而号里其实有钱。两者的**正确动作完全相反**:
  exhausted → 等额度重置
  unknown   → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
  locked    → 可解锁永久积分(同样不是"用尽")

我们此前**没有**这个出口:`credits_balances` 只报逐账号错误,调用方拿不到
"为什么整池不可用"的结论,只能自己猜。

## 实现
新增 `channels/credits/pool_reason`,返回 `reason` ∈
`no_accounts | unknown | usable | locked | exhausted` + 可读 detail。
判定优先级(顺序即语义):
  no_accounts(无启用账号)
  → unknown(平台未声明余额端点 —— **无法判定 ≠ 用尽**)
  → usable(有可用账号)
  → unknown(**有查询失败** —— 不能当成用尽)
  → locked(锁定且只剩永久积分)
  → exhausted(都查到了且确为 0)

## 判据(6 条 + 变异 2/2)
internal/core/channels_pool_reason_test.go
- 余额确为 0 → exhausted
- 查询失败 → unknown(且说明必须点明"查询失败")
- 无启用账号 → no_accounts
- 有钱 → usable
- 无余额端点 → unknown(不编造用尽)
- 锁定且只剩永久积分 → locked,且**解锁后同一份余额变 usable**
  (证明 locked ≠ exhausted)
变异:查询失败也报 exhausted → 报红;locked 混同 exhausted → 报红。

## 踩到的坑(都是我的判据/夹具问题,非实现)
① 夹具用扁平 `{"total":100}`,而 buddy 的余额是**深层嵌套**
   `data.Response.Data.Accounts[]`(每包含 Status/CycleCapacityRemain/
   DeductionEndTime)—— 夹具形状不对导致全部走 unknown。
② `addAccount` 写死 platform="buddy",于是"无余额端点"那条用例在 minimax 上
   其实没有账号,命中优先级更高的 no_accounts(**正确**行为)却被判成错。
   加 `addAccountOn(platform, id)` 修正。
③ Lua 里嵌套引号:`"(...不是"额度用尽")"` 语法错 ⇒ 改用中文引号「」。
2026-10-16 09:00:47 +08:00
8109acf7cd feat(trae): batch 模型目录(含参考记录的两个真实缺陷修复 + 元数据)
我们此前 trae 的模型列表是**纯硬编码**(32 项名字),既没有真实元数据,也没打
batch 端点。参考的 parseTraeBatchModelList 记录了**两个已修的缺陷**,移植时
必须一起带上,否则等于把 bug 再引进一遍:

## ① 通道白名单是「准入」而不只是「排序」(issue IKJOZ7)
22 个 function 里有一半在本插件推理端点下**不可调用**(实测 chat→4023
model unknown、builder→4001、inline_chat→3003)。只把白名单用于"同模型择优"
的旧写法 = "目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,而错误文案
指向**模型**,极易被误判成"这个模型坏了"。现在不在表内的通道**整组丢弃**。

## ② 空档位不得覆盖有档位(issue IKI7WT/IKILR7)
旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据**相反**
—— 上游把空档位的 solo_work_lite/solo_design_remote 排在**最后**,实测 13 个
模型因此丢掉 reasoning_effort_config、全部显示"未提供推理等级"。
合并规则(与参考逐条对齐):空档位不得覆盖有档位;两侧都有档位时按通道优先级
取更靠前者;其余保持"后覆盖前"。

## ③ is_custom_model 是「仅可见但不可调用」的权威判据
该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。
⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志随服务端下发变化,
曾据名字把 qwen3.8-flash 误记为应剔除(它其实正常可用)。

## 附带:远端目录**元数据**不再丢
原来目录只回 id ⇒ 参考能提供的上下文窗口/输出上限/思考档位/消耗倍率全丢。
现按参考存下完整条目并在 model_list 以 `remote` 字段报出。
⚠ contextWindow 取 `context_window_tokens.dev` 而非 `max`:max 需开启
display_config.max_mode 才可用(我们不实现该开关),采信 max 会让上层以为有
1M 窗口、实际被上游拒。

## 基础设施
- refreshRemoteModels 支持 ep.body(POST 型目录端点)与 ep.sign(复用平台既有
  的头部构造,不另写一套 —— 否则"chat 能通、目录 401"极难查)。
- trae 走 sign="trae_solo" 复用 traeCheckinHeaders(Cloud-IDE-JWT + 约 20 个
  客户端头;参考实测简化头会被拒)。
- 新增 __trae_entry_probe(诊断"合并规则选中了哪条"),此前只能靠猜。

## 判据(3 条 + 变异 3/3)
internal/core/channels_trae_catalog_test.go
- 白名单准入 + 档位来源(空档位不得覆盖有档位)
- 四条硬性过滤(usage/config_switch/is_invisible_to_user/is_custom_model)
  且 **is_custom_model=false 的 custom_model_* 条目必须保留**(判据是标志值)
- 请求体必须传全部 22 个 function(与真实 IDE 一致;只传聊天通道会让非聊天
  通道条目位置错乱甚至被跳过)
变异:白名单不做准入 → 报红;无条件后覆盖前 → 报红;不过滤 custom → 报红。

★ 调试中发现并修掉我自己写的一个**静默失效**:
`declaresEffort` 读 `e.reasoning_effort_config` 而 `parseEntry` 存的是
`e.reasoning` ⇒ 判定恒为 false ⇒ 规则①永不生效(正是它要防的缺陷)。
判据当场抓到。**教训:跨函数用同一份数据时,字段名必须对齐 —— 这类不一致
不会报错,只会让规则静默失效。**

验证:internal/core 369s 全绿。
2026-10-16 08:28:36 +08:00
7c82c7ff6b fix(channels): account_clear_credentialless 尊重 dry_run(args=req 回退吃掉 body)
## 真实缺陷(我自己在上一个提交里引入,部署后实测踩到)
handler 开头照其它 18 处写了 `if args.platform == nil then args = req end`。
但本函数**不传 platform 是合法用法**(清全部平台)⇒ args.platform 自然为 nil
⇒ args 被换成 HandlerRequest ⇒ 刚解出来的 dry_run 就丢了 ⇒
**传 dry_run=false 却被当成未传,静默只预演**。

破坏性操作里这是最糟的形态:操作者以为执行了、实际没执行(反过来同样危险)。
实测:不带 platform 时 deleted 恒为 nil,池子一个没动。

## 修法
兜底只认「req 本身就是参数表」这种形态(`req.body == nil and req.query == nil`),
不再用「某字段为 nil」当判据 —— 那个字段为 nil 可能只是本次调用**合法地没用它**。

## ★ 判据为什么第一版没咬住
我原来的夹具用 **map body** 调 Call,而缺陷只在**字符串 body**(HTTP 真实路径)
上暴露 —— `bodyOf` 对 table 直接返回,根本走不到回退那行。
补 `TestAccountClearCredentiallessRespectsDryRunViaStringBody`(用
`InvokeHandler` + 字符串 body + 不传 platform)后变异才咬住。
**教训:夹具的调用形态必须与生产一致 —— 用 map 便捷调用会绕过 body 解析路径。**

判据 4 条(含本条)+ 变异 3/3。
2026-10-16 07:51:21 +08:00
1f18643d1f feat(channels): 无凭据账号的批量清理能力(dry_run 默认开启)
## 为什么需要
`account_add` 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
于是"先建占位、再走登录补齐"是正常用法。但登录没走完时(用户放弃、或历史批量
导入)会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
(面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"难以判断。

实测生产:13 个账号里只有 1 个真正可用 —— codearts 10 个 + trae 2 个全是占位
(同秒批量创建、nickname=id、凭据库里无对应条目)。

## 安全性(破坏性操作,两条性质都被变异验证钉住)
① **只删没有凭据的**:有凭据的一律不动(哪怕它测试失败 —— 那是"可疑"而非
   "空壳",处置完全不同);
② **dry_run 默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
③ 复用 `plugin.account_delete`(同一份删除实现)—— 删除时要顺带做的事
   (drop source、清旧单账号源、删凭据)不另写一套,否则必然漂移。

## ★ 踩到 Lua 顶层 local 上限(第 13 次同类)
helper 写成顶层 `local function accountHasCredential` 后 `luac -p` 直接报
`too many local variables (limit is 200)`。改为挂在 plugin 表上(`plugin.__hasCredential`)
—— 同样只有一份实现,且不占顶层名额。**每次给本文件加顶层 local 前必须 luac -p。**

## 判据(3 条 + 变异 2/2)
internal/core/channels_clear_credentialless_test.go
- 只删空壳、保留有凭据的,且**有凭据者的凭据必须仍在**(防误删)
- 指定 platform 时范围隔离(不动别的平台)
- 未知平台必须报错(否则会误删全部平台)
变异:无条件全删 → 报红;dry_run 默认改 false → 报红。
2026-10-16 07:45:52 +08:00
e6fa2a678f feat(zcode): 3012 判据/文案/诊断模块(照参考逐行移植,含对抗性审计反例)
照参考 zcode-diagnostics.ts 移植,作为**两处消费者共用的一份源码文本**:
① 插件主体(领取路径 claimZcode);② channels-zcode 适配器(推理路径)。
共用是必须的:3012 有**账号冷却惩罚**(30 分钟起步,反复触发升级到 24 小时乃至
停用),"请勿连续重试"这句话绝不能只在一条路径上(参考为此专门抽成单一份)。

## 为什么这次移植的不是"锦上添花"
我们原来的领取路径是 `code == 3012 or msg:find("unusual activity")`,而参考用
**对抗性审计**打出了两个方向的真实缺陷(2026-10-05,实测记录在源码注释里):

**误判方向**(把限流当风控):两者用户动作**完全相反**(稍后重试 vs 别重试),
且领取路径据此**短路**、跳过本可领取的 plan。实测反例:
  `too many requests, retry after 3012 ms`        ← 毫秒数被当风控
  `<h1>Error 3012</h1>upstream connect timeout`   ← HTML 里的数字
  `rate limited: request blocked, retry after 3012 ms`  ← block 距 14 字符
  `temporarily blocked; see error 3012`                 ← block 距 12 字符
  `plan-3012-trust` / `trust_3012` / `x3012y` / `13012` ← 标识符形态

**漏判方向**(真风控被放过):上游 3012 的 msg 原文
`request has been blocked due to unusual activity.` **不含任何数字**(数字在 code
字段里)⇒ "必须有独立词 3012"这条门禁会反过来把它漏掉。

⇒ 判据以**语义短语**为主、数字只作兜底且必须与 block **紧邻(≤10 字符)**;
词边界集必须含 `-`/`_`(参考两版才收全:先只排数字 ⇒ plan-3012 仍命中;
再补字母 ⇒ 仍命中,因连字符不属 [0-9A-Za-z])。
另:业务码认**纯数字字符串**(上游回 {"code":"3012"} 时,只认 number 会让风控码
整条丢失 ⇒ 用户看到那句裸上游串)。

## 一并移植的其它实测教训
- `formatGap`:分钟档必须**逐档单独算**。参考初版写 `floor(seconds % 60)`,
  90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)——该错误是在真实探针
  输出里发现的,纯函数单测当时没覆盖 ≥60 秒区间。
- 诊断的「距上条」必须用**单独存的 previousSentAt**,不能拿 lastSentAt 顶替
  (调用顺序是"先记本次、再诊断",顶替会让它恒为 0ms、永远没有信息量)。
- 形态诊断必须读**真正要发出去的那个对象**,不读常量:常量只能证明"源码里写了
  多少字",证明不了"线上发出去多少字";空 system 时必须**如实显示 0**。
- 诊断行**绝不写凭据**:账号只以进程内自增序号出现。

## ★ 接线上的一个真实 bug(我自己引入并自查出来)
`installZcodeAdapter` 最初把 DIAG 源码直接 `.. ZCODE_ADAPTER_SRC` 拼接 ——
而 DIAG 末尾是 `return Z`,直接拼会让那个 return 结束**整个 chunk** ⇒ 适配器
代码永不执行,且**装载会静默成功**("加载成功但适配器不存在")。
修法:包进 `(function() ... end)()` 让 return 只结束该函数。

## 判据
`internal/core/channels_zcode_3012_test.go`(走共用探针打**那一份**实现):
- 19 条分类矩阵(含上列全部审计反例)
- formatGap 逐档(含 90000ms 必为「1分30秒」)
- 诊断行凭据泄漏守卫(账号 id 的每个 ≥4 字符子串都不得出现)
- 文案共用 + 必含「请勿连续重试」+ 空参不留空壳
- 形态诊断读实际对象(空 system 必须显示 0)

★ 变异 2/2 咬住。其中分类矩阵**第一版没咬住** —— 因为"紧邻检查"独立挡住了
那些用例,从未**隔离**词边界规则;补 5 个"block 紧邻但 3012 在标识符里"的
用例后才有效。教训:**必须有一组用例只被被测规则区分**,否则别的守卫会把它
的失效掩盖掉(本轮第 4 次"判据太弱")。

验证:internal/core 351s 全绿。
2026-10-16 01:38:34 +08:00
b871c0c713 fix(zcode): 日期块对齐参考的两条规则(幂等 + 只看第一条),修 off-by-one
通读参考 zcode-identity.ts 的 withContextPrefix 后发现我的实现漏了两条官方行为,
且其中一条藏着真实 bug:

## ① ★ 幂等判断 off-by-one(真 bug)
`"<system-reminder>"` 是 **17** 个字符,而我写的是 `c:sub(1, 16) == SR`
—— 判断**永远为假** ⇒ 幂等完全失效:同一请求重试/多次 transform 时日期块
会叠加成多层,改变 content 的前缀结构(上游检查的正是这个结构)。
修法:用 `#SR` 算长度,不写死数字。

## ② 只看**第一条**消息,且它必须 role == "user"
我原来实现成「往后扫第一个 user 消息」。官方行为是:只处理 `messages[0]`,
且若它不是 user 就**完全不动** —— 不是去找后面那条 user。已对齐。

## ③ 其它类型兜底
参考对非字符串/非数组的 content 也会置为 `[prefix]`,照做。

判据 2 条(新增):
- TestZcodeContextPrefixIsIdempotent     已含日期块时不重复插
- TestZcodeContextPrefixOnlyTouchesFirstMessage  首条非 user 时不动、首条 user 时插入
变异 2/2 咬住:把 #SR 改回写死的 16 → 报红;去掉幂等判断 → 报红。

★ 我自己在写这条判据时又犯了一个错并当场自查出来:断言「文本 content 应保持
字符串」,而 anthropic 基座把文本转成块数组是**正常**的协议转换行为 —— 差点把
正确的实现判成错。判据要验的是「**日期块**有没有被插进来」这个事实,不是
content 的容器类型。

验证:internal/core 335s + internal/gateway 207s 全绿。
2026-10-16 01:13:11 +08:00
8b318a8507 feat(zcode): 身份块适配器 + 修复调度器泄漏 + codearts 目录 + 备份裁剪泛化
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:

    无 system                        → 3012 ✗
    仅 cliPrefix(42 字符)           → 3012 ✗
    cliPrefix + stable(约 2898 字符)→ 200  ✓

⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。

实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
  block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
  / block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
  只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
  日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。

判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。

## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。

## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
  ① opengw.../gateway/config  → result.models(benefit)
  ② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。

## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。

## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。

## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。

清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
2026-10-16 00:56:14 +08:00
69da3c9b92 fix(kernel): Close() 从不调 on_unload —— 孤儿进程与回调文件泄漏
## 症状
本机积累 **995 个 llmsproxy-loopback.py 进程**(828 个超 24 小时),全是
/tmp/Test*/ 与 .build-work/Test*/ 下的测试残留;生产 /etc/llmsproxy/plugins/
另有 **18 个孤儿 callback-*.port**(对应 .json 早已消失),且永远清不掉。

## 真因(两层)
1. **回环回收本是惰性的**:reapLoopback 只在**会话过期**时被 sweepSessions
   触发。测试跑完直接退出 ⇒ 会话从未「过期」⇒ 进程常驻。生产会话真会过期,
   所以生产不漏进程 —— 但见第 2 条。
2. **★ 根因在内核**:Plugins.Close() 只关 shard 与 saver,**从不调用
   on_unload** —— 而 on_unload 只在**单插件 Unload** 时调。于是「进程关闭/
   测试结束」这条路径上,起了 OS 进程的插件永远收不了尾。插件的
   plugin.on_unload(回收浏览器栈 + 回环)写得完全正确,只是从不被调用。
   这是「两处只修一处」缺陷的又一例:Unload 有、Close 没有。

## 修法
- 抽 invokeOnUnload(p, name) 助手,**Unload 与 Close 共用同一份实现**
  (两处各写一遍必然漂移,本次缺陷形状正是如此);Close() 新增
  callOnUnloadAll()。
- ★ 顺序:shards.Shutdown() → save.Close()(落盘)→ callOnUnloadAll()。
  落盘必须在前 —— saver 的最终写入要读插件的 Lua 表,on_unload 之后 state
  已关,读到的就是空,最后一个区间的累积静默丢失(TestCloseFlushesTail 正是
  为守住这一点而存在)。我第一版把顺序写反,被该测试当场咬住。
- channels:启动清扫上一进程留下的孤儿 callback-*.port/.json。loopbacks
  追踪表只活在内存,进程被强杀时 on_unload 没机会跑,重启后内存表为空 ⇒
  reapLoopback 无从知晓 ⇒ 那 18 个文件永久残留。
- channels:补 reap_loopbacks 端点(生产运维也可用:不必等会话过期)。
- channels:__set_loopback_probe 从**覆盖**改为**合并**条目 —— 覆盖会丢掉
  真实会话的 pid/port_file,让 reapLoopback 杀不掉接收器,**探针自己成为
  泄漏源**(实测:其他测试已归零,只有它留 1 个孤儿)。

## 判据(均做变异验证)
- internal/lua/plugin_unload_on_close_test.go
    TestPluginsCloseCallsOnUnload      看真实外部效应(插件在 on_unload 写磁盘
                                       marker),而非「钩子被调用过」这类内部信号
    TestPluginsCloseSurvivesFailingOnUnload  best-effort 契约
- internal/core/channels_orphan_callback_test.go
    TestPluginLoadSweepsOrphanCallbackFiles  看目录真实状态:孤儿必须消失,
                                       非回调文件(.channels-store.json)不得误删
- 变异均咬住:注释掉 callOnUnloadAll → 报红;清扫改 no-op → 报红。

## 实测
修复前跑一次起会话的测试批次留 7×N 个残留进程 → 修复后 **0 个**;全量回归
(go test -tags luajit ./... 10 包)后回环/VNC 残留均为 0。

## 过程中自查出的两处疏漏(已修)
1. 判据顺序又写反一次(同上 TestCloseFlushesTail 咬住)。
2. **两枚变异残渣留在工作区**未还原:internal/gateway/discover.go 里
   `add(raw, "/models", 0)` 被删、`classifyAuthStatus` 的 `|| len(body) >= 0`
   恒真注入。已 git checkout 还原。教训:变异测试必须逐次收尾,别靠记忆。
2026-10-15 22:43:08 +08:00
1be34557dd fix(trae): 登录失败真因 —— 虚构的「授权码交换」+ 空 client_id
用户报「TRAE 登录后显示失败」。定位到三处偏差,其中一处是核心缺陷:

① ★ **虚构了一次不存在的「授权码交换」**(核心)
   poll_login 的 trae 分支自己发明了一段逻辑:回调只带 code 时,拿
   exchange_body 去打 ExchangeToken。但:
     · ExchangeToken 的签名是 RefreshToken,不是 code;
     · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
     · client_id 当时是空串。
   于是稳定 400,日志回一句误导性的「Trae 授权码交换失败: 400」——
   把真因「上游走了 PKCE 流程」掩盖成「交换出错了」,排查方向完全错。

   按参考实现(trae-oauth.ts parseTraeCallbackDetailed)修正:带 code 的
   回调是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
   删除虚构交换,两条路径共用 traeCallback 内建的 PKCE 判定。

② client_id 从空串改为实测真值 en1oxy7wnw8j9n(对齐参考 trae-product.ts
   的 clientId;ExchangeToken 请求体里 ClientID 必填,留空稳定 400)。

③ 两条路径(oauth_callback / poll_login)现在共用同一结论,不再漂移 ——
   这正是缺陷能藏住的原因:修复只覆盖一条、另一条继续错。

测试支撑:
- 新增 __set_loopback_probe(注入 loopback 回调文件),否则无法驱动
  poll_login 的 callback 分支(那才是真实登录走的路径)。
- 判据 4 条,全部做变异验证:
    TestTraePkceCallbackIsReportedNotExchanged  打 poll_login,断言没打 ExchangeToken 且点名 PKCE
    TestTraeExchangeSendsRealClientID           token 直传真调交换,请求体形状正确
    TestTraeClientIDIsNotBlank                  client_id 必须是实测真值
    TestTraeCallbackPathsAgreeOnPkce            两条路径必须共用 traeCallback
- 变异 2/2 咬住:client_id 改回空串 → 报红;虚构交换加回去 → 「实际打了 1 次
  ExchangeToken」。

★ 我在过程中自己犯的两个错(均由变异测试揭穿,已修正):
1. **判据打错了路径** —— 第一版打 oauth_callback(那条一直是对的),注入缺陷后
   判据**仍绿**。改成打 poll_login 才咬住。教训:判据必须打**真实被使用的那条
   路径**,否则等于没测。
2. **判据写错了契约** —— poll_login 用 HTTP 200 + json.state 表达终态,我按
   status != 200 断言,把正确的实现判成了错。

回归:go test -tags luajit ./... 10 包全绿。
2026-10-15 22:04:01 +08:00
9e5fec08b8 feat(opencode): Zen 免费层可用 —— 门禁四要素 + id 公式逆出
问题:localzen 源(opencode Zen)整个免费池恒 403 FreeTierError
("free tier can only be used from within OpenCode"),space-bunny-free
等免费模型全部失效。

排查(装官方 CLI 抓包 + 逆其本地 opencode.db,见下):门禁是**四条请求形状
条件**,与 TLS 指纹 / UA / 凭据来源 / system 内容 / 消息条数全部无关:

  1. 端点必须是 /inference/v1/chat/completions(原来打的 /zen/v1 恒 403)
  2. 必须同时发 x-opencode-session 与 x-opencode-session-id(值相同)
  3. tools 里必须有一个名为 read 的工具,且其 parameters.properties **非空**
     (实测:抽掉官方 11 个工具中任意一个仍 200,**只有抽掉 read 变 403**;
      properties 里放任意属性名即可,描述/$schema/required 都不需要;
      改成 properties:{} 或只有 type:object 则 403)
  4. stream 必须为 true(同 body 改 false 恒 403)

★ 两个 id 的 12 位 hex 段由**确定公式**给出(逆自 opencode.db 的 session 表,
用 11/12 条真实 id 逐条比对完全一致):

  MASK = 2^48-1 ; K = 0x1afffffffffffe   (注意不是 27<<48,两者差 2)
  session 段 = (K - now_ms*4096) & MASK   ← 倒计时式,Δseg/Δms ≡ -2^12
  request 段 = (now_ms*16) & MASK

  时间容差实测 ≥1 小时(-3600s 仍 200),故 os.time() 的秒精度足够;
  now_ms*4096 ≈ 7.3e15 < 2^53,在 double 安全整数范围内。

实现:
- id 生成换成上述公式(原用 sha256 派生,完全不符)
- 补 x-opencode-session-id;两个头同值
- 会话号按**整分钟桶**取:既满足门禁的倒计时公式,又保留上游前缀缓存命中
  (实测 cached_tokens: 256 生效)
- tools 兜底:客户端没带 tools 时注入带非空 properties 的 read 桩;
  用户自带 read 则原样保留,不覆盖其定义
- 强制 stream=true
- ★ transform_response 支持 SSE 聚合:免费层只能流式,而客户端可能发非流式,
  此时内核按客户端意图走 Chat/transform_response(期望 JSON)⇒ 必须自行把
  SSE 聚合成 chat.completion,否则报 "invalid character 'd'"

配置(已在生产落地):
- localzen base_url: /zen/v1 → /inference/v1(billing 规则同步)
- 免费模型 1 个 → 8 个(含 deepseek-v4.1-flash-free)
- 适配器移入 internal/lua/adapters/,随 deploy.sh 自动投放

判据(4 条):
- TestOpencodeZenAdapterE2E                       头公式/两头部同值/tools 兜底/不覆盖
- TestOpencodeZenAggregatesSSEForNonStreamClient   SSE → chat.completion 聚合
- TestOpencodeZenLiveE2E                          ★真上游:纯对话/自定义system/多轮/算术
- TestOpencodeZenLiveStreamThroughAdapter         ★真上游:完整流解析链(25 chunk)
  (后两条需 LLMSPROXY_LIVE_ZEN=1 + 真实凭据,默认 skip)

生产 e2e 实测:非流式返回 GW-NONSTREAM-OK(usage 含 cached_tokens 256);
流式原生 SSE 透传;多轮+工具返回正确结果(12*12=144 → 144+1=145)。

回归:go test -tags luajit ./... 10 包全绿。

★ 方法论教训(本轮绕了 4 轮、自我推翻 3 次):
- 一次只改一个变量。我多次同时改 system+条数+tools,拟合出"消息条数上限 3"
  这种假规律,后续每次反转都源于此 —— 真相是那些测试**都缺 tools**。
- 重放成功 ≠ 解释成功。第一次逐字节重放就 200,我却当"重放了官方刚发的请求",
  继续追 TLS 指纹/端点/形状。正确做法:一旦重放成功立刻做**单变量消去**。
- "毒药短语"陷阱:我二分到 963/964 字符精度锁定 Never use tools 是触发点,
  但它单独出现全 200 —— 真相是缺 read 的 properties。凡"某内容触发"的规律
  必须交叉验证。
2026-10-15 20:30:33 +08:00
68446fb6fe fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接
真实缺陷(白屏真因之一):view_url 里传了**站点根路径**作为 noVNC 的
path 参数:

  .../proxy/<id>/vnc.html?path=api%2Fplugins%2Fhttp%2Fchannels%2Fbrowser%2Fproxy%2F<id>%2Fwebsockify

但 noVNC 用 `new URL(path, location.href)` 解析它(app/ui.js 的 connect()),
即**相对 vnc.html 所在目录**解析,默认值就是相对名 "websockify"。传根路径
会被当相对路径 ⇒ 实际请求变成路径重复拼接:

  .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify

于是 WebSocket 永远连不上,noVNC 显示连接失败,页面看似白屏。

修法:只传相对名 path=websockify,与 noVNC 默认值一致。

★ 判据:必须解析**产品自己给出的 URL**(而不是测试里手拼)。我上一轮验证时
手拼 URL 并省掉了 path 参数(默认值恰好正确)⇒ 掩盖了这个真实缺陷。
新判据断言:path 存在、不以 / 开头、且 ResolveReference 后等于同级 websockify。
2026-10-15 20:30:33 +08:00
7e0a428c23 docs(plugins): 补「前缀代理」章节 —— 插件自起服务挂到网关同端口 2026-10-15 15:01:58 +08:00
7a8d97001c feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口
问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它
嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且
websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。

★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里
①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。

内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理):
- plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径
- handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍
  ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一)
- 鉴权与角色门禁复用现有链路,无旁路
- statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败)
- 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost
  主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写
- 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道)
- 不向插件后端转发 Authorization/Cookie/api_key

channels 接入:
- 新增 channels/browser/proxy/:session(prefix=true)
- 会话代号 = seq-随机8位,防猜到活跃会话
- view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify
- 会话过期即回收浏览器栈并返回 404

判据(每条咬真实外部效应):
- 静态资源转发
- 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点)
- 网关凭据不泄漏(在服务端断言请求头)
- 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo)
- 跨站 Origin 403
- 未鉴权 401、过期会话 404、非子路径 404

回归:go test -tags luajit ./... 9 包全绿
2026-10-15 14:57:13 +08:00
850920155a 避免部署构建占满临时文件系统 2026-10-15 13:01:58 +08:00
eaac0ef6b3 修复用量总计与渠道账本统计 2026-10-15 12:43:11 +08:00
226493b5ae fix(部署+插件): VNC 兜底登录**真正可用** —— 放开浏览器栈不需要的加固 + 修 3 个插件缺陷
用户原话:「全部放开,插件不让用这些还叫插件吗」。方向是对的:插件架构的边界
应当是**内核暴露给插件的 API 权限**,而不是一层把插件自己的浏览器栈也关在门外的
进程级 syscall 过滤器。

## ★ 白屏的根因(本次花了大量时间,记录完整线索链)

同一页面、同一 chromium 参数,只换 seccomp:

| SystemCallFilter | 结果 |
|---|---|
| `@system-service` | 页面**永远空白**(截图非白像素 7.9%,连 `typeof WebAssembly` 都不执行) |
| 放开 | 完整渲染 TRAE 登录页(非白像素 **90.6%**,标题 `登录 \| TRAE - The Real AI Engineer`) |

TRAE 授权页是 React SPA + 字节风控 SDK(`sdk-glue.js`),依赖 WASM/JIT。
白名单**只减不增**地挡住了它。

**为什么不再逐个补白名单**:那是没有终点的维护 —— 每接一个新平台、每换一次前端
SDK 都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索形态。本次为此**错误
怀疑过** 5 个方向(profile 锁 / /tmp 满 / PrivateTmp / 可写 HOME / AF_NETLINK),
全部被排除后才靠**逐 directive 二分**定位。补白名单只会让下一个人重复这个过程。

`packaging/llmsproxy.service` 因此去掉两项:
- `SystemCallFilter=@system-service` + `SystemCallArchitectures=native`
- `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`(缺 `AF_NETLINK` 会让
  chromium 建套接字失败 → SSL `net_error -101` → 导航不了)

**保留**的加固(已逐条实测不影响浏览器栈):`NoNewPrivileges` /
`ProtectSystem=strict` / `ReadWritePaths=/etc/llmsproxy` / `ProtectHome` /
`PrivateTmp` / `CapabilityBoundingSet=`(空)/ `ProtectKernel*` /
`RestrictSUIDSGID` / `RestrictRealtime` / `LockPersonality`。可写面仍收窄到
`/etc/llmsproxy`。

## 修掉的 3 个插件缺陷(都是真 bug,各自有判据)

1. **前向引用(第 12 次)**:`sweepSessions` 调 `reapLoopback`/`browserStack`/
   `reapBrowser`,三者定义在**后面** ⇒ 未声明时是全局 nil。加载期不报错,只有走到
   「会话过期回收」才炸 ⇒ **第一次登录正常、第二次 500**,且已起的浏览器栈不被回收
   (进程与端口泄漏)。→ 加前向声明 + 定义改赋值式。
2. **`spawnBrowser` 里 `url` 是未定义全局**(形参名是 `start_url`)⇒ chromium 打开
   `http://nil/`。修复前页面 URL = `http://nil/`,修复后 = 完整 `trae.cn/authorization?...`。
3. **chromium 在加固环境自杀**:`FATAL: GPU process isn't usable. Goodbye.`
   → `--in-process-gpu`(GPU 逻辑跑浏览器进程内,不 fork)。另给可写
   HOME/XDG(`ProtectHome=yes` 下 /root 只读会刷 permission denied 噪声)。

另加**存活探针**:此前只等 CDP 端口就报「已就绪」,即便浏览器已死也报成功 ——
**谎报比直接报错更坏**(运维会以为是自己操作的问题)。现在会如实报出
`浏览器 CDP 无应答:...FATAL: GPU process isn't usable`。

## 测试与生产抢资源的修复

生产 `PrivateTmp=yes` 下 Xvfb `:91` 的锁在**服务自己的 /tmp** 里,宿主测试看不到
⇒ `xdisplayBusy(91)` 判"空闲"⇒ 测试去抢 91 ⇒ `Xvfb 未就绪`。
→ 显示器起始号可被 `LLMSPROXY_XDISPLAY_BASE` 覆盖,测试台统一用 300+;
`TestXDisplaySelectionReclaimsStaleLocks` 的号段也跟随(否则它造出的陈旧锁**根本
不会被扫描到**,断言永远失败 —— 那是判据与被测对象脱节)。
(端口不需要覆盖点:端口是全局资源,`portFree` 查 /proc/net/tcp 看得见生产占用。)

## 判据

- `TestSweepSessionsHelpersAreForwardDeclared`:三助手必须前向声明 + 定义必须是
  赋值式 + 声明必须**先于**使用。
- `TestBrowserStackVerifiesLivenessNotJustPort`:必须有 CDP 真应答判据 + /proc 存活
  + 日志真因 + 失败时回收整组进程。
- `TestBrowserStackUsesInProcessGPU`:`--in-process-gpu` / 可写 HOME/XDG 不得被删。
- `TestChannelsVncStackEndToEnd` / `TestXDisplaySelection*`:真实起栈验证。

## 端到端验证

`bash deploy.sh` → 生产 `active` / `NRestarts=0`;真实登录请求 → `server_browser=True`,
VNC 上**完整渲染 TRAE 登录页**(手机号登录 / 抖音 / 苹果 / 企业账号登录全部可见),
标题 `登录 | TRAE - The Real AI Engineer`。
回归:9 个包全绿。
2026-10-15 08:22:57 +08:00
d3a0eb6311 docs(channels): 第三批逐模块对照 —— 新补 3 个真缺陷 + 判定不适用的附理由
新补:自动领取每天漏领(covers_today)、凭据续期是假的、isFree 与声明矛盾。
插件自装适配器增至 4 个(+channels-qoder / channels-cline)。

★ 判不适用的逐条附理由,而不是简单说没做:
- account-pool 并发写竞态:我们落盘是同步 tmp+rename(Lua 无异步),已加判据
- gemini-messages SSE 末帧:我们的 bufio.Scanner 对无尾换行照样产出(已实测自证)
- cline 30 分钟策略冷却:我们每账号一源,被拦只冷却自己
- retry-after.ts:参考那份自己就是死代码
- image-budget / message-shape / loop-recovery / reasoning-ladder:前提不存在或属内核改动

另记录一条环境教训:/tmp 是 8.8G tmpfs,被构建缓存占满会让测试看起来像死锁。
2026-10-14 20:15:08 +08:00
cbad61a3ea test(channels): 钉住账号池落盘的原子性与内存/磁盘一致性(照参考 account-pool 的真实缺陷)
参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
`await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
后完成者带着它更早的快照覆盖磁盘。实测形态:
- 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
- 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**;
- 与内存副本分叉。

我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
`plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
(脚本语言在这里反而是优势 —— 参考那个缺陷正是"异步后端 + 共享可变快照"的产物。)

**但"不可能"必须被验证,否则只是一句自信**。判据逐条钉住三种形态:
① 已删除的账号不得在磁盘上复活;② 新增账号不得被静默丢失;
③ 内存与磁盘必须一致(分叉本身就是缺陷)。同时断言文档是合法 JSON
(tmp+rename 的原子性 —— 绝无半截文件),并断言**测试目录不是生产目录**
(隔离失效会造成真实数据事故,此前已发生过一次)。

★ 写这条判据时我自己踩了两个坑,都在此记录以避免重犯:
1. 先读到空文件就断言"没落盘" —— 真因是**每次 `poolCore` 拿到的是新的
   `t.TempDir()`**,我在另一个调用里读路径;
2. 磁盘结构是 `{version, state:{pool:…}}`,我按顶层 `pool` 读 ⇒ 永远空。
   两次都是**判据写错而不是被测对象有问题** —— 这正是"判据与被测对象脱节"。
2026-10-14 20:12:10 +08:00
2a2067284d fix(channels): isFree 与声明自相矛盾 —— 免费判定改为**按平台声明权威来源**
## 缺陷

cline 的 3 个模型来自远端 **free 数组**(`recommended-models` 的唯一权威 free 集合),
却声明在 `models` 里 ⇒ 生产实测**全部 isFree=False**,与旁边的注释
「免费模型(远端 free 数组实测)」直接矛盾。用户看不到哪个可以不花钱用。

## ★ 关键认识:两个参考用的是**不同机制**,不能混成一条链

| 平台 | 免费判定的权威 |
|---|---|
| cline | 远端 `free[]` → 命名约定 `cline-free/*` → 静态兜底表(**不查 cost**) |
| opencode | models.dev 的 `cost`(`/zen/v1/models` 连能力字段都没有,cost 是**唯一**来源) |

我第一版把两者写成一条「OR 链」(任一来源说免费即免费),结果与既有判据
`TestCapabilityOverridesFreeFlag` **正面冲突** —— 那条判据要求「能力表说收费时
必须为 false」。冲突暴露了真问题:**只有平台自己知道它的免费名单在哪**。

⇒ 改为平台声明 `free_source`:
- `"capability"`(opencode)—— cost 权威,本地声明只作能力表拉不到时的兜底;
- 缺省 —— 声明链权威(**OR 语义**:任一来源确认免费即免费)。
  OR 语义是刻意的:写成「后者覆盖前者」会让「远端说免费、能力表拉不到」
  静默退回收费,正是这类静默降级。

## 其他改动

- `parseRemoteModels` 现在把远端的 `free[]` 记成**权威免费集合**
  (此前只收了 id、丢掉了"它免费"这层信息),provider 走**显式参数**而非
  隐藏全局状态(隐藏状态是漂移源);
- cline 补 `free_prefix = "cline-free/"`(远端未响应时的兜底依据)与显式
  `models_free`(远端抖动时不把免费模型误标成收费)。

## 判据

`TestIsFreeUsesAuthorityChain`:① cline 的 `cline-free/*` 与
`stealth/space-bunny-alpha` 必须免费;② 命名约定在远端未响应时能独立判出免费;
③ **反向**:收费通道的同名模型不得因前缀规则被判免费(参考点名
「免费模型是独立 id」)。既有 `TestCapabilityOverridesFreeFlag` 同时保持绿 ——
两条语义靠平台声明共存,而不是二选一。

## 附带发现

全量回归曾出现 `TestConcurrentPluginAdapterUpdates` 挂 59 分钟:
单独跑 0.058s 通过 ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建缓存占满 100%**,
不是代码死锁。清理 6G 后全量 9 包全绿。
2026-10-14 20:00:58 +08:00
742e2b5ed3 feat(channels): Cline 用插件自装的 channels-cline 适配器 —— 解析英文句子里的等待时长
## 为什么(这是**比参考更严重**的形态)

Cline 的 429 **既不给 `retry-after` 头,也不给绝对时刻**,等待时长写在
**人类可读的英文句子**里。抓包真值(参考实测,`no-retry: true`):

```
HTTP 429
{"error":{"code":"INFERENCE_CAP_ERROR",
  "message":"Error 429: Daily free limit reached on model
             deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
```

- **参考**退到"1 小时快照兜底" ⇒ 用户看到「永远 60 分钟」(等满一小时再试仍被拒,
  且每失败一次就**从当下**重新计一小时 ⇒ 无限循环)。
- **我们更糟**:内核把 429 当普通限流、用**固定 30 秒**冷却 ⇒
  **每 30 秒重试一次、永远失败**,在猛敲上游。

## 做法:厂商知识留在插件域,产出归一成内核已认的中立语义

插件自装 `channels-cline`(照 trae/gemini/qoder 同一机制):

1. 解析真实等待时长 —— 各 token **累加**(`1h 30m` = 90 分钟),
   单位按**首字母**判(h/m/s 互不重叠)⇒ 一次扫描即可,不会重复计数
   (参考点名 `minutes?` 与 `m` 同时匹配会让时长翻倍)。
2. 把文案归一成内核**已认的中立短语**「额度已用完」⇒ 内核据此走
   `RecordQuotaExhausted`(冷却到窗口边界)而不是 30 秒限流冷却。
   **内核不认识任何厂商码/厂商文案**,只认自己的通用关键词表。
3. 话术把「是什么 + 何时恢复 + 重试无用」说清楚,**不**复述上游英文原文,
   也**不**退回「等 1 小时就好」那种误导。
4. 402 单独处理:等待**不会**恢复,必须说清楚需充值。

## 判据(5 组,含"不得谎报"的反向项)

`TestClineAdapterParsesProseWait`:
① 归一成「额度已用完」(否则内核按 30 秒猛敲)+ 真实时长出现 + 明确「重试无用」
  + **不得**退回「1 小时」误导;
② 通过**真实 transform_error 路径**验证时长累加(`1h 30m` → 「1 小时 30 分」)、
  秒级也能解析;
③ 402 提示充值;④ **普通 429 不得谎报成额度耗尽**(那会让内核长时间冷却一个
  健康源);⑤ 多种外壳(`{error:"…"}` / `{msg}` / 非 JSON 纯文本)都要认
  —— 只认一种会在别的形态下静默丢掉整条信息。

回归:9 个包全绿;内核非测试代码零厂商名。
2026-10-14 20:00:58 +08:00
81b3661336 fix(channels): 自动领取每天静默漏领 —— 按 covers_today 记账,且间隔改为 2 小时
## 真实缺陷(照参考 credits.ts / auto-checkin.ts,2026-10-02 审查定位)

活动刷新时刻**各平台不同**:Qoder 是**每日 10:00(UTC+8)**才刷新。
而我们的自动领取只在 **08:00 跑一次**,于是:

- 上午那次看到的是**昨天**那条 `CLAIMED` → 报 `already-claimed`;
- 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就**再也不会去领**;
- ⇒ **每天静默漏掉 100 积分**(且不可逆:用户以为今天不必再领)。

## 修法:不靠"时刻表",让产出方自己声明

参考的做法(照抄,因为它是从根上对的):**不**给每个渠道配一张「刷新时刻表」
(那必然漂移),而是让**看得见该渠道刷新语义的那一层**声明
`covers_today`(本次结果算不算"今天这一轮"),记账方只数
「有几条能证明今天已被处理」。

- `covers_today` **缺省 true** ⇒ 绝大多数渠道(每日签到语义)行为完全不变;
- Qoder 在**刷新前**置 `false` ⇒ 不得据此认定今天已处理;
- **判据只看 `covers_today`,绝不用 `kind` 反推** ——
  `already-claimed` 天然有歧义(可能就是昨天那一轮的痕迹)。
- 未覆盖今天的平台会**待重试**并记日志;已覆盖的(按 UTC+8 日界记账)
  下一次直接跳过,不会重复打上游。

## 调度频率(两半都要,缺一仍漏)

| 改动 | 为什么必须 |
|---|---|
| `every` 86400 → **7200** | 刷新时刻晚于那次执行、或那次没覆盖到的平台,一天一跑就整天漏 |
| `at="08:00"` → **first_due_now** | 任何时刻重启都能补上,不等一个周期 |
| 新增 `claimCovered` 持久化 | **这才是防重领的正确机制** —— 不是靠"一天只跑一次"碰巧避开 |

★ 旧判据(`TestAutoClaimScheduledWithModelRefresh`)明写「auto_claim 是每日任务,
**不该** first_due_now(重启就重领是错的)」—— **那个假设本身就是缺陷的一部分**。
已按事实更新,并把这段经过写进注释(避免以后有人照旧判据再改回去)。

## 判据

- `TestAutoClaimCountsCoversTodayNotKind`:① 刷新前必须 `covers_today=false`
  且**绝不报** `already-claimed`;② 刷新后为 true;③ 六组记账判据逐条钉住
  (含"刷新前的 already-claimed 不得算已处理"这一关键项);
- `TestAutoClaimScheduledMoreThanOnceDaily`:间隔必须远小于一天 + first_due_now
  + 记账键存在;
- `TestQoderAdapterParsesQueueAndBilling`(上一提交):5 组,含与通用 openai
  适配器的**对比**(断言通用器做不到,否则专用适配器无存在理由)。

回归:9 个包全绿;内核非测试代码零厂商名。
2026-10-14 20:00:58 +08:00
9521a35a03 feat(channels): Qoder 用插件自装的 channels-qoder 适配器 —— 排队与额度耗尽分开说
## 为什么

Qoder 的错误信封是**双层编码**的,且两个业务码语义**恰好相反**:

| 码 | 含义 | 正确处置 |
|---|---|---|
| `10605` | 模型排队 | **暂时**受阻 —— 等若干秒就能过 |
| `110` | 当日额度耗尽 | 立刻重试 / 等 15 秒 / 等 30 分钟,结果一样 ⇒ **必须立即失败** |

参考实测(2026-09-27 用户报障):`110` 原先被归成通用的「服务端错误」→
**白重试 5 次**(≈15.5 秒),用户看到「重试延迟:7220毫秒」。
而我们此前用通用 `openai` 适配器,对它只能产出
`10605: {"isQueued":true,…}` —— 用户根本看不出是排队还是额度用完。

## 做法:厂商知识留在插件域(内核零厂商名)

照 `channels-trae` / `channels-gemini` 既有机制,本插件自装 `channels-qoder`。
内核只把 `transform_error` 的返回文本当作**客户端可读原因**,不认识任何厂商码。

★ **两个踩过的坑(都写进代码注释与判据)**:
1. **内层没有 `code` 字段** —— 那是 SSE 错误帧的 `message`(一个 JSON 字符串)。
   参考第一版要求「必须命中 code 才算排队」,导致内层形态被判成非排队,
   SSE 通道的修复**静默失效**(探针显示 sleep=0、错误照旧)。
   判据改为:命中 `code=10605`,**或**直接出现排队字段(`isQueued`/
   `serviceAvailable`)。`message`/`data`/`result`/`body` 都要展开并**再解析**。
2. **必须先在带排队字段的节点上取值** —— 外层 `root` 只有 `code`,
   没有 `retryAfterSeconds`/`queueCount`。一命中就返回会让这两个参数**永远丢失**
   (实测:只输出「排队中,请稍后重试」,参数全丢)。
3. **额度关键词必须窄** —— 只认 `billing daily count exceeded` /
   `daily count exceeded` / `billing_error`。`quota`/`balance` 这类泛词会把
   模型正文里讨论「余额」误判成故障。

## 判据(5 组,全部咬住真实行为)

`TestQoderAdapterParsesQueueAndBilling`:
① 双层编码取到内层的 30 秒 / 12 个排队;② **内层无 code** 也必须识别(参考的坑);
③ `110` 说成额度耗尽、且明确「重试无用」、**绝不说成排队**;
④ 泛词 `quota`/`balance` 不误伤;
⑤ **与通用 openai 适配器对比** —— 断言通用器**做不到**这件事,
   否则这个专用适配器就没有存在理由(防"加了但没用")。

回归:9 个包全绿;内核非测试代码零厂商名。
2026-10-14 20:00:58 +08:00
252e3486bd fix(channels): 凭据续期此前是假的 —— 现在真发请求、真回写、真报错
★ 修的是一个**比不做更坏**的缺陷:`account_refresh` 只重同步了源、**一个换
token 的请求都不发**,却恒返回 `ok:true` ⇒ `refresh_creds` 打出
「凭据已续期 N 个」而实际什么都没续。日志在说谎 —— 排查时会去看网络、看上游,
而真因是根本没有这段代码。这是本轮第三次遇到"静默恒真"(前两次是
`account_test` 恒返回 ok、`pcall` 只取 ok 不看返回值)。

实现要点(每条都是参考踩过的坑):
- **各平台续期字段名不同**,写错服务端只回泛化的认证失败、极难定位:
  Cline 是**驼峰** `refreshToken` + `grantType`(不是 OAuth 的
  refresh_token/grant_type)、响应套 `{success,data}` 信封且字段是**驼峰**
  `accessToken`;Google 走标准 `refresh_token` 表单编码 + client_secret。
- **Google 偶尔轮换 refresh_token**:响应给了新的**必须回写**,否则下次用旧的
  直接失效。
- **`expires_in` 是相对秒数** → 必须换算绝对时刻,否则每轮都判定"快过期"重续。
- **按需续期必须回写索引的 expiresAt**(照 expiry-sync):不回写会出现
  「显示已过期、但发消息正常」的数据源分叉。
- 失败**如实回错**(HTTP 状态 / `error_description` 都带出来),但仍重同步源 ——
  旧 token 可能还没真过期,不该因续期失败就撤源。
- `refresh_creds` 的计数判据改用 `refreshed`,**不是** `ok`:
  `account_refresh` 在"平台未声明续期方式"时也回 ok:true(源照旧同步),
  用 ok 计数就会把"没续"算成"续了"—— 这正是日志说谎的机制。

判据(必须**看到真请求**,否则还是在验一个恒真的返回值):
1. 假上游断言**真发出 1 次请求**(原实现是 0 次却报成功)、路径正确、请求体是
   驼峰 `refreshToken`/`grantType`、新 token 与轮换的 refresh_token 都落进凭据;
2. 401 时**绝不报 refreshed=true**,且错误含 HTTP 状态;无 refresh_token 时
   提示"需重新登录";
3. `expires_in:7200` → 凭据里是**绝对** expires_at(不是 7200),且
   **账号列表**(不是保险库探针)里 read 到索引 expiresAt 已回写。

另修两个同处踩过的坑:`credentialIsRefreshable` 定义在使用点**之后**
(Lua local 前向引用,第 10 次)、`urlencode` 不存在(Google 表单编码需要)。

回归:9 个包全绿。
2026-10-14 16:46:43 +08:00
0c56392d29 docs(channels): 逐模块对照更新(含本轮新补 7 项与未实现清单的理由)
穷尽对照 143 模块 / 77720 行后的当前状态:本轮新补 dead-model-store /
catalog-gate / balance-rank+selector / account-probe / model-gate /
ttl-cache+serial-queue / opencode-capability,另修 3 个静默失败。

未实现清单逐条给出理由(不是没做,而是三类:需要真实浏览器/验证码的
captcha 系 ~3300 行、二进制缺失的 qoder wasm、以及宿主 UI 特性或前提不存在
的模块如 message-shape(DSH 消息形状迁移,我们无此问题)与 loop-recovery
(我们的内核没有循环守卫,前提不存在))。
2026-10-14 16:29:42 +08:00
f813ec714f fix(channels): 能力元数据拉不到的真因说清楚(内核 4MiB 堆保护,非网络故障)
部署后发现 `capabilitiesMeta` 一直是空。逐层排查出来的真因:

1. `models.dev` 未在出站白名单 → 而 refreshCapabilities 的 `pcall` 把错误**静默
   吞掉**了,表现是"能力永远是空",排查零线索。→ 失败必须留痕(已修)。
2. 批准该 host 后暴露第二层:**内核的 `__llmsproxy_http` 有 4 MiB 响应上限**
   (`httpMaxResponse`,刻意的堆保护),而 `models.dev/api.json` 实测 **5.64 MB**。
3. 参考提到的"轻量分片端点"(`/api/v1/opencode.json`)实测返回 **HTML 首页**
   (2.0 MB 的页面,不是 JSON)—— 参考也记录了这个坑。

⇒ 决定:**不为一个插件的便利去放宽内核的堆保护上限**(那会让所有插件都能拉
5MB+ 进网关堆)。能力元数据定位为**可选增强**:
- 拉得到 → isFree/上下文/模态用权威值;
- 拉不到 → 如实退回兜底表,并把"响应超限"单独讲清楚(让运维知道这是**上限约束**
  而不是网络坏了)。不把"查不到"说成别的原因。

★ 这条本身也是本轮的一个教训:`pcall` 只写 `local ok = pcall(f)` 而不看返回值,
等于把一手诊断信息扔了。同类问题本轮已修两处(另一处是 host 授权静默失效)。

回归:9 个包全绿(能力增强不可用时功能不退化,判据已覆盖"冷启动退回兜底")。
2026-10-14 16:28:29 +08:00
7a6e03692d feat(channels): 模型能力元数据(照参考 opencode-capability)—— isFree 改由权威源决定
`/zen/v1/models` 只返回 4 个字段(id/object/created/owned_by),**不含任何能力
信息**;能力在 models.dev。此前我们的 `models_free` 是**硬编码快照** ——
上游调整免费策略后不会跟着变,于是列表里标着"免费"的模型可能早已收费。

三个字段的用途(照参考):
- `modalities.input` → 能力主源(video/audio/pdf **一律降级为 text**,
  因为下游只认 text/image 两值,声明不存在的模态会投影出我们并不发送的内容)
- `cost.input`/`output` → **免费判定的权威来源**(两者同时为 0)
- `limit.context`/`output` → 上下文窗口与输出上限

★ **非阻塞是硬约定**(参考的真机事故):该接口实测 **5.6 MB / 2.86s**。参考最初
在模型列表渲染路径上直接等它,模型选择器要等 1.4s+ 才出列表 —— 用户报障
「点开是一片空白」。故:渲染路径只读同步缓存;持久化让冷启动直接命中;
拉取挂在 `refresh_models` 定时任务里(后台),失败**不清缓存**。

判据三条(都用**真实** models.dev 响应,不是合成样本):
1. 冷启动下 `model_list` 必须**立刻返回**(退回兜底 isFree)—— 证明没阻塞
2. 能力表就绪后 `isFree` **必须由它决定**(注入"原本标 free 的说收费、
   原本非 free 的说免费",断言翻转过来了)
3. 真实数据解析:实测 opencode **122 模型 / 39 免费 / 82 多模态 / 122 有上下文**
   —— 字段路径(modalities.input / cost.input / limit.context)因此得到验证。
   合成样本会把这条验证变成自说自话。

`model_list` 现在额外带 `capabilities`(modalities/contextWindow/maxOutputTokens/
reasoning/efforts/toolCall),供前端显示。
回归:9 个包全绿。
2026-10-14 16:18:27 +08:00
420379784b feat(channels): 抽通用件 TTL 缓存 + 串行锁(照参考 ttl-cache / serial-queue)
理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"逻辑
(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,边界条件容易漏
—— 本轮就漏了一个(见下)。集中一处才好审。

已把余额缓存改用 ttlCacheGet(消掉手写的 60s 逻辑)。

★ 抽出来立刻验证出价值 —— 实测踩到一个边界:
**清缓存时必须一并清掉"在飞标记"**。否则「清缓存 → 同一秒内重载 → 被去重逻辑
挡住 → 返回 nil」,调用方看到的是"刚清了缓存却拿不到值",而数据其实好好的。
(改动窗口后立即查余额就得到 nil,就是这个。)失效的语义是"忘掉这次加载",
在飞标记也是这次加载的一部分。

另外两条语义也写进注释与判据:
- **失败不缓存**:loader 返回 nil 时不写缓存(让下次能重试),但记在飞标记
  防止同一瞬间重复打。这与"失败进冷却"是两件事(冷却由调用方决定)。
- **墙钟回拨防护**:`cached_at` 在未来时视为已过期,而不是"还没到" ——
  回拨一天会让缓存永远命中、上游改了也自愈不了。

Lua 无协程 ⇒ 做不了"共享同一个 Promise"也做不了"排队等待"。取舍:
- 去重用**时间戳**(in_flight_at 短窗口)等价实现;
- 串行锁只能做到**互斥 + 让路**(抢不到立刻返回,由调用方跳过本次),
  且必须带 **TTL 自解**(持有者崩了不能永久卡死该 namespace)。

判据 `TestTtlCacheSemantics` 四组:命中缓存不重载、**失效后同一秒内能重载**、
ttl=0 每次重载、串行锁"抢到→抢不到→释放后能再抢"。
回归:9 个包全绿。
2026-10-14 16:09:22 +08:00
92c240cc25 feat(channels): 源改为串行 + 按模型节流(照参考 model-gate.ts)
照参考的实测依据:这些平台的**并发配额按模型**分别计量,与"还剩多少 token"
无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:

    GLM-5.3-Flash  605 次 200    0 次限流   ← 从未撞过
    GLM-5.3         74 次 200   21 次重试  6 次最终 429

两条措施缺一不可:
① **串行**(max_concurrent=1):并发发起必然浪费一个配额;
② **最小间隔**(rpm):串行只保证"不重叠"、不保证"有间隔" —— 连续放行的两次
   调用可能只隔几十毫秒,仍会撞并发窗口。

此前插件注册源一律 `max_concurrent = 2` 且**从不设 rpm** ⇒ 既会重叠又无间隔。
现全部改为 `max_concurrent = 1` + `rpm = 20`(默认 3 秒一个,可按平台覆盖)。

⚠️ 参考的另一条教训也记进注释了:闸门**只包 fetch,不包 captcha**。
那边第一版把 mint 与 fetch 一起放进闸门,mint 耗时从 200-500ms 暴涨到
2500-3100ms(因为 mint 被串行后变成"排在 N 个人后面再 mint")。
我们的 rpm 节流在内核层、只作用于上游 dispatch,天然不涉及这一点。

顺带:`list_sources` 补暴露 `rpm` —— 运维与判据都要能看到它。只看
max_concurrent 无法区分"串行但有间隔"与"串行且无间隔"(后者仍会撞窗口)。

判据 `TestChannelSourcesAreSerialWithThrottle`:源必须 max_concurrent=1 且 rpm>0。
回归:9 个包全绿。
2026-10-14 16:00:34 +08:00
5720e60fc9 fix(channels): account_delete 留下陈旧源(旧口径 base_url → 推理返回空)
真实缺陷(排查"推理返回空内容"时抓到):旧版单账号路径建的源 `channels-<plat>`
**不随账号删除而消失**。它的 base_url 是**旧口径** —— buddy 缺
`/v2/chat/completions` 前缀,于是:

  base_url=https://copilot.tencent.com + openai 适配器的 /chat/completions
  = https://copilot.tencent.com/chat/completions   ← 错的

这个源以**同名模型**对外服务(挑的是模型名,不是源名),于是同一个
`codebuddy_deepseek-v4.1-flash` 请求可能落到正确源(新口径)也可能落到陈旧源
(旧口径)—— 表现为**时好时坏**:一会儿正常、一会儿返回空内容(HTTP 200 但无正文)。
我为此绕了很久,因为轮转使得单次测量不可复现。

修法:删账号后若池**已空**,清掉 `channels-<plat>`。
⚠️ 只在池空时清 —— 还有账号时不动它(它可能是运维手工配的兼容源)。

★ 排查过程中的另一条重要澄清(免得下次误判):
`content=""` 且 `reasoning_content` 有内容、`finish_reason="length"` **不是缺陷** ——
那是**推理模型**把 max_tokens 全花在思考上了。实测:
  max_tokens=8  → content=''   finish=length(思考用光预算)
  max_tokens=40 → content='OK' finish=stop
判据若用 max_tokens=8 去测推理模型,会得到"空内容"的假故障。

回归:internal/{core,lua} 全绿。
2026-10-14 15:51:22 +08:00
42ec552c6d feat(channels): account_adopt —— 正确接管升级前的旧凭据(不手填密文)
真实场景:v2.0.x 之前登录只写一条 `channels:<plat>`(单账号模式),引入账号池后
凭据落在 `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 ——
**表现为"账号消失了"**(数据其实还在)。让用户重新登录是可见的额外代价
(要再走一次授权)。

新增 `account_adopt`:把非池凭据接管进指定池账号。**全程走插件的保险库 API** ——
`__llmsproxy_secrets.get` 读出的是**明文**(保险库负责解密),写回时插件自己加密。

★ 这个动作的存在本身就是为了**避免我上次踩的那个坑**:我当时手填保险库文件里的
密文,而保险库读取时会解密 ⇒ 凭据被**二次加密**(1971 → 2965 字符),
该账号推理开始返回空。判据里专门有一条钉住"接管后凭据必须与原文逐字节相同"。

判据(channels_account_adopt_test.go)四条:
1. 接管后池里出现该账号且 has_credential=true
2. **凭据与原文逐字节相同**(长度也一致)—— 防二次加密
3. 昵称等展示字段从凭据补齐
4. 没有旧凭据时如实报错,不假装成功

回归:9 个包全绿。
2026-10-14 15:43:09 +08:00
67e2ba070f fix(channels): account_test 从不发请求(假阳性)+ 业务级错误被当成功
两个真实缺陷,都属"看着能用、一用就错"的假阳性 —— 正是用户报「完全不可用」时
最容易误导人的那类信号。

## ① account_test 一个网络请求都不发

原实现只做 syncAccountSource,然后查内核源列表里有没有那条记录,就返回
`{ok=true, ms=1}`(`ms` 甚至是硬编码的 1)。于是"测试账号"**永远绿**,
包括凭据已失效、被限流、上游宕机的情况。

照参考 account-probe 的语义改为**真打一次上游**:优先余额端点(最轻、所有有
余额的平台都有),退化到 checkin_status;两者都没有的平台如实报
「未声明可探测端点,仅校验本地凭据与源」,**不假装成功**。

失败还分两类(照参考:只说"失败"用户不知道该做什么):
- `kind=auth`(401/403/invalid_grant/expired)→ "凭据已失效,请重新登录该账号"
- `kind=transient` → "上游暂时不可用,可稍后重试"

## ② fetchAccountJSON 只看 HTTP 状态码,业务级错误被当成功

多家上游**HTTP 200 但 body 里带 error**(实测 buddy 的 invalid_grant 就是这个
形态)。只看状态码时"凭据已失效"会被当成成功。现补判 body 里的
`error`(字符串/对象)、`code`(非 0 即失败)。

这一条同时修好了所有走这个助手的路径:余额查询、签到状态、以及新的账号探测。

## 判据

`channels_account_probe_test.go`(假上游 + 请求计数器)三条:
1. 正常凭据:**必须真的发了请求**(用原子计数器证明 hits>0)且 probed=true
2. 凭据失效:ok=false、kind=auth、必须带可操作提示
3. 无凭据账号:如实报错,不假装成功

回归:9 个包全绿。
2026-10-14 15:35:49 +08:00
29c87ce015 feat(channels): 余额分档选号(照参考 balance-rank/selector)
解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
而**服务端扣哪个包不由我们决定** —— 插件能做的是决定**用哪个号**:
- 优先用「还有 15 天内到期积分」的号(那部分不用就作废)
- 只剩永久积分的号排最后
- **锁定永久积分**时,只剩永久积分的号**等同于不可用**

为什么是 15 天(照参考的实测对照表,两站差别极大):
- WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天)
  → 锁定后 4 个号仍可用、1 个不可用,正是期望效果
- CodeBuddy 中国版:裂变包距到期**最少 17 天** → 17 > 15 ⇒ 全算永久
  ⇒ 锁定后立刻「无可用账号」—— 判据没错,这类池想用锁定要把窗口放宽

实现要点(三条都是参考点名的坑):
1. **只缓存原料,绝不冻结分类**。「临时/永久」是**当前时刻的函数**,而宿主长期
   开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
   越过了线。若把分桶结果也缓存,选号会继续按「永久」处理一笔马上要作废的积分
   (锁定时更糟:本该可用的号被判成不可用)。故缓存 60s 的**包列表明细**,
   每次都重新分桶。
2. **失效包必须跳过**(`active === false`):服务端仍返回它们的余额,并进任何
   一桶都会虚增可用额度。
3. **到期时间未知归入永久桶**:保守方向 —— 最坏是锁定时少用一个号,
   而不是误把长期积分当快到期烧掉。

口径与既有 `total_fn` 一致:只算 **remaining**(CycleCapacityRemain,本周期剩余),
与 IDE 顶部的 Credits Balance 一致。刻意不用 TotalDosage —— 那是**终身口径**
且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。

★ 两个 Lua 环境的实测发现(写在这里免得下次再踩):
- 本 VM 的 **os 表没有 setenv**(实测键:clock/date/difftime/execute/exit/
  getenv/remove/rename/setlocale/time/tmpname),所以"面板改窗口"必须走
  settings 而不是设环境变量;getenv 只用于读部署期注入值。
- `os.setenv and os.setenv(...)` 是**语法错误** —— `and` 是表达式,不能作语句。

判据(channels_balance_tier_test.go,假余额上游):
临时桶=100(只有 10 天内那个)、永久桶=50(远期+未知)、失效包的 9999 被跳过、
档位判定、锁定后仍可用(有临时积分)、窗口设 0 后临时桶归零且锁定后不可用。
回归:9 个包全绿。
2026-10-14 15:24:24 +08:00
583976f7da feat(channels): 补两个参考明确"不能省"的基础设施(已失效模型 + 目录闸门)
穷尽对照 143 个参考模块后,挑出两个**有实测理由**的补上。

## ① 已失效模型记录(照参考 dead-model-store.ts,708 行)

修的是真实报障:用户选中某平台的兜底模型,每轮 404 —— 该模型**已被上游下架**,
但列表里仍显示、还标着「免费」。根因:兜底表是**编译期快照**,上游下架不会
自己变。

★ 关键纪律(参考文件头有实测教训):**不能用「远端目录里没有 ⇒ 已下架」反推**。
那个判据只在远端目录完整权威的平台上成立;buddy 那类平台的远端目录**已知残缺**
(CLI token 只拿得到 13 个内部别名、IDE 有 20 个),按目录比对会把「你有权用、
只是 token 看不到」的模型删掉;qoder 更是没有可比对的目录。
⇒ 唯一跨平台安全的判据是**阳性证据**:真的 404 过才记。

判据逐条对齐参考(17 个用例,全是它踩过的误判):
- `model_not_found` 只在**独占一句话成分**时算(后面只允许结束/句末标点/收尾
  括号引号,**不含逗号**)—— 它在遥测里常作字段名出现:
  `error_class=model_not_found, retry allowed` 曾被误判下架
- rate limit / quota / insufficient / unauthor / forbidden / invalid key 一律**不是**
- `expired` 必须带主语(token/session/key/…),否则会误杀真实下架文案
- 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")
- 判定顺序:NOT_GONE 先于 GONE(与参考 `isModelGoneError` 逐行一致)

TTL 30 天(过期自动复活,照参考 DSH_DEAD_MODEL_TTL_DAYS 语义)。
`model_clear_dead` 语义**修正**:原实现清的是"被禁用且不在目录里"(判据用错了、
拿目录反推),现改为清**失效记录**。

## ② 远端目录加载闸门(照参考 remote-catalog-gate.ts)

参考点名「**不能省**」:宿主对每个 provider 拉目录、再对每个模型解析一次,
于是「目录没拿到」被放大成**一次请求 × 每个模型**,而超时是 30–60s 量级 ——
未登录/离线时表现就是「首屏巨长」。三道语义:命中缓存 / 并发去重 / 失败冷却。

Lua 无协程做不了「共享同一个 Promise」,用**时间戳闸门**等价实现
(in_flight_at 防同轮重复 + retry_at 失败冷却)。
含参考的**墙钟回拨防护**:`retry_at - now > cooldown` 一律按已到期处理 ——
否则系统时间回拨一天会把冷却变成事实上的永久卡死。

## ③ 顺手修:状态落盘不建目录

`persistState` 直接 `io.open(path..".tmp")`,目录不存在就失败。而失败只打
stderr 一行「状态落盘失败」—— 表现是「状态永不落盘、重启即丢」,很容易被忽略。
现先 `mkdir -p`。这是实测发现的(authorize 测试的临时目录尚未创建时报的)。

## 判据

`channels_dead_model_test.go`:判据 17 例(含 6 个"看着像但不是下架"的反例)、
记录-剔除-恢复闭环、闸门冷却。
`channels_pool_test.go`:`model_clear_dead` 断言按新语义改写,并加"清失效记录
**不得**动 disabled 黑名单"的隔离断言。

回归:9 个包全绿。
2026-10-14 15:11:05 +08:00
a020247627 docs(channels): 记录数据事故全链(测试覆盖生产账号池)+ 修复中的自踩坑
含根因实测输出、为什么 DisableStatePersistence 不够(调度器 goroutine 比
t.Setenv 生命周期长)、两层修法、判据与变异验证。

另记一条我自己的操作事故:恢复账号时把保险库里的**密文**当明文喂给
account/add,而 vault 读取时会解密 → 凭据被二次加密(1971→2965 字符),
该账号推理开始返回空。教训:凭据只能由登录/续期路径写入。
2026-10-14 11:36:15 +08:00
af8b93bd7c fix(内核+插件): 测试写生产账号池 —— 13 个账号被覆盖成 0 个(真实数据事故)
用户报"完全不可用"。逐平台查 account/list,发现 13 个账号里只有 1 个有凭据,
其余是占位壳子 —— 再查发现**账号池被清空了**,而保险库里凭据还在
(症状正是"账号凭空消失")。本提交修的是这个数据事故的根因。

## 根因(全程实测,非推断)

channels 插件用 `storeDir()` 决定自己的存储位置,它有多级兜底:
env → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** → 硬编码。
测试台没设 `LLMSPROXY_PLUGIN_DIR` 时,后两级直接命中**生产**:

    测试插件目录 = /tmp/TestXXX/002/plugins
    storeDir 结果 = /etc/llmsproxy/plugins          ← 实测输出

于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。
一次全量 `go test ./internal/lua` 就把
`/etc/llmsproxy/plugins/.channels-store.json` 从 1730 覆盖成 1986 字节、
账号池清空(inode 都换了)。

同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。

## 修法(两层,第二层才是根治)

1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
   新增 `__llmsproxy_plugin_dir` 全局,在 bootPluginState 里注册。
   `storeDir()` 优先用它 —— 这样**即使没有环境变量**也不会落到生产路径。
   实测:撤掉 env 后 storeDir 仍落在临时目录。
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`
   (插件声明了 schedule 时内核会自动起调度器,**后台 goroutine** 比
   `t.Setenv` 生命周期长,测试结束后仍可能落盘)。

## 判据 + 变异验证

`internal/lua/store_dir_isolation_test.go`:
- storeDir 绝不能是 /etc/llmsproxy 前缀
- 必须落在临时目录
- env 必须真的被设上(机制而非巧合)

**变异验证**:把隔离设成生产路径 → 判据变红并打印
「storeDir 指向了**生产**目录」;恢复 → 变绿。

全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
2026-10-14 11:31:41 +08:00
3819b00ded fix(lua): 持久化的出站主机授权在重启后不生效(LoadDir 缺折叠)
生产实测暴露的内核缺陷:**重启后所有已批准出站主机全被拒**。

根因链:
1. `LoadKernelPermissions` 把 `.kernel-perms.json` 里的 `jp.Hosts` 装进
   `ps.extraHosts`,但它明确注释「折叠由调用方负责」——**启动路径上没人负责**。
2. `SetOutboundHosts` 早已跑过(core.New 里,在 LoadDir **之前**),
   于是 allowlist 只含 source 主机,插件声明的整套 host 全被拒。
3. 症状是经典的「重启丢掉运维的决定」:运维一打开插件页重新保存就恢复。

**它还静默打坏了定时任务**:`refresh_models` 在启动后 5s 补跑
(first_due_now),正好落在这个窗口里,于是每个平台的刷新都报
「host "zcode.z.ai" is not an allowed outbound destination」——看起来像
"白名单缺 host",实际是授权根本没被折叠。我按这个假象查了一圈(去核对
.kernel-perms.json 里 zcode.z.ai 明明在),才定位到折叠这一步缺失。

修法:LoadDir 末尾补 `rebuildOutboundAllowlistLocked()`。

判据:`TestPersistedHostGrantsSurviveRestart` —— 造一份持久化授权、模拟
启动顺序(SetOutboundHosts → LoadDir),断言已批准的 host 可用、
source 主机未丢、未批准的仍被拒。
**变异验证**:撤掉修复 → 判据变红(两条"被拒");加回 → 变绿。

回归:internal/{lua,core,provider,gateway} 全绿。
2026-10-14 10:55:50 +08:00
10ad4421f9 feat(channels): 自动领取得以真正跑起来 + 自动获取可用模型
用户要求两件事:① 自动签到与领取 ② 自动获取可用模型。
两者都有调度缺口,且能力表与参考真值不符(表错了只静默少领,无任何报错)。

## ① 自动签到的能力表(三处偏差,全部修正)

| 平台 | 我们 | 参考 | 后果 / 修法 |
|---|---|---|---|
| buddy | claim=true | 无 | **自相矛盾**:表说支持、claim_daily 说不支持(既无 checkin 也无 claim_fn)→ 改为只报余额 |
| raccoon | 空表 | 有 | 白丢一个可领渠道 → 补 claimRaccoon(POST /api/web/desktop/v1/login/points/grant,幂等判据是 `granted`,重复领取同返 200 但 granted=false) |
| zcode | claim=true 无实现 | auto-exclude | 补 claimZcode(3 步:event/report → preview → claim/plan),并照参考 isAutoCheckinExcluded 标记 auto_exclude |

**同为"表说支持、函数不支持"的还有 loomy** —— 补 claimLoomy
(POST /points/first-login;语义是**触发每日额度重置**而非 +5000 积分;
幂等判据 `alreadyProcessed` → already-claimed 而非 claimed)。

一条一致性判据抓出全部四例:声称 claim=true 的平台必须真的可领
(有 checkin 表或有 claim_fn)。

## ② 两个调度任务(此前只有每日领取一个)

参考有三个调度器,我们只有一个;模型获取**根本没有调度**:
- refresh_models:每 2 小时拉远端目录(照参考 MODEL_REFRESH_INTERVAL_MS),
  成功后重同步该平台所有源(模型清单写在源里)。失败**不清缓存**。
- refresh_creds:每 30 分钟检查续期(照参考 REFRESH_INTERVAL_MS)。
  ⚠️ **不看 enabled**(照参考铁律:停用只影响选号,不该停续期);
  ⚠️ **只对距过期不足 1 小时的账号发请求**(照参考 expiry-sync),
  否则启动时会打出几十个无谓请求。

## ③ 调度器新增 first_due_now(内核)

照参考的事后复盘:只用 interval 时,宿主重启后短寿命凭据(cline 1h /
codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示「已过期」、
积分行 401。故声明式加 first_due_now —— 任务在启动后 5s 先补跑一轮。

**且必须 opt-in**:真正周期性的任务(每日签到)不该因重启就重跑。
判据同时断言"auto_claim 不得带该标记"。

## 实测(生产)

- 模型刷新:buddy 真拉到 **22 个远端模型**(含兜底表没有的 hy4-preview、
  step-5-preview、codewise-* 等);zcode 2 个;其余报"无可用凭据"(登录未完成)
- 推理链路:`POST /v1/chat/completions` 返回 "OK"

## 宿主排障(顺手)

VNC 端到端测试失败,查证**不是回归**(stash 改动后同样失败):宿主有
**10 小时前的孤儿 `Xvfb :91`**(PID 1074031)占着显示号,加上 126 个孤儿
SHM 段、18 个陈旧 X socket。按纪律(先 SIGTERM 再 SIGKILL)回收后测试通过。
另记一笔:`ps | awk '/[X]vfb/'` 这种写法会匹配到**执行它的 shell 自身**
(命令行里含该字样),清理必须用 /proc/<pid>/exe 精确匹配 + 保护祖先链。
2026-10-14 10:46:05 +08:00
f55d514e85 docs(channels): 生产实测 —— 不可用的真相是 13 个账号里只有 1 个有凭据
用户报「完全不可用」。逐平台实测 account/list 后根因不是代码:

| 平台 | 账号 | 有凭据 |
|---|---|---|
| buddy | 2 | 1 |
| trae/minimax/lobsterai/codearts | 3/1/1/5 | 0 |
| 其余 7 平台 | 0 | 0 |

13 个账号只有 1 个真有凭据,其余是**占位壳子**(has_credential:false)。
此前把「12 平台 0 个 manual」当作已接好的判据是**错的** —— manual 是 flow
类型,不等于登录成功。

排除项(全部实测,非推断):
- 部署一致性:4 插件 sha256 与仓库全部一致,关键标记全在位
- **推理链路可用**:POST /v1/chat/completions 实测返回 "OK"
- 登录机制可用:minimax 真实返回 user_code,trae 起了服务端浏览器
- 设备码轮询正确:未授权时持续 pending,不误判成功/失败

结论:剩 12 平台都需**人工授权**(device-poll 去官方页输码、callback 在
VNC 里点、qr/wechat 扫码)。

B 类修正判断:
- qoder-wasm:二进制不在仓库(find -name '*.wasm' 为空),拿到才有意义
- zcode-captcha:**登录不需要它**。captcha 只用于签到/领取
  (zcode-auth.ts:22-23 每个 plan 单独 mint)—— 真实价值是让领取全自动,
  不是让平台可用
2026-10-14 00:28:32 +08:00
faa3be5c37 test+docs(channels): 三私有协议平台的源接线判据 + 自装适配器机制实录
补齐"装了适配器 ≠ 源用了它"这条判据。这是这类改动最可能的漏配,且
**不会报错** —— 只是签名/信封不生效、上游返回流内错误或 401,看起来像
"平台不稳定"。三个平台的失败模式不同、根因同一个(平台协议差异被当成
适配器名差异):
  codearts → 华为云 AK/SK 签名
  trae     → SOLO 载荷格式
  gemini   → 私有 /v1internal: 信封 + 身份五头
判据同时验 adapter 名、base_url 与"源里必须有模型"。

docs/channels-parity.md 补两节:
- 三个私有协议平台已改用插件自装适配器(含 credential_shape 的引入原因:
  codearts 凭据没有 access_token,credentialOf 取不到 → 静默不出源)
- 附录:机制(内核早已具备,channels 插件引用数为 0)、三条实测能力、
  六个"静默失败"类踩坑、判据清单
2026-10-13 23:44:30 +08:00
8e2d384a7b feat(channels): Gemini 用插件自装的私有协议适配器(channels-gemini)
Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
参考 gemini.ts 点名三件必须保留,我们此前**一件都没有**(gemini.lua 产的是
公开的 contents/parts 裸体、一个身份头都不发,端点还指着公开路径):

1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
   x-goog-api-client(User-Agent/x-client-name/x-client-version/
   x-machine-id/x-vscode-sessionid)
2. **流式刻意不带 Accept**(抓包一致)
3. **信封逐层字母序序列化**(上游按字节比对;Go 的 encoding/json 对 map
   自动字母序,而我们用 json.encode,故须自己排——实现 sorted_json)

信封形状(私有端点要的不是裸 contents):
  {model, project, request:{contents, systemInstruction, tools,
   generationConfig, sessionId}, requestId, userAgent}
响应也是信封(candidates 嵌在 response 里)→ 必须**剥一层**,否则下游
拿不到 candidates,表现为"模型没回话"(静默失败)。

另加前导助手 gemini_response / gemini_stream_chunk(candidates/parts/
usageMetadata → 统一信封;STOP→stop 等 finishReason 映射)。

★ 踩到的两个坑:
- **Lua 长字符串里不能有字面 NUL 字节**(Python heredoc 写的 "\0" 变成了真
  字节)→ Lua 报 "unfinished long string near '<eof>'",且报在文件末尾,
  完全指不到现场。
- **本 Lua 构建不支持按位异或 `~`**(unexpected symbol near '~')→ FNV-1a
  的 XOR 改用算术等价形式。参考自己明言 sessionId 的哈希本体未反推出来、
  数值不与原版逐字相同,所以这里要的是**确定性 + 依赖维度正确**
  (吃 project 与首条文本、不吃 model),不是数值一致。
- 另给三个自装适配器统一加**本地预编译护栏**:内核装载失败只回"源码无法
  编译"不给行号;先 load 一次就能拿到行号(上面两个坑都是靠它定位的)。

⛔ 仍不做:gemini-sigstore(签名持久化,268 行)——推理走无状态适配器,
插件侧缓存会成为死代码。理由已记在 docs/channels-parity.md。

判据(internal/lua/channels_gemini_adapter_test.go):走真实路径断言
身份五头逐值、**反向断言** x-goog-api-key/x-goog-api-client/Accept 不得出现、
信封五个顶层字段齐全、顶层键字母序(解析式校验,不是字符串匹配)、
响应剥信封后 content/finish_reason/token_usage 都到位。

回归:internal/{lua,core,provider,gateway} 全绿。
2026-10-13 23:42:04 +08:00
ff9b460eba feat(channels): TRAE 用插件自装的 SOLO 适配器(channels-trae)—— 载荷真转换
TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 点名:「载荷必须转换:OpenAI 的
{model,messages,tools,tool_choice,stream} 需要映射为 SOLO 格式(function、
config_name 等字段),不能透传」。上一轮我把端点改对了(/api/agent/v3/
llm_utils_chat)但**载荷仍原样直发** —— 上游会回流内 4001,而 HTTP 状态码
仍是 200,属静默失败。

本次补齐 transformToSOLOBody 的 7 条规则(逐条对齐参考):
  1. messages.content 字符串 → [{type:"text",text:…}];数组透传
  2. stream 强制 true(非流式由服务端聚合)
  3. model → config_name + model **双字段**(支持 __dev 后缀消除)
  4. function 通道名,缺省 solo_work_lite
  5. tool_choice 归一化:none → 删 tool_choice **与** tools;
     auto/required 保留;{type:function,function:{name}} → 字符串 name
  6. assistant 的 tool_calls: function → function_call,并剔除无 name 的条目
     (孤儿 tool_call 会让整条会话被拒)
  7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)

★ 命名为什么是 channels-trae 而不是 trae:内置 trae.lua 服务的是**运维自建
的 trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)。
判据里专门断言"内置 trae 未被顶掉"。

★ 踩到的坑:`function` 是 Lua 关键字,写 `body.function = x` 是**语法错误**
('<name>' expected near 'function'),必须 `body["function"]`。它只在 load()
时才暴露 —— 插件脚本本身能编译通过,适配器源码要运行期才报错。

判据(internal/lua/channels_trae_solo_test.go):走**真实路径**
(装插件 → 调已安装的适配器),而不是在插件 VM 里 load 一份副本 ——
副本拿不到内核注入的共享前导,也发现不了"装了但没生效"。逐条断言 7 条规则
+ 三处反向断言(extra_body 必剥离、无 name 的 tool_call 必剔除、
tool_choice=none 时 tools 与 tool_choice 都必须消失)。

回归:internal/{lua,core,provider,gateway} 全绿。
2026-10-13 23:28:27 +08:00
1f54143956 feat(channels): CodeArts 用**插件自装的专用适配器**接入(华为云 AK/SK 签名)
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
LlmAdapter 的真类(CodeArtsAdapter 自己做 SDK-HMAC-SHA256 签名);我们
此前把平台协议差异当成 adaptter 名差异(全塞 openai),签名无从表达。

内核其实早已实现 __llmsproxy_adapters.install(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 **0**。本提交第一次真正用它。

改动:
1. 插件在加载末尾装自己的 codearts 适配器(CODEARTS_ADAPTER_SRC):
   build_headers 里做华为云 SDK-HMAC-SHA256 签名
   (canonical = METHOD\\nURI\\nQUERY\\nCANONICAL_HEADERS\\n\\nSIGNED_HEADERS
   \\nPAYLOAD_HASH;Access= 不是 Credential=;URI 补尾斜杠;
   Agent-Type/X-Language 签名后追加不进 canonical)
2. codearts 平台 adapter=codearts,新增 credential_shape="full"
3. syncAccountSource 支持 credential_shape:签名平台要**完整凭据 JSON**
   (适配器自己解 AK/SK),其余平台仍交裸 access token

★ 途中挖出的两个真实缺陷(都是"静默失败",没有任何报错):

① **凭据形状不匹配 → 不出源**。syncAccountSource 用 credentialOf 取
   access_token,而 codearts 凭据是 {access_key_id, secret_access_key,
   security_token} —— 没有 access_token,于是 tok 为空、函数静默 return。
   加了 credential_shape="full" 解决。

② **add_source 失败是静默的**。内核拒绝(插件无权限、形状不符)时
   syncAccountSource 不记日志,表现为"账号加了但不出源",排查无线索。
   现补上失败日志(区分内核 err 与 json.ok==false)。
   我自己就被 ① 绕了一圈:用 kernelTestCore 裸加载插件(没授 add_source
   权限)时源一直不出,直到把内核原始返回值带出来才看到
   "kernel action add_source is not granted to plugin channels"。
   ——判据里必须能看到**真实函数的返回值**,否则只能在黑盒里猜。

判据(全绿):
- TestCodeArtsAdapterInstalledByPlugin:适配器确实被装上、归属是 channels、
  且**用 Go 标准库独立复算 HMAC** 验签(形态对但算法错时形态断言全绿,
  复算才咬得住);另断言 canonical 不含 authorization/agent-type、
  URI 有尾斜杠
- TestCodeArtsSourceUsesPluginAdapter:源**真的**引用 codearts 适配器
  ("装了但源还指向 openai"是这类改动最可能的漏配,且不报错)

回归:internal/{lua,core,provider,gateway} 全绿。
2026-10-13 23:14:04 +08:00
a7a0c6adee refactor(lua): 11 个适配器改用共享前导 —— 删掉约 20KB 逐字重复
按前导抽出三块共通实现(都是实测逐字节相同的):

| 钩子 | 原本重复 | 各 2.2KB |
|---|---|---|
| transform_stream_chunk | github/groq/kimicode/mistral | 2210B × 4 |
| transform_response     | openai/groq/kimicode/mistral/github | 2206B × 5 |
| transform_error        | deepseek/github/groq/kimicode/openai/sensenova | 292B × 6 |

适配器总行数 2409(收工前 groq 单文件 146 行 → 46 行)。

★ 两个必须记下的教训(都在本提交里踩到并修好):

1. **抽公共代码要抽最全的实现,不是最小的**。我先抽了 transform_error 的
   最小版(只认 {error:{message}}),打挂了 TestOpenAITransformErrorEnvelopes
   的 6 个子用例 —— 最小版丢掉了 HTML 错误页、裸文本、扁平 {code,message}、
   FastAPI {detail} 等形状。openai.lua 原有的 1580B 版本才是该进前导的那份。
   抽最小实现是**静默降级**:不报错,只是少认几种错误体。

2. **抽出共性时不能丢掉"非共性但重要"的细节**。openai_response 我漏了两处:
   reasoning_content 透传、以及 tool_calls 的 id 规整(safe_tool_id)。
   后者尤其危险 —— 本网关是扇出路由器,一个非法 id("bash:0")会被回放到
   每个 Claude 上游导致整条 AUTO 链失败;且**流式与非流式必须同规则**,
   否则同一请求两条路径产出不同 id(实测报错:
   流式 "bash_0_40248ca0" vs 非流式 "bash:0")。现两条路径都走
   __prelude.tool_id。

判据:TestPreludeRefactorIsByteIdentical —— 固定输入下逐字节比对
(含"arguments 必须解码成表""usage-only chunk 必须是 done:false"
"arguments 不得留原样 JSON 字符串"等反向断言),并覆盖错误体 6 种形状
与"非 OpenAI 形状必须返回 ok=false"。

另修 channels_pool_test:断言改为**关系**(关一个模型源里少一个)而非写死
数量 —— 兜底目录会随对齐进度变化(minimax 3→4),写死数量会把正常的目录
扩充变成测试失败。

回归:internal/{lua,provider,core,gateway} 全绿。
2026-10-13 22:51:50 +08:00
741edfea98 feat(lua): 适配器共享前导 —— 消除 12680 字节的逐字重复
问题(实测证据,按钩子做字节级哈希分组):
  transform_response      github/groq/mistral 三份完全相同(2207B × 3)
  transform_stream_chunk  github/groq/kimicode/mistral 四份相同(2211B × 4)
  transform_error         deepseek/github/groq/kimicode 四份相同(293B × 4)
  合计 12680 字节完全重复。openai.lua 自己的注释就写着
  「(anthropic.lua carries the same helper; Lua adapters have no shared
  prelude.)」—— safe_tool_id 在 openai.lua 与 anthropic.lua 各复制了一份。

参考插件有 src/openai-compat.ts(1196 行,12 个 adapter 里 8 个 import),
我们没有对应物。本提交补上这一层。

做法:**编译期把一份中性助手库拼在适配器脚本前面**,注入点在
LoadAdapterSource —— 适配器有三个编译点(worker boot / HasFn 探针 /
inspectScript),全部源自这里存进 pool 的 script 文本,拼一处即全覆盖;
且不像运行时 SetGlobal 那样会在热重载/池重建时丢失。

助手只做**协议层面的中立清理与组装**,不含任何厂商名(守住内核零厂商名
纪律):decode_body / strip_control(剥内核私有控制字段 —— 那是网关内部
约定,v2 改一次就要改 12 个文件)/ unified_skeleton / finish_reason /
usage(含 cache 细分,属协议族已知变体)/ choices / tool_id(路由器性质:
坏 id 会被回放到每个 Claude 上游,一票否决整条 AUTO 链)/ derive_key。

判据两条:
- TestAdapterPreludeInjectedAndCorrect:不只断言存在,抽查**语义值**
  (end_turn→stop、tool_use→tool_calls、合法 id 原样透传、非法 id 带摘要
  改写、cache_hit 从 prompt_cache_hit_tokens 取)
- TestAdapterPreludeDoesNotChangeExistingAdapters:旧适配器(自己实现全部
  钩子、不引用前导)输出逐字节不变

回归:internal/lua + internal/provider 全绿(16 个适配器行为不变)。
2026-10-13 22:34:13 +08:00
ed1e250452 test(lua): 锁死插件适配器的三条能力(实测,非推断)
为协议转换类平台(trae SOLO / gemini 信封 / codearts AK/SK 签名)装专用
适配器铺路,先把能不能做实测清楚:

① 能力:适配器**能**通过 build_headers 的 meta.api_key 拿到凭据
   (AK/SK 签名的前提),且**拿不到** __llmsproxy_secrets —— 该全局只注入
   插件 VM(plugin_bridges.go 开篇明说),安全边界完好。
   同时验 url/method/body 三个签名字符串原材料都能拿到。

② 能力:transform_stream_chunk **跨 chunk 保持状态**(5 个 chunk 拿到同一个
   Lua 状态,表地址恒定,calls 1→5)。这是 trae 跨帧累积 tool_calls、
   gemini 跨帧剥信封的前提。此前只是从池化 LIFO 复用**推断**的,现实测钉死。

③ 能力:适配器能用内核的 sha256_hex(用标准向量 sha256("abc") 校验),
   AK/SK 签名不需要外部进程。

背景:内核早已实现 __llmsproxy_adapters.install(含归属校验与冲突仲裁),
但 channels 插件对该全局的引用数是 0 —— 平台协议差异此前被当成 adapter
名差异(全塞 openai)。
2026-10-13 22:28:34 +08:00
fe9d5c6054 docs(channels): 补架构差异分析 —— 为什么参考正常、我们靠 adapter 名硬撑
查证参考仓架构后的结论:
- 参考:一个平台一个继承宿主 LlmAdapter 的真类(trae 做 SOLO 转换、
  codearts 做 AK/SK 签名、zcode 走 Anthropic),公共逻辑抽在
  openai-compat.ts(1196 行,8/12 adapter 复用)
- 我们:内核是 proxy,插件只注册源;平台差异被当成 adapter 名差异
- 关键:内核**早已实现** __llmsproxy_adapters.install(含归属校验、冲突
  仲裁、测试 TestPluginInstallsAdapterFromLua 通过),但 channels.lua
  对这个全局的引用数是 **0** —— 一个适配器都没装过
- 我们还缺参考的共享前导:openai.lua 与 anthropic.lua 各复制一份
  safe_tool_id
- 记录了下一步落地前必须先探的两条风险(适配器 VM 能否读 secrets、
  流式转换能否跨 chunk 保持状态)
2026-10-13 18:40:00 +08:00
3e94047889 docs(channels): 三线对齐表(登录/模型获取/接入模型服务)逐平台实录
按参考仓的命名规律(X-auth / X-product / X-adapter)建表,记录:
- 本轮修的四件事(codearts 登录、模型兜底表、chat 端点、gemini 私有协议)
- 三个**未闭环**项并写明理由(codearts 模型获取需 AK/SK 签名、
  trae 载荷需转 SOLO 格式、gemini 信封需字母序 + 身份五头)
- 明确不做的四项(gemini-sigstore 不适用、qoder-wasm/zcode-captcha/
  captcha 池属 B 类)
- 本机实测的 5 个坑
2026-10-13 17:04:09 +08:00
4a90099fb2 fix(channels): Gemini 接入改私有 /v1internal: 动词路径 + 源端点声明式覆盖
两条线一起修:

1. Gemini 接入模型服务用错协议。Cloud Code 凭据**不能**打公开的
   /v1beta/models/{model}:generateContent(参考 gemini.ts:78 明确走私有
   /v1internal:generateContent;流式 /v1internal:streamGenerateContent?alt=sse)。
   此前源注册沿用适配器的公开路径,凭据与端点不匹配。

2. syncAccountSource 支持平台声明 endpoint / endpoint_stream / headers 覆盖。
   平台的 chat 端点常不是 base_url 的简单拼接(Gemini 私有动词、zcode 只有
   一条 plan 路径),用声明式覆盖表达;内核只把它们当**不透明字符串**,
   不引入任何厂商知识(守住内核零厂商名纪律)。

判据:逐平台比对 base_url+endpoint 与参考真值,11/11 一致。
2026-10-13 17:02:58 +08:00
59229990ac fix(channels): 修正 zcode 接入端点(须带 /api/v1/zcode-plan/anthropic 前缀)
参考 zcode-anthropic.ts:7 注释点名:该平台**只存在**
/api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径,
openai 与裸 v1/chat/completions 一律 404 page not found。
此前 base_url 只填 https://zcode.z.ai,拼出的地址必然 404。
2026-10-13 17:01:06 +08:00
6238265502 fix(channels): 修正各平台接入模型服务的 chat 端点(base_url 缺路径前缀)
接入源时 base_url + adapter.endpoint 拼出的 chat URL,此前**全部缺路径前缀**,
对着参考逐个核对后发现 9 个平台里 9 个都不对(点开一看全是 /chat/completions
的裸拼接):

- trae      → /api/agent/v3/llm_utils_chat(非 OpenAI 兼容,SOLO 端点)
- cline     → /api/v1/chat/completions
- lobsterai → /api/proxy/v1/chat/completions
- qoder     → /model/v1/chat/completions
- buddy     → /v2/chat/completions
- codearts  → /api/v2/chat/completions
- opencode  → /zen/v1/chat/completions
- raccoon   → /api/web/llm/v2/chat/completions
- loomy     → /chat/completions

改法:base_url 直接含完整 chat 路径(适配器 endpoint 保持通用),
注册源时即为最终地址。判据:逐平台与参考真值字符串比对,9/9 一致。

⚠️ 已知未闭环:trae 的**载荷**仍是 OpenAI 形状直发,而参考要求转 SOLO
格式(config_name/function,trae-adapter.ts:374 有 serializeMessages)。
该转换属独立性工作,下一步单独处理。
2026-10-13 17:00:04 +08:00
4bdde9cd61 fix(channels): 逐平台对齐参考插件的模型目录(兜底表全量)
照参考 *-product.ts 的 FALLBACK_MODELS 逐项补齐,此前每个平台都只挑了
几个"看着像"的 id,属偷工减料:

- trae      7 → 28 可见 + 4 hidden(照 isHidden 语义单列 models_hidden,
            不污染用户可选列表:browser_use_subagent / explore_sub_agent_v13 /
            explore_sub_agent_v2 / summary 都是内部子 agent)
- lobsterai 3 → 19(远端 /api/models/available 须带 model_flag,否则少
            kimi-k3 —— 参考注释点名)
- opencode  3 → 14,并带 models_free(isFree 是官方语义:7 个 free 匿名可用)
- qoder     17 → 19(补 CN 独有的 q37fmodel / gm51model,均 1M 上下文)
- minimax   3 → 4(补 MiniMax-M2.7)

model_list 相应支持 models_hidden(不进可选列表)与 models_free(isFree 字段)
—— 此前 isFree 被硬编码为 false,把官方免费模型标成了付费。

对齐后与参考逐平台零差异(比对脚本结果):trae / minimax / cline /
lobsterai / opencode / qoder / zcode / loomy / raccoon 全部一致。
2026-10-13 16:56:21 +08:00
70c24ba158 fix(channels): CodeArts 改为真实 IAM OAuth 登录(PKCE + DPoP ES256)
此前标成 flow="paste"(手填凭据)是**错的**:参考 login.ts/oauth.ts 默认走
新式 IAM OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、
收 code 后带 DPoP 证明向华为 STS 端点换 AK/SK/securityToken。

逐项对齐参考:
- authorize URL:code_challenge_method=SHA-256(**不是** S256,错值会让
  portal 回退旧 ticket 流程)、theme=2、locale=zh-cn、plugin-name=snap_AIIDE、
  plugin-version=5.2.0,且**不得**含 auth_callback_url
- 回调路径 /oauth/callback,端口 ≥10000(低于此 portal 拒绝)
- PKCE:verifier=48 随机字节 base64url,challenge=sha256(verifier) base64url
- DPoP:ES256 JWS,header{alg,typ,jwk} + payload{htm,htu,iat,jti},
  签名为 **raw r||s 64 字节**(openssl 出 DER,必须转格式)
- token 请求:POST 表单 + DPoP 头;终态只认 invalid_grant /
  ExpiredRefreshToken(**不认** InvalidDPoPHeader)
- 凭据含 refresh_token/code_verifier/dpop_private_key_jwk 以支持静默续期
- 旧 ticket 流程保留作回退(pollForCredential 语义)

本机实测的坑(已修):xxd 不存在(用 od);openssl ec -text 会截断 priv
(d 用 asn1parse 的 [HEX DUMP],tail -c 32 拿到的是公钥 y);min_port 参数
读到 argv[2](与 out_path 冲突)。

判据(4 条,全绿):
- TestCodeArtsDpopJwsVerifiesAsES256:用 Go crypto/ecdsa 当权威验签器,
  验 JWS 是 64 字节 raw 且真实可验(只断言"能拼出字符串"防不住格式错)
- TestCodeArtsOAuthEndToEnd:假 STS 上游,检查交换请求形态(DPoP 头、
  grant_type、code_verifier、redirect_uri)与凭据含续期材料
- TestCodeArtsLoopbackPortAtLeast10000:量实际端口与回调路径
- TestCodeArtsSignatureMatchesReference:既有签名判据
2026-10-13 16:51:15 +08:00
c1733feeaa feat(channels): Qoder 每日活动领取(多活动逐个领)+ 三条误报陷阱全部钉死
## 参考依据
`qoder-credits.ts` 的 `claimQoderDailyCheckin` / `parseQoderCampaigns` /
`claimQoderCampaign` / `hasQoderCampaignRefreshedToday`。
参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在实现与判据里钉死:

### ① 「无可领活动」必须是 inactive,不能报 already-claimed
旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项(含**请求头不完整**、
未到刷新时间、本就无活动三种情形),界面就谎报已领,与官方 IDE 的"可领取"直接矛盾。
用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」。

### ② 每日 10:00(UTC+8)刷新;刷新前的 CLAIMED 属于**昨天**
此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。
必须用**算术平移**判 UTC+8 —— 用本机时区在出差/改时区时会判错方向
(偏东提前把当天记为已处理、真漏领;偏西一天判两次)。

### ③ 未开通要给 action_required 的可操作提示
判据是**两条同时**满足(无 CLAIM_BENEFIT + 用量响应里 `addOnQuota` **字段缺失**)。
⚠️ 用「字段是否存在」而非「remaining 是否为 0」—— 后者对"额度用光"与"从未开通"
不可区分,会把用光额度的老账号误报成未开通。文案要可操作(去官方客户端登录一次),
并作为显式字段透传给 UI,而不是让 UI 猜文案。

### 其他要点
- 一个账号可能同时有**多个** CLAIM_BENEFIT 活动 → 逐个领取(实测有每日 100 与运营活动)
- 幂等判据是响应体的 **`replayed`**,不是 HTTP 状态码:重复领取同样回 200,
  但 `replayed:true` 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")
- 请求体必须是**空串**(抓包实测 `content-length: 0`)
- 缺 `Cosy-MachineToken/MachineType` 时服务端不下发可领活动(见上一个提交)

## 判据
- `TestQoderClaimDecisionTable`:6 个用例覆盖四类结果 + claim 次数 + body 必须为空
- `TestQoderRefreshUsesUTC8NotLocalTime`:**注入 UTC 时刻**验证刷新判定

★ 修判据自身两处缺陷(首轮变异存活):
1. `TestMain` 把测试进程 TZ 钉成 **UTC** —— 本机恰是 HKT(+0800),于是
   「UTC 平移」与「本机时区」结果**完全相同**,误用本机时区的实现抓不到(判据不可达)。
2. 决策表缺「无任何活动且已开通(addOnQuota 存在)」用例 →
   把 inactive 误报成 already-claimed 的变异得以存活。补齐后两个变异都被抓。

## 统一前向声明(Lua 作用域,本文件第 N 次)
把 `qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion`
统一在 PLATFORMS 之前前向声明。此前逐个补、位置还放错过一次,导致运行时报
`attempt to call global 'x' (a nil value)` 而编译期静默。

## 文档
`docs/channels-parity.md` 更新:gemini-sigstore 标注为**⛔ 不适用**
(llmsproxy 的推理走无状态适配器,插件不参与推理 —— 插件侧缓存会成为死代码;
若将来要支持,位置在适配器侧,与"适配器无状态"约定冲突,须单独决策)。
2026-10-13 16:11:11 +08:00
5b433b3266 feat(channels): Qoder 设备身份(Cosy-MachineToken/Type)—— 活动可见性的前提
## 参考依据
`qoder-machine.ts`。参考用**抓包 + 消融实验**定位过一个真实缺陷:用户报
「Qoder 没领过积分却显示『今日已领取』,去 IDE 看还能领」。

根因:`/sash/` 端点不只要 `Cosy-ClientType`,还校验**设备身份**。消融表(同账号、只改头):

| 请求头 | /sash/api/v1/me/campaigns |
|---|---|
| 仅 Cosy-ClientType: 10 | `claimable:false`,1 条 VIEW_DETAILS |
| + MachineToken + MachineType | **`claimable:true`,2 条,含 CLAIM_BENEFIT/amount:100** |
| 全套 − MachineToken(或 − MachineType) | 退回 1 条(失效) |

⇒ 两值**必须成对**,`Cosy-ClientType` 是必要但不充分条件。

## 实现(三级,与参考一致)
1. **主路径**:实时调 `runtime-info`,参数顺序必须是 `[environment, '--account-stdin']`,
   `environment='3'`(对应 asar 的 `'global'` 分支)。**漏环境值会拿到另一套身份**
   (参考实测:漏了只回 1 条 VIEW_DETAILS)。stdin 传 `{"account":""}`,
   从输出第一个 `{` 起解析 `machineToken`/`machineType`。
   定位方式照参考:`<home>/.qoder[|-cn]/.bin/umid-*/runtime-info`(目录名带哈希,必须枚举)。
2. **退路**:磁盘缓存 `machine_token.json` 的 `token`/`type`(候选路径含 Linux
   `~/.config/Qoder/…`,并支持 `QODER_MACHINE_TOKEN_PATH` 覆盖)。
3. **降级**:都拿不到时**照常发请求、不带这两个头**(保守降级)——
   纯插件登录、未装桌面端的账号没有该文件,不能让整个积分功能报错。
   注意:**不写空串**(发空头可能被按非法设备处理)。

结果缓存(拿不到也记 null),不反复读盘/spawn。

## 接线
qoder 平台加 `headers_fn`:`Cosy-ClientType: 10` + (拿到时)成对的身份头,
余额与(后续的)活动领取共用。

## 判据
`TestQoderMachineIdentityPairingAndDegradation`:有缓存 → 成对带上;无任何来源 →
降级且不报错、仍带 clienttype。变异验证:只发 token(不成对)、拿不到时写空串
—— 两个都被抓。

## Lua 前向引用(本文件第 N 次)
`qoderMachineIdentity` 定义在文件后段,而 qoder 平台表在前面 —— 前向声明必须
放在 **PLATFORMS 之前**(第一次放错位置,等于没声明)。
2026-10-13 15:52:26 +08:00
d1b7d94043 feat(channels): 有道每日活动领取(3 步流程)+ 修 claim_daily 前置条件
## 参考依据
`lobsterai-credits.ts:claimLobsteraiDailyCheckin`。此前只实现了余额查询
(profile-summary),**每日活动领取缺失** —— 用户的余额能看,活动奖励领不到。

## 实现(逐行对齐参考的判定顺序)
① `GET /api/client-activities/slot?placement=desktop_sidebar&clientVersion=…
   &containerApiVersion=2&platform=win32`
   - 查询失败 → failed;`slotState≠available` 或无 `activityCode` → **inactive**
② `GET /api/client-activities/{code}/context?configRevision=…`
   - 查询失败 → failed;`claimedToday` → already-claimed;`actions` 不含 `check_in` → inactive
③ `POST /api/client-activities/{code}/actions/check_in`
   body 带 `configRevision` 与**客户端幂等键**(对齐参考的 uuid4)
   积分字段三级回退:`creditsGranted` → `rewardCredits` → `credits`

参考注释点名:**无可用活动是正常业务状态**,不能显示成"领取失败"(否则用户会去查
网络/账号,方向全错)。这条在判据里被钉死(含反向变异)。

## 顺带修掉一个阻断性缺陷
`claim_daily` 原本硬要求 `plat.checkin`,而**有道根本没声明 checkin** ⇒ 新钩子
永远走不到(测试直接暴露:返回"平台未声明 checkin")。改为「有 claim_fn 即放行」
(codearts 的 4 步流程同样受益)。通用路径对 `plat.checkin` 的取值也加了保护。

## Lua 前向引用(本文件第 N 次踩)
新代码段用到定义在文件后段的 `local` 助手,Lua 作用域看不到后面的定义:
`lobsteraiClientVersion` / `rfc3986Escape` / `newToken` 依次报
`attempt to call global 'x' (a nil value)`。统一在段首前向声明
(改为赋值形式)。这类错误只在运行时暴露,编译期完全静默。

## 判据
`TestLobsterActivityClaimDecisionTable`:5 个用例覆盖决策表四类结果
(inactive×2 / already-claimed / success)+ 「claim 是否该发出」+ 「body 不得为空」。
变异验证:把 inactive 误报为 failed、忽略 claimedToday —— 两个都被抓。

## 可测性
`LOBSTERAI_ACTIVITY_BASE` 可由 `set_endpoints` 覆盖(测试指到假上游)。
2026-10-13 15:44:51 +08:00
1586a051da fix(channels): CodeArts 签名按参考重写(华为云 SDK-HMAC-SHA256)+ 完整 4 步领取
## 问题:签名是实现者凭印象编的
原实现用 `SDK-HMAC-SHA256 Credential={ak}/{ts}/{nonce}/sdk_request` —— 那是另一套
协议(阿里风格)的形状,与参考的**华为云 SDK-HMAC-SHA256** 完全不同:
- canonical request 拼装错(参考是 METHOD\nURI\nQUERY\n排序后的 k:v 行\n\nSIGNEDHEADERS\nPAYLOADHASH)
- string-to-sign 格式错(参考是 "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}")
- Authorization 头用 Credential= 而非 **Access=**
- 少了 x-sdk-content-sha256 / x-security-token 参与签名
- URI 末尾未补 '/'
⇒ 服务端只会回 `APIG.0301 ... verify ak sk signature fail`,看起来像"账号/权限问题",
这个平台注定领不到额度。

## 修
逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`:
1. canonical request / string-to-sign / signature 全部照参考
2. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
   (非 GET 再加 content-type),按字典序拼 SignedHeaders
3. Authorization = `SDK-HMAC-SHA256 Access={ak},SignedHeaders=…,Signature=…`
4. Agent-Type / X-Language 是**签名后**追加的,绝不进 canonical(否则验签失败)
5. URI 末尾补 '/'
6. 完整领取 4 步(参考的 6 步判定顺序):账户类型 → 活动列表 → claim →(benefit.id
   非空时)confirm;用 done 哨兵回给 claim_daily(它自己发多个请求,契约只发一个)
   - 非积分账户 → inactive(Token 账户不该被报成"领取失败")
   - activity.claimable=false 且状态属已领取态 → already-claimed(**唯一的幂等保护**,
     本协议没有幂等键)
   - confirm 失败不算整体失败(积分已在待确认态),只是降级提示

## 判据(差分,与参考逐字节一致)
`TestCodeArtsSignatureMatchesReference` + `tools/codearts_sig_expected.mjs`
(照抄参考函数生成期望值)。覆盖 canonical / string_to_sign / signature /
SignedHeaders / Authorization 形状 / canonical 不得含签名后追加的头。
变异验证:`Access=`→`Credential=`、去掉 content-type 参与签名、URI 不补斜杠
—— 三个全部被抓。

★ 修判据自身缺陷:首版探针**自己重算** canonical,导致真实签名被改坏时判据仍绿
(去掉 content-type 的变异存活)。改为让签名函数返回真实中间值,探针直接取用。

## 文档
新增 docs/channels-parity.md:与参考逐平台对齐清单(登录 / 额度 / 推理链路三张表
+ 差分判据表 + 未对齐项按影响排序),未对齐项**明示**而非含糊。
2026-10-13 15:38:35 +08:00
41ff6d1be3 fix(channels): 修服务端浏览器栈的四处泄漏(含宿主 SHM 耗尽)
这些缺陷让「测试单独跑绿、全量跑红」,在生产同样会让登录用到一半失败。

## ① 宿主级:SIGKILL 杀 X 进程 → SysV SHM 段耗尽
X 服务用 MIT-SHM 与客户端通信,被强杀时这些段无人释放,成为系统级孤儿段
(nattch=0 但占着 kernel.shmmni 名额)。实测单个 Xvfb 约 84 段,上限 4096
⇒ 约 48 次异常退出即耗满。用满后**宿主上任何**新进程 shmget 都失败:
x11vnc 从 0.2s 能监听变成永远起不来(表现为「x11vnc 端口未就绪」)。
修:回收改为**先 SIGTERM、等 0.4s、再 SIGKILL**,让 X 自己释放 SHM。
实测 core 全包前后 shm 84 → 84(零泄漏)。

## ② 显示器锁泄漏 → 号段被烧穿
强杀留下 /tmp/.X<n>-lock,而选号是「锁/socket 存在就当被占」⇒ 每个被强杀的
显示器永久烧掉一个号;累积满 91 起后 pickFree 扫不到空位就 return start(91)
→ Xvfb 报 “Server is already active for display 91”(实测 41/41 全占)。
修:pickFreeX 回收陈旧锁(用 /proc/<pid> 判存活,不信 shell 退出码)+ 扫 200 个号
+ 扫不到如实报错;收尾清掉自己占的锁与 socket。

## ③ chromium profile 复用 → SingletonLock 卡死
数据目录原用 seq 编号,插件重启后 seq 重来 ⇒ 复用同目录 ⇒ 残留 SingletonLock
让 chromium 只打印 “Opening in existing browser session.” 就退出,新端口永不监听
→ 报「chromium 端口未就绪」。
修:目录名用唯一 tag(时间+随机),并在收尾清掉。

## ④ callback 流漏收浏览器栈
callback 流在 start_login 就拉起服务端浏览器栈,但没有任何路径负责回收
⇒ 每次放弃登录都留一套(实测一轮 core 全包攒 7 套 websockify+x11vnc)。
修:会话过期时若栈属于该会话就收掉;起新栈前先收旧栈(单会话栈);
kernelTestCore 统一 t.Cleanup 调 stop_browser。

## 判据
- TestXDisplaySelectionReclaimsStaleLocks:陈旧锁必须被**回收复用**(只跳过不算通过)
- TestXDisplaySelectionKeepsLiveLocks:活锁不得被回收(反向)
- 两个变异(去掉收尾清理 / 有锁就当被占)均被抓

## 另:变异测试会污染生产源码,加进程级兜底
discover_mutate_test.go 会改写 internal/gateway/discover.go,靠 defer 回滚 ——
进程被强杀(go test 超时 / Ctrl-C / 误杀 shell)时 defer 不执行,变异体留在工作区,
下次运行会把它当"原始源码",所有裁决静默失效(本仓真实踩过两次)。
新增 mutation_guard_test.go:进包即自愈已知变异体。
2026-10-13 15:24:07 +08:00
6fe6014d92 chore: 清理 emoji 与修正发布脚本仓库名
产品代码统一为纯文本风格(照参考实现),去掉 emoji:billing/quota/usage 插件、
channels 的提示文案、WebUI 文案,以及 go 测试、docs、packaging 脚本中的
注意/OK/FAIL 等符号化写法。第三方目录(vendor、node_modules、.probe)不动。

另:packaging/upload-release.sh 的仓库名改为真实全名
JianFeeeee/ModelRouter → HeNanMU-EEA/ModelRouter(前者是重定向,POST/PUT 不跟随
会 404)。
2026-10-13 13:42:57 +08:00
5ab047e48b fix(channels): 修复 12 平台登录的系统性偏差(callback 闭环 + 动态模型 + Trae 设备身份)
用户报障:「除了 codebuddy,其他的登陆都不太正常,尤其是有道那个」。逐平台审计
参考实现(vendor/channel-pack/src)后发现根因不是 12 个独立 bug,而是几个系统性偏差。

## ① callback 流的回调是回环地址 → 必须走服务端浏览器(核心认识)
Trae/Gemini/有道 的 redirect_uri 都是回环地址(127.0.0.1 / localhost)。回环地址由
「打开授权页的那台机器」解析:远程用户在自己电脑上打开,重定向就落在**用户机器**上,
回调永远到不了网关。CodeBuddy 一直是好的,因为它走 state-poll,**根本不需要回调**。
→ 插件自起 loopback 接收器(Python,绑回环、双栈,路径按各平台注册值),
  由服务端浏览器(VNC)承载授权页,重定向落回本机。

## ② 三个平台的回调路径各是注册值(写错就是 redirect_uri_mismatch)
  有道 /auth/callback、Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)
  且 Gemini 的 redirect_uri 主机是 **localhost** 而不是 127.0.0.1。
  另:Trae 的回调**不带 state**(token 直接放 query),其余平台必须带 → 校验规则按形态分。

## ③ 有道版本号不能写死
exchange/续期/签到的 version 是必填。实测 2026-10-13 上游已是 2026.9.23,而参考的兜底
常量还停在 2026.9.4。新增 lobsteraiClientVersion:远端为主(data.value.version,**非统一
信封**)+ 失败用兜底但**不缓存**(照参考 resolver)。失败时把上游 message 带出来
(实测形如 code=40102「无效或已过期的授权码」)。

## ④ Gemini token 交换缺 client_secret(症状正是「授权成功但账号不出现」)
参考注释点名:token 端点强制校验客户端身份,只发 client_id 会 invalid_request。
同时补 access_type=offline / prompt=consent(缺了拿不到 refresh_token),
并去掉误标的 pkce(上游 Cloud Code 用的是最朴素的 authorization_code + client_secret)。

## ⑤ Trae 授权 URL 少了 5 个必需参数
参考列为「一个都不能少」:login_trace_id(回调**反查 pending 的唯一凭据**)、
machine_id/device_id(hex32,每账号必须不同,否则被「该设备已签到」拦)、
x_device_id/x_machine_id。此前**完全没造**设备身份 → 登录页停在「授权中」。

## ⑥ Trae 签到头与设备身份
授权方案是 `Cloud-IDE-JWT`(不是 Bearer);参考点明简化头**实测签到失败**,
需约 20 个客户端头,且 X-Device-Id 是**基于 user_id 确定性派生的 15 位数字**。
实现 seededStream(SHA256(utf8(salt:seed) ++ counterBE32) 串联)+ 三级派生,
并用参考实现(Node)生成期望值做**差分验证**逐字节对齐。
凭据改为 JSON 以保留 uid/device_id/machine_id(签到与轮换需要)。

## ⑦ 同段逻辑抽一个助手,防两条路漂移
lobsteraiExchange 被 oauth_callback 与 poll_login 两条路共用(原先各内联一份)。

## 判据(新增测试)
- TestLobsterLoopbackClosedLoop / TestLobsterExchangePreservesIdentity:loopback 闭环、
  state 不匹配 400、重放 409、凭据含 uuid/first_keyfrom
- TestCallbackPlatformsLoopback:三个平台的路径与 state 规则、Trae 无 state 也能接住
- TestAuthorizeURLsHaveNoUnfilledPlaceholders:授权 URL 不留 {占位符}
- TestTraeDeriveMatchesReference:与参考实现**逐字节**差分(tools/trae_derive_expected.mjs)
- TestTraeCheckinHeaders:Cloud-IDE-JWT + 派生设备身份 + 客户端头齐全
- TestRemoteModelCatalogMergesWithFallback:远端为主、兜底补位、失败不清缓存

## 顺带
- set_endpoints 补 callback / client_version 键(否则测试无法把端点指到假上游)
- 删掉 spawnBrowser 里会把 redirect_uri 一律改写为插件挂载点的死代码(对有道致害)
- session 过期与插件卸载时回收 loopback 接收器进程(防进程泄漏)
- e2e/channels_flows_e2e_test.go:qoder 已从 manual 改为 device-poll,断言同步更新
2026-10-13 13:42:45 +08:00
ec5e6dd57c fix(channels): Loomy 真实微信 appId/uuid 正则修正 —— 之前是我编的值
## 用真上游实测逼出来的两处错误
批准出站主机后逐个平台打真上游,Loomy 报「授权页未包含二维码 uuid」。
核对参考常量后发现:

1. **appId 是我编的**:我写 `wx16e5c2b8e0c0e1f2`,参考是
   `wx18d60be432287cf8`。上游不认这个 appid,返回的页面里自然没有二维码。
   同时补上微信要求的固定片段 `#wechat_redirect`。
2. **uuid 正则不是参考的那条**:我写的是 `uuid="..."` 形式,而真实微信页把
   uuid 内嵌在 `<img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>`,
   兜底路径是 `l/qrconnect?uuid=<uuid>`。照参考 extractLoomyWechatUuid 的两条
   正则改写(它还带一个 `^[A-Za-z0-9_\-=+/]{6,64}$` 的字符集校验)。
3. UA 逐字统一为参考的 WECHAT_UA(`Chrome/120.0.0.0`)。

## 教训(值得记)
「照参考逐行移植」这一条,我在这两处做成了「凭印象写个看起来像的」。
**判据是端到端打真上游**,不是测试通过 —— 单测用的是我自己编的夹具,
夹具与生产同源出错时测试照样绿。这次是真上游把它逼出来的。

## 判据
- 测试夹具改为**真实微信页面形态**(`<img ... src="/connect/qrcode/<uuid>">`):
  用别的形态(如 `var uuid = "..."`)测出来的正则不是生产要用的那条。
- 生产实测:Loomy 从真微信拿到二维码 uuid(`open.weixin.qq.com/connect/qrcode/<uuid>`)。
- 四个新平台真上游全部 pending 且返回有效授权入口:
  qoder→qoder.com(真实 PKCE challenge)、zcode→bigmodel.cn、
  loomy→open.weixin.qq.com(真实 uuid)、raccoon→xiaohuanxiong.com(本地 code)
- 全量 core 回归通过;内核对照 v2.0.2 diff 为空。

## 出站主机授权
channels 的 27 个声明主机已全部在设置页批准(8 个新增 + 陈旧条目自动清理),
落盘到 .kernel-perms.json,重启后保留。
2026-10-13 10:17:36 +08:00
705e079285 feat(channels): Raccoon 扫码登录实现 —— 12 个平台零 manual
第四处也是最后一处「把没实现说成做不到」:此前标 manual,理由"要扫图片二维码"。

参考事实(raccoon-oauth.ts / raccoon-login-page.ts 注释):
  · `qrcode_code` 由**宿主侧本地随机生成**(16 字节 hex),服务端接受任意
    自造 code 并进入 pending —— 完全绕开收不到的自定义协议回调
  · 二维码只是一张公开页面 {apiBase}/login/mp?code=...&appname=...
  · 轮询 POST /api/web/auth/v1/login_with_qrcode_code {qrcode_code}
    → data.status ∈ pending / logging / canceled / success
  · **success 但缺 token 视为未完成**(否则产出空凭据并让流程卡死)
  · 参考明确:任何异常都降级为 pending(网络抖动/畸形响应/未知 status)
真正需要人工的只有短信路径(阿里云滑块 captcha_param + 手机号 AES 加密)——
那一条不做,但扫码这条完全自动化。

## 同时按参考修正的两处错误
- `base_url` 写成 raccoon.sensetime.com,实际是 **xiaohuanxiong.com**;
  推理路径是 `/api/web/llm/v2`(标准 OpenAI 兼容)
- 余额路径缺 `/api/web` 前缀,且未带 `X-Org-Code` / `X-Raccoon-Language` 头
  (照参考 raccoonHeaders)

## 判据
TestRaccoonQrPollBehavior(5 项):本地生成 32 位 hex code + 二维码 URL;
logging 保持 pending;success 且带 token 才完成;canceled 是终态失败;
**缺 token 的 success 必须 pending**(防空凭据卡死)。
全量 core 回归通过;内核对照 v2.0.2 diff 为空。

## 十二个平台现状(零 manual)
trae/minimax/cline/buddy/gemini/lobsterai/opencode/codearts 原有流程 +
qoder(device-poll) / zcode(init-poll) / loomy(wechat-poll) / raccoon(qr-poll)
2026-10-13 10:08:39 +08:00
8949405746 feat(channels): Qoder / ZCode / Loomy 从 manual 变为真正可用的自动化登录
审计 12 个平台时发现:四个标 flow="manual" 的平台里,**三个的登录协议完全可以在
Lua 里实现** —— 我把"没实现"写成了"做不到",理由全是错的。

## Qoder:WASM 是聊天加密,不是登录
错误理由:「登录要 WASM 签名头」。实际:WASM 用于 **/algo/ 加密推理端点**,
登录本身是标准 PKCE 设备码轮询(qoder-oauth.ts / qoder.ts):
  授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
           &nonce&machine_id&client_id(本地拼装,不打网络)
  轮询   = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
三个必须照抄的细节(参考点名):
  · client_id 用 **prod 的**(非 prod 会在授权回调阶段被拒)
  · poll 挂 openApiBase(openapi.qoder.sh);authBase 返回 401
  · 404 = 尚无会话(**继续等**,判失败会让登录永远失败)

实现:新增 judge="pending404" 判据 + 既有的 device-poll 机制支持
`GET_QUERY`/`auth_query`(PKCE 授权页形态,无 device_code)。
顺带修 poll_login 的条件(原要求 `s.device_code`,PKCE 形态没有它 → 永远不推进)。

## ZCode:授权流不涉及 captcha
错误理由:「登录要解 captcha」。实际:captcha 属于**另一条已废弃的旁路**
(读本机官方客户端数据);本流注释明确写着「普通进程就能走完」:
  ① POST {origin}/api/v1/oauth/cli/init  Bearer <本地造的 flow_secret>
     → {flow_id, authorize_url, poll_interval_sec}
  ② 用户浏览器打开 authorize_url
  ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
判据(参考点名):4xx(**除 408/429**)才是终态失败;code!=0 或 data 缺失 → pending;
token(zcode JWT) 与 user.user_id 必需,第三方 token 取 bigmodel/zai 二者之一。
另按参考修正 base_url/adapter(走 Anthropic 协议的 zcode.z.ai 免费额度通道)
并补余额端点。

## Loomy:扫码在手机上,协议在 HTTP 里
错误理由:「扫码在微信里完成,Lua 做不了」。实际:扫码动作确实在用户手机上,
但协议全是纯 HTTP:
  ① 取 uuid(授权页 HTML 里抽)② 二维码图 URL(前端渲染)
  ③ 长轮询 long.open.weixin.qq.com/connect/l/qrconnect
  ④ wx_code → 讯飞账号端点换凭据(HMAC-SHA1 签名)
判据(参考点名,早期版本**读反了**导致"扫码后卡在已扫码"):
  408=等待;404=已扫码待确认(**继续轮询**);405=**已确认**(wx_code 在此帧);
  403=取消;402=失效
签名:内核只有 sha256/hmac_sha256,没有 sha1/md5 —— 插件用 openssl 算
(HMAC-SHA1 与 Python 实测一致),**不改内核**。账号头前缀是 `account ak:sig`。

## 顺带修的真 bug
1. `loomyAuthHeaders` 调 `newToken()`,而它定义在文件后段 → Lua 词法作用域下是
   nil,调用即崩。改用内核全局 sha256_hex 造 nonce。
2. `declaredHosts()` 只看 hosts/base_url/checkin → 各 flow 的端点域名
   (open.weixin.qq.com / account.xfyun.cn / openapi.qoder.sh / zcode.z.ai …)
   不在出站白名单里,授权页与长轮询会被拦。已按 flow 补全推导。
3. `set_endpoints` 不支持 wechat —— 自动化测试无法把 Loomy 端点指到假上游
   (端点覆盖是参考明确的产品配置能力,也是测试缝)。已补 wechat/initpoll。
4. 两处 Lua 字符串引号错误(`"$(cat ...)"` 与 `"([^"]+)"` 提前结束字符串,
   插件编译失败)。插件加载测试抓到的。

## 判据
- TestQoderDevicePollBehavior(5 项):授权页含 PKCE 四要素与 prod client_id;
  404 保持 pending;轮询带 verifier/nonce;拿 token 即完成;池路径凭据落账号 ref
- TestZcodeInitPollBehavior(4 项):init 带会话密钥与 provider;pending 保持;
  ready 完成且凭据含 zcode_jwt + 第三方 token;池路径
- TestLoomyWechatPollBehavior(4 项):抽出 uuid 并给二维码;**408/404 都是
  pending**(404 非完成);405 才完成且账号端点用 account ak:sig 签名;403/402 失败
- 全量 core 回归通过;确认测试不写生产插件目录、无孤儿进程
- 内核对照 v2.0.2:diff 为空

## 剩余
Raccoon 仍为 manual(本地承载页 + SMS,含人工输入验证码),另做。
2026-10-13 10:02:30 +08:00
d86514c474 feat(channels): VNC 兜底真正可用(依赖预检 + 就绪探针 + CDP 取凭据)
用户指出「vnc 无头浏览器兜底登陆你根本没有实现」。核对后确认:进程层代码存在,
但**只接在一个我编的假平台上**(loopbackish,已随夹具移出),四个真实平台全标
flow="manual" 并给一句"请你自己去某处搞定"。且代码写着「运行 deploy 脚本一键
安装」,而那个安装机制根本不存在(deploy.sh 里零浏览器栈内容)。

## 实现
- **依赖预检** browserDeps():探测 Xvfb/x11vnc/websockify/noVNC/chromium/
  python3-websocket,如实报告缺什么、装哪个包、干什么用。
- **就绪探针** waitPort + X socket 等待:不再 sleep 2 拍脑袋(view_url 先于
  服务可用会让 iframe 白屏)。
- **CDP 取凭据**:浏览器带 --remote-debugging-port,登录完成后经 CDP 读
  localStorage/sessionStorage/cookie 或平台声明的 collect_expr。人只负责在 VNC
  窗口里交互,机器负责把结果取出来 —— VNC 不该只是"看屏幕"。
  实测:在真 HTTP 页面上 `localStorage.getItem('access_token')` → REAL-TOKEN-XYZ,
  `document.cookie` → session=COOKIE-VAL,全部正确。
- **新端点**:channels/deps/status、browser/collect、browser/stop、browser/status。
- **安装入口** packaging/install-browser-stack.sh:识别 apt/dnf/yum/apk,
  --check 只检查;装进发行包(/usr/share/llmsproxy/)。插件 install_hint 指向它。
  默认不自动装:系统级包、数百 MB、chromium 安全面大,多数部署用不到 VNC。

## 过程中修掉三个真 bug(都是测试逼出来的)
1. **Xvfb 从未被启动**:steps 数组里有 Xvfb,但循环明确 `if st[1] ~= "Xvfb"`
   跳过它(想先等 socket)→ 等一个永远不会出现的 socket。
2. **display 号按 seq 取模**:`90 + (seq % 10)`。Xvfb 锁文件在强杀后残留,
   重启后 seq 从头 → 又选同一个号 → `Fatal: Server is already active`,
   而报错只显示"Xvfb 未就绪"。改为**探测空闲**号(锁+socket+端口)。
3. **io.open 判 socket 存在**(两处):X socket 是 socket 文件,io.open 返回 nil
   → 探测误判"空闲"、等待循环永远等不到。改用 `[ -e ]`。
4. **孤儿进程**:spawn 往局部 pids 表追加,只在函数末尾回写 b.pids →
   中途失败时 reapBrowser 读到空表,已起的 Xvfb 不被杀(实测留下 4 个孤儿)。
   改为即时回写。

## 判据
- TestChannelsVncStackEndToEnd:注入 flow=vnc 平台 → 真起浏览器栈(view_url 含
  vnc.html + CDP 端口)→ 回收。缺依赖则 SKIP 并说明。
- TestChannelsVncNoOrphanOnFailure:回收后 running=false、无孤儿。
- TestChannelsBrowserStackDeps:依赖清单三个字段齐全、缺件必须给安装提示。
- TestChannelsBrowserEndpointsRequireSession:无会话时三个端点如实报错/幂等。
- 全量 core 回归后确认:生产插件目录未被写入、无孤儿进程。

内核对照 v2.0.2:diff 为空(本次零内核改动)。
2026-10-13 09:33:18 +08:00
d4ae2a1d13 fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
## 回退的内核改动
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
  1. internal/lua/vm.go  +32 行  新增 base64_decode 全局函数
  2. internal/lua/plugin_http_handler.go +16 行  InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。

## 为什么两处都是错的
参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
  → 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
    nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
  `channel-pack/state.json`,从不依赖宿主 settings。
  → 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
    后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
    宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
    分发路径的实现细节。

## channels 改为插件自持存储
- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
  (账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
  凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
  各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
  经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
  状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
  文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
  时也删遗留,否则内核 restore() 会在启动时用它覆盖。

## 顺带修了两个真实缺陷
1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
   决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
   **生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
   prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
   且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。

## 判据(变异全部咬人)
- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
  新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
  变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
  只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
  遗留文件删除。
- 内核对照 v2.0.2:diff 为空。

go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
2026-10-13 09:18:15 +08:00
652a035d47 fix(channels): 测试夹具不再内联进生产目录 + callback 池路径漏网
用户指出「为什么生产插件会有实例平台?」—— 确实是设计错误。

## ① 夹具内联进生产源码(根因)
`PLATFORMS` 里内联了 4 个测试夹具:
  example / googleish / loopbackish / cookieish
后果:生产 `/api/plugins/http/channels/platforms` 返回 **16 个平台,其中 4 个是
夹具**,运维在 UI 上真的能选到。夹具属于测试,不属于发行物。

修法:
- 生产 `PLATFORMS` 只留 12 个真实厂商。
- 新增 `plugin.state.injected_platforms` + `allPlatforms()`;`platformById`
  先查内建再查注入表。
- 新增端点 `channels/inject_platforms`(roles=admin):运行时整表替换注入,
  边界校验 —— 不得覆盖内建平台(注入不能劫持真实厂商)、拒绝重复 id、
  拒绝无 id、限 32 条;空数组 = 清空。
- 8 处运行时遍历点(list_platforms/provider_status/backup/auto_checkin/
  gateway 开关等)改用 `allPlatforms()`;4 处**加载期**遍历(HMAC 钩子绑定、
  declaredHosts)保持 `PLATFORMS` —— 它们在 plugin.state 建立之前、注入尚未
  发生,改了无意义。
- 测试改为经 `injectTestPlatforms` 注入夹具(channels_plugin_test /
  channels_stress_test);e2e 本就用真实平台(minimax/cline/opencode/qoder),
  无需改动。

## ② callback 路径的 account_id 漏网
UI 流程 = account/add 建占位账号 → login 带 account_id → 完成时回填。
`oauth_callback`(trae/gemini/lobsterai 的 callback 流)调 complete_login 时
**没传 account_id** —— 与 buddy poll_login 同一缺陷,此前只修了 device-poll /
state-poll 两条分支,这条是漏网:凭据落到与占位账号无关的 `channels:<plat>`,
池账号永远 has_credential=false。

## 判据
- TestInjectPlatformsRejectsBadInput:注入生效 + 三条拒绝规则 + 空数组清空
- TestTraeCallbackPoolPath:callback 池路径凭据落 `channels:trae:<aid>` 且
  account_list 报 has_credential=true
- 变异:callback 去掉 account_id → TestTraeCallbackPoolPath 立即 FAIL(已实测)

go test ./internal/core/ 全绿(119s)。
2026-10-13 08:33:28 +08:00
6bec260513 fix(deploy): 内置插件的修复无法下发 + buddy 余额解析口径
## deploy.sh:内置插件修复本该覆盖
sync_plugins 原为「只补缺,已存在一律不动」。看着安全,实际造成功能性缺口:
**内置插件自身的修复永远下发不了**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版
(实测 sha256 不同、headers_fn 计数 0),生产行为毫无变化。我自己的验证还被
这个"静默不生效"骗过一轮:测试全绿、部署成功,线上仍是旧逻辑。

修法:与 sync_adapters 同纪律 —— **内置同名文件覆盖(带备份)**,
运行时新增/运维自加的非内置名字一律不动。内置源码是发行物的一部分,
部署必须与二进制内容对齐;"只补缺"想保护的是非内置文件,两者不是一回事。

判据:部署日志出现 `~ 更新内置插件: channels.lua`;部署后生产文件 sha256
与仓库一致。

## buddy 余额:双层嵌套 + 只累加有效包
余额端点响应是 **data.Response.Data.Accounts[]**(与签到端点的单层 data
不同),原实现按顶层找 totalCredit → 永远解析不出、回落 0 或报错。
且刻意**不用**服务端的 TotalDosage:那是终身口径且取整(参考实测同一响应
TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts 现算:
只累加有效包的 CycleCapacityRemain(失效包服务端仍返回,并入会虚高),
按两位小数规整;双层缺失时如实报错,不把「查不到」当「余额为 0」。

判据 TestBuddyBalanceParseDoubleNested:100.5+55.17=155.67(失效的 500
不计入)+ 单层 data 判为失败。变异:改用 TotalDosage → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 08:05:39 +08:00
ed954b77bd fix(channels): buddy 计费请求缺身份头 → 上游 401(逐行移植参考 checkinHeaders)
修好凭据读取后,余额端点仍报 HTTP 401。逐行核对参考 checkinHeaders
(credits.ts) 后发现我们只发了 Authorization,而 buddy 要求身份标识头齐全:

  X-Domain: copilot.tencent.com   (以产品配置为准,不用凭据里的 domain 快照)
  X-Product: SaaS                 (部署类型)
  X-Product-Code: codebuddy       (产品码)
  User-Agent: CodeBuddyIDE/1.106.1
  X-User-Id / X-Enterprise-Id / X-Tenant-Id(凭据携带时)

参考的注释特别警告 X-Domain 的方向:凭据里的 domain 是「登录时那个域名」的
快照,跨产品迁移后会指错区域,所以必须以产品端点为准。

## 实现
- 平台级声明 headers_fn(accessToken, cred) → 返回请求头表;buddy 据此逐行移植。
- fetchAccountJSON 增加头钩子位:spec 自己的 headers_fn 优先,否则回退平台级。
  未声明的平台仍走通用默认头(Bearer + JSON),不改变既有行为。
- set_endpoints 补齐 checkin_status / balance 端点覆盖(此前只覆盖旧的
  plat.checkin,buddy 这类平台无从改指,自动化测试也指不到假上游)。
  端点覆盖是参考明确的产品配置能力,也是测试缝。

## 判据
TestBuddyBillingHeadersMatchReference:把 buddy 余额端点指到本地假上游,
断言四个必备身份头逐字段正确。变异:改名 headers_fn → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:57:46 +08:00
cb3ea25586 fix(lua): InvokeHandler 从不 markDirty —— 无 hooks 的插件状态永不落盘
## 现象
channels 插件重启后账号池静默清空:status 返回 accounts={}、pool=null、log=0 条,
磁盘上 `.channels.state.json` 从未存在过(billing/quota 的 .state.json 都在)。
用户侧表现为「登录了,但重启一次账号就没了」。

## 根因
插件状态持久化有三条分发路径,其中两条会 markDirty:
  - hook 路径(invoke)        → markDirtyLocked ✓
  - 导出函数路径(Call)       → markDirtyLocked ✓
  - HTTP handler 路径(InvokeHandler) → **从不 markDirty** ✗

channels 声明 `hooks = {}`,它的一切状态变更(账号入池、凭据引用、模型黑名单、
事件日志)都只经过自己的 HTTP 路由。于是状态从未被标记为脏,
`.channels.state.json` 从未写出,重启即全部丢失。

billing/quota 侥幸没踩到:billing 有 hooks,quota 走 Call。

## 修复
InvokeHandler 在成功返回前 markDirtyLocked(p),并在 handler 抛错的早退分支
也标记(抛错前可能已改过状态,那次改动仍然是真实的)。与 Call / hook 路径同模式,
只碰 stateSaver 自己的锁,不重入 p.mu(调用方已持锁)。

## 判据
internal/lua/persist_test.go::TestInvokeHandlerIsPersisted
用「无 hooks、只走 HTTP handler」的插件复现 channels 形态:两次 handler 调用后
Close()(等最后一次 flush),断言 .state.json 存在且含 2 个账号。
变异:撤掉 markDirtyLocked → 测试立即 FAIL(已实测)。

go test ./internal/lua/ 与 ./internal/core/ 全绿。
2026-10-13 07:48:15 +08:00
0232b9cd24 fix(channels): 登录后账号拿不到凭据 —— 完成路径不传 account_id + 只存裸 token
用户报障:「登录了,但插件这边无法获取账号信息」。实证:
  account_list → has_credential=false,credits/balances → 「无凭据」,
  而 UI 上账号显示已登录。

## 根因一:完成路径没把凭据写进池账号的键
UI 的真实流程是「account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
→ login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支
(device-poll / state-poll)调用 complete_login 时**没有传 account_id**,
于是走了兼容旧路径,凭据落到 `channels:<plat>` 这个与占位账号无关的键;
池账号的 credentialRef 指向 `channels:<plat>:<aid>`,从未写入
→ account_list 的 has_credential 恒为 false。

修:两条分支都传 `account_id = s.account_id`(对齐参考 BuddyAuth.login 的
`flowOptions.accountId` 语义:多账号场景下凭据按账号 ref 写入)。

## 根因二:只存裸 access_token,丢掉 refresh_token 与过期信息
state-poll 分支只取 `data.accessToken` 当凭据存,而参考
`runBuddyLoginFlow` 存的是 `buildCredential(token, account)` 的**完整 JSON**
(access_token + refresh_token + expires_at + refresh_expires_at + token_type
+ scope + domain + user_id + nickname + enterprise_id + account_type)。
只存裸 token 的后果:refreshable 恒为 false → 无静默续期;expires_at 未知。

修:新增 buildCredential(逐字段对齐参考 buddy.ts)+ jwtClaim/absExpiryMs
(参照实现:token 端点只回 expiresIn 不回 expiresAt,昵称只在 JWT 声明里)
+ credentialExpiresAtMs。state-poll 完成时存完整凭据 JSON 并回报
expires_at/refreshable。

## 配套
- 新增 credentialOf(ref):凭据取值助手,**裸 token 与完整 JSON 两种形态都认**
  (历史数据/device-poll/paste 是裸 token,buddy 是 JSON)。所有消费点
  (上游源 api_key、Bearer 头、HMAC 签名、备份)改走它,无须关心存储形态。
- complete_login 从凭据自动补齐 nickname/expiresAt/refreshable,调用方漏传也不丢。
- 内核新增 base64_decode(标准/base64url/无填充都认):插件读 JWT payload
  必需,否则要在 Lua 里重写 base64。通用能力,无厂商知识。

## 判据
TestBuddyStatePollBehavior 行2/行4:
- 行2 断言凭据是完整 JSON(含 refresh_token + 昵称),非裸 token
- 行4 回归用户报障:走 account/add → 带 account_id 登录,断言
  ① 凭据落在 channels:buddy:<aid> ② account_list 报 has_credential=true
  ③ refreshable=true
变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:41:42 +08:00
a07449d32e fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00
84a9fccd9a fix(plugins): 新内置插件要能落地 —— SeedBundled 改为按文件补齐,deploy.sh 补插件同步
真实事故:channels.lua(多平台账号接入插件)在仓库里、在 2.0 二进制里
(go:embed),生产却完全看不到它 —— /api/plugins 只有 billing/quota/usage。

根因是两处叠加:

1. SeedBundled 只在 plugin_dir **不存在**时才写内置插件。生产 plugin_dir 从
   首次安装起就存在,于是后来加入二进制的新插件永远不会落盘。目录存在不等于
   内容是最新的 —— 这个假设把"新增内置插件"整条路径堵死了。
   改为**按文件补齐**:只在该文件缺失时才 seed,已存在的一律不动(运维自己改
   过、或运行时新增的插件必须跨升级存活 —— 与适配器同一条规则)。

2. deploy.sh 完全不处理 plugins(只同步 adapters/)。即使内核修好了,部署脚本
   也没有把插件内容与仓库对齐的动作。新增 sync_plugins:备份、只补缺、对已有
   文件零改动、末尾逐个校验内嵌插件都在目标目录,摘要里打印插件目录。

测试(internal/lua/vm_test.go):
- TestSeedBundledFillsMissingFilesInExistingDir:构造确切事故条件(目录已存在、
  只有部分插件),断言缺失的被补齐、运维自己的 billing.lua 不被覆盖、补齐后
  channels 确实出现在插件表里。
- TestSeedBundledIdempotent:二次 seed 不覆盖已就位文件。

deploy.sh 通过 bash -n;go test ./internal/lua/ 全绿。
2026-10-12 19:07:09 +08:00
46 changed files with 214 additions and 7037 deletions

View File

@ -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 代码变更时递增。

View File

@ -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…`),只是**不在我们仓库**。接入时应顺手更正这句,
> 但**不要**为了「改注释」而放宽判据。

View File

@ -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`:校验器写对了但
> **没接到入口上**,问题依旧存在 —— 两条判据分别咬「规则对不对」和「规则有没有被调用」。

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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 例外也过闸")
}

View File

@ -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("✓ 判据对坏数据有判别力")
}

View File

@ -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 安全")
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -1,215 +0,0 @@
package core
import (
"regexp"
"sort"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
//
// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在
// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource`
// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是
// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。
//
// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里
// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)——
// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。
func TestAccountDeleteRemovesItsSource(t *testing.T) {
t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) {
h := newRemoveSourceHarness(t, true)
h.addAccount("del-a")
if got := h.sourceNames(); !hasName(got, "channels-buddy-del-a") {
t.Fatalf("前置条件:账号源应已建立,实得 %v", got)
}
h.deleteAccount("del-a")
if got := h.sourceNames(); hasName(got, "channels-buddy-del-a") {
t.Fatalf("账号已删但源仍在 ⇒ 已删账号继续对外服务:%v", got)
}
t.Log("✓ 删除账号同时撤掉其源")
})
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
h := newRemoveSourceHarness(t, false)
h.addAccount("del-b")
h.deleteAccount("del-b")
// 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
logs := h.eventLog()
joined := strings.Join(logs, " | ")
if !strings.Contains(joined, "撤源失败") {
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
"「删了账号却还在用」)\n事件日志: %s", joined)
}
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)
})
}
// ── harness ──
type removeSourceHarness struct {
t *testing.T
ps *lua.Plugins
}
func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness {
t.Helper()
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)
}
// ★ 照生产现状:add_source 有、remove_source 视参数而定
// 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项)
actions := []string{"add_source", "list_sources"}
if grantRemove {
actions = append(actions, "remove_source")
}
_ = ps.SetPluginKernelPermission("channels", actions, false)
return &removeSourceHarness{t: t, ps: ps}
}
func (h *removeSourceHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
}
func (h *removeSourceHarness) deleteAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{
"platform": "buddy", "account_id": id,
}); err != nil {
h.t.Fatalf("account_delete(%s): %v", id, err)
}
}
func (h *removeSourceHarness) sourceNames() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["sources"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if n, ok := m["name"].(string); ok {
out = append(out, n)
}
}
}
return out
}
// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。
// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里,
// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。
func (h *removeSourceHarness) eventLog() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["log"].([]interface{})
for _, e := range arr {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func hasName(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。
//
// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source`
// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为
// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败),
// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。
//
// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人
// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。
func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 抽出声明清单
declStart := strings.Index(s, "plugin.kernel = {")
if declStart < 0 {
t.Fatal("找不到 plugin.kernel 声明(判据需更新)")
}
declEnd := strings.Index(s[declStart:], "}")
if declEnd < 0 {
t.Fatal("plugin.kernel 声明没有收尾大括号")
}
declBlob := s[declStart : declStart+declEnd]
declared := map[string]bool{}
for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) {
declared[m[1]] = true
}
if len(declared) == 0 {
t.Fatal("声明清单解析为空(判据失效)")
}
// ② 抽出实际调用的动作
called := map[string]bool{}
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
called[m[1]] = true
}
if len(called) == 0 {
t.Fatal("没扫描到任何内核调用(判据失效)")
}
// ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒)
var missing []string
for a := range called {
if !declared[a] {
missing = append(missing, a)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("插件调用了未声明的内核动作 %v\n"+
"(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+
"的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)",
missing)
}
t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called))
}

View File

@ -1,251 +0,0 @@
package core
import (
"testing"
"llmsproxy/internal/lua"
)
// ★ account_reorder 必须**真的改变内核里的源顺序**。
//
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
// 顺序 remove+add 一遍。
//
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
h := newReorderHarness(t)
h.addAccount("acct-a")
h.addAccount("acct-b")
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
// 去掉重注册的变异**照样通过**(判据没咬住)。
before := h.sourceOrder()
if len(before) < 2 {
t.Fatalf("应有 2 个账号源,实得 %v", before)
}
if before[0] != "channels-buddy-acct-a" {
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
}
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
// 目标顺序与初始相反,故只有真的重注册才能达成。
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
after := h.sourceOrder()
if len(after) < 2 {
t.Fatalf("重排后源丢了:%v", after)
}
idxOf := func(list []string, id string) int {
for i, n := range list {
if n == id {
return i
}
}
return -1
}
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
if b < 0 || a < 0 {
t.Fatalf("重排后找不到账号源:%v", after)
}
if b > a {
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
"快到期积分仍不会优先消耗)", after)
}
t.Logf("✓ 源顺序已按重排生效: %v", after)
}
// ── harness ──
type reorderHarness struct {
t *testing.T
ps *lua.Plugins
}
func newReorderHarness(t *testing.T) *reorderHarness {
t.Helper()
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)
}
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
// ("kernel action add_source is not granted"),源一个都不会出现 ——
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
return &reorderHarness{t: t, ps: ps}
}
func (h *reorderHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
}
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
//
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
// (实测表现:顺序忽而两个忽而一个)。
func (h *reorderHarness) sourceOrder() []string {
h.t.Helper()
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
h.t.Fatalf("__list_sources_probe: %v", err)
}
j := jsonOf(h.t, ls)
var out []string
arr, _ := j["sources"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
n, _ := m["name"].(string)
if len(n) > 15 && n[:15] == "channels-buddy-" {
out = append(out, n)
}
}
return out
}
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
//
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
//
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
func TestPoolListOrderIsDeterministic(t *testing.T) {
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
//
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
h := newReorderHarness(t)
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
h.addAccount(id)
}
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
order := h.sourceOrder()
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
if got := joinStrings(order, ","); got != want {
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
}
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
h.clearPriorities()
if !sameSecond(h.createdAts()) {
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
}
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
}); err != nil {
t.Fatal(err)
}
h.clearPriorities()
h.resync("buddy")
order2 := h.sourceOrder()
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
if got := joinStrings(order2, ","); got != want2 {
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
got, want2)
}
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
}
func joinStrings(xs []string, sep string) string {
out := ""
for i, x := range xs {
if i > 0 {
out += sep
}
out += x
}
return out
}
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
func (h *reorderHarness) createdAts() []int64 {
h.t.Helper()
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
if err != nil {
h.t.Fatal(err)
}
var out []int64
for _, a := range accountsOf(h.t, res) {
if v, ok := a["createdAt"].(float64); ok {
out = append(out, int64(v))
}
}
return out
}
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
func sameSecond(ts []int64) bool {
if len(ts) < 2 {
return false
}
for _, v := range ts {
if v != ts[0] {
return false
}
}
return true
}
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
func (h *reorderHarness) clearPriorities() {
h.t.Helper()
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
"platform": "buddy",
}); err != nil {
h.t.Fatalf("__clear_priorities_probe: %v", err)
}
}
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
func (h *reorderHarness) resync(platform string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
"platform": platform,
}); err != nil {
h.t.Fatalf("__resync_sources_probe: %v", err)
}
}

View File

@ -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{}

View File

@ -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"])
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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 签名," +
"否则响应会被当成畸形数据")

View File

@ -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))
}
}

View File

@ -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> 开头则不重复插。

View File

@ -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")
}

View File

@ -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)
}
})
}
}

View File

@ -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
}

View File

@ -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("✓ 四个转换函数齐备")
}

View File

@ -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 已丢弃")
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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 = ""

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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("✓ 三类形状判据齐全,且均直接咬真实实现")
}

View File

@ -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

View File

@ -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)
}
})
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}
})
}
}

View File

@ -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

View File

@ -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=

View File

@ -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
}