Compare commits

...

74 Commits

Author SHA1 Message Date
6eeb5832a3 fix(provider): 上游错误文案别再丢 —— 补网关/腾讯错误体形状 + 修误导性日志
## 发现路径:读生产日志(不是测试报红)
```
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 currently not supported",...}
```

### ★ 缺陷 A:错误体形状漏了「网关」与「腾讯系」两类
`__prelude.openai_error()` 只认 `error.message` / `error`(裸串) / `{code,message}`
/ `detail` / HTML `<title>` / 裸文本 六种,**没有** `error_msg`(华为/APISIX 网关)
与 `msg`(腾讯系用 msg 而非 message)⇒ 返回 nil
⇒ 客户端只看到 `api error 404: unknown error`,**而 body 里明明有可用文案**。

修法照参考(llm-adapter.ts:524 `errorDetail()` 显式认 `error_code`/`error_msg`;
buddy-adapter.ts:576 认 `msg` + `displayMsg.{zh,en}`):
- 新增 `{error_msg}` / `{msg}` / `{error_code,+error_msg}` 三种形状;
- 新增 `displayMsg.{zh,en}` 本地化文案,**排在最前**(参考注释明说"本地化文案
  最可读";其早期实现只读 `error.*`/`message` ⇒ 全部落空 ⇒ 把 291 字符原始
  报文糊进错误提示,而服务端早已备好中文说明)。

覆盖 7 个用共享前导的适配器:buddy/deepseek/github/groq/kimicode/openai/sensenova。

### ★ 缺陷 B:内核那句日志会把人带偏
措辞是 `adapter %q lacks transform_error`,但 **openai.lua:139 与 buddy.lua:166
都明明定义了该钩子** —— 真实原因只是它**返回了 nil**(认不出那个形状)。
我照着这句话去适配器里找"有没有这个函数",查了几轮才发现函数在、是返回值空。
修法:内核直接查事实(`p.vm.HasFn(adapter, "transform_error")`),
两种原因**分开措辞**,不再猜。

## 判据
`internal/lua/adapter_prelude_error_shapes_test.go`:
- 12 个形状子用例(4 个新增 + 6 个回归 + 2 个 displayMsg),
  **装载仓库里真实的 openai.lua**(含共享前导),不是自造桩 ——
  第一版用空 VM 跑,12 个全 FAIL(`adapter openai not loaded`),
  那等于什么也没验。
- 配对判据 `...StillReportsUnknownShapes`:真正认不出的形状仍须 `ok=false`,
  防止"为了让上面通过就把『抽不出』和『没这个钩子』混为一谈"。
- 变异:删掉新增的 error_msg/msg 分支 ⇒ APISIX 用例报红。

## 备注
`anthropic/gemini/mistral/ollama/opencodezen/qwen38/trae` 有自己写的
`transform_error`,不走共享前导,**未获得**本次新增形状(各自协议的错误体
本就不同)。此处只修共享前导的 7 个。
2026-10-17 00:37:32 +08:00
a189c4f61c fix(channels): zcode 适配器真正继承 anthropic 基座(此前 9 个函数只有 2 个)
## 缺陷(潜伏,从未被触发)
`channels-zcode` 适配器的手法是把 `anthropic.lua` 源码读进来执行、只覆盖
`transform_request`(身份块注入),其余协议转换全部复用基座。

但那句读取写在**适配器内部**。实测:
- 适配器运行在**另一个沙箱 VM** —— 那里 `__llmsproxy_plugin_dir` 与
  `LLMSPROXY_PLUGIN_DIR` **都不存在**、**也没有文件系统访问**;
- ⇒ `loadAnthropic()` 必然返回 nil,**且静默降级**:适配器照常装载,
  但只有它自己定义的 **2** 个函数,缺
  `transform_response` / `transform_stream_chunk` / `serialize_*` /
  `deserialize_request` 共 **7** 个。

**后果**:内核 `Transform()` 对缺失函数**响亮报错**(不是静默透传)⇒
非流式在响应阶段 502,流式**逐帧**报错 ⇒ 该渠道**完全不可用**。

**为什么一直没暴露**:zcode 从来没有凭据(0 账号)⇒ 源未注册
⇒ 该路径从未被调用过。拿到账号的那一刻才会炸,且报错指向内核而非配置。

## 修法
基座源码改由**插件主体**读好并**内联**进适配器源码(插件 VM 有 io,
实测可读)。与 v2.0.35 buddy 适配器同一手法。

两处必须注意的坑:
1. **不能拼接后 gsub 替换**:gsub 的替换串里 `%` 有特殊含义(`%1` 是捕获
   引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
   改用 `find(ph, 1, true)` + `sub` 的纯字符串替换。
2. **占位符 token 不能在注释里出现字面量**(第一版就栽在这:gsub 先命中了
   解释它的那行注释,替换掉注释、代码里仍留着未定义全局)。
3. 顺带补上**插件主体缺失的 `plugin.adaptersDir()`** —— 原先这个函数只
   存在于 zcode 适配器源码**内部**,插件主体从来没有过。

## 判据
- `internal/core`(**真实路径**,会咬变异):
  - `TestZcodeAdapterInjectsOfficialIdentityBlocks`
  - `TestZcodeAdapterKeepsAnthropicProtocolConversion`(工具调用 / 图像块存活)
  - `TestZcodeAdapterFailsLoudlyWithoutAnthropicBase` —— 语义已随设计更新:
    基座不可得现在应在**安装期**失败(新增 `adapters_dir` 测试缝隙把
    "基座缺失"从运行期逐帧炸变成安装期一条明确错误)
- `internal/lua/adapter_zcode_base_test.go`:验证源码模板 + 合并逻辑,
  **并如实标注边界** —— 它在测试里自己做替换,所以**不能**证明安装处内联了
  非空基座(实测:把 `asrc` 换成空串它不报红;那份由上面的 core 判据覆盖)。

**变异验证**:把安装处的 `asrc` 换成空基座 ⇒ core 三条判据全部报红;
用重构前的适配器源码 ⇒ `TestZcodeAdapterInheritsAnthropicBase` 报红(缺 7 个)。
2026-10-17 00:01:00 +08:00
58073a6cbe refactor(channels): 纯常量收进 plugin.K,腾出顶层 local 余量(此前余量为 0)
## 问题(实测)
`channels.lua` 的主 chunk **顶到 Lua 上限 200 个 local** ——
往文件顶部加**哪怕 1 个**顶层 local 就报:
  `main function has more than 200 local variables`
实测(LuaJIT 与 luac 5.4 一致,**响亮失败**,不是静默降级)。

后果不是"某个功能出错",而是**整个渠道插件编译失败 ⇒ 全渠道不可用**。
换句话说:这个文件此前**一个顶层 local 都加不了**,任何后续改动都会立刻炸。

## 修法
把 20 个**纯常量**(字符串/数字/自闭合字面量)从裸 `local` 收进 `plugin.K` 表 ——
**挂表字段不占 local 槽位**。只搬常量,**函数一律保留 local**:
函数之间存在真实的调用顺序依赖(前向引用),搬迁风险与收益不成比例。

搬迁是纯机械的,且脚本带**长字符串保护**:替换时逐行跳过
`[===[ ... ]===]` 区间,绝不改动适配器源码文本
(本文件里那 7 段长字符串是适配器代码,误改会直接弄坏适配器)。

### 实测结果
| 指标 | 前 | 后 |
|---|---|---|
| 加 1 个顶层 local | ✗ 编译失败 | ✓ 通过 |
| 加 10 / 20 个 | ✗ | ✓ 通过 |
| 字节数 | 589195 | 590042 |

## 判据(2 条 + 变异 2/2)
- `TestChannelsLuaHasLocalHeadroom`:注入 1 与 10 个顶层 local 后**仍能编译**。
  为什么用 **luajit** 而不是 `luac`:内核链接 `libluajit-5.1.so.2`,
  而 luac(Lua 5.4)对同一文件给出**不同结论**,用错工具会得到假绿。
  为什么不"数一遍 local":主 chunk 的 local 数受作用域嵌套影响,
  静态计数不可靠(同一文件被不同脚本数出 228/232/248 三个数);
  而"能不能再加"是**决定性**的,也正是我们需要的性质。
- `TestChannelsConstantsLiveInPluginK`(配对):抽样断言这些常量确实在
  `plugin.K` 内、且**没有**变回裸 local —— 否则余量会再次归零。
- 变异:用重构前的文件 → 报红;把常量改回裸 local → 报红。

## ★ 顺带更正两处我先前的错误说法(本轮实测推翻)
1. **不是静默失败**。我先前说"超出的 local 静默退化为全局" —— 错。
   LuaJIT 与 luac 都在第 201 个报**编译错误**。危险度远低于我所说。
2. **`adaptersDir` 为 nil 的真实原因不是"local 被顶掉"** ——
   它定义在 **`ZCODE_ADAPTER_SRC` 长字符串内部**(适配器源码文本),
   在**适配器沙箱**里执行,而那里没有 `__llmsproxy_plugin_dir`、没有文件系统。
   所以 zcode 的 `loadAnthropic` 必然返回 nil、BASE=nil
   (它自写了全部转换所以功能没问题,但"复用 anthropic、协议不漂移"的
   设计意图从未生效)。
   ⇒ **教训**:读 10000+ 行的 Lua 文件时,先扫出所有长字符串区间;
   落在里面的"函数定义"只是文本,不属于插件作用域。
2026-10-16 21:49:12 +08:00
a5923b3797 fix(buddy): 新增内置 buddy 适配器(glm-5.1 的 reasoning_effort + 流式 Accept)
## 真实缺陷 ①:glm-5.1 恒 400(用户报"这个模型不可用")
上游回 `400 {"code":11150,"msg":"the reasoning effort value is not supported
by the current model"}`。探针逐字对照实验(body 完全由探针给定):
  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 **独有**要求。
参考 product.ts:237 声明它只有 `['medium']` 一档。

## 真实缺陷 ②:流式请求缺 Accept: text/event-stream → 空回复
不发该头时上游**接受请求(200)但只回一帧空内容**(completion_tokens=0、
finish_reason 直接 stop),既不报错也不产出正文 —— 表现是"模型不回答",
极易误判成模型坏了或额度不足。同 body 加该头 → 39~43 帧正常 SSE。
参考 buddy-adapter.ts 的 send() 固定发它。

## 实现:内置 `buddy.lua`(不是插件自装)
**为什么不用插件自装适配器**(channels-zcode/qoder/cline 那条路):
`channels.lua` 的**主 chunk 已 248 个 local**,Lua/LuaJIT 上限是 200 ——
超出的 local 会**静默退化为全局**(`luac -p` 通过,运行时才 `attempt to call
global 'x' (a nil value)`)。实测证据:`adaptersDir` 是第 208 个 ⇒ 调用点拿到 nil
⇒ `loadAnthropic` 静默返回 nil ⇒ **zcode 适配器从来没有基座**
("复用 anthropic"的意图从未生效,只是它自写了全部转换所以能用)。
我第一版就是这么写的,结果适配器装不上(`adapter channels-buddy not loaded`),
且 `hy3`/`glm-5.3`/`kimi-k3-1` 全被打成空回复 —— 只有 `glm-5.2` 因多源兜底
"看起来正常",症状再次指向错误方向。

⇒ 改走内置适配器(与 trae/sensenova/opencodezen 同性质,`deploy.sh` 自动同步),
`channels.lua` 只改**一行** `adapter = "buddy"`,不再触碰那个已贴上限的文件。

## ★ 排查中发现的沙箱事实(值得记)
**适配器运行在另一个沙箱 VM**:那里 `__llmsproxy_plugin_dir` 与
`LLMSPROXY_PLUGIN_DIR` **都不存在**(实测均为 nil),**也没有文件系统访问**。
故任何"在适配器里读兄弟适配器源码"的写法都拿不到基座且静默降级。
判据 `TestBuddyAdapterDefinesAllTransforms` 咬住这条。

## 顺带修:hy4-preview-x 从目录剔除
实测它对上游**恒回 11102**(`model service info not found`,加/不加
reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。它被服务端 agents
引用,于是 `agentRef` 例外把它放回目录 ⇒ 用户选中必 400 而错误指向模型。
参考用**白名单重建**达到同样效果(其兜底表里没有它)。本仓加等价的精确黑名单
(只放实测确认的,不做猜测),并在两处兜底目录里一并剔除。

## 验证
buddy **15/15 模型全部可用**(含 glm-5.1),逐一实测无失败;
`hy4-preview-x` 已从源目录消失(16 → 15)。

## 判据(3 条 + 变异 2/2)
- `TestBuddyAdapterEffortAndHeaders`:glm-5.1 补 medium / hy3 不补 /
  调用方给的档位被尊重 / Accept 必须是 SSE + 身份头齐备
- `TestBuddyAdapterKeepsOpenAIWire`:协议转换与 openai 一致(只是补两个字段)
- `TestBuddyAdapterDefinesAllTransforms`:**四个转换函数都存在** ——
  咬"静默降级"而非"某模型能回话"(后者会被多源兜底掩盖,本会话已栽两次)
- 变异:删 stream_chunk / 不补 effort → 均报红

## 一起提交的其它独立缺陷(均在本次调查中发现)
- buddy 目录过滤(四类判据)与 hy4-preview-x 剔除
2026-10-16 20:47:14 +08:00
91f6d1f32f fix(channels): 日志截断按字符(中文被切出 U+FFFD)+ 面板排版
## 真实缺陷(v2.0.33 上线后在截图里看到)
日志尾显示成:`能力增强不�`
根因:`tostring(cerr):sub(1, 120)` —— **Lua 的 sub 按字节切**,而中文占 3 字节
⇒ 切在字符中间产生**替换字符 U+FFFD**。一句本来完整的诊断("…能力增强不可用,
已退回兜底表")看起来像**数据损坏**,实际只是截断方式错。

生产实测复现:该条 120 字节截断后,末尾正好落在"不"字的第 1 字节上,
python 侧解码得到 `…能力增强不\ufffd`。**修法**:`plugin.utf8Clip(s, maxBytes)`
先按字节切、再回退到最后一个完整 UTF-8 序列边界(最多退 2 字节)。
三处中文文案截断全部改用它。

## 判据(1 条静态 + 1 条行为 + 变异 1/1)
- `TestLogTruncationIsUTF8Safe`(静态):助手存在 / 三处中文截断不再用裸 sub /
  未超长时原样返回 / 回退逻辑处理孤立首字节
- `TestUtf8ClipBehaviour`(**行为**,经 `plugin.__utf8_clip_probe` 走真实函数):
  对 6 个长度断言 `utf8.ValidString(out)` 且不超字节上限。静态断言看不出
  "结果是否真的合法 UTF-8",所以必须有这一条 —— 实测 n=120 从
  `…能力增强不�`(非法)变为 `…能力增强不`(合法)
- 变异:把 utf8Clip 换回裸 sub → 报红

## 排版抛光(截图里可见的三处)
- 头部控件原用 `<h2>` + flex,checkbox 被挤到换行且文字竖排 ⇒ 改 `<b>` +
  独立 flex 行 + `white-space:nowrap`
- 长诊断在窄卡片里被**截断**(而截断的尾巴正是最该看的部分)⇒ `word-break`
- 列表加 `line-height`,可读性

## 兼容性
历史 state 里的 41 条老条目没有 `level` 字段 —— 前端 `e.level || "info"`
已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。
2026-10-16 17:44:04 +08:00
c1048dcd4e fix(channels): 运行日志加级别/归属/折叠,并从渠道详情移出(C+A)
## 问题(生产实测,非猜测)
用户问「为什么 WebUI 默认显示插件全部日志」。用 CDP 打开真实 UI + 读
`/channels/status` 实测,发现是**三个问题叠加**,而表层症状是"看到的全是噪音":

### ① 归属错误:全局日志渲染在「单个渠道详情」里
`plugin.state.log` 是**单一全局数组**(`logEvent` 写入的条目只有 `{at, msg}`,
**连 platform 字段都没有**),但这个 `<ul id="ch-log">` 的 DOM 位于
`#ch-detail`(点开某渠道才显示)⇒ 用户点开 buddy,看到的却是
trae/minimax/qoder 的日志混在一起,**无从区分**。

### ② 50 条里 48 条(96%)是周期任务噪音
实测分类:`已同步源…` 11×、`模型刷新失败…` 9×、`模型目录已刷新` 9×、
`能力元数据未刷新` 9×、`自动领取…` 9× —— **唯一模式只有 8 种**。
原因:`refresh_models`(2h) + `auto_claim`(2h) + `refresh_creds`(0.5h) 三个
`first_due_now` 任务每次跑写 3~5 条 ⇒ **约 2 小时就把 50 条环形缓冲冲满**,
于是真正的用户动作(如「账号删除」)被挤到看不见。

### ③ 无级别、无去重、无折叠 → 噪音与错误同权重并排

## 修法(用户选 C+A)
**C —— 修正归属**:把「运行日志」从 `#ch-detail` 移出,成为**常驻的独立卡片**
(`#ch-log-card`)。判据断言它与渠道详情是**兄弟节点**(div depth=0),
不是父子 —— 否则"点 buddy 看到 trae"会以另一种形式回来。

**A —— 加级别与过滤**:
- `logEvent(msg, level, platform)`:level ∈ info|task|warn|error。
  定时任务的**常规**结果记 `task`(前端默认折叠),只有异常升为 warn/error。
- **紧邻同文折叠**:与 `log[1]` 完全同文时不新增条目,只 `n=n+1` 并刷新时间。
  ⚠️ 只对**紧邻**条目折叠 —— 否则一条每小时出现的消息会不断"跳"到最前,
  时间线失去意义。前端显示成 `×N`,既保留"重复了多少次"又不刷屏。
- 三个视图开关:级别门槛(全部/警告及以上/仅错误)、显示例行任务、
  按当前选中渠道过滤(不带 platform 的跨平台汇总只在「全部渠道」出现)。
- `refresh_models` 的失败改为**逐平台各记一条**(原先 N 个挤成一条汇总,
  既不属于 trae 也不属于 minimax ⇒ 点进任何单渠道都看不到自己的问题)。

## 判据(3 条 + 变异 2/2)
- `TestChannelsLogEventCarriesLevelAndPlatform`:签名三参 / 条目带两字段 /
  紧邻折叠 / **折叠对象必须是 log[1]**
- `TestChannelsPeriodicTaskLogsAreDowngraded`:逐条咬住**具体调用点**被降级
  (而不是"存在 task 这个词"——那样一处就为真,测不出漏降级的噪音源)
- `TestChannelsLogPanelHasFilters`:日志卡片与渠道详情是**兄弟**(div depth=0)
- 变异:去掉折叠 / 把卡片放回详情内 → 均报红

## 顺带修好的环境事故(重要)
排查中全量测试突然大批失败(`loopback 未就绪(未取得端口)`)。我一度以为是
自己的改动,**`git stash` 后在干净版本上复现 → 预先存在**,继续追到根因:

**`/tmp/bisect.py`(另一个 agent 的临时脚本)遮蔽了 Python 标准库的 `bisect`**
—— 而 `tempfile` → `random` → `bisect`,于是**任何 `import tempfile` 都炸**,
回环接收器(python3)起不来。`sys.path` 含 `/tmp` 就是这个后果。
已移走(`/tmp/_shadowed-stdlib-conflicts/`,不删除以免打断那个 agent),
Python 与全部测试立刻恢复。

⇒ **教训**:临时脚本**绝不能**在 `/tmp` 下用 stdlib 模块名命名;
它会让全机 Python 静默失效,而症状("回环未就绪")与原因毫无关联。
2026-10-16 17:19:46 +08:00
2b8fcaf434 fix(opencodezen): 必须回传 reasoning_content(thinking 模式硬要求)
## 真实缺陷(我自己的会话被它打断过)
生产实测(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.
审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。

根因:`transform_request` 里写着 `msg.reasoning_content = nil`,注释理由是
"Zen 免费池不接受回传(Go 侧相反)"。**该假设方向是反的** —— 上游要的就是它。

## ★ 为什么这个缺陷极难归因(值得记)
失败是**交替**出现的:只有当上一轮**真的产出了 reasoning**(thinking 模型 +
reasoning_effort 开启 + 长上下文)时,回放那段历史才会被拒;普通回合照常 200。
于是"同一个模型同一会话,成功与失败交替"——看起来像**上游抖动或限流**,
而客户端看到的 502 与"上一轮想过"这件事**毫无关联**。

我一开始也走了弯路:用简短的 curl 复现不出来(8/8 成功),因为那些请求的
历史里没有"模型自己产出的 reasoning"。**必须让上一轮真的产生思考**才复现。

参考实现的方向一致:`openai-compat.ts` 的 `serializeMessages`(opencode 适配器
直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
    `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
即「有就回传」,无额外条件。

## 修法
保留 `reasoning_content`(只把**空串**丢掉 —— 多余字段部分上游会挑)。
不改成"仅当 thinking 开启才回传":我们**无法**从请求可靠判定上游是否处于
thinking 模式(真正知道的是上游)。而"有就回传"是零风险方向 ——
上游不需要时忽略即可(实测传与不传同为 200),需要时缺了就是硬 400。

## ★ 顺带发现的第二处问题
提交 `6113924` 的说明声称
"Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split" ——
**该测试在全仓不存在**(`git log -S` 全历史搜索为空)。也就是说这条行为
**从未被任何判据固定**,所以它反了也没人知道。本轮补上真正的判据。

## 判据(2 条 + 变异 1/1)
- `TestOpenCodeZenKeepsAssistantReasoningContent`:assistant 回合带 reasoning 时
  出站体必须有它。**咬住"被剥掉"这个具体行为**,而不是"请求成功"——
  后者在非 thinking 回合下永远为真,测不出本缺陷。
- `TestOpenCodeZenDropsEmptyReasoningContent`:空串必须丢掉(配对判据,
  防"改成无条件保留"这类过度修正)。
变异:恢复 `msg.reasoning_content = nil` → 报红。

## 说明
`opencodezen.lua` 在仓库内,而 `deploy.sh` 的 `sync_adapters` 只覆盖
**与仓库同名**的适配器文件 ⇒ 本修复会自动生效(生产另有 opencodego/opencode/
agentrouter 三个仅存在于线上的文件,不受影响)。
2026-10-16 14:52:13 +08:00
47fe759025 fix(channels): buddy 目录过滤不可调用模型(照参考 isChatModel 四类判据)
## 真实缺陷
`/v3/config` 的 `data.models` 混着**不是对话模型**的条目,我们原样全收 ⇒
生产源里 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` / `fast-model`(国际版抽象别名,但被 craft agent 引用,
     是官方推荐入口)。参考注释专门警告过这点。
  ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,或 `supportsExtra`
     (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
  ③ `maxOutputTokens > 0 且 ≤ 256`(补全用途;对话模型普遍 ≥24000)
  ④ 带 `text-to-image` 标签(如 hunyuan-image-alpha)

⇒ 故 `models` 先建成 id→meta 映射(③④ 需要元数据),再逐条判定。
同时收 `data.agents[].models` 里**被 agent 引用**的清单并合并 —— 参考据此给
`agentReferenced` 打标(`hy4-preview-f` 只由本端点下发且被 agent 引用,
白名单式重建会把它丢掉)。

## 判据(1 条 + 变异 1/1)
`TestBuddyCatalogFilterDropsNonChatModels`:断言四类规则**都在**源码里
("有人精简掉一条"就会把不可调用模型放回选择器),并**反向**断言
`default` 没被写成前缀匹配(那会误伤 `default-model`)。
变异:删掉 `nes-` 规则 → 报红。

## 说明
这是上一提交(v2.0.30)同一轮的第二个发现:v2.0.30 修掉 URL 拼接后,
buddy 通道**才真正可达**,于是"可达之后目录里哪些真能调"这件事第一次暴露出来。
两个缺陷是串联的 —— 不先修 URL,这个目录问题无法被观察到。
2026-10-16 14:06:51 +08:00
d795266572 fix(channels): 8 个平台 base_url 侵占 endpoint 路径 ⇒ 上游 404 Route Not Found
## 真实缺陷(用户可见)
内核的上游 URL 是 `base_url + endpoint`(provider.go ChatURL),而适配器默认
`endpoint = "/chat/completions"`。多个渠道平台把**完整路径**写进了 `base_url`
⇒ 实际请求变成
  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` 覆盖,形态正确)。
另有 loomy 的**第二处**错误:host 用了 `loomy.xunfei.cn`(Web 前端/OAuth 回调域),
实测该 host 的 /chat/completions 返回 **HTML 网页**;正确推理域是参考的
`loomyad.xunfei.cn/api/v1`(返回正常 JSON)。

## ★ 为什么这个缺陷活了很久(最重要的教训)
**模型名被多个源服务**。`deepseek-v4.1-flash` 同时由 4 个源提供
(alittokenplan/vela/oin/channels-buddy)⇒ buddy 源失败后网关**继续尝试下一个源**
并成功 ⇒ 用户看到"能回话" ⇒ "buddy 通道是好的"这个结论**完全没有证据**。
本轮我一度正是这么误判的。而 `hy3` **只由 buddy 一个源**提供 ⇒ 一调就暴露真因。

⇒ **判据必须断言源的结构不变量(URL 形态),不能靠"某个模型能调通"** ——
后者会被多源冗余掩盖。已按此写判据。

## 排查手法(可复用)
怀疑"某模型不可用"时,先列**该模型由几个源服务**:
  1 个 ⇒ 该模型是那条源的健康探针(用它测)
  >1 个 ⇒ 它调通**不能**证明任何单一源可用(换独有模型再测)
另:非流式请求会被 buddy 直接 400 拒绝
(`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`),
我第一次用 stream=false 时四个模型全 400,**看不出**模型差异,差点把结论下成
"头组合无区别"就收工。

## 修法
`base_url` 只留 host(或协议公共前缀),路径移入该平台的 `endpoint`。
逐平台按参考的 `apiBase` + `chatPath` 取值核对,并**实测**修正后的 URL:
buddy/cline/lobsterai/qoder/raccoon → 401(缺凭据,正确)
loomy → 200 `{"code":"100002","desc":"缺少 token"}`(正确)
opencode → 500(路由存在;`https://opencode.ai/v1/...` 是 404+HTML,已证伪)
测试注入平台也一并对齐(免被当范例复制)。

## 判据(2 条 + 变异 2/2)
- `TestChannelSourceBaseURLHasNoChatPath`:静态扫描全部 `base_url`,不得含
  `/chat/completions` `/v1/messages` `/generateContent` `/api/chat`。
- `TestChannelBaseURLJudgeBitesOnBadInput`:用**故意坏数据**验证判据的判别力
  (防判据自己写歪成恒真)。
变异:把 buddy 恢复成旧形态 → 报红。

## 顺带修正
`TestUpstreamGapsAreDocumented` 原先钉死 qoder 的**整串 URL 字面量** ⇒
这次纯形态重构(语义未变)把它弄红了 —— **判据在咬自己**。改为断言
`base_url` 与 `endpoint` **两半**,语义不变但对形态重构免疫。
2026-10-16 13:51:36 +08:00
a82ae2b60e fix(channels): 撤源失败的诊断必须带真正原因(不再只说"无返回")
## 我上一版(v2.0.28)自己引入的缺陷
内核桥的签名是 `call(name, args) -> (result, nil) | (nil, err)` ——
**失败时第一个返回值是 nil,原因在第二个返回值里**。
我写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒为 nil ⇒ 日志只说
「撤源(重建)失败 channels-buddy-buddy-main: 无返回」。

**一句不可操作的诊断比没有更糟**:它把"权限没授予 / 超时 / 拒删最后一个源"
三种完全不同的原因(对应三种完全不同的处置)压成同一句空话。生产实测已复现
这条日志(部署时全量重同步,权限尚未授予)。

## 修法
两个撤源点都改成接**两个返回值**,日志带上内核给出的原文原因:
  `kernel action "remove_source" is not granted to plugin "channels";
   enable it in the plugin's kernel permissions`
并把"要授予哪个动作、声明在 plugin.kernel"一并写进提示。

## 判据收紧(旧判据咬不住这个变异)
原来只断言日志含"撤源失败"和"remove_source" —— **只接第一个返回值的版本照样通过**。
新增断言:日志必须含内核给的**真实原因**(`not granted`)。
这是本会话第 9 类判据教训:**判据只看"失败了",不看"为什么失败"**,
于是它无法区分"诊断可用"与"诊断是空话"。
2026-10-16 10:26:55 +08:00
2d2c27c430 fix(channels): remove_source 漏声明(删了账号源还在对外服务)
## 真实缺陷(用户可见)
`plugin.kernel` 只声明了 5 个内核动作,而插件在 **5 处**调用 `remove_source`
—— 声明与使用**漂移**。生产按 declared 授予 ⇒ **撤源全部被拒**。

更坏的是它**静默**:`Core.AddSource` 是**同名 upsert**,所以 `syncAccountSource`
里"先 remove 再 add"的 remove 失败**看不出来**(add 会覆盖同名源)——
只有**纯删除路径**才暴露:
  • `dropAccountSource`(账号停用/删除)→ **账号删了,它的源还在对外服务**
  • `account_delete` 收尾清旧单账号源
  • 两处 `channels-<plat>` 旧源清理

我实测确认:只授 add_source 时删账号,其源**仍在**内核源列表里。

## 修法
① 声明补上 `remove_source`(并加注释说明"加动作时同时改这里")。
② `dropAccountSource` / `syncAccountSource` 的撤源**不再忽略返回值**,
   失败**记进事件日志**(面板可见,附可操作提示)——
   "一个被丢弃的返回值"不该是权限缺失的唯一痕迹。
③ 判据 `TestChannelsDeclaresEveryKernelActionItCalls`:**静态扫描**源码里所有
   `__llmsproxy_kernel.call("X")`,要求每个 X 都在 `plugin.kernel` 里声明。
   将来任何人加了内核调用却忘了声明,会在这里报红,而不是等线上静默失效。

## 判据(3 条 + 变异 3/3)
- 授予 remove_source 时:删账号**确实撤掉源**
- 未授予时:**必须可见地失败**(事件日志含"撤源失败")
- 声明必须覆盖全部实际调用
变异:忽略返回值 → 报红;漏声明 remove_source → 报红。

## 部署后需补的一次操作
生产权限集当前只有 add_source ⇒ 需授予 remove_source(否则既有账号的
"停用/删除"仍撤不掉源)。已在部署后手工授予,并复核 declared == granted。
2026-10-16 10:04:13 +08:00
0f83fc048a fix(channels): 账号顺序的确定性 + reorder 真正改内核源顺序
## ① 真实缺陷:同秒创建的账号,顺序**不确定**
`poolList` 的排序键是 `priority` 降序 + `createdAt` **升序**,而 `createdAt` 是
**秒级**(`os.time()`)⇒ 同一秒建的两个账号比较**相等** ⇒ 相等时 `table.sort`
的结果取决于 `pairs()` 遍历顺序 —— **不确定**。
实测:按 a,b,c 顺序建三个账号,`poolList` 回的是 `[a,c,b]`。

后果不只是"顺序看着乱":**源注册顺序 = poolList 顺序 = 内核尝试顺序**
⇒ 同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下影响先用谁的积分),
且可能每次进程重启都变。修法:加 **id 作稳定兜底键**(唯一且不变)。

## ② reorder 只改字段、不影响路由
`account_reorder` 只设 `acct.priority`,**不重注册源**。而一个源一旦注册,
顺序就固定了 ⇒ 重排对实际路由**毫无影响**("优先账号"并不会先被试)。
修法:重排后按新顺序**整体重放一遍** `syncAccountSource`
(remove+add 会把源移到最后,故必须整体重放,只补被移动的那几个相对顺序仍不对)。

## 判据(2 条 + 变异 2/2)
internal/core/channels_reorder_effect_test.go
- reorder 必须真的改变**内核 list_sources 的顺序**(判据看内核里的真实顺序,
  不是 pool 里的 priority 字段)
- 同优先级 + 同秒创建时,顺序必须由**稳定键**(id)决定(字典序)

★ 两个判据第一版**都不咬变异**,原因各异,值得记下:
1. reorder 判据:初始顺序恰好**已是**目标顺序 ⇒ 无法区分"重排生效"与"本来就对"。
   改法:显式断言初始顺序、并让目标顺序与之**相反**。
2. 确定性判据:我直接断言 `account_add` 之后的顺序,而 `account_add` 是
   **直接同步自己那一个账号**(源顺序 = add 顺序),**走不到排序逻辑** ⇒
   测的其实是 add 顺序。另外**多轮 in-process 反复构造也测不出** ——
   `pairs()` 顺序在同一次进程里是稳定的,只有跨进程/跨分配才变。
   改法:走**批量重注册**路径(`__resync_sources_probe`)+ 显式把 priority 拉平
   构造"排序键完全相同"的场景,再断言字典序。

## 顺带修的判据自身问题
- `sourceOrder` 原来顺带调了 `__sync_source_probe`(内部 remove+add)⇒
  **"测之前先动了被测对象"**,顺序忽而两个忽而一个。改为**只读**。
- 探针取内核返回时按 `{sources:[...]}` 取,而 `actionListSources` 返回的是
  **裸数组** ⇒ 恒得空列表。
- 夹具漏授 `add_source` 内核权限 ⇒ 源一个都不出现(内核会明确拒绝,只是我
  没看返回值)。子测试才能拿到独立的 env 与目录(同函数内 `t.Setenv` 只生效一次)。
2026-10-16 09:36:02 +08:00
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
139 changed files with 21285 additions and 1083 deletions

View File

@ -36,8 +36,15 @@ readonly REPO_DIR="/home/program/llmsproxy"
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
readonly BIN_NAME="llmsproxy"
# 构建目录在 /tmp(tmpfs 更快)
readonly TMP_DIR="/tmp/llmsproxy-deploy"
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
readonly BUILD_SUBDIR=".build-work"
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
else
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
fi
# 同文件系统暂存文件(用于原子 rename)
readonly STAGING_BIN="${TARGET_BIN}.staging"
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
@ -122,14 +129,14 @@ backup_old_state() {
log "备份旧二进制到 $BACKUP_BIN"
if [[ -f "$TARGET_BIN" ]]; then
cp -f "$TARGET_BIN" "$BACKUP_BIN"
log "✓ 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
log "OK 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
else
warn "旧二进制不存在,跳过备份(无回滚点)"
fi
if [[ -f "$TARGET_CONFIG" ]]; then
cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"
log "✓ 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
else
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
fi
@ -142,7 +149,7 @@ build() {
mkdir -p "$TMP_DIR/bin"
cd "$REPO_DIR"
CGO_ENABLED=1 go build -tags luajit \
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
-trimpath \
-ldflags="-s -w" \
-o "$TMP_DIR/bin/$BIN_NAME" \
@ -175,9 +182,9 @@ build() {
)
for c in "${checks[@]}"; do
if strings "$NEW_BIN" | grep -qF "$c"; then
log " ✓ 新二进制包含: $c"
log " OK 新二进制包含: $c"
else
warn " ⚠ 新二进制不包含: $c(非预期但非致命)"
warn " 注意: 新二进制不包含: $c(非预期但非致命)"
fi
done
}
@ -207,7 +214,7 @@ atomic_replace_binary() {
# 验证
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
log "✓ 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
}
# ---------- 原子替换配置 ----------
@ -223,7 +230,7 @@ atomic_replace_config() {
log " rename → $TARGET_CONFIG (原子)"
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
log "✓ 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
}
# ---------- 配置预检 ----------
@ -236,7 +243,7 @@ verify_config() {
local out
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
log "✓ 配置合法"
log "OK 配置合法"
return 0
fi
@ -276,7 +283,7 @@ verify_master_key() {
log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)"
# 走真实解密路径验证,而不是只检查格式
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
@ -292,7 +299,7 @@ verify_master_key() {
log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)"
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
@ -302,48 +309,60 @@ verify_master_key() {
return 1
}
# ---------- 适配器备份保留策略 ----------
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
# ---------- 备份目录保留策略 ----------
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
#
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
KEEP_ADAPTER_BACKUPS=5
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
# 就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
#
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
KEEP_BACKUPS=5
prune_adapter_backups() {
prune_backups() {
local prefix="$1"
local base="/etc/llmsproxy"
[[ -d "$base" ]] || return 0
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
case "$prefix" in
adapters|plugins) ;;
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
esac
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
local all
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
local cap=$((KEEP_BACKUPS * 4))
if (( ${#all[@]} > cap )); then
warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
local i=0
for d in "${all[@]}"; do
i=$((i + 1))
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
if (( i <= ${#all[@]} - cap )); then
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
fi
fi
done
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
fi
# 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
local total=${#all[@]} i=0
for d in "${all[@]}"; do
i=$((i + 1))
# i <= total-KEEP 的是较旧的,要删。
if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
if (( i <= total - KEEP_BACKUPS )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
@ -352,8 +371,15 @@ prune_adapter_backups() {
done
local left
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
}
# prune_all_backups:两个前缀都裁。
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
prune_all_backups() {
prune_backups adapters
prune_backups plugins
}
# ---------- 同步适配器 ----------
@ -373,7 +399,7 @@ sync_adapters() {
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
prune_adapter_backups
prune_all_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua
@ -388,7 +414,7 @@ sync_adapters() {
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
done
log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)"
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
log "OK 适配器已同步到 $TARGET_ADAPTERS/"
}
# 同步内置插件到 plugin_dir。
@ -449,14 +475,14 @@ sync_plugins() {
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
log "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
}
# ---------- 重启服务 ----------
restart_service() {
log "重启服务(中断 <500ms)"
systemctl restart llmsproxy.service || fail "systemd restart 失败"
log "✓ llmsproxy.service restarted"
log "OK llmsproxy.service restarted"
}
# ---------- 健康检查 ----------
@ -471,7 +497,7 @@ healthcheck() {
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "000")
if [[ "$code" == "200" || "$code" == "401" ]]; then
log "✓ 服务健康 (status=$code, ${attempt}/${attempts})"
log "OK 服务健康 (status=$code, ${attempt}/${attempts})"
return 0
fi
log " 等待中 ($attempt/$attempts, status=$code)"
@ -540,7 +566,7 @@ rollback() {
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000")
if [[ "$code" == "200" || "$code" == "401" ]]; then
log "✓ 回滚成功,旧版本已恢复服务 (status=$code)"
log "OK 回滚成功,旧版本已恢复服务 (status=$code)"
log " 新版本部署失败,请检查构建/配置后重试"
return 0
else

540
docs/channels-parity.md Normal file
View File

@ -0,0 +1,540 @@
# channels 插件与参考插件的三线对齐表
参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆
`/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于
`vendor/channel-pack/src/`(144 个 TS 文件)。
命名规律(照它逐厂商对齐的三条线):
| 后缀 | 职责 | 我们要对齐的东西 |
|---|---|---|
| `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 |
| `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 |
| `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 |
## 一、登录(line ①)
| 平台 | 参考流程 | 我们的 flow | 状态 |
|---|---|---|---|
| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) |
| minimax | device-poll | device-poll | ✅ |
| cline | device-poll | device-poll | ✅ |
| buddy | state-poll(**无回调**) | state-poll | ✅ |
| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) |
| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) |
| opencode | paste | paste | ✅(参考本身即粘贴凭据) |
| **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) |
| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) |
| zcode | init-poll | init-poll | ✅ |
| loomy | 微信扫码 poll | wechat-poll | ✅ |
| raccoon | 二维码 poll | qr-poll | ✅ |
### codearts 登录(本轮修复的重点)
此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts`
默认走**新式 IAM OAuth**:
1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝),
路径 `/oauth/callback`
2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项:
`theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、
`code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal
**回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、
`plugin-name=snap_AIIDE`、`plugin-version=5.2.0`;
**不得**含 `auth_callback_url`(会被判异常并回退)
3. 回调收到 `code` 后,带 **DPoP 证明**向
`https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换
`credentials{access_key_id, secret_access_key, security_token, expiration}`
+ `refresh_token`
4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk`
DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、
`payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER
——必须转换格式,否则服务端验签失败而字符串看起来完全正常。
终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`;
**不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、
网关抖动都会触发,当终态会把完好账号一步标死)。
旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询)
保留作回退。
## 二、模型获取(line ②)
远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。
本轮把**兜底表**逐平台补成参考全量:
| 平台 | 参考来源 | 兜底数 | 状态 |
|---|---|---|---|
| trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) |
| minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ |
| cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ |
| buddy | (无独立表) | 7 | ✅ |
| gemini | — | 2 | ✅ |
| lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ |
| opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) |
| codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 |
| qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) |
| zcode | `GET /api/v1/client/configs` | 2 | ✅ |
| loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ |
| raccoon | 远端 + 兜底 | 6 | ✅ |
**trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` /
`explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。
`model_list` 已按此过滤(`models_hidden`)。
**opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。
此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。
### codearts 模型获取(未闭环)
参考 `models.ts` 从两个端点拉:
- `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`)
→ `result.models`,这是 **benefit(免费额度)模型**权威来源
- `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型
两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId`
(去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带
`maas_type: benefit`,不带则 404 `model is not registered`)。
**这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。
## 三、接入模型服务(line ③)
**本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的
chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正:
| 平台 | 正确 chat 端点 | 修正前 |
|---|---|---|
| trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` |
| cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` |
| lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` |
| qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` |
| buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` |
| codearts | `…/api/v2/chat/completions` | `…/chat/completions` |
| opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` |
| raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` |
| loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` |
| zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` |
| gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) |
### ✅ 已闭环:三个私有协议平台改用**插件自装适配器**
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。
内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用:
| 平台 | 自装适配器 | 解决的协议差异 |
|---|---|---|
| codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) |
| trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 |
| gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 |
命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是
**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)——
判据里专门断言内置 `trae` 未被顶掉。
**新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己
解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用
`credentialOf` 取 `access_token`,于是 codearts 凭据(只有
`access_key_id`/`secret_access_key`/`security_token`)取不到 token →
**静默 return、不出源**。现已按 `plat.credential_shape` 区分。
**另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败
(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。
现补失败日志(区分内核 err 与 `json.ok==false`)。
### 仍未闭环:gemini 的 sigstore(明确不做)
参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。
我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒,
适配器如实透传错误。理由与结论已记在此处,避免反复讨论。
## 四、明确不做的(附理由)
| 参考模块 | 行数 | 为什么不做 |
|---|---|---|
| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ |
| `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 |
| `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 |
| `captcha-*.ts` | 1223 | 同上 |
## 五、本机实测踩到的坑(都已修)
1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。
改用 `od -An -tx1`。
2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用
`openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里
**公钥的 y**,不是 `d`。
3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` /
`b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块
**之前**(本文件累计踩过 6 次)。
4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个
参数才是 minPort。
5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报
invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。
---
# 为什么参考插件是"正常"的:架构差异(2026-10-13 查证)
用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**,
不是某一个端点的笔误。
## 参考的架构:一个平台 = 一个适配器类
参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。
每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事:
| 文件 | 类 | 干什么 |
|---|---|---|
| `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) |
| `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 |
| `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) |
| `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 |
| `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 |
注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)`
(`llm-adapter.ts:2080`)。
**但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)——
`serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、
`httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。
12 个 adapter 里有 8 个 `import from './openai-compat'`。
即:**公共协议逻辑一份,平台差异各自一个类**。
## 我们的架构:内核是 proxy,插件只注册源
- 适配器是 Lua 文件(12 个),通过 4 个钩子参与:
`transform_request` / `transform_response` / `transform_stream_chunk` /
`transform_error`,外加 `build_headers`(动态签名,见下)。
- 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。
- 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台
注册为上游源,`source.adapter` 引用适配器名。
## 关键发现:机制早就有,但 channels 插件从没用过
内核**完整实现了**插件自装适配器:
```
internal/lua/plugin_adapter_bridge.go
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
```
- 有归属校验(`adapterOwnership`:只能改自己装的)
- 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
- 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS)
**但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。**
channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。
这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。
## 我们还缺的一层:共享协议模块
参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明
「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与
`anthropic.lua` 里**各复制了一份**(各 6 处引用)。
所以正确做法是两层:
1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类),
或内核在加载时注入 —— 消掉复制粘贴
2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai`
### 所需的适配器能力(crypto + 出站 + 流式)
已核实内核提供:
- `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals)
- `build_headers(meta)` 收 `{url, method, body, api_key, timestamp,
client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它
(与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出)
- `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`)
- 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名
### 未验证的风险
- 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名)
- 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)
这两条是下一步落地前必须先探的。
---
# 插件自装适配器:机制与判据(2026-10-13)
## 机制(内核早已具备,本轮才开始使用)
```
internal/lua/plugin_adapter_bridge.go
InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove
```
注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期
就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。
## 三条能力(实测,不是推断)
`internal/lua/plugin_adapter_capability_test.go` 锁死:
| 能力 | 结论 | 为什么关键 |
|---|---|---|
| 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) |
| 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 |
| 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 |
## 踩过的坑(都是"静默失败"类,值得记)
1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误**
(`'<name>' expected near 'function'`),必须 `body["function"]`。
插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。
2. **Lua 长字符串里不能有字面 NUL 字节** —— 报
`unfinished long string near '<eof>'`,且**报在文件末尾**,完全指不到现场。
3. **本 Lua 构建不支持按位异或 `~`**(`unexpected symbol near '~'`)。
4. **装载失败只回"源码无法编译"、不给行号** —— 三个自装适配器现统一加
**本地预编译护栏**(先 `load` 一次拿行号)。上面三个坑都是靠它定位的。
5. **抽公共代码要抽最全的实现**。前导重构时我先抽了 `transform_error` 的
最小版(只认 `{error:{message}}`),打挂 6 个既有测试 —— 最小版丢掉了
HTML 错误页、裸文本、扁平 `{code,message}`、FastAPI `{detail}`。
抽最小实现是**静默降级**:不报错,只是少认几种错误体。
6. **判据必须能看见真实函数的返回值**。我一度用 `kernelTestCore` 裸加载插件
(没授 `add_source` 权限)测源注册,源一直不出;直到把内核原始返回
(`kernel action add_source is not granted to plugin channels`)带出来才
定位。**黑盒断言只在失败时告诉你"不对",不告诉你"为什么"**。
## 判据清单
| 文件 | 锁死什么 |
|---|---|
| `internal/lua/plugin_adapter_capability_test.go` | 三条能力(凭据/流式状态/加密原语) |
| `internal/lua/adapter_prelude_test.go` | 前导注入 + 语义值正确 + 旧适配器行为不变 |
| `internal/lua/adapter_prelude_equivalence_test.go` | 重构前后逐字节等价(含反向断言) |
| `internal/lua/channels_trae_solo_test.go` | SOLO 7 条规则 + 三处反向断言 |
| `internal/lua/channels_gemini_adapter_test.go` | 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 |
| `internal/core/channels_codearts_adapter_test.go` | 装机/归属 + **Go stdlib 独立复算 HMAC 验签** |
| `internal/core/channels_private_adapter_wiring_test.go` | 三平台的**源真的引用了**自装适配器 |
---
# 生产实测:不可用的真相(2026-10-14)
## 症状与根因
用户报"完全不可用"。实测(`/api/plugins/http/channels/account/list` 逐平台):
| 平台 | 账号数 | **有凭据的** |
|---|---|---|
| buddy | 2 | **1** |
| trae | 3 | 0 |
| minimax | 1 | 0 |
| lobsterai | 1 | 0 |
| codearts | 5 | 0 |
| 其余 7 平台 | 0 | 0 |
**13 个账号里只有 1 个真有凭据**,其余全是**占位壳子**(`has_credential: false`)。
占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的
行。此前的判断只看「12 平台 0 个 manual」,那是**错的判据** —— manual 是
flow 类型,不是"登录成功"。
## 排除项(都实测过,不是猜)
| 检查 | 结果 |
|---|---|
| 服务状态 | `active`,`NRestarts=0` |
| 部署一致性 | 4 个插件 sha256 与仓库**全部一致**;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 |
| **推理链路** | `POST /v1/chat/completions` 实测返回 `"OK"`(走 buddy 源、deepseek-v4.1-flash)—— **网关本身完全可用** |
| 登录机制 | minimax 真实返回 `user_code=T49S-RC7G` + 验证地址;trae 起了服务端浏览器 + 回环回调 |
| 设备码轮询 | 5 次全部 `pending`(未授权时**不误判**为失败或成功 —— 这是正确行为) |
## 结论:不是代码坏了,是**授权没完成**
剩下的 12 个平台都需要**人工动作**才能走完登录:
- **device-poll**(minimax / cline / qoder):把 `user_code` 拿到
`account.minimax.cn/oauth-authorize` 之类的**官方页面**输入
- **callback**(trae / gemini / lobsterai):在 **VNC 窗口**里点授权
(回环回调只能由本机解析,远程浏览器永远接不住)
- **qr-poll / wechat-poll**(raccoon / loomy):扫码
- **init-poll**(zcode):授权页 + 可能的验证码
这些**按设计就必须人工**,assistant 无法独立端到端完成。
## B 类:实测结论
| 项 | 行数 | 结论 |
|---|---|---|
| qoder-wasm + envelope | 706 + 226 | **二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。没有 `qoder-auth-wasm.wasm` 无法实现;拿到才有意义 |
| zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且**登录本身不需要它** —— captcha 只用于**签到/领取**环节(`zcode-auth.ts:22-23`:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 |
| captcha-pool | 280 | 同上,服务于领取环节的验证码供给 |
**修正判断**:B 类不是"登录闭环的拦路虎"。zcode 的登录(`/api/v1/oauth/cli/init`
→ poll)不需要验证码;captcha 只在**自动领取**时出现。所以 B 类的真实价值是
"让领取全自动",而不是"让平台可用"。
---
# 真实数据事故:测试覆盖了生产账号池(2026-10-14)
## 症状
用户报「完全不可用」。逐平台 `account/list` 后发现:**13 个账号里只有 1 个有凭据**,
其余是占位壳子;再查发现**账号池被清空了**(`pool` 全为空 `{}`),
而保险库 `.secrets/channels.json` 里的凭据**还在**。
症状正是「账号凭空消失」。
## 根因(实测输出,非推断)
channels 插件用 `storeDir()` 决定存储位置,它有多级兜底:
```
env LLMSPROXY_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码
```
测试台没设 `LLMSPROXY_PLUGIN_DIR`,**后两级直接命中生产**:
```
测试插件目录 = /tmp/TestXXX/002/plugins
storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出
```
于是测试加载真 `channels.lua` 时**读的是生产存储、写的也是生产存储**。
一次全量 `go test ./internal/lua` 就把
`.channels-store.json` 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。
只有 `internal/lua` 复现,`internal/core` 不复现 —— 因为 core 的测试台
(`kernelTestCore`)设了 `LLMSPROXY_PLUGIN_DIR`。**这是隔离做得不一致**的代价。
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
## 为什么 `DisableStatePersistence` 不够
第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule`
时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`),
真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并
`logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** ——
测试结束后它仍可能落盘,此时环境变量已还原。
## 修法(两层,第二层才是根治)
1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`),
`storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** ——
实测:撤掉 env 后 `storeDir` 仍落在临时目录。
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。
## 判据 + 变异验证
`internal/lua/store_dir_isolation_test.go`:
`storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。
**变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」;
恢复 → 变绿。
全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
## ⚠️ 修复过程中我自己踩的坑
恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的
`credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被
**二次加密**(1971 → 2965 字符),该账号的推理开始返回空
(`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。
教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。
要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。
---
# 逐模块对照的当前状态(2026-10-14 更新)
穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定:
✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做
## 本轮(第 2 批)新补的
| 模块 | 参考规模 | 补了什么 / 为什么 |
|---|---|---|
| `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) |
| `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 |
| `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 |
| `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 |
| `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) |
| `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 |
| `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 |
另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带
error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。
## 仍未实现(按影响面排序)
| 模块 | 行数 | 为什么不做的理由 / 阻塞 |
|---|---|---|
| `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" |
| `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 |
| `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 |
| `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) |
| `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) |
| `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 |
| `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** |
| `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 |
| `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 |
| `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) |
| `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) |
| `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 |
## 一句话结论
**平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台
自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。
**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动
(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。
其余是宿主 UI 特性或前提不存在的模块。
---
# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现**
## ★ 本轮新补的三个真缺陷
### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`)
活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。
**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**,
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。
### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`)
`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true`
⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。
这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。
### ③ `isFree` 与声明自相矛盾(`cline-models.ts`)
cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。
★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`,
opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。
⇒ 加 `free_source` 由**平台声明**权威来源。
## 插件自装适配器(第 3、4 个)
| 适配器 | 解决的问题 |
|---|---|
| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) |
现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。
## 对照后判定「不适用」的(附理由,不是"没做")
| 模块 | 为什么不需要 |
|---|---|
| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 |
| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels-<plat>-<acct>`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) |
| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) |
| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 |
## 运行环境的一条教训(不是代码缺陷)
全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、
看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建
缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。

View File

@ -46,7 +46,7 @@ plugin.ui = {
page = {
page_id = "hello", -- kebab-case
title = "Hello",
icon = "👋",
icon = "",
order = 90, -- 侧栏排序
mount = [[<div id="hello">hello</div>]],
},
@ -211,7 +211,7 @@ plugin.ui = {
page = {
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
title = "Billing", -- 必填,侧栏文字
icon = "💰", -- 可选
icon = "", -- 可选
order = 40, -- 侧栏排序,默认 100
mount = [[...HTML...]],
},
@ -377,7 +377,48 @@ plugin.http = {
路径,未认证会跳登录页。
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
### 5.6 在运行时安装/更新适配器(token 轮换)
### 5.6 前缀代理:把插件自起的服务挂到网关同端口
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{status, headers, body/json}`)。
若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要
**字节级转发**,包括 **WebSocket 升级** —— 前者做不到。
为此声明 `prefix = true`,handler 每次请求返回一个**回环目标**:
```lua
plugin.http = {
{ path = "myplugin/proxy/:session", method = "GET", prefix = true,
roles = { "admin" }, handler = "proxy" },
}
function plugin.proxy(req)
-- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
if not sessionAlive(req.params.session) then
return { status = 404, json = { error = "no such session" } }
end
return { proxy = "http://127.0.0.1:60100" }
end
```
规则:
- **只能返回 `{ proxy = <目标> }`**,没有 `status` / `body`。写了 `status` 会被忽略。
- 目标必须是 **带显式端口的字面回环 IP**(`http://127.0.0.1:60100`)。
拒绝 `https://`、`localhost` 这类主机名、无端口、带 userinfo/path/query 的地址。
理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
- 路由匹配同前缀下的**所有子路径**,`:param` 仍照常捕获,`req.path` 是完整
请求路径,子路径由插件自己从 `req.path` 里切。
- **鉴权先于代理**:`roles` / `keys` / `public` 语义与普通端点一致。
- **不转发网关凭据**:`Authorization`、`Cookie`、`api_key` 都不会传给后端服务。
- **拒绝跨站**:`Origin` 与网关自身 host 不一致返回 403。WebSocket 由浏览器
发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
- **撤销点唯一**:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。
客户端拿到的 URL 是**相对网关口**的绝对路径(如
`/api/plugins/http/myplugin/proxy/<id>/vnc.html`),因此远程访问、HTTPS 入口、
Cookie 鉴权都自然生效,无需暴露额外端口。
### 5.7 在运行时安装/更新适配器(token 轮换)
插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
@ -428,7 +469,7 @@ end
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState` / **定时任务**)
都用这同一把互斥锁串行化。
⚠️ 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
注意: 这条约束对**定时任务尤其关键**:任务和钩子必须是同一个 VM,否则任务写的
`plugin.state` 是一个请求路径永远读不到的副本 —— token 轮换看起来成功了,
对实际流量完全无效。
@ -449,7 +490,7 @@ end
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
注意: `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
### 6.4 命名
@ -536,7 +577,7 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
{ "prices": { ... }, "count_failures": true }
```
### 7.5 ⚠️ 计费插件只报表,不执法
### 7.5 注意: 计费插件只报表,不执法
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
本插件不参与任何路由或配额决策。

View File

@ -17,7 +17,7 @@ import (
//
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
// paste(opencode:直接 complete_login 贴 API key)
// manual(qoder:start_login 返回 manual_hint,再凭据完成)
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
//
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
func TestChannelsFlowsE2E(t *testing.T) {
@ -40,7 +40,7 @@ func TestChannelsFlowsE2E(t *testing.T) {
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`)
// ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)----
case "/v1/models":
fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qoder-default","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
case "/v1/chat/completions":
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
@ -166,7 +166,9 @@ func TestChannelsFlowsE2E(t *testing.T) {
t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody)
}
// ---------- ③ manual(qoder):hint 明确 + 凭据完成 ----------
// ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ----------
// qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程
// (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"qoder","base_url":%q}`, plat.URL))
if st != 200 {
@ -176,9 +178,16 @@ func TestChannelsFlowsE2E(t *testing.T) {
if st != 200 {
t.Fatalf("qoder start_login status=%d", st)
}
hint, _ := m["manual_hint"].(string)
if !strings.Contains(hint, "WASM") {
t.Fatalf("qoder manual_hint 应说明 WASM 不可自动, 实得 %q", hint)
// 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint),
// 且会话声明为 device-poll。
if hint, _ := m["manual_hint"].(string); hint != "" {
t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint)
}
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" {
t.Fatalf("qoder 会话 flow 应 device-poll: %v", m)
}
if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") {
t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL)
}
st, m = call("POST", "/api/plugins/http/channels/login/complete",
`{"platform":"qoder","credential":"QD-E2E-KEY"}`)
@ -189,12 +198,12 @@ func TestChannelsFlowsE2E(t *testing.T) {
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qoder-default") {
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "qoder-default") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qoder-default: %s", modelsBody)
if !strings.Contains(modelsBody, "qmodel_38max") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
}
}

View File

@ -47,7 +47,7 @@ func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
// Paid sources must NOT be marked, or their unlisted models bill $0.
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("★ wildcard token rule marked %q as priced at 0 — its unlisted "+
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
"models will bill $0 instead of appearing as unpriced", name)
}
}

View File

@ -0,0 +1,102 @@
package core
import (
"testing"
)
// account_adopt:把升级前的**非池**凭据接管进账号池。
//
// 为什么需要它:v2.0.x 之前登录只写一条 `channels:<plat>`,引入账号池后凭据落在
// `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 ——
// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。
//
// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 API**(读出的是明文,
// 写回时插件自己加密)。绝不要把保险库文件里的密文当明文喂给 API ——
// 保险库读取时会解密,那样会把凭据**二次加密**(实测踩过:1971 → 2965 字符,
// 该账号推理开始返回空)。
func TestAccountAdoptLegacyCredential(t *testing.T) {
_, ps := poolCore(t)
const legacy = `{"access_token":"LEGACY-TOK","refresh_token":"RT","nickname":"老账号"}`
// 造一条"旧版"非池凭据(模拟升级前留下的)
if _, err := ps.Call("channels", "__set_legacy_cred_probe", map[string]interface{}{
"platform": "buddy", "credential": legacy,
}); err != nil {
t.Fatalf("造旧凭据: %v", err)
}
// 接管进一个新池账号
m, err := ps.Call("channels", "account_adopt", map[string]interface{}{
"platform": "buddy", "account_id": "adopted1",
})
if err != nil {
t.Fatalf("account_adopt: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("接管失败: %#v", m)
}
// ① 池里必须出现该账号,且**有凭据**
lm, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
lj, _ := lm.(map[string]interface{})["json"].(map[string]interface{})
found := false
for _, a := range asArrInterface(lj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
found = true
if am["has_credential"] != true {
t.Errorf("接管后应 has_credential=true,实得 %v", am["has_credential"])
}
}
}
if !found {
t.Fatalf("接管后账号未出现在池里: %#v", lj["accounts"])
}
// ② 凭据必须是**原文**(未被二次加密)——这是本判据的核心
out, err := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:buddy:adopted1",
})
if err != nil {
t.Fatalf("读凭据: %v", err)
}
rj, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got, _ := rj["credential"].(string)
if got != legacy {
t.Errorf("接管后的凭据应与原文逐字节相同(不得二次加密)\n 期望: %s\n 实得: %s",
legacy, got)
}
if rj["len"] != nil {
if n, _ := rj["len"].(float64); int(n) != len(legacy) {
t.Errorf("凭据长度 %v,应为 %d(长度变化说明被二次加密)", n, len(legacy))
}
}
// ③ 补齐了展示字段(昵称从凭据里取)
for _, a := range asArrInterface(lj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
if am["nickname"] != "老账号" {
t.Errorf("昵称应从凭据补齐为「老账号」,实得 %v", am["nickname"])
}
}
}
// ④ 没有旧凭据时必须如实报错(不能假装成功)
_, _ = ps.Call("channels", "__clear_all_creds_probe", nil)
m2, _ := ps.Call("channels", "account_adopt", map[string]interface{}{
"platform": "buddy", "account_id": "adopted2",
})
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
if j2["ok"] == true {
t.Error("没有旧凭据时应如实失败,不能假装成功")
}
}
// asArrInterface 宽松取数组(插件桥回传的可能是 []interface{})。
func asArrInterface(v interface{}) []interface{} {
if a, ok := v.([]interface{}); ok {
return a
}
return nil
}

View File

@ -0,0 +1,105 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// `account_test` 必须**真打一次上游**。
//
// 真实缺陷:此前它一个网络请求都不发 —— 只 syncAccountSource 后查内核源列表里
// 有没有那条记录,就返回 {ok:true, ms:1}。于是"测试账号"永远绿,包括凭据已失效、
// 被限流、上游宕机的情况。这正是「看着能用、一用就错」的假阳性,也正是用户报
// 「完全不可用」时最容易误导人的那种信号。
//
// 判据把三条都钉住:① 真发请求 ② 上游失败时必须 ok=false ③ 失败要区分
// "凭据失效"(需重新登录)与"暂时性失败"(可重试)—— 只说"失败"用户不知道该做什么。
func TestAccountTestReallyProbesUpstream(t *testing.T) {
var hits int32
fail := false
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
if fail {
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"error":"invalid_grant: token expired"}`))
return
}
body, _ := json.Marshal(map[string]interface{}{
"data": map[string]interface{}{"Response": map[string]interface{}{
"Data": map[string]interface{}{"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100},
}},
}},
})
_, _ = w.Write(body)
}))
defer ts.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
"credential": `{"access_token":"TOK"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
// ① 正常路径:必须真的打了上游
atomic.StoreInt32(&hits, 0)
m, err := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
})
if err != nil {
t.Fatalf("account_test: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j["ok"] != true {
t.Fatalf("正常凭据应 ok=true: %#v", j)
}
if j["probed"] != true {
t.Errorf("必须标记 probed=true 并真的发请求;实得 probed=%v", j["probed"])
}
if n := atomic.LoadInt32(&hits); n == 0 {
t.Fatal("account_test 未向上游发任何请求 —— 这就是「看着能用」的假阳性")
}
// ② 凭据失效:必须 ok=false,且归类为 auth(给出可操作提示)
fail = true
m2, _ := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
})
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
if j2["ok"] != false {
t.Errorf("凭据失效时必须 ok=false,实得 %v", j2["ok"])
}
if j2["kind"] != "auth" {
t.Errorf("应归类为 auth(需重新登录),实得 %v", j2["kind"])
}
if s, _ := j2["action"].(string); s == "" {
t.Error("失败必须给出可操作提示(否则用户不知道要重新登录还是等一会)")
}
// ③ 无凭据的账号:如实报错,不假装成功
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "naked",
}); err != nil {
t.Fatalf("account_add(无凭据): %v", err)
}
m3, _ := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "naked",
})
j3, _ := m3.(map[string]interface{})["json"].(map[string]interface{})
if j3["ok"] != false {
t.Errorf("无凭据时必须 ok=false,实得 %v", j3["ok"])
}
}

View File

@ -0,0 +1,182 @@
package core
import (
"net/url"
"regexp"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。
//
// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定:
// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri)
// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致
// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起
// - redirect_uri 必须百分号编码(含 :// 与 :)
func TestCallbackAuthorizeUrls(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)
}
start := func(t *testing.T, plat string) (string, map[string]interface{}) {
t.Helper()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s start_login: %v", plat, err)
}
j := out.JSON.(map[string]interface{})
u, _ := j["url"].(string)
return u, j
}
t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) {
u, _ := start(t, "trae")
if u == "" {
t.Fatal("trae 未给出授权 URL")
}
if !strings.Contains(u, "auth_callback_url=") {
t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u)
}
if strings.Contains(u, "redirect_uri=") {
t.Fatalf("trae 不该有 redirect_uri 参数: %s", u)
}
for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} {
if !strings.Contains(u, want) {
t.Fatalf("trae 缺参数 %s: %s", want, u)
}
}
// 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接
if strings.Contains(u, "authorization&") {
t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u)
}
// 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数,
// 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定:
// login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外
// 还可能被风控拦;x_* 是客户端形态伪装。
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("trae 授权 URL 无法解析: %v", err)
}
q := parsed.Query()
for _, k := range []string{
"login_version", "auth_from", "login_channel", "plugin_version",
"auth_type", "client_id", "redirect", "login_trace_id",
"auth_callback_url", "machine_id", "device_id",
"x_device_id", "x_machine_id", "x_device_brand", "x_device_type",
"x_os_version", "x_app_version", "x_app_type",
} {
if q.Get(k) == "" {
t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u)
}
}
// 设备身份是 hex32,trace_id 是它们拼接后的末 16 位
m, d := q.Get("machine_id"), q.Get("device_id")
if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) {
t.Fatalf("machine_id 应为 hex32: %q", m)
}
if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) {
t.Fatalf("device_id 应为 hex32: %q", d)
}
if m == d {
t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)")
}
joined := m + d
if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] {
t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got)
}
// 同一平台两次登录必须得到不同设备身份(否则多账号共用设备,
// 会被上游以「该设备已签到」拦截)
u2, _ := start(t, "trae")
q2, _ := url.Parse(u2)
if q2.Query().Get("device_id") == d {
t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)")
}
// Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点):
// 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。
if cb := q.Get("auth_callback_url"); cb != "" {
if !strings.HasSuffix(cb, "/authorize") {
t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb)
}
if !strings.HasPrefix(cb, "http://127.0.0.1:") {
t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb)
}
}
})
t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) {
u, _ := start(t, "gemini")
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("授权 URL 无法解析: %v", err)
}
ru := parsed.Query().Get("redirect_uri")
if !strings.HasPrefix(ru, "http://localhost:") {
t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru)
}
// 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)——
// 换成别的路径就是 redirect_uri_mismatch。
if !strings.HasSuffix(ru, "/oauth-callback") {
t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru)
}
for _, k := range []string{"client_id", "response_type", "scope", "state"} {
if parsed.Query().Get(k) == "" {
t.Fatalf("gemini 缺参数 %s: %s", k, u)
}
}
// 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。
if got := parsed.Query().Get("access_type"); got != "offline" {
t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got)
}
if got := parsed.Query().Get("prompt"); got != "consent" {
t.Fatalf("gemini prompt 应为 consent,实得 %q", got)
}
// 参数不得重复(通用分支曾再追加一次 redirect_uri)
if n := strings.Count(u, "redirect_uri="); n != 1 {
t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u)
}
if n := strings.Count(u, "client_id="); n != 1 {
t.Fatalf("client_id 出现 %d 次: %s", n, u)
}
})
t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) {
u, _ := start(t, "lobsterai")
if !strings.Contains(u, "/portal#/login?") {
t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u)
}
if strings.Contains(u, "/login&") {
t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u)
}
if !strings.Contains(u, "source=electron") {
t.Fatalf("lobsterai 缺 source=electron: %s", u)
}
// redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败
if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") {
t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u)
}
})
t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) {
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
u, _ := start(t, plat)
if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" {
t.Fatalf("%s 不应硬编码 8081: %s", plat, u)
}
if strings.Contains(u, "{port}") {
t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u)
}
}
})
}

View File

@ -0,0 +1,111 @@
package core
import (
"strings"
"testing"
)
// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。
//
// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动
// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天**
// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也
// 不去领。
//
// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答**
// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数
// 「有几条能证明今天已被处理」。
func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) {
_, ps := poolCore(t)
// ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修)
out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
"refreshed": false,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["kind"] == nil {
t.Fatalf("探针应返回 kind,实得 %#v", out)
}
if j["covers_today"] != false {
t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"])
}
if j["kind"] == "already-claimed" {
t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)")
}
// ② 刷新后:正常报已领,且 covers_today=true
out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
"refreshed": true,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if j2["covers_today"] != true {
t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"])
}
// ③ 记账判据必须**只看 covers_today**,不能用 kind 反推
//(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹)
cases := []struct {
name string
kind string
cov interface{}
want bool
}{
{"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false},
{"刷新后的 already-claimed 算已处理", "already-claimed", true, true},
{"success 算已处理", "success", true, true},
{"covers_today 缺省为 true", "claimed", nil, true},
{"inactive 不算已处理", "inactive", true, false},
{"failed 不算已处理", "failed", true, false},
}
for _, c := range cases {
o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{
"kind": c.kind, "covers_today": c.cov,
})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
oj, _ := o.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := oj["covers"].(bool); got != c.want {
t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got)
}
}
}
// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。
func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) {
_, ps := poolCore(t)
out, err := ps.Call("channels", "__schedule_probe", nil)
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
every, _ := j["auto_claim_every"].(float64)
if every <= 0 {
t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j)
}
// 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8),
// 一天只跑一次就必然有平台漏领。
if every >= 86400 {
t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every)
}
if j["first_due_now"] != true {
t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)")
}
// 已覆盖今天的平台必须靠记账跳过,否则会重复打上游
if !strings.Contains(toString(j["has_coverage_key"]), "true") {
t.Error("需要「今天已覆盖」的持久化记账键")
}
}
func toString(v interface{}) string {
if b, ok := v.(bool); ok {
if b {
return "true"
}
return "false"
}
return ""
}

View File

@ -0,0 +1,159 @@
package core
import (
"strings"
"testing"
)
// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据,
// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。
//
// 实测踩到的三处偏差(本轮修正):
//
// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn →
// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、
// 函数说不支持)。参考里 buddy **没有** claim。
// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励
// POST /api/web/desktop/v1/login/points/grant(每号一次)。
// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium,
// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。
// 自动跳过,手动仍可领。
func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) {
c, ps := poolCore(t)
_ = c
// 参考真值(逐个 grep 参考仓的 claim 导出确认)
wantClaim := map[string]bool{
"trae": true, "minimax": true, "lobsterai": true, "qoder": true,
"loomy": true, "raccoon": true, "codearts": true,
"cline": false, "gemini": false, "zcode": true, "buddy": false,
}
// 自动签到必须跳过的(领取有代价)
wantAutoExclude := map[string]bool{"zcode": true}
for plat, want := range wantClaim {
t.Run("claim_"+plat, func(t *testing.T) {
got := creditsCapability(t, ps, plat)
if got == nil {
t.Fatalf("%s 无能力声明", plat)
}
if has := got["claim"] == true; has != want {
t.Errorf("%s claim = %v,参考为 %v", plat, has, want)
}
if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] {
t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat])
}
})
}
// 关键一致性:声称 claim=true 的平台**必须**真的可领
//(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。
for plat, want := range wantClaim {
if !want {
continue
}
m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("%s: %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
t.Fatalf("%s 无返回", plat)
}
if j["capable"] != true {
t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+
"函数说不支持」的自相矛盾", plat, j["reason"])
}
}
// 反向:不声称 claim 的平台必须被判为不可领
m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"})
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j != nil && j["capable"] == true {
t.Error("buddy 不该被判为可领(它没有 claim)")
}
}
// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。
//
// auto_claim 走调度器(不是 HTTP export),故断言调度声明。
func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
for _, want := range []struct{ name, needle string }{
{"每日领取", `fn = "auto_claim"`},
{"模型目录自动获取", `fn = "refresh_models"`},
{"凭据自动续期", `fn = "refresh_creds"`},
{"启动即补跑", `first_due_now = true`},
} {
if !strings.Contains(b, want.needle) {
t.Errorf("调度表缺 %s(%s)", want.name, want.needle)
}
}
// 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时)
if !strings.Contains(b, `fn = "refresh_models", every = 7200`) {
t.Error("refresh_models 间隔应为 7200(参考 2h)")
}
// 按**行**判断(固定字符窗会溢到下一行,误报)
lineOf := func(fn string) string {
for _, ln := range strings.Split(b, "\n") {
if strings.Contains(ln, `fn = "`+fn+`"`) {
return ln
}
}
return ""
}
// auto_claim **现在**也 first_due_now,且间隔远小于一天。
//
// ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务,
// 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。
// 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次
// 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半:
// ① 频率提高 + first_due_now(任何时刻重启都能补上);
// ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**,
// 而不是靠"一天只跑一次"来碰巧避开)。
// 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。
if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") {
t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln)
}
// 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈)
for _, fn := range []string{"refresh_models", "refresh_creds"} {
ln := lineOf(fn)
if ln == "" {
continue
}
if !strings.Contains(ln, "first_due_now = true") {
t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln)
}
}
// auto_exclude 必须挡住 zcode(领取有代价:headful Chromium)
if !strings.Contains(b, "auto_exclude = true") {
t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)")
}
if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 {
t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)")
}
}
// creditsCapability 读插件的能力表条目。
func creditsCapability(t *testing.T, ps interface {
Call(string, string, map[string]interface{}) (interface{}, error)
}, plat string) map[string]interface{} {
t.Helper()
m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("credits_capability(%s): %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
return nil
}
if cap, ok := j["capability"].(map[string]interface{}); ok {
return cap
}
return nil
}

View File

@ -0,0 +1,122 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)。
//
// 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
// 而**服务端扣哪个包不由我们决定** —— 插件能决定的是**用哪个号**:
// 优先用「还有 15 天内到期积分」的号(那部分不用就作废),只剩永久积分的号排最后;
// **锁定永久积分**时只剩永久积分的号等同于不可用。
//
// 判据用假余额上游,逐条钉住分档规则(含三个易错点)。
func TestBalanceTierSelection(t *testing.T) {
// 假 buddy 余额端点:返回三个包,覆盖三种情形
// pkg A: 有效 + 10 天内到期 + 剩余 100 → 临时桶
// pkg B: 有效 + 到期时间未知 + 剩余 50 → 永久桶(保守)
// pkg C: **已失效** + 剩余 9999 → 必须跳过(否则虚增可用额度)
future := nowMs() + 300*24*3600*1000 // 300 天后
soon := nowMs() + 10*24*3600*1000 // 10 天后
body := map[string]interface{}{
"data": map[string]interface{}{
"Response": map[string]interface{}{
"Data": map[string]interface{}{
"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100, "DeductionEndTime": soon},
{"Status": 1, "CycleCapacityRemain": 50, "DeductionEndTime": future},
{"Status": 2, "CycleCapacityRemain": 9999, "DeductionEndTime": future},
},
},
},
},
}
raw, _ := json.Marshal(body)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(raw)
}))
defer ts.Close()
_, ps := poolCore(t)
// 把一个 buddy 账号加进池,并把余额端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "tieracct",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
// ① 分档:临时=100(只有 10 天内那个),永久=50(未知/远期),失效包被跳过
out, err := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
if err != nil {
t.Fatalf("分档探针: %v", err)
}
tw, _ := out.(map[string]interface{})
tj, _ := tw["json"].(map[string]interface{})
if tj == nil {
t.Fatalf("无 json: %#v", out)
}
if tj["error"] != nil {
t.Fatalf("分档报错: %v", tj["error"])
}
if got, _ := tj["expiring"].(float64); got != 100 {
t.Errorf("临时余额应为 100(仅 10 天内到期的包),实得 %v", tj["expiring"])
}
if got, _ := tj["permanent"].(float64); got != 50 {
t.Errorf("永久余额应为 50(到期未知/远期),实得 %v(若为 10049 说明失效包没跳过)",
tj["permanent"])
}
// ② 档位判定:有临时积分 → "expiring"(最优先)
if tj["tier"] != "expiring" {
t.Errorf("有 15 天内到期积分时档位应为 expiring,实得 %v", tj["tier"])
}
// ③ 锁定永久积分:该账号**仍可用**(因为有临时积分)
if _, err := ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatalf("锁定探针: %v", err)
}
out2, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if tj2["usable"] != true {
t.Errorf("锁定永久积分时,有临时积分的号仍应可用,实得 usable=%v", tj2["usable"])
}
// ④ 窗口可被显式覆盖:把窗口设成 0 ⇒ 没有"临时积分" ⇒ 只剩永久 ⇒ 锁定后不可用
if _, err := ps.Call("channels", "__set_expiring_window_probe",
map[string]interface{}{"days": 0}); err != nil {
t.Fatalf("窗口探针: %v", err)
}
out3, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := tj3["expiring"].(float64); got != 0 {
t.Errorf("窗口=0 时临时桶应为 0,实得 %v", tj3["expiring"])
}
if tj3["usable"] != false {
t.Errorf("窗口=0 且锁定永久积分时该号应不可用,实得 usable=%v", tj3["usable"])
}
}
// nowMs 返回当前毫秒时间戳(与插件里 DeductionEndTime 的口径一致)。
func nowMs() int64 { return time.Now().UnixMilli() }

View File

@ -0,0 +1,85 @@
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

@ -0,0 +1,220 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"llmsproxy/internal/lua"
)
// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同:
//
// Trae /authorize 不带 state(token 直接放 query 里)
// 有道 /auth/callback 必须带 state
// Gemini /oauth-callback 必须带 state
//
// 路径写错 → 上游 redirect_uri_mismatch;校验写错 → 要么接不住回调、
// 要么被伪造回调塞入凭据。本测试打真上游形态(本地 loopback 接收器)。
func TestCallbackPlatformsLoopback(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)
}
start := func(t *testing.T, plat string) (m map[string]interface{}, nonce, sid string) {
t.Helper()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s start_login: %v", plat, err)
}
m = out.JSON.(map[string]interface{})
s := m["session"].(map[string]interface{})
return m, s["nonce"].(string), s["id"].(string)
}
poll := func(t *testing.T, sid string) map[string]interface{} {
t.Helper()
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
return o.JSON.(map[string]interface{})
}
t.Run("行1 Trae 回调路径是 /authorize 且无 state 也能接住", func(t *testing.T) {
_, _, sid := start(t, "trae")
// 交换端点指到假上游
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"Token":"ACCESS-NEW"}`))
}))
defer ts.Close()
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae", "callback": map[string]interface{}{"exchange_url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 重新起会话,拿到带 loopback 的 redirect_uri
m, nonce, sid := start(t, "trae")
ru, _ := m["redirect_uri"].(string)
if !strings.HasSuffix(ru, "/authorize") {
t.Fatalf("trae 回调路径必须是 /authorize,实得 %q", ru)
}
// Trae 的回调**不带 state**,而是把 token 放 query 里
q := url.Values{}
q.Set("refreshToken", "RT-1")
q.Set("userInfo", `{"UserID":"u1","ScreenName":"张三"}`)
resp, err := http.Get(ru + "?" + q.Encode())
if err != nil {
t.Fatalf("打 loopback 接收器失败: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("Trae 回调(无 state)应被接住,实得 %d", resp.StatusCode)
}
st := waitState(t, poll, sid, "completed")
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, _ := ps.GetSecret("channels", "channels:trae")
if !ok {
t.Fatal("凭据未落库")
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
if cred["access_token"] != "ACCESS-NEW" {
t.Fatalf("凭据应是交换后的新 access,实得 %v", cred["access_token"])
}
if cred["uid"] != "u1" {
t.Fatalf("uid 应从 userInfo.UserID 取出(签到设备身份的种子), 实得 %v", cred["uid"])
}
_ = nonce
})
t.Run("行2 Gemini 路径是 /oauth-callback 且错 state 被拒", func(t *testing.T) {
m, nonce, sid := start(t, "gemini")
ru, _ := m["redirect_uri"].(string)
if !strings.HasSuffix(ru, "/oauth-callback") {
t.Fatalf("gemini 回调路径必须是 /oauth-callback(参考 GEMINI_CALLBACK_PATH),实得 %q", ru)
}
if !strings.HasPrefix(ru, "http://localhost:") {
t.Fatalf("gemini 回调主机必须是 localhost(参考注释明确),实得 %q", ru)
}
// 错 state 必须被接收器拒(400),且不推进会话
bad, err := http.Get(ru + "?code=C-1&state=WRONG")
if err != nil {
t.Fatal(err)
}
bad.Body.Close()
if bad.StatusCode != 400 {
t.Fatalf("state 不匹配应 400(防伪造回调),实得 %d", bad.StatusCode)
}
if st := poll(t, sid); st["state"] != "pending" {
t.Fatalf("错 state 不应推进会话,实得 %v", st)
}
// 正确 state → 完成(交换端点指到假上游)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
// 必须带 client_secret:只发 client_id 会 invalid_request,
// 症状正是「浏览器显示授权成功但账号不出现」
if r.PostFormValue("client_secret") == "" {
t.Error("token 交换必须带 client_secret")
}
if r.PostFormValue("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", r.PostFormValue("grant_type"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"GA-1","refresh_token":"GR-1","expires_in":3600}`))
}))
defer ts.Close()
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "gemini",
"callback": map[string]interface{}{"token_url": ts.URL, "mode": "code"},
}); err != nil {
t.Fatal(err)
}
good, err := http.Get(ru + "?code=C-1&state=" + nonce)
if err != nil {
t.Fatal(err)
}
good.Body.Close()
st := waitState(t, poll, sid, "completed")
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, _ := ps.GetSecret("channels", "channels:gemini")
if !ok {
t.Fatal("凭据未落库")
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
if cred["access_token"] != "GA-1" {
t.Fatalf("access_token = %v", cred["access_token"])
}
})
}
// waitState 轮询到目标状态或超时(loopback 写文件是异步的,需要一拍)。
func waitState(t *testing.T, poll func(*testing.T, string) map[string]interface{},
sid, want string) map[string]interface{} {
t.Helper()
var st map[string]interface{}
for i := 0; i < 30; i++ {
st = poll(t, sid)
if st["state"] != "pending" {
return st
}
time.Sleep(100 * time.Millisecond)
}
return st
}
// 占位符未替换检查:授权 URL 里残留 {xxx} 会让上游收到字面量占位符
// (而且往往不报错,只是行为诡异)。三个 callback 平台逐个验。
func TestAuthorizeURLsHaveNoUnfilledPlaceholders(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)
}
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s: %v", plat, err)
}
m := out.JSON.(map[string]interface{})
for _, key := range []string{"url", "redirect_uri"} {
v, _ := m[key].(string)
if v == "" {
continue
}
if strings.Contains(v, "{") || strings.Contains(v, "nil") {
t.Errorf("%s 的 %s 仍有未替换占位符: %s", plat, key, v)
}
}
}
}

View File

@ -0,0 +1,55 @@
package core
import (
"os"
"testing"
)
// 用**真实的 models.dev 响应**验证解析口径(5.6MB / 229 键 / opencode 122 模型)。
//
// 为什么必须用真数据:字段路径(modalities.input / cost.input / limit.context)
// 是我照参考注释写的,只有真数据能证明写对了。合成样本会把这个验证变成自说自话。
//
// 样本不在仓库里(5.6MB),缺失时跳过 —— 但 CI 里能拉到就一定会跑。
func TestCapabilityParseAgainstRealModelsDev(t *testing.T) {
b, err := os.ReadFile("/tmp/modelsdev.json")
if err != nil {
t.Skip("无 models.dev 样本(/tmp/modelsdev.json),跳过")
}
_, ps := poolCore(t)
out, err := ps.Call("channels", "__parse_capability_probe", map[string]interface{}{
"body": string(b), "key": "opencode",
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["error"] != nil {
t.Fatalf("解析失败: %v", j["error"])
}
total, _ := j["total"].(float64)
free, _ := j["free"].(float64)
image, _ := j["image"].(float64)
ctx, _ := j["with_ctx"].(float64)
t.Logf("opencode: total=%.0f free=%.0f image=%.0f with_ctx=%.0f", total, free, image, ctx)
// ① 规模:实测 122 个模型(允许上游增减,给个合理区间)
if total < 50 {
t.Errorf("解析出的模型数太少(%.0f)—— 字段路径可能没对上真实结构", total)
}
// ② 免费判定必须有命中的(实测有若干 cost 全 0 的模型)
if free == 0 {
t.Error("一个免费模型都没识别出来 —— cost 判定路径可能错了")
}
if free >= total {
t.Error("全部被标免费 —— cost 判定过于宽松")
}
// ③ 多模态也要有命中的(gpt-5.4 之类含 image)
if image == 0 {
t.Error("没识别出任何多模态模型 —— modalities 路径可能错了")
}
// ④ 上下文窗口要能取到(limit.context)
if ctx == 0 {
t.Error("没取到任何上下文窗口 —— limit 路径可能错了")
}
}

View File

@ -0,0 +1,86 @@
package core
import (
"testing"
)
// 模型能力元数据(照参考 opencode-capability)。
//
// 关键纪律:`/zen/v1/models` 只返回 4 个字段、**不含能力信息**;能力在 models.dev
// (实测 5.05 MB / 下载 1.4s)。参考的真机事故:最初在模型列表渲染路径上直接
// 等这个下载,模型选择器要等 1.4s+ 才出列表 —— 用户报障「点开是一片空白」。
//
// ⇒ 三条硬约定:渲染路径只读同步缓存;磁盘缓存冷启动命中;拉取在后台。
// 我们额外:本插件是服务端进程,但 5MB 仍不能放请求路径上 ⇒ 挂定时任务。
//
// 本判据钉住"非阻塞"这一条:**缓存未就绪时 model_list 必须立刻返回**,
// 而不是去等网络。
func TestCapabilityReadNeverBlocks(t *testing.T) {
_, ps := poolCore(t)
// 清掉能力缓存(模拟冷启动)
if _, err := ps.Call("channels", "__clear_capabilities_probe", nil); err != nil {
t.Fatalf("清缓存: %v", err)
}
// 冷启动下取模型列表:必须成功返回(退回兜底 isFree),且不得等待网络。
// 判据:能返回就说明没阻塞(真去下 5MB 会超时/极慢)。
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("冷启动下 model_list 必须成功(退回兜底),实得 %#v", m)
}
models := asArrInterface(j["models"])
if len(models) == 0 {
t.Fatal("冷启动下仍应列出兜底模型")
}
// 冷启动时 isFree 应退回声明的 models_free(不能凭空说全部免费)
sawFree := false
for _, it := range models {
if mm, ok := it.(map[string]interface{}); ok && mm["isFree"] == true {
sawFree = true
}
}
if !sawFree {
t.Error("冷启动时 opencode 应有 free 模型(退回声明的 models_free)")
}
}
// 能力元数据一旦就绪,isFree 必须改由它决定(cost 权威,而非快照)。
func TestCapabilityOverridesFreeFlag(t *testing.T) {
_, ps := poolCore(t)
// 注入一份假能力:把某个原本标 free 的模型改成收费、把非 free 的改成免费
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
"platform": "opencode",
"model_id": "big-pickle",
"is_free": false, // 原本声明 free,能力表说收费 ⇒ 必须以能力表为准
}); err != nil {
t.Fatalf("注入: %v", err)
}
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
"platform": "opencode",
"model_id": "glm-5.2",
"is_free": true, // 原本不在 free 列表,能力表说免费
}); err != nil {
t.Fatalf("注入: %v", err)
}
m, _ := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
if got["big-pickle"] {
t.Error("能力表说收费时 isFree 必须为 false(权威来源是 cost,不是快照)")
}
if !got["glm-5.2"] {
t.Error("能力表说免费时 isFree 必须为 true")
}
}

View File

@ -0,0 +1,235 @@
package core
import (
"testing"
"llmsproxy/internal/lua"
)
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
//
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
// (codearts 10 + trae 2 全是占位)。
//
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
h := newAccountCleanupHarness(t)
// ① 默认 dry_run:必须**不删**任何东西
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
preJ := jsonOf(t, pre)
if preJ["dry_run"] != true {
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
}
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
if got := len(accountIDs(t, h, "codearts")); got != 4 {
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
}
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
}
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
}
// ② 真执行:删无凭据的,**保留有凭据的**
if _, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"dry_run": false}); err != nil {
t.Fatal(err)
}
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
}
// ★ 关键:留下的必须是**有凭据的那个**
left := accountIDs(t, h, "codearts")
if len(left) != 1 || left[0] != "withcred" {
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
}
// 有凭据的账号其凭据也必须仍在(不能被误删)
if !h.hasCredential("withcred") {
t.Fatal("有凭据账号的凭据被误删了")
}
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
}
// 指定平台时只动那个平台,别的不碰。
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
h := newAccountCleanupHarness(t)
if _, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
t.Fatal(err)
}
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("codearts 应剩 1,实得 %d", got)
}
if got := len(accountIDs(t, h, "trae")); got != 2 {
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
}
t.Log("✓ 平台范围隔离正确")
}
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
h := newAccountCleanupHarness(t)
res, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
if j["error"] == nil {
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
}
// 且什么都没删
if got := len(accountIDs(t, h, "codearts")); got != 4 {
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
}
}
// ── harness ──
type accountCleanupHarness struct {
t *testing.T
ps *lua.Plugins
}
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
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)
}
h := &accountCleanupHarness{t: t, ps: ps}
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
for _, id := range []string{"empty1", "empty2", "empty3"} {
h.add("codearts", id, "")
}
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
h.add("trae", "tempty1", "")
h.add("trae", "tempty2", "")
return h
}
func (h *accountCleanupHarness) add(platform, id, cred string) {
h.t.Helper()
args := map[string]interface{}{"platform": platform, "account_id": id}
if cred != "" {
args["credential"] = cred
}
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
}
}
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "account_list",
map[string]interface{}{"platform": "codearts"})
if err != nil {
h.t.Fatal(err)
}
for _, a := range accountsOf(h.t, res) {
if a["id"] == accountID {
return a["has_credential"] == true
}
}
return false
}
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
func jsonOf(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
}
// accountsOf 取 account_list 返回的账号数组。
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
t.Helper()
j := jsonOf(t, got)
arr, _ := j["accounts"].([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// accountIDs 列某平台的账号 id。
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
t.Helper()
res, err := h.ps.Call("channels", "account_list",
map[string]interface{}{"platform": platform})
if err != nil {
t.Fatalf("account_list(%s): %v", platform, err)
}
var out []string
for _, a := range accountsOf(t, res) {
if id, ok := a["id"].(string); ok {
out = append(out, id)
}
}
return out
}
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
//
// 反例(实测踩到,我自己写的):照其它 handler 写
//
// `if args.platform == nil then args = req end`
//
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
//
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
h := newAccountCleanupHarness(t)
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
if err != nil {
t.Fatal(err)
}
if res.Status != 200 {
t.Fatalf("status=%d body=%v", res.Status, res.Body)
}
j, _ := res.JSON.(map[string]interface{})
if j["dry_run"] == true {
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
}
if j["deleted"] == nil {
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
}
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
}
if got := len(accountIDs(t, h, "trae")); got != 0 {
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
}
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
}

View File

@ -0,0 +1,91 @@
package core
import (
"strings"
"testing"
)
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
//
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
//
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
//
// 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"}}
func TestClineAdapterParsesProseWait(t *testing.T) {
c, _ := poolCore(t)
vm := c.VM()
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
out, ok, err := vm.TransformError("channels-cline", 429, body)
if err != nil || !ok {
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
}
if !strings.Contains(out, "额度已用完") {
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
}
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
if !strings.Contains(out, "19") {
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
}
if !strings.Contains(out, "重试无用") {
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
}
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
}
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
acc, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
if !strings.Contains(acc, "1 小时 30 分") {
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
}
// 秒级也要能解析(否则秒级限流会退化成通用文案)
secs, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
}
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
out402, _, _ := vm.TransformError("channels-cline", 402,
`{"error":{"message":"Insufficient credits"}}`)
if !strings.Contains(out402, "充值") {
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
}
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
out429, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
if strings.Contains(out429, "额度已用完") {
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
}
if !strings.Contains(out429, "限流") {
t.Errorf("普通 429 应说成限流,实得 %q", out429)
}
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
//(只认一种会在别的形态下静默丢掉整条信息)
for _, shell := range []string{
`{"error":"Daily free limit reached. Try again in 1h"}`,
`{"msg":"Daily free limit reached. Try again in 1h"}`,
`Daily free limit reached. Try again in 1h`,
} {
o, _, _ := vm.TransformError("channels-cline", 429, shell)
if !strings.Contains(o, "额度已用完") {
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
}
}
}

View File

@ -0,0 +1,171 @@
package core
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
// 就破了"内核零厂商名"纪律。
//
// 这组判据要证明三件事:
// 1. 插件加载时确实把适配器装上了
// 2. 装上之后,源能引用它(不是"装了但引用不到")
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
func TestCodeArtsAdapterInstalledByPlugin(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)
}
// ① 适配器必须在插件加载后就位
adapters := c.ListAdapters()
found := false
for _, a := range adapters {
if a.Name == "codearts" {
found = true
}
}
if !found {
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
}
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
t.Errorf("适配器归属应为 channels,实得 %q", owner)
}
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
"method": "POST",
"body": `{"model":"CodeArts-Pangu"}`,
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
})
if err != nil {
t.Fatalf("签名探针: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("探针无 json: %#v", got)
}
if m["error"] != nil {
t.Fatalf("探针报错: %v", m["error"])
}
auth, _ := m["authorization"].(string)
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
}
if !strings.Contains(auth, "Signature=") {
t.Fatalf("Authorization 缺 Signature: %s", auth)
}
// 签名后追加的头必须在,且不参与 canonical
for _, k := range []string{"Agent-Type", "X-Language"} {
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
t.Errorf("缺签名后追加的头 %s", k)
}
}
if m["hdr_Agent-Type"] != "PromptCenter" {
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
}
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
canon, _ := m["canonical"].(string)
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
}
// Authorization 不得进 canonical
if strings.Contains(canon, "authorization") {
t.Errorf("canonical 不应含 authorization:\n%s", canon)
}
// x-sdk-content-sha256 说明 payload hash 参与了签名
if !strings.Contains(canon, "x-sdk-content-sha256") {
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
}
// URI 必须以 / 结尾(参考点名的易错点)
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
}
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
// 必须与适配器给出的 Signature 逐字节相同。
//
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
// 形态依然全对,但上游会 401。复算才是真正的判据。
sts, _ := m["string_to_sign"].(string)
if sts == "" {
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
}
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
parts := strings.SplitN(sts, "\n", 3)
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
t.Fatalf("string_to_sign 头段不符: %q", sts)
}
canonSum := sha256.Sum256([]byte(canon))
if parts[2] != hex.EncodeToString(canonSum[:]) {
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
hex.EncodeToString(canonSum[:]), parts[2])
}
mac := hmac.New(sha256.New, []byte("SKTEST"))
mac.Write([]byte(sts))
wantSig := hex.EncodeToString(mac.Sum(nil))
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
if gotSig != wantSig {
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
}
b, _ := json.Marshal(m)
t.Logf("签名探针输出: %s", string(b))
}
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
//("kernel action add_source is not granted to plugin channels"),
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
c, ps := poolCore(t)
// 建一个 codearts 账号 → 触发源注册
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts",
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
names := []string{}
for _, x := range srcs {
if n, _ := x["name"].(string); n != "" {
names = append(names, n)
}
}
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
}
if ad, _ := sm["adapter"].(string); ad != "codearts" {
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
}
bu, _ := sm["base_url"].(string)
if !strings.Contains(bu, "snap-access") {
t.Errorf("base_url 不符: %q", bu)
}
}

View File

@ -0,0 +1,174 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。
//
// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的
// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是:
//
// ① opengw gateway/config → result.models(benefit 模型)
// ② snap-access /v1/model/builtin → builtinModels(常规模型)
//
// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer
// ⇒ 这两个端点从来没有被打过。
//
// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空"
// —— 后者只要兜底表在就恒真。
type codeartsCatalogHarness struct {
t *testing.T
ps *lua.Plugins
mu sync.Mutex
hits map[int]int // 端点序号 → 命中次数
gateway string
builtin string
authSeen []string
}
func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness {
t.Helper()
h := &codeartsCatalogHarness{t: t, hits: map[int]int{}}
mk := func(idx int, body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.hits[idx]++
// ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名
authSeen := []string{
r.Header.Get("X-Sdk-Date"),
r.Header.Get("X-Sdk-Content-Sha256"),
r.Header.Get("Authorization"),
}
h.authSeen = append(h.authSeen, strings.Join(authSeen, "|"))
h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
}
// ① gateway:benefit 模型在 result.models
gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`)
// ② snap builtin:常规模型在 builtinModels(**对象**,键为序号)
bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`)
t.Cleanup(gw.Close)
t.Cleanup(bi.Close)
h.gateway, h.builtin = gw.URL, bi.URL
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)
}
h.ps = ps
// 把两个端点都指到假上游(顺序与平台配置一致)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"models_remote": map[string]interface{}{
"endpoint0": h.gateway,
"endpoint1": h.builtin,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
// 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据")
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts",
"id": "cat-test",
"credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` +
`"security_token":"STTEST","user_name":"cat"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
return h
}
func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) {
h := newCodeartsCatalogHarness(t)
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
h.mu.Lock()
hits := map[int]int{}
for k, v := range h.hits {
hits[k] = v
}
authSeen := append([]string(nil), h.authSeen...)
h.mu.Unlock()
// ① 两个端点都必须被命中(只打一个是真实缺口)
if hits[0] == 0 {
t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到")
}
if hits[1] == 0 {
t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到")
}
// ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点)
for i, seen := range authSeen {
parts := strings.Split(seen, "|")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen)
}
if !strings.Contains(parts[2], "SDK-HMAC-SHA256") {
t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2])
}
}
// ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认)
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, got)
joined := strings.Join(ids, ",")
for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} {
if !strings.Contains(joined, want) {
t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids)
}
}
t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids))
}
// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。
func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) {
h := newCodeartsCatalogHarness(t)
// 让两个端点都 500
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer dead.Close()
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"models_remote": map[string]interface{}{
"endpoint0": dead.URL, "endpoint1": dead.URL,
},
}); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
t.Fatalf("远端失败不该让 model_refresh 报错: %v", err)
}
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, got)
if len(ids) == 0 {
t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)")
}
t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids)
}

View File

@ -0,0 +1,186 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换
// (带 DPoP)→ 凭据落盘。判据检查**交换请求本身的形态**(DPoP 头、
// grant_type、code_verifier、redirect_uri 端口),因为只断言"存了个 token"
// 无法防止把 OAuth 悄悄换回手填。
func TestCodeArtsOAuthEndToEnd(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)
}
// 假 STS 上游:校验请求形态,返回 credentials + refresh_token
var gotForm url.Values
var gotDpop string
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotDpop = r.Header.Get("DPoP")
_ = r.ParseForm()
gotForm = r.PostForm
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"credentials": map[string]interface{}{
"access_key_id": "AKTEST",
"secret_access_key": "SKTEST",
"security_token": "STTEST",
"expiration": "2026-12-31T00:00:00Z",
},
"refresh_token": "RT-TEST",
})
}))
defer sts.Close()
// 把 STS 端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"codearts_sts": map[string]interface{}{"url": sts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
// 1) start_login → 拿回调地址与登录 URL
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if m["error"] != nil {
t.Fatalf("start_login 报错: %v", m["error"])
}
loginURL, _ := m["url"].(string)
redirectURI, _ := m["redirect_uri"].(string)
sess := m["session"].(map[string]interface{})
sid, _ := sess["id"].(string)
t.Logf("login_url = %s", loginURL)
t.Logf("redirect_uri = %s", redirectURI)
// 2) 登录 URL 必须逐项符合参考 buildOAuthLoginUrl
if !strings.HasPrefix(loginURL, "https://codearts.huaweicloud.com/portal/authorize?") {
t.Fatalf("authorize base 不对: %s", loginURL)
}
for _, want := range []string{
"client_id=codearts-agent",
"uri_scheme=codearts-agent",
"code_challenge_method=SHA-256", // 不是 S256
"plugin-name=snap_AIIDE",
"plugin-version=5.2.0",
"theme=2",
"locale=zh-cn",
} {
if !strings.Contains(loginURL, want) {
t.Errorf("authorize URL 缺 %q\n实际: %s", want, loginURL)
}
}
// 反向:**不得**含 auth_callback_url(参考注释:会被 portal 判异常并回退旧流程)
if strings.Contains(loginURL, "auth_callback_url") {
t.Errorf("authorize URL 不应含 auth_callback_url(会导致 portal 回退旧流程): %s", loginURL)
}
// code_challenge 必须存在且非空
if !strings.Contains(loginURL, "code_challenge=") ||
strings.Contains(loginURL, "code_challenge=&") {
t.Errorf("缺 code_challenge: %s", loginURL)
}
// 3) 模拟上游重定向到回调(带 code)
u, err := url.Parse(redirectURI)
if err != nil {
t.Fatal(err)
}
q := u.Query()
q.Set("code", "AUTHCODE-TEST")
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
t.Fatalf("打回调: %v", err)
}
resp.Body.Close()
// 4) poll_login → 触发真实 STS 交换
pout, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatalf("poll_login: %v", err)
}
pm := pout.JSON.(map[string]interface{})
if pm["state"] != "completed" && pm["state"] != "success" && pm["state"] != "done" {
t.Fatalf("poll_login 未成功: %#v", pm)
}
// 5) 交换请求形态(判据的核心)
if gotDpop == "" {
t.Fatal("STS 请求缺 DPoP 头 —— 不是新式 OAuth")
}
if strings.Count(gotDpop, ".") != 2 {
t.Errorf("DPoP 应是三段 JWS,实得 %q", gotDpop)
}
if gotForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q,应为 authorization_code", gotForm.Get("grant_type"))
}
if gotForm.Get("client_id") != "codearts-agent" {
t.Errorf("client_id = %q", gotForm.Get("client_id"))
}
if gotForm.Get("code_verifier") == "" {
t.Error("缺 code_verifier —— PKCE 没生效")
}
if gotForm.Get("code") != "AUTHCODE-TEST" {
t.Errorf("code = %q", gotForm.Get("code"))
}
ruWant := "http://127.0.0.1" + u.Port() + "/oauth/callback"
if !strings.HasPrefix(redirectURI, ruWant[:len("http://127.0.0.1")]) {
t.Errorf("redirect_uri 主机不对: %s", redirectURI)
}
if gotForm.Get("redirect_uri") != redirectURI {
t.Errorf("redirect_uri = %q,应为回调地址 %q", gotForm.Get("redirect_uri"), redirectURI)
}
// 6) 凭据必须含续期材料(refresh_token / code_verifier / DPoP 私钥 JWK)
// 凭据必须含**续期材料** —— 这是 OAuth 流程存在的意义。
// 只断言"登录成功"无法防止凭据被截断成裸 AK/SK(那样 refresh 就废了)。
raw, err := ps.Call("channels", "__codearts_cred_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("凭据探针: %v", err)
}
w, _ := raw.(map[string]interface{})
cj, _ := w["json"].(map[string]interface{})
if cj == nil {
t.Fatalf("探针无 json: %#v", raw)
}
if cj["error"] != nil {
t.Fatalf("凭据未落盘: %v (refs=%v)", cj["error"], cj["refs"])
}
t.Logf("凭据 ref=%v access_key_id=%v", cj["ref"], cj["access_key_id"])
if cj["access_key_id"] != "AKTEST" {
t.Errorf("access_key_id = %v,应为 AKTEST(STS 响应未正确解析)", cj["access_key_id"])
}
for _, k := range []string{"refresh_token", "code_verifier", "dpop_private_key_jwk"} {
if cj[k] == nil || cj[k] == "" {
t.Errorf("凭据缺 %s(无法静默续期)", k)
}
}
if cj["refresh_token"] != "RT-TEST" {
t.Errorf("refresh_token = %v,应为 RT-TEST", cj["refresh_token"])
}
_ = json.Marshal
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
Method: "POST", Body: `{"session_id":"` + sid + `"}`, Role: "admin",
})
}

View File

@ -0,0 +1,112 @@
package core
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
)
// CodeArts 的 DPoP 证明是标准 ES256 JWS。用 Go 标准库(crypto/ecdsa)当权威
// 校验器验它 —— 只断言"能拼出字符串"是不够的:JWS 的 ES256 签名必须是
// **raw r||s(各 32 字节)**,而 openssl 默认输出 DER;写错格式服务端会拒,
// 而字符串看起来完全正常(这是最容易蒙混过去的一类缺陷)。
func TestCodeArtsDpopJwsVerifiesAsES256(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)
}
got, err := ps.Call("channels", "__codearts_dpop_probe",
map[string]interface{}{"htm": "POST", "htu": "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["error"] != nil {
t.Fatalf("探针报错: %v", m["error"])
}
jws, _ := m["jws"].(string)
if jws == "" {
t.Fatal("jws 为空")
}
parts := strings.Split(jws, ".")
if len(parts) != 3 {
t.Fatalf("JWS 应为三段,实得 %d 段", len(parts))
}
dec := func(s string) []byte {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatalf("base64url 解码失败: %v", err)
}
return b
}
// ---- header ----
var hdr map[string]interface{}
if err := json.Unmarshal(dec(parts[0]), &hdr); err != nil {
t.Fatalf("header 不是 JSON: %v", err)
}
if hdr["alg"] != "ES256" {
t.Errorf("alg 应为 ES256,实得 %v", hdr["alg"])
}
if hdr["typ"] != "dpop+jwt" {
t.Errorf("typ 应为 dpop+jwt,实得 %v", hdr["typ"])
}
jwk, _ := hdr["jwk"].(map[string]interface{})
if jwk == nil {
t.Fatal("header 必须内嵌 jwk(DPoP 要求公钥随证明走)")
}
// ---- 用 jwk 重建公钥 ----
xb, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string))
if err != nil {
t.Fatal(err)
}
yb, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string))
if err != nil {
t.Fatal(err)
}
if len(xb) != 32 || len(yb) != 32 {
t.Fatalf("P-256 的 x/y 应各 32 字节,实得 %d/%d", len(xb), len(yb))
}
pub := &ecdsa.PublicKey{Curve: elliptic.P256(),
X: new(big.Int).SetBytes(xb), Y: new(big.Int).SetBytes(yb)}
// ---- payload ----
var pl map[string]interface{}
if err := json.Unmarshal(dec(parts[1]), &pl); err != nil {
t.Fatalf("payload 不是 JSON: %v", err)
}
for _, k := range []string{"htm", "htu", "iat", "jti"} {
if _, ok := pl[k]; !ok {
t.Errorf("payload 缺 %s(照参考 signDpopJws)", k)
}
}
if pl["htm"] != "POST" {
t.Errorf("htm = %v", pl["htm"])
}
// ---- 验签(关键:raw r||s,不是 DER)----
sig := dec(parts[2])
if len(sig) != 64 {
t.Fatalf("ES256 签名必须是 64 字节 raw r||s,实得 %d 字节(若是 70-72 字节,说明漏了 DER→raw 转换)", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(pub, digest[:], r, s) {
t.Fatal("ES256 验签失败 —— 签名内容或格式不对,服务端会拒绝这个 DPoP")
}
}

View File

@ -0,0 +1,65 @@
package core
import (
"fmt"
"testing"
"llmsproxy/internal/lua"
)
// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的
// MIN_CALLBACK_PORT = 10000 及 `assigned >= MIN_CALLBACK_PORT` 校验)。
// 判据直接量**实际分配的端口**,不复用参考常量做断言来源。
func TestCodeArtsLoopbackPortAtLeast10000(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)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m, _ := out.JSON.(map[string]interface{})
if m == nil {
t.Fatalf("无 JSON: %#v", out)
}
if m["error"] != nil {
t.Fatalf("报错: %v", m["error"])
}
ru, _ := m["redirect_uri"].(string)
if ru == "" {
t.Fatalf("无 redirect_uri: %#v", m)
}
t.Logf("redirect_uri = %s", ru)
var port int
if _, err := fmt.Sscanf(ru, "http://127.0.0.1:%d/", &port); err != nil {
if _, err2 := fmt.Sscanf(ru, "http://localhost:%d/", &port); err2 != nil {
t.Fatalf("解析端口失败: %v / %v (%s)", err, err2, ru)
}
}
if port < 10000 {
t.Fatalf("回调端口 %d < 10000 —— portal 会拒绝(参考 MIN_CALLBACK_PORT)", port)
}
// 回调路径也必须是注册值 /oauth/callback(参考 REDIRECT_PATH)
if !hasSuffix(ru, "/oauth/callback") {
t.Fatalf("回调路径应为 /oauth/callback,实得 %s", ru)
}
if s, _ := m["session"].(map[string]interface{}); s != nil {
if id, _ := s["id"].(string); id != "" {
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
Method: "POST", Body: `{"session_id":"` + id + `"}`, Role: "admin",
})
}
}
}
func hasSuffix(s, suf string) bool {
return len(s) >= len(suf) && s[len(s)-len(suf):] == suf
}

View File

@ -0,0 +1,204 @@
package core
import (
"testing"
)
// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。
//
// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住:
//
// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容),
// 只在它"独占一句话成分"时才算阳性;
// - rate limit / quota / unauthor / forbidden 一律**不是**下架;
// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案
// `model expired-v3 does not exist`;
// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。
func TestDeadModelGoneJudgement(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)
}
cases := []struct {
msg string
want bool
why string
}{
// ── 阳性:真的是下架 ──
{"model not found", true, "经典文案"},
{"unknown model: foo", true, "unknown model"},
{"no such model", true, "no such model"},
{"The model does not exist", true, "does not exist"},
{"error: model_not_found", true, "独占一句话(行尾)"},
{"model_not_found.", true, "后面是句末标点"},
// ── 阴性:看着像但**不是**下架 ──
{"error_class=model_not_found, retry allowed", false,
"字段名后面还有内容(初版误判下架,实测教训)"},
{"the model_not_found metric was not exported", false,
"作为指标名出现(实测教训)"},
{"rate limit exceeded for model foo", false, "限流换时段就能用"},
{"quota exceeded", false, "额度问题"},
{"insufficient balance", false, "余额问题"},
{"401 unauthorized", false, "认证问题"},
{"invalid api key", false, "认证问题"},
{"token expired", false, "token 过期(带主语)"},
// ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE,
// 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。
{"model expired-v3 does not exist", true,
"expired 无主语 → 不算认证失败;does not exist 才是真判据"},
// ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面):
// 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。
// 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是
// NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表),
// 一旦命中排除词就直接 false、不再看后面的不存在语义。
// 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。
{"the model lease expired", false, "租约/认证问题,不是下架"},
{"the model lease expired and does not exist", false,
"参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"},
// available/supported 不算(换套餐就能恢复)
{"model not available in your region", false,
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
{"", false, "空串"},
}
for _, tc := range cases {
out, err := ps.Call("channels", "__is_model_gone_probe",
map[string]interface{}{"message": tc.msg})
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
got, _ := j["gone"].(bool)
if got != tc.want {
t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why)
}
}
}
// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。
func TestDeadModelRecordAndPrune(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)
}
before := modelIDsFor(t, ps, "zcode")
if len(before) == 0 {
t.Fatal("zcode 应有兜底模型")
}
victim := before[0]
// 记录一条阳性证据 → 该模型必须从列表消失
if _, err := ps.Call("channels", "__record_dead_probe",
map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil {
t.Fatalf("记录探针: %v", err)
}
after := modelIDsFor(t, ps, "zcode")
for _, id := range after {
if id == victim {
t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim)
}
}
if len(after) != len(before)-1 {
t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after))
}
// model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用)
m, err := ps.Call("channels", "model_clear_dead",
map[string]interface{}{"platform": "zcode", "model_id": victim})
if err != nil {
t.Fatalf("model_clear_dead: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("model_clear_dead 失败: %#v", m)
}
restored := modelIDsFor(t, ps, "zcode")
found := false
for _, id := range restored {
if id == victim {
found = true
}
}
if !found {
t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored)
}
}
// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。
// 判据用冷却状态本身,不依赖真实网络。
func TestRemoteCatalogGateCooldown(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)
}
// 初始不冷却
g := func() (bool, bool) {
out, err := ps.Call("channels", "__catalog_gate_probe",
map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
began, _ := j["began"].(bool)
cooling, _ := j["cooling"].(bool)
return began, cooling
}
if _, cooling := g(); cooling {
t.Error("初始不该处于冷却")
}
// 模拟一次失败 → 进冷却
if _, err := ps.Call("channels", "__catalog_gate_fail_probe",
map[string]interface{}{"platform": "zcode"}); err != nil {
t.Fatalf("失败探针: %v", err)
}
began, cooling := g()
if !cooling {
t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)")
}
if began {
t.Error("冷却期内不该再发起拉取")
}
}
// modelIDsFor 读某平台当前对外的模型 id 列表。
func modelIDsFor(t *testing.T, ps interface {
Call(string, string, map[string]interface{}) (interface{}, error)
}, plat string) []string {
t.Helper()
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("model_list(%s): %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
var out []string
if arr, ok := j["models"].([]interface{}); ok {
for _, it := range arr {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
out = append(out, id)
}
}
}
}
return out
}

View File

@ -0,0 +1,76 @@
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

@ -0,0 +1,67 @@
package core
import (
"testing"
)
// `isFree` 的判定链(照参考 cline-models.ts)。
//
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
//
// 参考的判定链(权威在前):
//
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
// ③ 声明的 models_free
// ④ 能力表的 cost(models.dev)
//
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
func TestIsFreeUsesAuthorityChain(t *testing.T) {
_, ps := poolCore(t)
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
if !got["cline-free/mimo-v2.6-flash"] {
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
}
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
if !got["stealth/space-bunny-alpha"] {
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
}
// 命名约定必须生效(远端未响应时唯一的兜底依据)
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "cline-free/some-new-model",
})
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
if pj["is_free"] != true {
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
}
// 反向:收费通道的同名模型**不得**被判免费
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
})
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
if pj2["is_free"] == true {
t.Error("收费通道的模型不得因为前缀规则被判成免费")
}
}

View File

@ -0,0 +1,63 @@
package core
import (
"testing"
)
// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。
//
// 照参考 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** ⇒ 既会重叠又无间隔。
func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
c, ps := poolCore(t)
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "gated",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
t.Fatal("账号未出源")
}
// ① 串行
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
}
// ② 节流(rpm>0 才有间隔)
if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 {
t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"])
}
}
func toFloat(v interface{}) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case int:
return float64(x), true
case int64:
return float64(x), true
}
return 0, false
}

View File

@ -0,0 +1,110 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。
// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。
func TestLobsterActivityClaimDecisionTable(t *testing.T) {
type c struct {
name string
slot string
ctx string
wantKind string
wantCalled bool // 是否应发出 claim 请求
}
cases := []c{
{"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false},
{"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false},
{"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false},
{"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false},
{"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimHit := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/client-activities/slot":
fmt.Fprint(w, tc.slot)
case r.URL.Path == "/api/client-activities/A1/context":
if tc.ctx == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.ctx)
case r.URL.Path == "/api/client-activities/A1/actions/check_in":
claimHit++
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
claimBody = string(b)
fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
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)
}
// 把账号基址指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"lobster_activity_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 存入凭据(claim 需要结构化 credential 对象)
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "lobsterai", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"lobsterai"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome 判别联合,实得 %v", j)
}
if k, _ := oc["kind"].(string); k != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"])
}
if (claimHit > 0) != tc.wantCalled {
t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled)
}
if tc.wantCalled && claimBody == "" {
t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)")
}
})
}
}

View File

@ -0,0 +1,75 @@
package core
import (
"encoding/json"
"llmsproxy/internal/lua"
"net/http"
"net/http/httptest"
"testing"
)
func TestLobsterExchangePreservesIdentity(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var b map[string]string
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
t.Error(err)
}
if b["authCode"] != "quote\"&code" || len(b["uuid"]) != 36 || b["firstKeyfrom"] == "" || b["firstKeyfrom"] == "cli" {
t.Errorf("invalid exchange fields: %#v", b)
}
if r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "LobsterAI/0.1.0" {
t.Error("wrong headers")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0,"data":{"accessToken":"at-test","refreshToken":"rt-test","expiresIn":3600,"user":{"id":"uid1","userId":"account1","nickname":"test"}}}`))
}))
defer ts.Close()
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)
}
// The fixture changes only the transport destination, not the upstream contract.
if _, err = ps.Call("channels", "set_endpoints", map[string]interface{}{"platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.URL}}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin"})
if err != nil {
t.Fatal(err)
}
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
req := &lua.HandlerRequest{Method: "GET", Query: map[string]string{"state": nonce, "authCode": "quote\"&code"}}
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
if err != nil {
t.Fatal(err)
}
if out.Status != 200 {
t.Fatalf("callback: %#v", out)
}
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
if err != nil || !ok {
t.Fatal("missing credential", err)
}
var cred map[string]interface{}
if err = json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "latest_keyfrom", "expires_at", "uid", "user_id"} {
if cred[k] == nil || cred[k] == "" {
t.Errorf("missing %s", k)
}
}
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
if err != nil {
t.Fatal(err)
}
if out.Status != 409 || calls != 1 {
t.Fatalf("replay accepted: status=%d calls=%d", out.Status, calls)
}
}

View File

@ -0,0 +1,165 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"llmsproxy/internal/lua"
)
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
// (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、
// 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后,
// 重定向落本机端口,接收器写文件,poll_login 读它完成交换。
//
// 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验),
// state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/
// uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。
func TestLobsterLoopbackClosedLoop(t *testing.T) {
var exch int
var gotVersion string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
exch++
var b map[string]string
_ = json.NewDecoder(r.Body).Decode(&b)
if b["authCode"] != "AC-1" {
t.Errorf("exchange authCode = %q", b["authCode"])
}
gotVersion = b["version"]
if b["firstKeyfrom"] == "" || b["uuid"] == "" {
t.Errorf("exchange 缺字段: %#v", b)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`)
}))
defer ts.Close()
// 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是
// 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态
// (非统一信封,值在 data.value.version)。
ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`)
}))
defer ver.Close()
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)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "status"}, false)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"callback": map[string]interface{}{"token_url": ts.URL},
"client_version": map[string]interface{}{"url": ver.URL},
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
sess := j["session"].(map[string]interface{})
nonce := sess["nonce"].(string)
sid := sess["id"].(string)
redirectURI, _ := j["redirect_uri"].(string)
if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") {
t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI)
}
url, _ := j["url"].(string)
if !strings.Contains(url, "/portal#/login?") {
t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url)
}
if !strings.Contains(url, "source=electron") {
t.Fatalf("授权页缺 source=electron: %q", url)
}
t.Run("行1 state 不匹配被拒", func(t *testing.T) {
resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode)
}
if exch != 0 {
t.Fatal("state 不匹配不应触发交换")
}
})
t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) {
resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode)
}
// 接收器写文件后 poll_login 读它完成交换(可能需要一拍)
var st map[string]interface{}
for i := 0; i < 20; i++ {
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
st = o.JSON.(map[string]interface{})
if st["state"] != "pending" {
break
}
time.Sleep(100 * time.Millisecond)
}
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
if err != nil || !ok {
t.Fatalf("凭据未落库: %v", err)
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} {
if cred[k] == nil || cred[k] == "" {
t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw)
}
}
if cred["access_token"] != "AT-1" {
t.Fatalf("access_token = %v", cred["access_token"])
}
// 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4
if gotVersion != "2026.9.23" {
t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion)
}
})
t.Run("行3 接收器用完即收", func(t *testing.T) {
// 完成后监听应已关闭(连接被拒)
_, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
if err == nil {
t.Fatal("接收器应在完成后关闭")
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") {
t.Logf("关闭判定: %v", err)
}
})
_ = fmt.Sprint
}

View File

@ -0,0 +1,190 @@
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

@ -0,0 +1,177 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// loomyHarness:Loomy 微信扫码长轮询的测试台。
//
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
//
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
type loomyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
confirmAt int32
gotAuth string
}
func newLoomyHarness(t *testing.T) *loomyHarness {
t.Helper()
h := &loomyHarness{t: t, confirmAt: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
w.Header().Set("Content-Type", "text/html")
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></html>`)
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
n := atomic.AddInt32(&h.polls, 1)
w.Header().Set("Content-Type", "text/plain")
if n == 1 {
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
return
}
if n < atomic.LoadInt32(&h.confirmAt) {
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
return
}
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
case r.URL.Path == "/login/thirdAccount/bind/auth":
h.gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "loomy",
"wechat": map[string]interface{}{
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
"poll_url": h.ts.URL + "/connect/l/qrconnect",
"account_base": h.ts.URL,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *loomyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
func TestLoomyWechatPollBehavior(t *testing.T) {
h := newLoomyHarness(t)
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "UUID-TEST-1") {
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
}
})
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 99)
_, sid := h.begin()
// 第一拍 408
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("408 应 pending, 实得 %v", st["state"])
}
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
}
})
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 3)
_, sid := h.begin()
h.pollOnce(sid) // 408
h.pollOnce(sid) // 404
st := h.pollOnce(sid) // 405
if st["state"] != "completed" {
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
}
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
if !strings.HasPrefix(h.gotAuth, "account ") {
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
}
if !strings.Contains(h.gotAuth, ":") {
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
if !ok || !strings.Contains(v, "RCODE-1") {
t.Fatalf("凭据应含 rcode, 实得 %q", v)
}
})
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
for _, tc := range []struct{ errcode, want string }{
{"403", "取消"}, {"402", "失效"},
} {
hh := newLoomyHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
fmt.Fprint(w, `<img src="/connect/qrcode/U">`)
return
}
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "failed" {
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
}
}
})
}

View File

@ -0,0 +1,73 @@
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

@ -0,0 +1,201 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。
//
// 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退"
// (cline-models.ts)。故这里逐条锁定:
// - 远端有的以远端为准(顺序也照远端)
// - 远端没有的用兜底表补位
// - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空)
// - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通)
type modelsHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
mu sync.Mutex
body string
code int
hits int
}
func newModelsHarness(t *testing.T, platform, body string) *modelsHarness {
t.Helper()
h := &modelsHarness{t: t, body: body, code: 200}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.hits++
code, body := h.code, h.body
h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, body)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "list_sources", "status"}, false)
// 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许,
// 不需要额外授权 —— 也就不需要碰内核的授权 API。
// 把该平台的远端端点指到假上游
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": platform,
"models_remote": map[string]interface{}{
"endpoint0": h.ts.URL,
},
}); err != nil {
t.Logf("set_endpoints(models_remote) 未支持: %v", err)
}
return h
}
// modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。
func modelIDs(t *testing.T, got interface{}) []string {
t.Helper()
j := got.(map[string]interface{})["json"].(map[string]interface{})
ms, _ := j["models"].([]interface{})
ids := make([]string, 0, len(ms))
for _, e := range ms {
ids = append(ids, e.(map[string]interface{})["id"].(string))
}
return ids
}
func TestRemoteModelCatalogMergesWithFallback(t *testing.T) {
// zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠
body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}`
h := newModelsHarness(t, "zcode", body)
t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) {
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]interface{})["json"].(map[string]interface{})
res := m["results"].([]interface{})[0].(map[string]interface{})
if res["ok"] != true {
t.Fatalf("刷新应成功, 实得 %v", res)
}
// NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表)
ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, ml)
joined := strings.Join(ids, ",")
if !strings.Contains(joined, "NEW-MODEL-X") {
t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids)
}
if !strings.Contains(joined, "GLM-5.3") {
t.Fatalf("兜底/远端共有项应保留: %v", ids)
}
// 远端与兜底重叠项不得重复
seen := map[string]int{}
for _, id := range ids {
seen[id]++
if seen[id] > 1 {
t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids)
}
}
})
t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) {
// 先成功一次建立缓存
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil {
t.Fatal(err)
}
// 再让上游 500
h.mu.Lock()
h.code = 500
h.body = `{}`
h.mu.Unlock()
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{})
if res["ok"] == true {
t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res)
}
ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
ids := modelIDs(t, ml)
if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") {
t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids)
}
})
}
// TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。
// 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 ——
// 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。
func TestPlatformModelsAreRealCatalogKeys(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)
}
got, err := ps.Call("channels", "provider_status", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{})
if len(plats) != 12 {
t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats))
}
realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"}
sawQoder := 0
for _, e := range plats {
p := e.(map[string]interface{})
id := p["id"].(string)
ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id})
if err != nil {
t.Fatalf("平台 %s model_list: %v", id, err)
}
ids := modelIDs(t, ml)
if len(ids) == 0 {
t.Fatalf("平台 %s 无模型", id)
}
for _, mid := range ids {
// 占位名特征:以 -default 结尾
if strings.HasSuffix(mid, "-default") {
t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid)
}
}
if id == "qoder" {
j := map[string]bool{}
for _, mid := range ids {
j[mid] = true
}
for _, want := range realQoder {
if j[want] {
sawQoder++
}
}
}
}
if sawQoder == 0 {
t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名")
}
}

View File

@ -0,0 +1,82 @@
package core
import (
"os"
"path/filepath"
"testing"
"llmsproxy/internal/lua"
)
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
//
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
// (对应的 .json 早已消失)。它们永远不会被清,因为:
//
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
// · 进程被强杀时 on_unload 没机会跑;
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
//
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
// 而不是"某函数被调用过"。
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
orphans := []string{
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
}
for _, f := range orphans {
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
t.Fatal(err)
}
}
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
keep := filepath.Join(pluginDir, ".channels-store.json")
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
t.Fatal(err)
}
// 加载插件 = 走真实启动路径
c := kernelTestCore(t)
defer c.Close()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
ps := c.Plugins()
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
for i := 0; i < 50; i++ {
left := 0
for _, f := range orphans {
if _, err := os.Stat(f); err == nil {
left++
}
}
if left == 0 {
break
}
}
for _, f := range orphans {
if _, err := os.Stat(f); err == nil {
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
"(真实事故:生产攒了 18 个)", filepath.Base(f))
}
}
if _, err := os.Stat(keep); err != nil {
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
}
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
}
var _ = lua.Plugins{}

View File

@ -92,7 +92,7 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
}
// 且不得出现在 state 里
if stateHasSecret(ps, "channels", "tok-1") {
t.Fatal("★ 凭据泄漏进了 plugin.state")
t.Fatal("凭据泄漏进了 plugin.state")
}
// 5. 运维授权后,重新接入应当真的把源注册进内核

View File

@ -0,0 +1,225 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"llmsproxy/internal/lua"
)
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
//
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
// 而号里其实有钱。两者的**正确动作完全相反**:
//
// exhausted → 等额度重置
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
//
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalance(0, 0)
got := h.reason()
if got["reason"] != "exhausted" {
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
got["reason"], got["detail"])
}
})
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalanceError(500)
got := h.reason()
if got["reason"] != "unknown" {
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
}
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
}
})
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
h := newPoolReasonHarness(t)
got := h.reason()
if got["reason"] != "no_accounts" {
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
}
})
t.Run("有可用账号 → usable", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalance(100, 0)
got := h.reason()
if got["reason"] != "usable" {
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
}
if u, _ := got["usable"].(float64); int(u) != 1 {
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
}
})
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
h := newPoolReasonHarness(t)
// ⚠ 必须有**启用的账号**:判定的优先级是
// no_accounts → 无余额端点 → usable → unknown/exhausted
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
// 那是**正确**行为),把正确的实现判成了错。
h.addAccountOn("minimax", "m1")
// minimax 未声明 balance 端点
got := h.reasonOn("minimax")
if got["reason"] != "unknown" {
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
got["reason"], got["detail"])
}
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
t.Fatalf("说明必须点明无法判定,实得 %q", d)
}
})
}
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
// (用户动作不同:解锁 vs 等重置)。
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
// total_fn 取包列表:只有永久积分(到期未知)
h.serveBalance(0, 50)
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatal(err)
}
got := h.reason()
if got["reason"] != "locked" {
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
got["reason"], got["detail"])
}
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": false}); err != nil {
t.Fatal(err)
}
h.serveBalance(0, 50)
if got2 := h.reason(); got2["reason"] != "usable" {
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
}
}
// ── harness ──
type poolReasonHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
body atomic.Value
fail atomic.Bool
hasAcct atomic.Bool
}
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
t.Helper()
h := &poolReasonHarness{t: t}
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
h.body.Store(buddyPkgBody(0, 0))
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h.fail.Load() {
w.WriteHeader(500)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, h.body.Load().(string))
}))
t.Cleanup(h.ts.Close)
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)
}
h.ps = ps
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": h.ts.URL},
}); err != nil {
t.Logf("set_endpoints(balance) 未支持: %v", err)
}
return h
}
func (h *poolReasonHarness) addAccount(id string) {
h.t.Helper()
h.addAccountOn("buddy", id)
}
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
// no_accounts —— 判据写错而非实现错)。
func (h *poolReasonHarness) addAccountOn(platform, id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": platform, "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", platform, err)
}
h.hasAcct.Store(true)
}
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
h.fail.Store(false)
h.body.Store(buddyPkgBody(expiring, permanent))
}
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
func buddyPkgBody(expiring, permanent int) string {
pkgs := []string{}
if expiring > 0 {
// 3 天到期(< 15 天窗口)
endMs := (time.Now().Unix() + 3*86400) * 1000
pkgs = append(pkgs, fmt.Sprintf(
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
}
if permanent > 0 {
// DeductionEndTime=0(未知)⇒ 永久桶
pkgs = append(pkgs, fmt.Sprintf(
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
}
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
}
func (h *poolReasonHarness) serveBalanceError(code int) {
h.fail.Store(true)
}
func (h *poolReasonHarness) reason() map[string]interface{} {
h.t.Helper()
return h.reasonOn("buddy")
}
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
h.t.Helper()
res, err := h.ps.Call("channels", "credits_pool_reason",
map[string]interface{}{"platform": platform})
if err != nil {
h.t.Fatalf("credits_pool_reason: %v", err)
}
return jsonOf(h.t, res)
}

View File

@ -124,13 +124,18 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
if !ok {
t.Fatal("带凭据账号应已同步源")
}
if n := len(asArr(sm["models"])); n != 3 {
t.Fatalf("源应有3模型, 实得 %d", n)
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
// 关掉一个模型,源里就少一个。
all := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
total := len(asArr(all["models"]))
if n := len(asArr(sm["models"])); n != total {
t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
if n := len(asArr(sm["models"])); n != 2 {
t.Fatalf("关1模型后源应2模型, 实得 %d", n)
if n := len(asArr(sm["models"])); n != total-1 {
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
}
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
m = invoke(t, ps, "provider_status", `{}`)
@ -260,17 +265,33 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) {
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`)
})
t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) {
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
// model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的
// "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。
//
// 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上
// 会删掉可用模型(参考文件头有实测教训),已弃用。
// 新语义的完整判据在 channels_dead_model_test.go。
t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) {
// 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么)
j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if n := len(asArr(j["removed"])); n != 0 {
t.Fatalf("在册模型不算死模型, 实得 %d", n)
if j["ok"] != true {
t.Fatalf("model_clear_dead 应成功: %v", j)
}
// 手动放一个不在 models 表里的黑名单项
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`)
j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if n := len(asArr(j["removed"])); n != 1 {
t.Fatalf("死模型应被清: %v", j)
if n, _ := j["removed"].(float64); n != 0 {
t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"])
}
// 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
off := 0
for _, m := range asArr(ml["models"]) {
if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true {
off++
}
}
if off != 1 {
t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`)
})

View File

@ -0,0 +1,86 @@
package core
import (
"strings"
"testing"
)
// 三个"私有协议"平台的源注册必须引用**插件自装**的适配器。
//
// 为什么单独测这一条:适配器装上了 ≠ 源用了它。"装了但源还指向 openai"
// 是这类改动最可能的漏配,且**不会报错** —— 只是签名/信封不生效、上游返回
// 流内错误或 401,看起来像"平台不稳定"。
//
// 三个平台的失败模式各不相同,但根因是同一个(平台协议差异被当成适配器名差异):
//
// codearts → 华为云 AK/SK 签名
// trae → SOLO 载荷格式(config_name/function)
// gemini → 私有 /v1internal: 信封 + 身份五头
func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
c, ps := poolCore(t)
cases := []struct {
platform string
wantAdapter string
wantBaseHas string
credential string
}{
{"codearts", "codearts",
"snap-access.cn-north-4.myhuaweicloud.com",
`{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`},
{"trae", "channels-trae",
"trae-api-cn.mchost.guru",
`{"access_token":"TRAE-TOK","refresh_token":"RT"}`},
{"gemini", "channels-gemini",
"cloudcode-pa.googleapis.com",
`{"access_token":"GEM-TOK","refresh_token":"GRT"}`},
}
for _, tc := range cases {
t.Run(tc.platform, func(t *testing.T) {
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": tc.platform, "credential": tc.credential,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
names := []string{}
for _, x := range srcs {
names = append(names, x["name"].(string))
}
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
"凭据形状不符或内核权限未授", tc.platform, names)
}
ad, _ := sm["adapter"].(string)
if ad != tc.wantAdapter {
t.Errorf("源 adapter = %q,应为插件自装的 %q"+
"(否则平台协议转换不生效,且不报错)", ad, tc.wantAdapter)
}
bu, _ := sm["base_url"].(string)
if !strings.Contains(bu, tc.wantBaseHas) {
t.Errorf("base_url = %q,应含 %q", bu, tc.wantBaseHas)
}
// 模型必须进源(否则账号看起来接入了但一个模型都调不到)
var nModels int
switch mv := sm["models"].(type) {
case []string:
nModels = len(mv)
case []interface{}:
nModels = len(mv)
}
if nModels == 0 {
t.Errorf("%s 源没有任何模型(%T)", tc.platform, sm["models"])
}
})
}
}

View File

@ -0,0 +1,88 @@
package core
import (
"strings"
"testing"
"llmsproxy/internal/lua"
)
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string {
t.Helper()
reason, ok, err := vm.TransformError("channels-qoder", status, body)
if err != nil {
t.Fatalf("transform_error 调用失败: %v", err)
}
if !ok {
t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body)
}
return reason
}
// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**:
//
// 10605 排队 → 暂时受阻,等一下就好
// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次)
//
// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` ——
// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**,
// 厂商码值留在插件域(内核零厂商名)。
func TestQoderAdapterParsesQueueAndBilling(t *testing.T) {
c, _ := poolCore(t)
vm := c.VM()
// ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状)
out := qoderReason(t, vm, 403,
`{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`)
if !strings.Contains(out, "排队") {
t.Errorf("10605 必须说成排队,实得 %q", out)
}
// 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到)
if !strings.Contains(out, "30") {
t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out)
}
if !strings.Contains(out, "12") {
t.Errorf("应从内层取到 queueCount=12,实得 %q", out)
}
// ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。
// ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、
// SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。
out2 := qoderReason(t, vm, 200,
`{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`)
if !strings.Contains(out2, "排队") {
t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2)
}
if !strings.Contains(out2, "8") {
t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2)
}
// ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用"
out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`)
if !strings.Contains(out3, "额度") {
t.Errorf("110 必须说成额度耗尽,实得 %q", out3)
}
if strings.Contains(out3, "排队") {
t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3)
}
if !strings.Contains(out3, "重试无用") {
t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3)
}
// ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误
//(关键词必须窄 —— 宽词会把模型正常输出误判成故障)
out4 := qoderReason(t, vm, 500,
`{"error":{"message":"the response discusses quota and balance concepts"}}`)
if strings.Contains(out4, "额度已用尽") {
t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4)
}
// ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事
//(否则这个专用适配器就没有存在理由)
gen, okGen, _ := vm.TransformError("openai", 403,
`{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`)
if okGen && strings.Contains(gen, "排队") {
t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要")
}
}

View File

@ -0,0 +1,222 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"llmsproxy/internal/lua"
)
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
// 全部必须在判据里钉死:
//
// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed
// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」);
// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive
// 而不是"今天已领取"(谎报且不可逆);
// ③ 未开通要给出 action_required 的可操作提示。
//
// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领"
// 误报成"成功 +100"。
func TestQoderClaimDecisionTable(t *testing.T) {
type c struct {
name string
campaigns string
usage string
wantKind string
wantAction bool
wantClaimed int
}
cases := []c{
{
"无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`,
`{"data":{"userQuota":{"remaining":0}}}`,
"inactive", true, 0,
},
{
"无可领但确实领过(CLAIMED)→ 视刷新时刻",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`,
`{"data":{"addOnQuota":{"remaining":0}}}`,
"", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一
},
{
"可领→success(多个活动逐个领)",
`{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}},
{"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`,
"",
"success", false, 2,
},
{
"可领但服务端回放 replayed→already-claimed(不是 success)",
`{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`,
"",
"already-claimed", false, 1,
},
{
"无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)",
`{"showCampaign":false,"claimable":false,"campaigns":[]}`,
`{"data":{"addOnQuota":{"total":100,"remaining":0}}}`,
"inactive", false, 0,
},
{
"有 CLAIM_BENEFIT 但已过期→inactive",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`,
`{"data":{"addOnQuota":{"remaining":0}}}`,
"inactive", false, 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimed := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/sash/api/v1/me/campaigns":
fmt.Fprint(w, tc.campaigns)
case r.URL.Path == "/sash/api/v2/me/usage":
if tc.usage == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.usage)
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
claimed++
b := make([]byte, 0)
if r.ContentLength > 0 {
b = make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
}
claimBody = string(b)
if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" {
fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`)
} else {
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
}
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
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)
}
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "qoder", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome,实得 %v", j)
}
kind, _ := oc["kind"].(string)
if tc.wantKind == "" {
// 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法,
// 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。
if kind != "already-claimed" && kind != "inactive" {
t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"])
}
} else if kind != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"])
}
if tc.wantAction && oc["action_required"] != true {
t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"])
}
if claimed != tc.wantClaimed {
t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed)
}
if tc.wantClaimed > 0 && claimBody != "" {
t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody)
}
})
}
}
// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。
// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同**
// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向:
// 判据的输入是 UTC 毫秒,与运行环境时区无关。
func TestQoderRefreshUsesUTC8NotLocalTime(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)
}
// UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00)
// UTC 02:00 = UTC+8 10:00 → **已刷新**
// 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到:
// 把 07:00Z 当本地 15:00 → 判已刷新(错)
cases := []struct {
name string
utcHour int
want bool
}{
{"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false},
{"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true},
{"UTC 00:30(UTC+8 08:30,未刷新)", 0, false},
{"UTC 15:00(UTC+8 23:00,已刷新)", 15, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰)
// 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入
// 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。
ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli()
var rec map[string]interface{}
got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{
"now_ms": float64(ms),
})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
rec, _ = w["json"].(map[string]interface{})
if rec == nil {
t.Fatalf("无 json: %#v", got)
}
if rec["refreshed"] != tc.want {
t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"])
}
})
}
}

View File

@ -0,0 +1,84 @@
package core
import (
"os"
"path/filepath"
"testing"
)
// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 ——
// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS),
// 症状是用户报的「没领过却显示今日已领取」。
// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头),
// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。
func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) {
t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "machine_token.json")
if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("QODER_MACHINE_TOKEN_PATH", p)
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)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != "T-abc" || m["type"] != "TY-def" {
t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m)
}
if m["client_type"] != "10" {
t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"])
}
})
t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) {
// 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info)
t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json"))
t.Setenv("HOME", t.TempDir())
t.Setenv("APPDATA", "")
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)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe 不应报错(必须保守降级): %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != nil && m["token"] != "" {
t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"])
}
if m["type"] != nil && m["type"] != "" {
t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"])
}
// 头仍然要有 clienttype(那是必要但不充分条件,与身份无关)
if m["client_type"] != "10" {
t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"])
}
})
}

View File

@ -0,0 +1,181 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// qoderHarness:Qoder 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
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
//
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
type qoderHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
poll int32 // 已发生的轮询次数
// step: 第几次轮询开始返回 token(0 = 一直 404)
tokenAt int32
gotQuery string
}
func newQoderHarness(t *testing.T) *qoderHarness {
t.Helper()
h := &qoderHarness{t: t, tokenAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/deviceToken/poll" {
h.gotQuery = r.URL.RawQuery
n := atomic.AddInt32(&h.poll, 1)
if n < atomic.LoadInt32(&h.tokenAt) {
w.WriteHeader(404) // 尚无会话
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
return
}
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
return
}
w.WriteHeader(404)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *qoderHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
func TestQoderDevicePollBehavior(t *testing.T) {
h := newQoderHarness(t)
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
j, _ := h.begin()
url, _ := j["url"].(string)
for _, want := range []string{
"qoder.com/device/selectAccounts",
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
} {
if !strings.Contains(url, want) {
t.Fatalf("授权页缺 %q:\n%s", want, url)
}
}
})
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
_, sid := h.begin()
for i := 0; i < 3; i++ {
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99)
_, sid := h.begin()
h.pollOnce(sid)
q := h.gotQuery
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
}
if !strings.Contains(q, "challenge_method=S256") {
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
}
})
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
}
// 凭据落保险库(旧路径键)
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
if !ok || !strings.Contains(v, "QD-AT-1") {
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
}
})
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid)
}
})
}

View File

@ -0,0 +1,165 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
//
// 参考语义(raccoon-oauth.ts 点名):
//
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
// → data.status ∈ pending / logging / canceled / success
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
type raccoonHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
step int32 // 第几次轮询开始 success
gotCode string
}
func newRaccoonHarness(t *testing.T) *raccoonHarness {
t.Helper()
h := &raccoonHarness{t: t, step: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
h.polls++
if h.polls < atomic.LoadInt32(&h.step) {
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
return
}
w.WriteHeader(404)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "raccoon",
"qr": map[string]interface{}{
"api_base": h.ts.URL,
"auth_prefix": "/api/web/auth/v1",
"poll_path": "/login_with_qrcode_code",
"login_page": "/login/mp",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
func TestRaccoonQrPollBehavior(t *testing.T) {
h := newRaccoonHarness(t)
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
}
// code 应是 32 位 hex(本地随机生成)
code := qr[strings.Index(qr, "code=")+5:]
if i := strings.IndexAny(code, "&"); i > 0 {
code = code[:i]
}
if len(code) != 32 {
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
}
})
t.Run("行2 logging保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.step, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 success且带token才完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.step, 2)
_, sid := h.begin()
h.pollOnce(sid)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("success+token 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
if !ok || !strings.Contains(v, "RC-AT-1") {
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
}
})
t.Run("行4 canceled是终态失败", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
})
hh.ps.SetSecret("channels", "channels:raccoon", "")
_, sid := hh.begin()
if st := hh.pollOnce(sid); st["state"] != "failed" {
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
}
})
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
}
})
}

View File

@ -0,0 +1,193 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
//
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
// 这段代码。
//
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
func TestAccountRefreshSendsRealRequest(t *testing.T) {
var hits int32
var gotBody, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
gotPath = r.URL.Path
b := make([]byte, 1024)
n, _ := r.Body.Read(b)
gotBody = string(b[:n])
w.Header().Set("Content-Type", "application/json")
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
}))
defer srv.Close()
_, ps := poolCore(t)
// 注入一个测试平台(夹具绝不写进生产源码)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入平台: %v", err)
}
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfptest", "account_id": "a1",
})
if err != nil {
t.Fatalf("account_refresh: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
}
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
t.Errorf("请求路径不对: %s", gotPath)
}
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
var sent map[string]interface{}
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
}
if _, ok := sent["refreshToken"]; !ok {
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
}
if sent["grantType"] != "refresh_token" {
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
}
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
if j["refreshed"] != true {
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
}
if j["ok"] != true {
t.Errorf("应报 ok=true,实得 %v", j["ok"])
}
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:rfptest",
})
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
cred, _ := rj["credential"].(string)
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
}
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
if !strings.Contains(cred, "NEW-REFRESH") {
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
}
}
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
func TestAccountRefreshReportsFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
}))
defer srv.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpfail", "account_id": "a1",
})
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
if j["refreshed"] == true {
t.Error("续期失败时绝不能报 refreshed=true")
}
msg, _ := j["message"].(string)
if !strings.Contains(msg, "401") {
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
}
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
"credential": `{"access_token":"OLD"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpnort", "account_id": "a1",
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
m2, _ := j2["message"].(string)
if !strings.Contains(m2, "重新登录") {
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
}
}
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
}))
defer srv.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpexp", "account_id": "a1",
}); err != nil {
t.Fatalf("account_refresh: %v", err)
}
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:rfpexp",
})
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
cred, _ := rj["credential"].(string)
var m map[string]interface{}
_ = json.Unmarshal([]byte(cred), &m)
ea, _ := m["expires_at"].(string)
if ea == "" {
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
}
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
if ea == "7200" {
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
}
if len(ea) < 9 {
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
}
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
accounts := asArrInterface(aj["accounts"])
if len(accounts) == 0 {
t.Fatal("账号列表为空")
}
first, _ := accounts[0].(map[string]interface{})
idx, _ := first["expiresAt"].(float64)
if idx == 0 {
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
}
// 且应当是**绝对**秒级时刻(不是相对 7200)
if idx > 0 && idx < 1e9 {
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
}
}

View File

@ -0,0 +1,215 @@
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

@ -0,0 +1,251 @@
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

@ -0,0 +1,125 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。
//
// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
// 后完成者带着它更早的快照覆盖磁盘。实测形态:
// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。
//
// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
// 但"不可能"必须被验证,否则只是一句自信。
func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
c, ps := poolCore(t)
// 找出插件的存储目录(内核给的 ground truth,不是猜的)
dirOut, err := ps.Call("channels", "__store_dir_probe", nil)
if err != nil {
t.Fatalf("store_dir_probe: %v", err)
}
dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{})
dir, _ := dj["store_dir"].(string)
if dir == "" {
t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut)
}
storePath := filepath.Join(dir, ".channels-store.json")
t.Logf("插件存储目录: %s", dir)
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
}
}
// 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"a", "b", "c"} {
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "atomic1", "account_id": id}); err != nil {
t.Fatalf("add %s: %v", id, err)
}
}
// 删 a、删 b(参考的缺陷场景)
for _, id := range []string{"a", "b"} {
if _, err := ps.Call("channels", "account_delete", map[string]interface{}{
"platform": "atomic1", "account_id": id}); err != nil {
t.Fatalf("delete %s: %v", id, err)
}
}
// 再加一个(参考的第二个缺陷场景:与删除交错的写)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "atomic1", "account_id": "d"}); err != nil {
t.Fatalf("add d: %v", err)
}
// 内存视图
mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"})
mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{})
memIDs := map[string]bool{}
for _, a := range asArrInterface(mj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok {
if id, ok := am["id"].(string); ok {
memIDs[id] = true
}
}
}
// 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件)
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("读存储: %v", err)
}
var disk map[string]interface{}
if err := json.Unmarshal(b, &disk); err != nil {
t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err)
}
// 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。
st, _ := disk["state"].(map[string]interface{})
if st == nil {
t.Fatalf("磁盘文档缺 state 段,实得 %v", disk)
}
pool, _ := st["pool"].(map[string]interface{})
diskIDs := map[string]bool{}
if pl, ok := pool["atomic1"].(map[string]interface{}); ok {
for id := range pl {
diskIDs[id] = true
}
}
// ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态)
for _, gone := range []string{"a", "b"} {
if diskIDs[gone] {
t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone)
}
if memIDs[gone] {
t.Errorf("已删除的账号 %q 仍在内存视图里", gone)
}
}
// ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态)
if !diskIDs["d"] {
t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)")
}
// ③ 内存与磁盘必须一致(分叉本身就是缺陷)
for id := range memIDs {
if !diskIDs[id] {
t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id)
}
}
for id := range diskIDs {
if !memIDs[id] {
t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id)
}
}
_ = c
}

View File

@ -53,7 +53,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) {
storePath := filepath.Join(dir, ".channels-store.json")
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+
t.Fatalf("插件自有存储未写出(%s): %v\n"+
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
}
var saved struct {
@ -94,7 +94,7 @@ func TestChannelsStoreIsPluginOwned(t *testing.T) {
}
}
if !found {
t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid)
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
}
}
@ -155,7 +155,7 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) {
}
}
if !found {
t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号")
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
}
// ② 自有存储必须已写出
@ -164,7 +164,7 @@ func TestChannelsMigratesLegacyKernelState(t *testing.T) {
}
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖")
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
}
}
@ -213,7 +213,7 @@ func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
// 写失败的态下,遗留文件必须原样保留
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
}
// 且自有存储不应存在(写失败了)
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {

View File

@ -204,7 +204,7 @@ func TestLegacyVaultPlaintextLoad(t *testing.T) {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") {
t.Fatalf("★ 重封后旧明文仍在盘上:\n%s", raw)
t.Fatalf("重封后旧明文仍在盘上:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("重封后应含密文:\n%s", raw)
@ -252,7 +252,7 @@ func TestLegacySourceSealedConfig(t *testing.T) {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") {
t.Fatalf("★ 旧明文 api_key 升级后仍明文落盘:\n%s", raw)
t.Fatalf("旧明文 api_key 升级后仍明文落盘:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw)

View File

@ -0,0 +1,230 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
// (参考 parseTraeBatchModelList 的注释记录了它们)。
//
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
//
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
//
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
//
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
// reasoning_effort_config,全部显示"未提供推理等级"。
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
var mu sync.Mutex
var gotBody string
h := newTraeCatalogHarness(t, func(body string) {
mu.Lock()
gotBody = body
mu.Unlock()
})
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
if strings.Contains(joined, "only-in-bad-channel") {
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
"否则用户选中它必然失败且错误指向模型:%v", ids)
}
// 但通用通道里的模型要在
if !strings.Contains(joined, "glm-5.2") {
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
}
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
if !h.modelHasReasoningOptions("glm-5.2") {
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
}
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
mu.Lock()
b := gotBody
mu.Unlock()
if b == "" {
t.Fatal("未捕获到目录请求体")
}
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
if !strings.Contains(b, want) {
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
}
}
}
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
h := newTraeCatalogHarness(t, nil)
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
if strings.Contains(joined, bad) {
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
}
}
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
}
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
if !strings.Contains(joined, "no-function-model") {
t.Errorf("合法组内的条目不该被丢:%v", ids)
}
t.Logf("✓ 过滤后目录: %v", ids)
}
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
type traeCatalogHarness struct {
t *testing.T
ps *lua.Plugins
}
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if onBody != nil {
onBody(string(b))
}
w.Header().Set("Content-Type", "application/json")
// 三个通道:
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
fmt.Fprint(w, `{"function_configs":[`+
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
`{"function":"solo_agent","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":true}},`+
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
// (参考点名:判据是标志的值,不是模型名)
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":false}}`+
`]},`+
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
`{"function":"solo_work_lite","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
`]},`+
// ③ 白名单外通道:整组必须被丢弃
`{"function":"inline_chat","config_info_list":[`+
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
`]}`+
`]}`)
}))
t.Cleanup(ts.Close)
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)
}
h := &traeCatalogHarness{t: t, ps: ps}
// 把目录端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 塞一个 TRAE 账号(目录请求需要凭据)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "trae",
"account_id": "cat-trae",
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
}); err != nil {
t.Fatal(err)
}
return h
}
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
t.Helper()
if _, err := h.ps.Call("channels", "model_refresh",
map[string]interface{}{"platform": platform}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
var out []string
arr, _ := j["models"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if id, ok := m["id"].(string); ok {
out = append(out, id)
}
}
}
return out
}
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
arr, _ := j["models"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
if m["id"] != modelID {
continue
}
// 档位的真实出口:model_list 把远端目录解出的档位放在
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
return true
}
}
if rm, ok := m["remote"].(map[string]interface{}); ok {
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
return true
}
}
}
return false
}

View File

@ -0,0 +1,111 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
// 但头不对就只是一个失败/风控响应。参考实现明确记录:
//
// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败);
// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**,
// 真正成功的是约 20 个客户端头;
// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字**
// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。
//
// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。
func TestTraeCheckinHeaders(t *testing.T) {
var got map[string]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = map[string]string{}
for k := range r.Header {
got[k] = r.Header.Get(k)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0}`))
}))
defer ts.Close()
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)
}
// 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"checkin": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs)
const uid = "12345"
// credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login
// 会原样存进保险库,取用时 credentialOf 再解出 access_token。
cred, _ := json.Marshal(map[string]interface{}{
"access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef",
})
body, _ := json.Marshal(map[string]string{
"platform": "trae", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"trae"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil {
t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j)
}
t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) {
if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" {
t.Fatalf("Authorization 必须是 Cloud-IDE-JWT <token>(Bearer 会 401),实得 %q", v)
}
})
t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) {
// 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的
if v := got["X-Device-Id"]; v != "707304999019651" {
t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v)
}
})
t.Run("行3 客户端身份头齐全", func(t *testing.T) {
for k, want := range map[string]string{
"X-Market-Client-Id": "VSCode 1.107.1",
"X-User-Region": "CN",
"Package-Type": "stable_cn",
"X-Lgw-Req-Sdk-Type": "3",
"X-Lscbd-Platform": "windows",
"App-Version": "0.1.52",
} {
if v := got[k]; v != want {
t.Errorf("头 %s = %q,期望 %q", k, v, want)
}
}
if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 {
t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"])
}
if len(got["Vscode-Sessionid"]) != 64 {
t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"])
}
if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" {
t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id")
}
})
}

View File

@ -0,0 +1,63 @@
package core
import (
"encoding/json"
"fmt"
"os"
"testing"
"llmsproxy/internal/lua"
)
// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的
// 且不会报错(上游只会返回风控/失败)。所以拿参考实现(Node + node:crypto)
// 生成期望值,再让插件的 Lua 实现算同一组输入做差分。
//
// 期望值由 tools/trae_derive_expected.json 提供(用参考 seededStream 生成)。
func TestTraeDeriveMatchesReference(t *testing.T) {
raw, err := os.ReadFile("trae_derive_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
U string `json:"u"`
Devid string `json:"devid"`
Market string `json:"market"`
Sess string `json:"sess"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
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)
}
for _, tc := range cases {
got, err := ps.Call("channels", "__trae_derive_probe", map[string]interface{}{"uid": tc.U})
if err != nil {
t.Fatalf("probe %q: %v", tc.U, err)
}
wrapped, _ := got.(map[string]interface{})
m, _ := wrapped["json"].(map[string]interface{})
if m == nil {
t.Fatalf("probe %q 无 json: %#v", tc.U, got)
}
if fmt.Sprint(m["devid"]) != tc.Devid {
t.Errorf("devid(%q) = %v,参考 %s", tc.U, m["devid"], tc.Devid)
}
if fmt.Sprint(m["market"]) != tc.Market {
t.Errorf("market(%q) = %v,参考 %s", tc.U, m["market"], tc.Market)
}
if fmt.Sprint(m["sess"]) != tc.Sess {
t.Errorf("sess(%q) = %v,参考 %s", tc.U, m["sess"], tc.Sess)
}
}
_ = lua.HandlerRequest{}
}

View File

@ -0,0 +1,200 @@
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
// 而不是去调 ExchangeToken 换一个不存在的码。
//
// 真实缺陷(用户报「登录后显示失败」):
// 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 分支,必须明确报出来。
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
h := newTraeHarness(t)
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
//
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
// 故判据直接摆好那个文件再调 poll_login。
status, body := h.beginAndPollWithCallback(map[string]string{
"code": "PKCE_AUTH_CODE_XYZ",
})
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
// 前者写成了后者,于是把正确的实现判成错。
if !strings.Contains(body, "state:failed") {
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
status, body)
}
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
"实际打了 %d 次", n)
}
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
blob := body
if !strings.Contains(blob, "PKCE") {
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
}
if strings.Contains(blob, "授权码交换失败") {
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
}
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
}
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
func TestTraeExchangeSendsRealClientID(t *testing.T) {
h := newTraeHarness(t)
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
status, body := h.beginAndCallback(map[string]string{
"refreshToken": "RT-FROM-CALLBACK",
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
})
if status != 200 {
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
}
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
}
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
}
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
func TestTraeClientIDIsNotBlank(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
src := string(b)
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
}
}
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
src := string(b)
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
"必然 400 且把真因掩盖成交换失败")
}
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
}
// ③ PKCE 判定必须仍在 traeCallback 内
if !strings.Contains(src, "本实现暂不支持该流程") {
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
}
}
// beginAndPollWithCallback 走**真实登录用的那条路径**:
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
//
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
// trae 分支里。
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
h.t.Helper()
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
h.t.Fatal(err)
}
j, ok := out.JSON.(map[string]interface{})
if !ok {
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
}
sess := j["session"].(map[string]interface{})
sid, _ := sess["id"].(string)
nonce, _ := sess["nonce"].(string)
if sid == "" {
h.t.Fatalf("会话缺 id: %#v", sess)
}
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
rec := map[string]string{"state": nonce}
for k, v := range cbQuery {
rec[k] = v
}
b, _ := json.Marshal(rec)
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
if err := os.WriteFile(cbPath, b, 0600); err != nil {
h.t.Fatal(err)
}
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
"session_id": sid, "file": cbPath,
}); err != nil {
h.t.Fatalf("设置 loopback 文件: %v", err)
}
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid},
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}
// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串**
// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。
//
// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链,
// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路,
// 后面的来源永远取不到。
func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) {
for _, tc := range []struct {
name string
q map[string]string
}{
{"code 参数", map[string]string{"code": "C1"}},
{"authCode 参数", map[string]string{"authCode": "C2"}},
{"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}},
{"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}},
{"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}},
} {
t.Run(tc.name, func(t *testing.T) {
h := newTraeHarness(t)
status, body := h.beginAndPollWithCallback(tc.q)
_ = status
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n)
}
if !strings.Contains(body, "PKCE") {
t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body)
}
})
}
}

View File

@ -1,6 +1,7 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
@ -115,10 +116,26 @@ func TestTraeCallbackBehavior(t *testing.T) {
if st != 200 {
t.Fatalf("应 200: %d %s", st, body)
}
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值。
// 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
if !strings.HasPrefix(v, "ACCESS-") {
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", v)
var cred map[string]interface{}
if err := json.Unmarshal([]byte(v), &cred); err != nil {
t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v)
}
if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") {
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at)
}
// uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到
if cred["uid"] != "u1" {
t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"])
}
if cred["enterprise_id"] != "t1" {
t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"])
}
// 设备身份来自登录会话(授权 URL 里下发的那对 hex32)
if d, _ := cred["device_id"].(string); len(d) != 32 {
t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d)
}
})
@ -131,8 +148,12 @@ func TestTraeCallbackBehavior(t *testing.T) {
t.Fatalf("应 200: %d %s", st, body)
}
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
if v != "JWT-TOKEN-1" {
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %q", v)
var cred2 map[string]interface{}
if err := json.Unmarshal([]byte(v), &cred2); err != nil {
t.Fatalf("凭据应为 JSON: %v (%q)", err, v)
}
if cred2["access_token"] != "JWT-TOKEN-1" {
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"])
}
if atomic.LoadInt32(&h.exchHit) != before {
t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)")
@ -208,7 +229,7 @@ func TestTraeCallbackPoolPath(t *testing.T) {
// ① 凭据必须落在池账号的 ref
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
if !ok || v == "" {
t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
}
// ② account_list 必须报 has_credential=true
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{

View File

@ -0,0 +1,100 @@
package core
import (
"testing"
)
// 通用件:TTL 缓存 + 串行锁(照参考 ttl-cache.ts / serial-queue.ts)。
//
// 抽它们出来的理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"
// 逻辑(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,
// 边界条件容易漏 —— 实测就漏了一个(见下面 ③)。
func TestTtlCacheSemantics(t *testing.T) {
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__ttl_reset_probe", nil); err != nil {
t.Fatalf("重置: %v", err)
}
// ① 首次加载后命中缓存:loader 只该被调一次
out, err := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["value"] != "v1" {
t.Errorf("首次应加载出 v1,实得 %v", j["value"])
}
if n, _ := j["loads"].(float64); n != 1 {
t.Errorf("首次后 loads 应为 1,实得 %v", n)
}
// 再取一次:命中缓存,loads 不变
out2, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j2["loads"].(float64); n != 1 {
t.Errorf("命中缓存时不该再加载,loads 实得 %v", n)
}
// ② 失效后必须能重新加载(包括同一秒内 —— 见 ③)
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
out3, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 这是实测踩到的坑:只清缓存、不清"在飞标记"时,同一秒内的重载会被
// 去重逻辑挡住,返回 nil —— 表现为"刚清了缓存却拿不到值"。
if j3["value"] == nil {
t.Fatalf("失效后立刻重载必须拿到值(清缓存时也要清在飞标记),实得 nil")
}
if n, _ := j3["loads"].(float64); n != 2 {
t.Errorf("失效后应重新加载(loads=2),实得 %v", n)
}
// ③ TTL=0 视为立即过期:不该命中
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
_, _ = ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
out5, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
j5, _ := out5.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j5["loads"].(float64); n < 3 {
t.Errorf("ttl=0 不该命中缓存(每次都要重载),loads 实得 %v", n)
}
// ④ 串行锁:抢到 → 第二次抢不到 → 释放后能再抢
a, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
ja, _ := a.(map[string]interface{})["json"].(map[string]interface{})
if ja["acquired"] != true {
t.Errorf("首次应抢到,实得 %v", ja["acquired"])
}
b, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jb, _ := b.(map[string]interface{})["json"].(map[string]interface{})
if jb["acquired"] != false {
t.Errorf("持有期间第二次不该抢到(这就是串行的意义),实得 %v", jb["acquired"])
}
if _, err := ps.Call("channels", "__serial_release_probe", map[string]interface{}{
"ns": "s"}); err != nil {
t.Fatalf("释放: %v", err)
}
c, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jc, _ := c.(map[string]interface{})["json"].(map[string]interface{})
if jc["acquired"] != true {
t.Errorf("释放后应能再抢到,实得 %v", jc["acquired"])
}
}

View File

@ -0,0 +1,67 @@
package core
import (
"strings"
"testing"
)
// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。
//
// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮),
// 于是反复派人去"补"。判据在此把事实钉住:
//
// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm`
// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个
// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`,
// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。
// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token
// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。
//
// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时,
// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。
func TestUpstreamGapsAreDocumented(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
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"`) {
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
"否则响应会被当成畸形数据")
}
// 明文端点没有信封层,故解包**不该**存在(存在反而是死代码)
if strings.Contains(s, "unwrapQoderEnvelope") {
t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" +
"要么端点已换成加密端点(那需要 wasm),要么这是死代码")
}
})
t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) {
if !strings.Contains(s, "本实现暂不支持该流程") {
t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " +
"参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" +
"若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)")
}
})
t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) {
for _, want := range []string{
"opengw.developer.huaweicloud.com/api/v1/gateway/config",
"snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
`sign = "codearts"`,
} {
if !strings.Contains(s, want) {
t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+
"**必须 AK/SK 签名**的端点,只打一个会少一半模型", want)
}
}
})
}

View File

@ -0,0 +1,38 @@
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

@ -0,0 +1,59 @@
package core
import (
"strings"
"testing"
)
// VNC 一直白屏的真因(2026-10-14 定位)。
//
// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出:
//
// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002
// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye.
//
// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报
// 「已就绪」,把白屏谎报成成功。
//
// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠
// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem
// 能复现,其余加固项无影响。
//
// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。
func TestBrowserStackUsesInProcessGPU(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来
if !strings.Contains(b, "--in-process-gpu") {
t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " +
"SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" +
"浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)")
}
// ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读,
// chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声
for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} {
if !strings.Contains(b, k) {
t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k)
}
}
// ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。
// 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏,
// 还会以为是自己操作的问题。谎报比直接报错更坏。
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功")
}
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」")
}
// ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify)
idx := strings.Index(b, "浏览器启动后即退出")
if idx < 0 {
t.Fatal("应有「浏览器启动后即退出」的失败分支")
}
if !strings.Contains(b[idx:idx+600], "reapBrowser()") {
t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)")
}
}

View File

@ -0,0 +1,70 @@
package core
import (
"strings"
"testing"
)
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
//
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
//
// attempt to call global 'reapLoopback' (a nil value)
//
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须存在对这三个助手的前向声明
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
"正常、第二次 500,且浏览器栈不会被回收)")
}
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
// —— 那会遮蔽前向声明,效果与没声明完全相同。
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
if strings.Contains(b, "local function "+fn+"(") {
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
}
if !strings.Contains(b, "\n"+fn+" = function(") {
t.Errorf("找不到 %s 的赋值式定义", fn)
}
}
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
use := strings.Index(b, "\n reapLoopback(id)\n")
if decl < 0 || use < 0 {
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
}
if decl > use {
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
}
}
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// CDP 真应答(端口在听 ≠ 服务可用)
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("必须用 CDP 真应答判活,不能只看端口")
}
// 进程真存活
if !strings.Contains(b, "/proc/") {
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
}
// 失败要把日志真因带出来(而不是一句"启动失败")
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须带出日志真因")
}
}

View File

@ -1,6 +1,7 @@
package core
import (
"net/url"
"strings"
"testing"
@ -53,14 +54,43 @@ func TestChannelsVncStackEndToEnd(t *testing.T) {
t.Fatalf("浏览器栈未能启动: %s", e)
}
view, _ := m["view_url"].(string)
if !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
}
sess := m["session"].(map[string]interface{})
if sess["cdp_port"] == nil {
t.Fatal("会话应带 CDP 端口(取凭据用)")
}
t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"])
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
// 页面看似白屏:
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
u, perr := url.Parse(view)
if perr != nil {
t.Fatalf("view_url 不可解析: %v", perr)
}
q := u.Query()
wspath := q.Get("path")
if wspath == "" {
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
"会在 URL 形状变化时静默破裂")
}
if strings.HasPrefix(wspath, "/") {
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
}
resolved := u.ResolveReference(&url.URL{Path: wspath})
pages := strings.TrimSuffix(u.Path, "/vnc.html")
if resolved.Path != pages+"/websockify" {
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
pages+"/websockify", resolved.Path)
}
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
// 收尾:回收浏览器栈(测试不留孤儿进程)
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{

View File

@ -0,0 +1,203 @@
package core
import (
"os"
"strconv"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
//
// 现场(2026-10-13):/tmp/.X<n>-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾
// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的
// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满
// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报
// “Server is already active for display 91”。
// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。
//
// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己
// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」):
// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active;
// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它);
// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。
func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
const ghost = "999999" // 必然不存在的 PID
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
//
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
base := 300
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
base = n
}
}
ghosts := []int{base, base + 1, base + 2}
for _, n := range ghosts {
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
if _, err := os.Stat(l); err == nil {
t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l)
}
if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil {
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
}
defer os.Remove(l)
}
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
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)
}
// 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。
injectTestPlatforms(t, ps, `[{
"id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18099/login",
"base_url": "https://api.xdispfx.example/v1", "adapter": "openai",
"models": ["x-model-1"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
if j == nil {
t.Fatalf("无 JSON 响应 (status=%d)", out.Status)
}
msg, _ := j["browser_error"].(string)
if strings.Contains(msg, "already active for display") {
t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg)
}
if msg != "" {
// 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。
if _, err := os.Stat(lock); err == nil {
t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost)
}
t.Skipf("浏览器栈未启动(与选号无关): %s", msg)
}
// ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。
st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("browser_status: %v", err)
}
sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{})
used := 0
if sess != nil {
if f, ok := sess["display"].(float64); ok {
used = int(f)
}
}
if used == 0 {
t.Fatalf("会话未报告 display: %v", st.JSON)
}
// 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。
// 只跳过不复用的实现(变异)会选 94,从而被抓到。
reclaimed := false
for _, n := range ghosts {
if used == n {
reclaimed = true
}
}
if !reclaimed {
t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts)
}
if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil {
pid := strings.TrimSpace(string(raw))
if pid == ghost {
t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used)
} else if pid != "" {
if _, err := os.Stat("/proc/" + pid); err != nil {
t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid)
}
}
} else {
t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used)
}
// ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
}); err != nil {
t.Fatalf("stop_browser: %v", err)
}
dl := "/tmp/.X" + strconv.Itoa(used) + "-lock"
ds := "/tmp/.X11-unix/X" + strconv.Itoa(used)
if _, err := os.Stat(dl); err == nil {
t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl)
_ = os.Remove(dl)
}
if _, err := os.Stat(ds); err == nil {
t.Errorf("收栈后 socket 未清理: %s", ds)
_ = os.Remove(ds)
}
_ = sock
}
// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。
func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) {
const disp = 92
lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock"
if _, err := os.Stat(lock); err == nil {
t.Skipf("前置条件不满足:%s 已存在", lock)
}
// 用一个**真实存活**的 PID(本进程)写锁。
if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
}
defer os.Remove(lock)
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)
}
injectTestPlatforms(t, ps, `[{
"id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18097/login",
"base_url": "https://api.xdispfx2.example/v1", "adapter": "openai",
"models": ["x-model-2"]
}]`)
defer func() {
_, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
}()
if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin",
}); err != nil {
t.Fatalf("start_login: %v", err)
}
// 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。
raw, err := os.ReadFile(lock)
if err != nil {
t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock)
}
if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) {
t.Errorf("活锁内容被改动: %q(应保持原样)", got)
}
}

View File

@ -0,0 +1,285 @@
package core
import (
"encoding/json"
"strings"
"testing"
)
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
// **完全相反**(稍后重试 vs 别重试)。
//
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
// (2026-10-05 自审 + 审计打出的真实缺陷)。
//
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
func TestZcode3012ClassificationMatrix(t *testing.T) {
h := newZcodeIdentityHarness(t)
cases := []struct {
name string
body string
want bool
}{
// ── 真风控(必须命中)──
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
// ── 必须**不**命中(误判会让用户做错事)──
{"13012 不是 3012", `{"code":13012}`, false},
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
{"标识符 trust_3012", `trust_3012`, false},
{"字母包裹 x3012y", `x3012y`, false},
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
{"block 距 12", `temporarily blocked; see error 3012`, false},
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
{"标识符+block 紧邻", `plan-3012 blocked`, false},
{"字母包裹+block 紧邻", `x3012 blocked`, false},
{"下划线+block 紧邻", `trust_3012 blocked`, false},
{"独立词+block 紧邻", `3012 blocked`, true},
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := h.callZD("hasUnusualActivity", tc.body)
if got != tc.want {
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
}
})
}
}
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
h := newZcodeIdentityHarness(t)
for _, tc := range []struct {
ms int
want string
}{
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
{59000, "59.0s"}, // 不足一分钟
{60000, "1分"}, // 整分钟
{3600000, "1小时"}, // 整小时
{3900000, "1小时5分"}, // 小时+分
{500, "500ms"},
} {
got := h.callZDString("formatGap", tc.ms)
if got != tc.want {
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
}
}
}
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
//
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
// 文案里」来钉死这条 —— 不是"看起来没有"。
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
h := newZcodeIdentityHarness(t)
const secretID = "zcode-abcdef1234567890-SECRET"
h.callZDAction("accountOrdinal", secretID)
h.callZDAction2("noteRequestSent", secretID, 1000)
h.callZDAction2("noteRequestOk", secretID, 1100)
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
"accountId": secretID,
"sentAt": 1200,
"now": 1300,
"status": 405,
})
if diag == "" {
t.Fatal("formatDiagnostic 返回空")
}
// 账号 id 的每个 >=4 字符子串都不得出现
for i := 0; i+4 <= len(secretID); i++ {
if strings.Contains(diag, secretID[i:i+4]) {
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
}
}
if !strings.Contains(diag, "账号#") {
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
}
t.Logf("✓ 诊断行无凭据片段: %s", diag)
}
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
//
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
h := newZcodeIdentityHarness(t)
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
"subject": "领取 plan-x",
"rawResponse": "request has been blocked",
})
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
if !strings.Contains(msg, want) {
t.Fatalf("风控文案缺 %q:%s", want, msg)
}
}
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
t.Fatalf("空参数不应留下空壳段:%q", bare)
}
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
}
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
//
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := jsonUnmarshal(out, &req); err != nil {
t.Fatal(err)
}
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
if shape == nil {
t.Fatal("describeRequestShape 返回空")
}
chars, _ := shape["identityChars"].(float64)
if chars < 2800 {
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
}
if shape["hasDateBlock"] != true {
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
}
if shape["firstRole"] != "user" {
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
}
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
if c, _ := empty["identityChars"].(float64); c != 0 {
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
}
}
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
//
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
v, ok := m["result"].(bool)
if !ok {
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
}
return v
}
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
h.t.Helper()
var res interface{}
var err error
if opts, ok := a.(map[string]interface{}); ok {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "opts": opts})
} else if fn == "formatGap" {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
s, _ := m["result"].(string)
return s
}
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
h.t.Helper()
var res interface{}
var err error
if len(args) == 2 {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": args[0]})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
out, _ := m["result"].(map[string]interface{})
return out
}
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
func jsonUnmarshal(s string, v interface{}) error {
return json.Unmarshal([]byte(s), v)
}

View File

@ -0,0 +1,411 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// ★ ZCode 的身份块是 3012 准入的**唯一开关**。
//
// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接
//
// HTTP 405 + {"code":3012,"msg":"...unusual activity."}
//
// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。
//
// 判据矩阵(照参考,逐条锁定):
//
// 无 system → 3012
// 仅 cliPrefix(42 字符) → 3012
// cliPrefix + stable(约 2898 字符)→ 200
//
// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。
//
// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值),
// 而不是"适配器装了没"这类内部信号。
func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) {
h := newZcodeIdentityHarness(t)
out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatalf("产出不是 JSON: %v\n%s", err, out)
}
// ① system 必须是**块数组**(官方形态),不是纯字符串
sys, ok := req["system"].([]interface{})
if !ok {
t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v",
req["system"], req["system"])
}
if len(sys) < 3 {
t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys))
}
// ② block[0] 必须是官方 CLI 前缀(准入必需)
b0, _ := sys[0].(map[string]interface{})
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got)
}
// ③ block[1] 必须是官方 stable 段(准入必需),长度对得上
b1, _ := sys[1].(map[string]interface{})
stable, _ := b1["text"].(string)
if len(stable) < 2800 {
t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)",
len(stable))
}
for _, marker := range []string{
"You are an interactive ZCode agent",
"# Harness",
"# ZCode Desktop Context",
} {
if !strings.Contains(stable, marker) {
t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker)
}
}
// ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台)
b2, _ := sys[2].(map[string]interface{})
env, _ := b2["text"].(string)
if !strings.HasPrefix(env, "# Environment") {
t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))])
}
for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} {
if !strings.Contains(env, marker) {
t.Fatalf("Environment 段缺 %q:%s", marker, env)
}
}
// ⑤ 总长度必须达到参考声明的量级(2898)
total := len("You are ZCode, an interactive coding agent") + len(stable)
if total < 2800 {
t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total)
}
// ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态)
msgs, _ := req["messages"].([]interface{})
if len(msgs) == 0 {
t.Fatal("messages 为空")
}
m0, _ := msgs[0].(map[string]interface{})
content, ok := m0["content"].([]interface{})
if !ok {
t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T",
m0["content"])
}
c0, _ := content[0].(map[string]interface{})
if got, _ := c0["text"].(string); !strings.Contains(got, "<system-reminder>") {
t.Fatalf("content[0] 必须是日期块(<system-reminder>),实得 %q", got)
}
t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total)
}
// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**,
// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。
func TestZcodeCallerSystemIsAppendedLast(t *testing.T) {
h := newZcodeIdentityHarness(t)
out, err := h.transformRequest(
`{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
sys, _ := req["system"].([]interface{})
if len(sys) < 4 {
t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys))
}
last, _ := sys[len(sys)-1].(map[string]interface{})
lt, _ := last["text"].(string)
if !strings.Contains(lt, "CALLER_PROMPT_XYZ") {
t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)")
}
// 且前三块仍是官方块(身份块没有被调用方内容挤走)
b0, _ := sys[0].(map[string]interface{})
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got)
}
}
// 适配器自检出口:身份块长度与 anthropic 基座装载状态。
// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。
func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) {
h := newZcodeIdentityHarness(t)
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc, ok := j["zcode"].(map[string]interface{})
if !ok {
t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j)
}
if zc["ok"] != true {
t.Fatalf("zcode 适配器装载失败: %v", zc["err"])
}
}
// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器),
// 然后直接调那个适配器的 transform_request。
//
// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是
// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。
type zcodeIdentityHarness struct {
t *testing.T
c *Core
ps *lua.Plugins
}
func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness {
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)
}
return &zcodeIdentityHarness{t: t, c: c, ps: ps}
}
// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。
func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) {
h.t.Helper()
vm := h.c.VM()
if vm == nil {
h.t.Fatal("VM 不可用")
}
return vm.Transform("channels-zcode", "transform_request", body)
}
func zcodeMin(a, b int) int {
if a < b {
return a
}
return b
}
// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。
//
// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖
// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化
// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。
func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("工具定义与工具调用存活", func(t *testing.T) {
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` +
`"tools":[{"type":"function","function":{"name":"get_weather",` +
`"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
// anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里)
if !strings.Contains(out, `"get_weather"`) {
t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out)
}
if !strings.Contains(out, `"input_schema"`) {
t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out)
}
})
t.Run("多模态图像块存活", func(t *testing.T) {
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` +
`{"type":"text","text":"what"},` +
`{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) {
t.Fatalf("图像块在转换中丢失:%s", out)
}
})
t.Run("基座装载状态可见", func(t *testing.T) {
// 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、
// 协议转换已退化为透传)
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc := j["zcode"].(map[string]interface{})
if zc["ok"] != true {
t.Fatalf("zcode 适配器未装载: %v", zc)
}
})
}
// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。
//
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
//
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
//
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
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)
}
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 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)
}
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)
}
if zc["ok"] == true {
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
}
msg, _ := zc["err"].(string)
if !strings.Contains(msg, "anthropic") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
}
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
}
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
//
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
in := `{"model":"m","messages":[{"role":"user","content":[` +
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
`{"type":"text","text":"hi"}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content := msgs[0].(map[string]interface{})["content"].([]interface{})
n := 0
for _, c := range content {
if b, ok := c.(map[string]interface{}); ok {
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
n++
}
}
}
if n != 1 {
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
}
}
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
//
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("首条不是 user 时不动", func(t *testing.T) {
in := `{"model":"m","messages":[` +
`{"role":"assistant","content":"opening"},` +
`{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
noReminder := func(content interface{}) bool {
switch c := content.(type) {
case string:
return !strings.Contains(c, "<system-reminder>")
case []interface{}:
for _, e := range c {
if b, ok := e.(map[string]interface{}); ok {
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
return false
}
}
}
}
return true
}
// 首条(assistant)不该被插日期块
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
}
// 后面的 user 也**不该**被插(官方不往后找)
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
}
// 且原文本内容必须保留(转换不能丢内容)
b, _ := json.Marshal(msgs[0])
if !strings.Contains(string(b), "opening") {
t.Fatal("首条 assistant 的内容丢了")
}
})
t.Run("首条是 user 时插入", func(t *testing.T) {
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
if !ok {
t.Fatal("首条 user 的 content 应升级为块数组")
}
b0 := content[0].(map[string]interface{})
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
}
})
}

View File

@ -0,0 +1,188 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
//
// 参考语义(zcode-login.ts 逐行):
//
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
// Authorization 是**本地造的会话密钥**(不是用户凭据)
// ② 用户在浏览器打开 authorize_url
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
// code!=0 或 data 缺失 = pending;status=pending = 继续
//
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
type zcodeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
readyAt int32
gotInit map[string]interface{}
gotAuth string
}
func newZcodeHarness(t *testing.T) *zcodeHarness {
t.Helper()
h := &zcodeHarness{t: t, readyAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init":
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &h.gotInit)
h.gotAuth = r.Header.Get("Authorization")
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
"expires_at":9999999999,"poll_interval_sec":2}}`)
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
return
}
n := atomic.AddInt32(&h.polls, 1)
if n < atomic.LoadInt32(&h.readyAt) {
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
"token":"ZCODE-JWT-1",
"user":{"user_id":"u7","name":"zcode-dev"},
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "zcode",
"initpoll": map[string]interface{}{
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
func TestZcodeInitPollBehavior(t *testing.T) {
h := newZcodeHarness(t)
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
if h.gotInit["provider"] != "bigmodel" {
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
}
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
}
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
t.Fatalf("应给出 authorize_url, 实得 %q", u)
}
})
t.Run("行2 pending状态保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 2)
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("ready 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
if !ok {
t.Fatal("凭据未落库")
}
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
if !strings.Contains(v, want) {
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
}
}
})
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
if st := h.pollOnce(sid); st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid)
}
})
}

View File

@ -0,0 +1,38 @@
[
{
"method": "GET",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
"body": "",
"ak": "AK1",
"sk": "SK1",
"st": "TOK1",
"canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182",
"signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549",
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
},
{
"method": "GET",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE",
"body": "",
"ak": "AK2",
"sk": "SK2",
"st": "",
"canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1",
"signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a",
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
},
{
"method": "POST",
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim",
"body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}",
"ak": "AK3",
"sk": "SK3",
"st": "T3",
"canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19",
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733",
"signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5",
"signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token"
}
]

View File

@ -0,0 +1,97 @@
package core
import (
"encoding/json"
"os"
"strings"
"testing"
)
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
//
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
//
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
//
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
// 以及参与签名的头列表(顺序与内容)。
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
raw, err := os.ReadFile("codearts_sig_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
Method string `json:"method"`
URL string `json:"url"`
Body string `json:"body"`
AK string `json:"ak"`
SK string `json:"sk"`
ST string `json:"st"`
Canonical string `json:"canonical"`
StringToSign string `json:"string_to_sign"`
Signature string `json:"signature"`
SignedHeaders string `json:"signed_headers"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases) == 0 {
t.Fatal("差分数据为空")
}
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)
}
for i, tc := range cases {
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
"method": tc.Method, "url": tc.URL, "body": tc.Body,
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
})
if err != nil {
t.Fatalf("case %d probe: %v", i, err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("case %d 无 json: %#v", i, got)
}
if m["error"] != nil {
t.Fatalf("case %d 探针报错: %v", i, m["error"])
}
if s, _ := m["canonical"].(string); s != tc.Canonical {
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
}
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
}
if s, _ := m["signature"].(string); s != tc.Signature {
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
}
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
}
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
if s, _ := m["authorization"].(string); len(s) == 0 ||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
}
// 签名后追加的头绝不能进 canonical
if s, _ := m["canonical"].(string); len(s) > 0 {
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
if strings.Contains(s, bad) {
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
}
}
}
}
}

View File

@ -66,7 +66,7 @@ func TestDevicePollMutants(t *testing.T) {
t.Fatal("基准决策表未全过 —— 测试自身失效")
}
if newDeviceHarness(t, mutated).runDecisionTable() {
t.Fatalf("★ 变异体幸存(%s)—— 该分支无覆盖", mu.name)
t.Fatalf("变异体幸存(%s)—— 该分支无覆盖", mu.name)
}
})
}

View File

@ -147,6 +147,11 @@ func (c *Core) actionListSources() interface{} {
"has_key": s.APIKey != "" || s.APIKeyEnv != "",
"key_hint": maskSecretHint(s.APIKey),
"max_concurrent": s.MaxConcurrent,
// rpm must be exposed too: it carries the per-model minimum gap, and
// max_concurrent alone cannot distinguish "serial WITH a gap" from
// "serial with no gap" — the latter still collides with the
// upstream's per-model concurrency window.
"rpm": s.RPM,
})
}
return out

View File

@ -29,6 +29,16 @@ func kernelTestCore(t *testing.T) *Core {
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
}
// ★ 显示器号与端口用**高段号**,避开生产。
//
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
// 故测试整体挪到 300+。
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
}
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
@ -46,6 +56,24 @@ func kernelTestCore(t *testing.T) *Core {
if err != nil {
t.Fatal(err)
}
// 测试结束**必须**把插件拉起的服务端浏览器栈收掉。
// 为何必需:callback 流(trae/gemini/有道)在 start_login 时就起一整套
// Xvfb+chromium+x11vnc+websockify,而绝大多数测试不会显式 stop_browser、
// 会话 TTL 也还没到 —— 于是一轮 core 全包跑完就攒出 7 套常驻进程
// (实测 websockify×7 + x11vnc×7 + 7 个 profile 目录),把显示器号与端口
// 段逐步耗光,后续测试报「chromium/websockify 端口未就绪」。
// 在这一处统一收掉,比让每个测试各自记得更可靠。
t.Cleanup(func() {
ps := c.Plugins()
if ps == nil {
return
}
for _, name := range []string{"channels"} {
_, _ = ps.InvokeHandler(name, "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
}
})
t.Cleanup(c.Close)
return c
}

View File

@ -34,6 +34,6 @@ return plugin
t.Fatalf("动作未成功: %#v", m)
}
if _, ok := c.FindKey("sk-kernel-0001"); ok {
t.Fatal("★ 动作报告成功但副作用没发生 —— dispatch 是空转")
t.Fatal("动作报告成功但副作用没发生 —— dispatch 是空转")
}
}

View File

@ -0,0 +1,22 @@
package core
import (
"os"
"testing"
"time"
)
// 把测试进程的时区钉成 **UTC**。
//
// 为什么必需:Qoder 的每日活动按 **UTC+8** 结算,判定必须用算术平移而不是
// 本机时区。但这台机器**本身就是 HKT(+0800)** —— 于是在真实环境里
// 「os.date("!*t")(UTC 平移)」与「os.date("*t")(本机时区)」结果**完全相同**,
// 判据无法区分二者(实测:把实现改成用本机时区,判据照样通过)。
// 钉成 UTC 后两者相差 8 小时,误用本机时区的实现立刻暴露。
//
// 只影响测试进程,不改系统时区。
func TestMain(m *testing.M) {
os.Setenv("TZ", "UTC")
time.Local = time.UTC
os.Exit(m.Run())
}

View File

@ -0,0 +1,44 @@
[
{
"u": "12345",
"devid": "707304999019651",
"market": "5daa819c-8425-4bae-88b7-2bbfec3c25ba",
"sess": "e5b8dfc5830ce0bdb141f7177effb90a7cf8227bb23b930a427a8550bf7357df"
},
{
"u": "UID-abc",
"devid": "579362202930608",
"market": "a5388fc1-5969-4666-bc74-be9a11f69567",
"sess": "a47da2e13fe16d49dc859fbed58d1d99e0faef5d116acafc64a2d26013d17a1f"
},
{
"u": "900000001",
"devid": "764948639142479",
"market": "e449daa5-b0c0-4f35-918e-8bb01b33f179",
"sess": "fdd37c37d1cc950439c25467e6dca75fcc84a2b0d294aa910387a9a965645650"
},
{
"u": "x",
"devid": "268405722745284",
"market": "4f2427c5-0583-4f82-8e18-4e38566dd857",
"sess": "79be40fff0fbaee572d1ba3ab13a47af9d2f4d943115ef512c56e42132e23a66"
},
{
"u": "",
"devid": "170789623621479",
"market": "b32897c1-f9c0-4ad2-b2b4-725455fffb6b",
"sess": "4a61a6b4c4bcbf1040bf0edf3df2b3e7d76a956f3ef81ef8f148eadcc03d25cc"
},
{
"u": "用户1",
"devid": "263026789643405",
"market": "1c1b4e1f-9d7a-4698-9f34-b057d9a74b0b",
"sess": "a13dbde68977eee49818e2e2fb7d3bbb1ffb4c807a6e6f1831168a84adea0dfe"
},
{
"u": "1234567890123456789012345",
"devid": "358669731114854",
"market": "449882ec-9a69-4a7a-b9ab-25aa59b28c50",
"sess": "305eb96fe14a194386c0fd4148d4919870ed803b69cf75296b53ea1f41e4c574"
}
]

View File

@ -67,7 +67,7 @@ func TestBillingRulesCRUD(t *testing.T) {
t.Fatal(err)
}
if n := len(got.DSL.Profiles[0].Rules); n != 2 {
t.Fatalf("★ re-adding an existing url appended instead of replacing: %d rules", n)
t.Fatalf("re-adding an existing url appended instead of replacing: %d rules", n)
}
var mode string
for _, r := range got.DSL.Profiles[0].Rules {
@ -105,7 +105,7 @@ func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) {
t.Fatal(err)
}
if len(got.Warnings) == 0 {
t.Fatal("★ a rule matching no configured source was accepted with no warning — " +
t.Fatal("a rule matching no configured source was accepted with no warning — " +
"it will price nothing and every request will be recorded as unpriced")
}
if !strings.Contains(got.Warnings[0], "typo.example.com") {
@ -136,7 +136,7 @@ func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) {
t.Fatal(err)
}
if len(got.DSL.Profiles[0].Rules) != 1 {
t.Errorf("★ a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
t.Errorf("a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules))
}
// An unparseable price must be refused too, not silently priced at zero.
if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token",
@ -175,7 +175,7 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(raw), "commandcode.ai") {
t.Errorf("★ the rule is not in config.yaml on disk — an edit that does not "+
t.Errorf("the rule is not in config.yaml on disk — an edit that does not "+
"survive a restart:\n%s", raw)
}
reloaded, err := config.Load(cfg.Path)
@ -226,7 +226,7 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) {
}
cc, ok := sources["commandcode"]
if !ok {
t.Fatalf("★ the rule was saved but never injected: prices has %v, "+
t.Fatalf("the rule was saved but never injected: prices has %v, "+
"no commandcode entry. Every request on it stays unpriced.", keysOf(sources))
}
if cc == nil {

View File

@ -33,13 +33,13 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
t.Fatalf("read deploy.sh: %v", err)
}
src := string(fn)
i := strings.Index(src, "prune_adapter_backups()")
i := strings.Index(src, "prune_backups()")
if i < 0 {
t.Fatal("prune_adapter_backups not found in deploy.sh")
t.Fatal("prune_backups not found in deploy.sh")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_adapter_backups has no closing brace")
t.Fatal("prune_backups has no closing brace")
}
body := src[i : i+j+3]
@ -47,20 +47,20 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
body = strings.Replace(body,
`local base="/etc/llmsproxy"`,
`local base="`+base+`"`, 1)
// KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
// here: hardcoding 5 in the shim overrode whatever the script configured, so
// a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The
// a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The
// value is part of what is under test.
var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`)
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
keepM := keepRe.FindStringSubmatch(src)
if keepM == nil {
t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " +
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
"policy would be undefined")
}
keep := keepM[1]
script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" +
body + "\nprune_adapter_backups\n"
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" +
body + "\nprune_backups adapters\n"
scriptPath := filepath.Join(base, "fn.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
t.Fatalf("write script: %v", err)
@ -88,7 +88,7 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
}
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out)
t.Fatalf("prune_backups failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
@ -102,11 +102,11 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
}
}
// 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE
// 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
// only three keepers made this assertion wrong in BOTH directions at first:
// …09 was named as "should be deleted" while it is in fact retained.
// KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and
// KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and
// irregular names are never deleted — so they consume slots rather than
// being ignored. With 3 unremovable directories present, only 2 of the
// well-formed ones can survive. That is the function's actual (and safe)
@ -202,3 +202,123 @@ func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
"captures the new file and rollback restores nothing")
}
}
// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。
//
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。
//
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
if _, err := os.Stat("../../deploy.sh"); err != nil {
t.Skip("deploy.sh not present (packaging-only checkout)")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
fn, err := os.ReadFile("../../deploy.sh")
if err != nil {
t.Fatal(err)
}
src := string(fn)
// ① prune_all_backups 必须存在且两个前缀都裁
i := strings.Index(src, "prune_all_backups()")
if i < 0 {
t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," +
"plugins.bak 会继续无上限堆积(实测 36 个 / 192M)")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_all_backups 没有收尾大括号")
}
body := src[i : i+j+3]
for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} {
if !strings.Contains(body, want) {
t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want)
}
}
// ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。
// 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如
// runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。
prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`)
found := map[string]bool{}
for _, m := range prefixRe.FindAllStringSubmatch(src, -1) {
found[m[1]] = true
}
if len(found) == 0 {
t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)")
}
for prefix := range found {
if !strings.Contains(body, "prune_backups "+prefix) {
t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+
"—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix)
}
}
// ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉
base := t.TempDir()
k := strings.Index(src, "prune_backups()")
kj := strings.Index(src[k:], "\n}\n")
fnBody := src[k : k+kj+3]
fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1)
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
km := keepRe.FindStringSubmatch(src)
if km == nil {
t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS")
}
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups plugins\n"
sp := filepath.Join(base, "fn.sh")
if err := os.WriteFile(sp, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
for _, ts := range []string{
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
} {
if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil {
t.Fatal(err)
}
}
// adapters 前缀**不该被动**(防止误删别的前缀)
if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil {
t.Fatalf("prune_backups plugins failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range entries {
if e.IsDir() {
got[e.Name()] = true
}
}
if !got["plugins.bak.20260801120000"] {
t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始")
}
if got["plugins.bak.20260101120000"] {
t.Error("最旧的 plugins 备份存活;裁剪没生效")
}
if !got["adapters.bak.20260101120000"] {
t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围")
}
// ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录)
bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups \"\"\n"
bp := filepath.Join(base, "bad.sh")
if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil {
t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out)
}
}

View File

@ -0,0 +1,71 @@
package gateway
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 变异测试会**改写生产源码** `discover.go`(discover_mutate_test.go),
// 靠 defer 把原文件写回。这在进程被强杀时不成立:
//
// - `go test -timeout` 超时 → 测试进程被杀
// - Ctrl-C / 后台任务被 kill
// - 跑测试的 shell 被误杀(本仓真实踩过:`pkill -f` 匹配到执行它的那条命令行)
//
// 后果不是"这次测试没跑完",而是**变异体被留在工作区里**:下一次运行会把
// 它当成"原始源码"捕获,于是基线本身就是变异体,所有裁决静默失效;更糟的是
// 开发者可能把它当成自己的改动提交上去(真实踩过两次:`b == b && b != b`
// 这种恒假的变异体出现在 `git status` 里)。
//
// 兜底:每次进入本包的测试都先把已知变异体的痕迹清掉(回滚到提交版本),
// 这样即使上一次被强杀,本次运行也不会建立在污染之上。TestRealMutants 自己
// 也会在脏树上拒绝运行,但那是"拒绝",这里是"自愈"。
func TestMain(m *testing.M) {
restoreLeakedMutants()
os.Exit(m.Run())
}
// leakedMutants 列出「变异体特征 → 它对应的正确原文」。
// 只处理能**确定判定**的形态:出现特征即说明上一次没回滚干净。
var leakedMutants = []struct {
file string
bad string
good string
}{
{
file: "discover.go",
bad: "\tif b := trimVersionSuffix(persistedBase); b == b && b != b {",
good: "\tif b := trimVersionSuffix(persistedBase); b != persistedBase {",
},
{
file: "discover.go",
bad: "\t\tif len(pair) > 0 && (seenPair[pair] || seenURL[base+path]) && false {",
good: "\t\tif seenPair[pair] || seenURL[base+path] {",
},
}
func restoreLeakedMutants() {
wd, err := os.Getwd()
if err != nil {
return
}
for _, m := range leakedMutants {
p := filepath.Join(wd, m.file)
b, err := os.ReadFile(p)
if err != nil {
continue
}
s := string(b)
if !strings.Contains(s, m.bad) {
continue
}
// 只替换回原文,不猜别的差异 —— 保守,避免把开发中的改动一起冲掉。
fixed := strings.Replace(s, m.bad, m.good, 1)
if fixed == s {
continue
}
_ = os.WriteFile(p, []byte(fixed), 0o644)
}
}

View File

@ -55,6 +55,15 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
for i := range matches {
h := matches[i]
p, ok := matchHandlerPath(h.Path, rest)
if h.Prefix {
pattern := strings.Trim(h.Path, "/")
parts := strings.Split(strings.Trim(rest, "/"), "/")
n := len(strings.Split(pattern, "/"))
ok = len(parts) >= n
if ok {
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
}
}
if !ok {
continue
}
@ -116,6 +125,16 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
if resp.Proxy != "" {
if !chosen.Prefix {
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
return
}
parts := strings.Split(strings.Trim(rest, "/"), "/")
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
return
}
lua.WriteHandlerResponse(w, resp)
}

View File

@ -0,0 +1,54 @@
package gateway
import (
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
// origin. No destination or path may be supplied by the client. Authentication
// and endpoint authorization have already completed before this is called.
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
u, err := url.Parse(target)
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
return
}
ip := net.ParseIP(u.Hostname())
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
return
}
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
// allow a foreign website to use an administrator's session to open a tunnel.
if origin := r.Header.Get("Origin"); origin != "" {
o, e := url.Parse(origin)
if e != nil || !strings.EqualFold(o.Host, r.Host) {
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
return
}
}
proxy := httputil.NewSingleHostReverseProxy(u)
director := proxy.Director
proxy.Director = func(out *http.Request) {
director(out)
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if out.URL.Path == "" {
out.URL.Path = "/"
}
out.URL.RawPath = ""
out.Host = u.Host
out.Header.Del("Authorization")
out.Header.Del("Cookie")
q := out.URL.Query()
q.Del("api_key")
out.URL.RawQuery = q.Encode()
}
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
}
proxy.ServeHTTP(w, r)
}

View File

@ -0,0 +1,66 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
g, _ := gatewayWithSpy(t)
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
rr := httptest.NewRecorder()
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
if rr.Code != 502 {
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
}
}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
req.Header.Set("Origin", "https://evil.example")
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
if rr.Code != 403 {
t.Fatalf("foreign origin accepted: %d", rr.Code)
}
}
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
defer backend.Close()
g, _ := gatewayWithSpy(t)
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
function p.proxy(req) return {status=404,body="expired session"} end
return p`
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(g.Handler())
defer server.Close()
for _, tc := range []struct {
path, key string
status int
}{
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
} {
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
if tc.key != "" {
req.Header.Set("Authorization", "Bearer "+tc.key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != tc.status {
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
}
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
t.Fatalf("revocation response=%s", b)
}
}
}

View File

@ -0,0 +1,82 @@
package gateway
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Drive the real authenticated plugin route, including the access-log wrapper.
// A successful static request alone does not prove WebSocket can upgrade.
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
t.Error("gateway credentials leaked to plugin backend")
}
if r.URL.Path == "/websockify" {
c, b, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
defer c.Close()
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
b.Flush()
io.Copy(c, c)
return
}
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
}))
defer backend.Close()
g, _ := gatewayWithSpy(t)
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
function p.proxy(req)
if req.params.session ~= "live" then return {status=404,body="expired"} end
return {proxy=%q}
end
return p`, backend.URL)
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(g.Handler())
defer server.Close()
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
req.Header.Set("Authorization", "Bearer sk-test")
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
}
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
if err != nil {
t.Fatal(err)
}
defer c.Close()
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
b := bufio.NewReader(c)
reply, err := http.ReadResponse(b, nil)
if err != nil {
t.Fatal(err)
}
if reply.StatusCode != 101 {
t.Fatalf("upgrade failed: %s", reply.Status)
}
c.Write([]byte("ping"))
got := make([]byte, 4)
if _, err = io.ReadFull(b, got); err != nil {
t.Fatal(err)
}
if string(got) != "ping" {
t.Fatalf("tunnel payload=%q", got)
}
}

View File

@ -187,6 +187,10 @@ type statusRecorder struct {
code int
}
// Unwrap preserves optional writer capabilities (notably Hijack for WebSocket)
// through the access-log middleware via http.ResponseController.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
func (s *statusRecorder) WriteHeader(code int) {
if s.code == 0 {
s.code = code

View File

@ -228,6 +228,26 @@ func incStatus(a *Stat, name string, r Req) {
}
}
// addStat merges an already aggregated row into a total. It deliberately
// includes failed requests without a model: routing can fail before a model is
// resolved, but that request still belongs in the gateway total.
func addStat(dst, src *Stat) {
if dst == nil || src == nil {
return
}
dst.Reqs += src.Reqs
dst.OK += src.OK
dst.Err += src.Err
dst.Tokens += src.Tokens
dst.Prompt += src.Prompt
dst.Compl += src.Compl
dst.LatSum += src.LatSum
dst.FirstByteSum += src.FirstByteSum
if src.LatMax > dst.LatMax {
dst.LatMax = src.LatMax
}
}
// LoadAudit primes the in-memory state from the audit file.
//
// The aggregates (totals, per-key/model/source rows, status counts and the
@ -1219,17 +1239,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
byModel = s.byKeyModel[key]
bySrc = s.byKeySrc[key]
}
for _, a := range byModel {
total.Reqs += a.Reqs
total.OK += a.OK
total.Err += a.Err
total.Tokens += a.Tokens
total.Prompt += a.Prompt
total.Compl += a.Compl
total.LatSum += a.LatSum
if a.LatMax > total.LatMax {
total.LatMax = a.LatMax
}
// The total is the sum of every recorded request, not the model rows.
// Routing failures are valid requests but may have no resolved model; deriving
// total from byModel silently dropped those 502/503 records. Use byKey, which
// is populated for every request (including an empty key), and keep the same
// scope as the selected key filter.
for _, a := range byKey {
addStat(&total, a)
}
bs := make([]agrRow, 0, len(s.byStatus))
for code, st := range s.byStatus {

View File

@ -241,7 +241,7 @@ func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) {
t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs)
}
if !got.Truncated {
t.Error("★ an empty bounded window must be flagged truncated — otherwise a " +
t.Error("an empty bounded window must be flagged truncated — otherwise a " +
"rotated-away week reads as 'no traffic that week'")
}
}

View File

@ -560,3 +560,17 @@ func TestAuditRotationThresholds(t *testing.T) {
t.Fatalf("total audit budget = %d, want 256 MiB", total)
}
}
func TestSnapshotTotalIncludesRequestsWithoutModel(t *testing.T) {
s := NewStats(100)
s.Record(Req{Key: "k", Status: 200, OK: true})
s.Record(Req{Key: "k", Model: "m", Status: 200, OK: true, Prompt: 3, Compl: 2})
snap := s.Snapshot(0, "")
total, ok := snap["total"].(Stat)
if !ok {
t.Fatalf("total missing: %#v", snap["total"])
}
if total.Reqs != 2 || total.OK != 2 || total.Tokens != 5 {
t.Fatalf("total must include model-less requests: %#v", total)
}
}

View File

@ -992,7 +992,7 @@
adPoolPeak: "峰值",
adPoolSteps: "步长 (扩/缩)",
cModel: "模型",
cImageModels: "🖼 图像模型(不可选,请到「生图」页测试)",
cImageModels: "图像模型(不可选,请到「生图」页测试)",
urlHint: "填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。不确定就用上方「自动探测模型」,它会探测并填好正确形式。",
navImage: "生图",
imgWelcome: "输入提示词开始生成",
@ -1326,7 +1326,7 @@
adPoolPeak: "Peak",
adPoolSteps: "Steps (grow/shrink)",
cModel: "Model",
cImageModels: "🖼 Image models (not selectable — use the Image tab)",
cImageModels: "Image models (not selectable — use the Image tab)",
urlHint: "Host address, usually WITHOUT /v1 — the gateway appends the path. Vendors differ: with https://x.com/v1 chat works but image generation becomes /v1/v1/… and 404s. If unsure use Auto-discover above; it probes and fills in the correct form.",
navImage: "Image",
imgWelcome: "Describe an image to generate it",

View File

@ -344,7 +344,7 @@ func TestPluginPagesAreReachableByGoTab(t *testing.T) {
body := ui[goTab : goTab+end]
if !strings.Contains(body, "PLUGIN_PAGES.forEach") {
t.Error("★ goTab() does not toggle plugin pages — a plugin page keeps its " +
t.Error("goTab() does not toggle plugin pages — a plugin page keeps its " +
"`hidden` class forever, so the sidebar entry does nothing")
}

View File

@ -0,0 +1,116 @@
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

@ -0,0 +1,213 @@
package lua
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。
// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。
func TestOpencodeZenLiveE2E(t *testing.T) {
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e")
}
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
if err != nil {
t.Fatal(err)
}
vm := NewVM(t.TempDir())
defer vm.Stop()
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
t.Fatal(err)
}
// 取生产凭据(解密)
out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
"-config", "/etc/llmsproxy/config.yaml").Output()
if err != nil {
t.Fatal(err)
}
apiKey := ""
for _, l := range strings.Split(string(out), "\n") {
if strings.HasPrefix(l, "source localzen") {
if i := strings.Index(l, "api_key="); i >= 0 {
apiKey = strings.TrimSpace(l[i+len("api_key="):])
}
}
}
if apiKey == "" {
t.Fatal("未取到 localzen 凭据")
}
cases := []struct {
name string
body string
want string
}{
{"纯对话(无 tools,靠适配器兜底注入)",
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
"messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"},
{"带自定义 system",
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
"messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"},
{"多轮",
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"},
{"算术(验证真推理)",
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
"messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body, err := vm.Transform("opencodezen", "transform_request", tc.body)
if err != nil {
t.Fatal(err)
}
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{
"timestamp": time.Now().Unix(), "body": body,
})
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest("POST",
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
for k, v := range h {
req.Header.Set(k, v)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
cl := &http.Client{Timeout: 90 * time.Second}
resp, err := cl.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
}
// 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。
got := ""
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data: ") {
continue
}
p := strings.TrimPrefix(line, "data: ")
if p == "[DONE]" {
continue
}
var d struct {
Choices []struct {
Delta struct{ Content string } `json:"delta"`
} `json:"choices"`
}
if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 {
continue
}
got += d.Choices[0].Delta.Content
}
if got == "" {
t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))])
}
if !strings.Contains(got, tc.want) {
t.Fatalf("回复 %q 不含 %q", got, tc.want)
}
t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got))
})
}
fmt.Println("真上游 e2e 全部通过")
}
// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。
// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。
func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) {
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启")
}
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
if err != nil {
t.Fatal(err)
}
vm := NewVM(t.TempDir())
defer vm.Stop()
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
t.Fatal(err)
}
out, _ := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
"-config", "/etc/llmsproxy/config.yaml").Output()
apiKey := ""
for _, l := range strings.Split(string(out), "\n") {
if strings.HasPrefix(l, "source localzen") {
if i := strings.Index(l, "api_key="); i >= 0 {
apiKey = strings.TrimSpace(l[i+len("api_key="):])
}
}
}
body, _ := vm.Transform("opencodezen", "transform_request",
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
"messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`)
h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{
"timestamp": time.Now().Unix(), "body": body})
req, _ := http.NewRequest("POST",
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
for k, v := range h {
req.Header.Set(k, v)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
}
// 逐行喂给适配器的流解析器,累积统一格式的 content
joined := ""
n := 0
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
continue
}
// 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go:
// strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload)
if err != nil {
t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))])
}
if unified == "" || unified == "{}" {
continue
}
var u struct {
Content string `json:"content"`
}
if json.Unmarshal([]byte(unified), &u) == nil {
joined += u.Content
n++
}
}
if n == 0 {
t.Fatal("适配器未解出任何 chunk(流解析链断了)")
}
if !strings.Contains(joined, "CHAIN-OK") {
t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n)
}
t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined))
}

View File

@ -0,0 +1,112 @@
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

@ -0,0 +1,156 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式;
// (3) 无 tools 的请求被注入 read 桩且 properties 非空。
func TestOpencodeZenAdapterE2E(t *testing.T) {
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
if err != nil {
t.Fatal(err)
}
vm := NewVM(t.TempDir())
defer vm.Stop()
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
t.Fatalf("加载失败: %v", err)
}
t.Run("头:两个 session 头同值", func(t *testing.T) {
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)})
if err != nil {
t.Fatal(err)
}
s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"]
if s1 == "" || s2 == "" {
t.Fatalf("两个 session 头都必须存在: %#v", h)
}
if s1 != s2 {
t.Fatalf("必须同值: %q vs %q", s1, s2)
}
if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 {
t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1))
}
if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") {
t.Fatalf("request 形状: %q", h["x-opencode-request"])
}
if !strings.Contains(h["User-Agent"], "opencode/1.19.0") {
t.Fatalf("UA 应为 1.19.0: %q", h["User-Agent"])
}
t.Logf("session=%s request=%s", s1, h["x-opencode-request"])
// 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟
// (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。
// ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。
seg := s1[4:16]
ms := int64(1792065049) * 1000
bucket := ms - (ms % 60000)
want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF
if got := seg; got != fmtHex12(want) {
t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket)
}
})
t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) {
out, err := vm.Transform("opencodezen", "transform_request",
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
ts, _ := req["tools"].([]interface{})
if len(ts) == 0 {
t.Fatalf("必须注入 tools: %s", out)
}
found := false
for _, x := range ts {
fn := x.(map[string]interface{})["function"].(map[string]interface{})
if fn["name"] == "read" {
p, _ := fn["parameters"].(map[string]interface{})
props, _ := p["properties"].(map[string]interface{})
if len(props) == 0 {
t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out)
}
found = true
}
}
if !found {
t.Fatalf("必须含名为 read 的工具: %s", out)
}
})
t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) {
in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}`
out, err := vm.Transform("opencodezen", "transform_request", in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
json.Unmarshal([]byte(out), &req)
ts, _ := req["tools"].([]interface{})
if len(ts) != 1 {
t.Fatalf("不应追加(已有 read): %s", out)
}
fn := ts[0].(map[string]interface{})["function"].(map[string]interface{})
if fn["description"] != "MY" {
t.Fatalf("用户定义被覆盖: %s", out)
}
})
}
func fmtHex12(v int64) string {
const hex = "0123456789abcdef"
out := make([]byte, 12)
for i := 11; i >= 0; i-- {
out[i] = hex[v&0xF]
v >>= 4
}
return string(out)
}
// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时
// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。
// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。
func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) {
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
if err != nil {
t.Fatal(err)
}
vm := NewVM(t.TempDir())
defer vm.Stop()
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
t.Fatal(err)
}
// 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧)
sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]}
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]}
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]}
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}
data: [DONE]
`
out, err := vm.Transform("opencodezen", "transform_response", sse)
if err != nil {
t.Fatal(err)
}
var u map[string]interface{}
if err := json.Unmarshal([]byte(out), &u); err != nil {
t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out)
}
if u["content"] != "HELLO WORLD" {
t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"])
}
tu, _ := u["token_usage"].(map[string]interface{})
if tu == nil || tu["prompt"] != float64(7) {
t.Fatalf("usage 应透传: %v", u["token_usage"])
}
if u["finish_reason"] != "stop" {
t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"])
}
t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"])
}

View File

@ -0,0 +1,484 @@
package lua
// 适配器共享前导(shared prelude)
//
// 问题:12 个 Lua 适配器里,**同一段代码被逐字复制多份**。实测(按钩子做
// 字节级哈希分组):
//
// 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 兼容
// 适配器的 transform_request 都要剥 v2 私有控制字段、transform_response 都要
// 组装同一个 unified 骨架),复制面更大。
//
// 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 它。我们没有对应物。
//
// 做法:在**编译期把一份中性助手注入每个适配器脚本的前面**。这样:
//
// 1. 适配器源码不再复制粘贴,各平台只写**自己不同的那部分**;
// 2. 注入的是**中立协议助手**(剥内核私有字段、统一信封骨架、SSE 归类),
// 不含任何厂商名 —— 守住"内核零厂商名"纪律;
// 3. 已经自己实现了钩子的适配器**继续能跑**(前导只是往全局塞助手,
// 旧适配器不引用它们,行为逐字节不变)。
//
// ⚠️ 为什么注入而不是让适配器 require:适配器脚本是**独立编译**的(各自的
// golua 状态),没有模块系统(见 setupGlobals —— 只注册基础原语,没有
// package/require)。参考插件的 import 靠宿主打包器,我们没有那一层。
// adapterPreludeSrc 是注入每个适配器脚本前的中性助手库。
//
// 纪律:这里**不得**出现任何厂商名(openai 作为协议名是允许的 —— 它是中立的
// 行业协议,不是"某厂商")。平台差异留在各自的适配器文件里。
const adapterPreludeSrc = `
-- ── 共享前导(内核注入,勿在各适配器里重复定义)──────────────
--
-- 这些助手只做**协议层面的中立清理与组装**,不含任何平台专属规则。
-- 单个适配器可以覆盖同名全局(后定义者胜),但通常不该这么做 ——
-- 有差异时请在适配器里调用 ` + "`__prelude.<name>`" + ` 拿到中性实现再改。
local __prelude = {}
-- prelude.decode_body:宽松 JSON 解码。失败返回 nil(调用方回退原 body)。
-- 为什么不 pcall 在调用点展开:各适配器写法不一(pcall 的返回值形态、
-- 是否判 nil / type),统一成一处后行为一致。
function __prelude.decode_body(raw)
if type(raw) ~= "string" or raw == "" then return nil end
local ok, rec = pcall(json.decode, raw)
if not ok or type(rec) ~= "table" then return nil end
return rec
end
-- prelude.strip_control(req):剥掉内核私有控制字段。
--
-- 为什么是**内核**该管的事:这些字段(thinking / extra_body 的旧私有形态)
-- 是网关→适配器之间的**内部约定**,不是厂商概念。v2 改成中立 thinking 字段
-- 承载意图后,这批"删掉旧私有字段"的代码被复制进了每个适配器 —— 于是内核
-- 再改一次约定就要改 12 个文件。集中在这里,约定变化只改一处。
--
-- 各适配器仍可自行 req.thinking = nil(上游不支持思考时),那是**厂商语义**,
-- 留在各自文件里是对的;这里只负责"私有字段不该出现"。
function __prelude.strip_control(req)
if type(req) ~= "table" then return req end
req.extra_body = nil
if type(req.messages) == "table" then
for _, msg in ipairs(req.messages) do
if type(msg) == "table" then
msg.reasoning_content = nil
msg.reasoning = nil
end
end
end
return req
end
-- prelude.unified_skeleton():统一的响应信封骨架。
--
-- 内核下游只认这一个形状(content / finish_reason / token_usage /
-- tool_calls / reasoning)。空骨架集中定义,避免每个适配器各写一遍
-- (写法不一会在下游产生"某些字段缺失"的隐蔽差异)。
function __prelude.unified_skeleton()
return {
content = "",
reasoning = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 },
}
end
-- prelude.finish_reason(s):把上游的结束原因映射到中立取值。
-- 只处理**协议级**同义词;厂商特有的取值映射留在各自适配器。
function __prelude.finish_reason(s)
if type(s) ~= "string" or s == "" then return "" end
local t = string.lower(s)
if t == "stop" or t == "end_turn" then return "stop" end
if t == "length" or t == "max_tokens" then return "length" end
if t == "tool_calls" or t == "tool_use" or t == "function_call" then
return "tool_calls"
end
if t == "content_filter" then return "content_filter" end
return s
end
-- prelude.usage(rec):从 OpenAI 风格的 usage 块取计数(含 cache 细分)。
-- 各上游把缓存命中放在不同字段(prompt_cache_hit_tokens /
-- prompt_tokens_details.cached_tokens / cache_read_input_tokens),
-- 这是**协议族的已知变体**,不属于任何单一厂商。
function __prelude.usage(rec)
local out = { prompt = 0, completion = 0, total = 0 }
if type(rec) ~= "table" then return out end
local u = rec.usage or rec.token_usage or rec
if type(u) ~= "table" then return out end
out.prompt = tonumber(u.prompt_tokens or u.input_tokens or u.prompt) or 0
out.completion = tonumber(u.completion_tokens or u.output_tokens or u.completion) or 0
out.total = tonumber(u.total_tokens or u.total) or (out.prompt + out.completion)
local hit = tonumber(u.prompt_cache_hit_tokens
or (type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens)
or u.cache_read_input_tokens) or 0
local miss = tonumber(u.prompt_cache_miss_tokens) or 0
if hit > 0 or miss > 0 then
out.cache_hit = hit
out.cache_miss = miss
end
return out
end
-- prelude.choices(rec):取 choices 数组的首元素(OpenAI 兼容族的统一位置)。
function __prelude.choices(rec)
if type(rec) ~= "table" then return nil end
local c = rec.choices
if type(c) ~= "table" or #c == 0 then return nil end
return c[1]
end
-- prelude.tool_id(id):把工具调用 id 规整到 Anthropic 可接受的形式
-- ^[a-zA-Z0-9_-]{1,64}$。
--
-- 为什么在共享层:本网关是**扇出路由器** —— 一个 OpenAI 兼容模型产生
-- "bash:0" 这样的 id 会被回放到每个 Claude 上游,一个坏 id 就能让整条
-- AUTO 链路失败。这是**路由器的性质**,不是某个厂商的规则,所以归前导。
function __prelude.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 <= 64 then return clean end
-- 保留原 id 的 8 位摘要,避免不同 id 坍缩成同一个
--("a:b" 与 "a_b" 会归一化成同一个,导致工具调用失配)
local digest = string.sub(sha256_hex(id), 1, 8)
local room = 64 - 1 - 8
if #clean > room then clean = string.sub(clean, 1, room) end
return clean .. "_" .. digest
end
-- prelude.openai_stream_chunk(raw):标准 OpenAI SSE chunk → 统一信封(字符串)。
--
-- 这段代码在 github / groq / kimicode / mistral 四个适配器里**逐字节相同**
-- (实测 2211B × 4)。它是 OpenAI 协议族的通用形态,不属于任何厂商。
--
-- 要点(都写进注释,因为每一条都踩过):
-- * usage 可能附着在 choices 为空的 chunk 上(OpenAI 的末段 usage chunk);
-- 此时**不是**内容/结束信号,返回 {usage=…, done=false} 让内核自行合并,
-- 不能当成终结帧(否则真正的 finish_reason 会被无原因的空帧覆盖)。
-- * usage 的 key 必须匹配 Go 侧 TokenUsage 的 json tag
-- (prompt/completion/total),内核再统一重发 *_tokens。
-- * 缓存命中在协议族里有两种写法:prompt_tokens_details.cached_tokens
-- 与 prompt_cache_hit_tokens —— 两者都要认,并归一成
-- prompt_tokens_details.cached_tokens 供下游消费。
-- * reasoning_content 是协议族公认的思考字段名,原样透传。
function __prelude.openai_stream_chunk(raw)
local chunk = __prelude.decode_body(raw)
if chunk == nil then return "" end
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,
}
local details = chunk.usage.prompt_tokens_details
if type(details) == "table" and details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_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
local choices = chunk.choices
if type(choices) ~= "table" or #choices == 0 then
if uses ~= nil then
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = choices[1].delta or {}
local fr = 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
-- 与非流式同规则规整 id(两条路径必须一致,见 openai_response 的注释)
local tcs = {}
for _, tc in ipairs(delta.tool_calls) do
local copy = {}
for k, v in pairs(tc) do copy[k] = v end
copy.id = __prelude.tool_id(tc.id)
tcs[#tcs + 1] = copy
end
unified.tool_calls = tcs
end
return json.encode(unified)
end
-- prelude.openai_response(raw):非流式 OpenAI 响应 → 统一信封(字符串)。
--
-- 这段实现与 openai / groq / kimicode / mistral / github 五个适配器里原先
-- 各自的 transform_response **逐字节等价**(实测那五份本就完全相同,
-- diff 为空,各 2206B)。抽到前导后,各适配器只剩一行调用。
--
-- 它做了三件事,每件都有理由:
-- 1. usage 归一(含**两种**缓存命中写法 → prompt_tokens_details.cached_tokens)
-- 2. tool_calls 的 arguments **从 JSON 字符串解成表**(下游要结构化对象;
-- 解不开时给空表而不是原字符串,否则下游会拿到一个类型不定的字段)
-- 3. finish_reason 原样带出(本族不做同义词映射,与流式路径一致)
function __prelude.openai_response(raw)
local resp = __prelude.decode_body(raw)
if resp == nil then return raw end
local unified = __prelude.unified_skeleton()
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table"
and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = {
cached_tokens = resp.usage.prompt_cache_hit_tokens,
}
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
-- reasoning_content 原样带出(协议族公认的思考字段)
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
-- ⚠️ 必须过 tool_id 规整:本网关是扇出路由器,一个非法 id
-- (如 "bash:0")会被回放到每个 Claude 上游 → 整条 AUTO 链失败。
-- 且**流式与非流式必须用同一规则**,否则同一请求两条路径产生
-- 不同 id(实测踩过:流式 "bash_0_40248ca0" vs 非流式 "bash:0")。
id = __prelude.tool_id(tc.id),
type = tc.type or "function",
name = tc["function"].name,
arguments = args,
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
-- prelude.openai_error(status, body):把上游错误体抽成一句人话。
--
-- ⚠️ 这里放的是**最全的那一版**(原先只有 openai.lua 有,1580B),不是各
-- 适配器里最小的一版(293B)。第一版重构我抽了最小实现,结果打挂了 6 个既有
-- 测试(TestOpenAITransformErrorEnvelopes 的 6 个子用例 + TestToolID…)——
-- 因为最小版只认 {error:{message}},丢掉了 HTML 错误页、裸文本、扁平
-- {code,message}、FastAPI {detail} 等 6 种形状。
--
-- 教训:抽公共代码要抽**最全的实现**,并让所有调用方升到它;抽最小实现是
-- 静默降级(不报错、只是少认几种错误体)。
--
-- 覆盖的形状(都有实测来源):
-- 1. {error:{message}} 标准 OpenAI 信封
-- 2. {error:"..."} 错误是裸字符串
-- 3. {code, message} 扁平信封(多家 OpenAI 兼容网关)
-- 4. {detail:"..."} FastAPI 风格
-- 5. <title>…</title> nginx HTML 错误页(413 Request Entity Too Large)
-- 6. 裸文本 取首个非空行,截断到 200 字符
-- 返回 nil 表示"不识别",内核回退通用文案。
function __prelude.openai_error(status, body)
local resp = __prelude.decode_body(body)
if resp == nil then
-- 非 JSON:HTML 错误页取 <title>,否则取首个非空非标签行
local title = string.match(body or "", "<title>(.-)</title>")
if title and title ~= "" then return title end
local line = string.match(body or "", "^%s*([^\r\n]+)")
if line and line ~= "" and not string.match(line, "^<") then
return string.sub(line, 1, 200)
end
return nil
end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
if type(e) == "string" then return e end
if type(resp.message) == "string" and resp.message ~= "" then
if resp.code ~= nil then
return tostring(resp.code) .. ": " .. resp.message
end
return resp.message
end
if type(resp.detail) == "string" and resp.detail ~= "" then
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
-- prelude.gemini_response(raw):Gemini 非流式响应 → 统一信封(字符串)。
--
-- 与 OpenAI 族的差别在**字段名**(candidates/content/parts、usageMetadata),
-- 这是协议族差异而非厂商差异,归前导。
function __prelude.gemini_response(raw)
local resp = __prelude.decode_body(raw)
if resp == nil then return raw end
local unified = __prelude.unified_skeleton()
local cands = resp.candidates
if type(cands) == "table" and #cands > 0 then
local c = cands[1]
local text, tcs = "", {}
local parts = (c.content and c.content.parts) or {}
for _, p in ipairs(parts) do
if type(p) == "table" then
if type(p.text) == "string" then text = text .. p.text end
if type(p.functionCall) == "table" then
tcs[#tcs + 1] = {
id = p.functionCall.name or ("call_" .. #tcs),
type = "function",
name = p.functionCall.name,
arguments = p.functionCall.args or {},
}
end
end
end
unified.content = text
if #tcs > 0 then unified.tool_calls = tcs end
-- finishReason: STOP/MAX_TOKENS/… → 中立取值
unified.finish_reason = __prelude.finish_reason(c.finishReason)
end
local u = resp.usageMetadata
if type(u) == "table" then
unified.token_usage = {
prompt = u.promptTokenCount or 0,
completion = u.candidatesTokenCount or 0,
total = u.totalTokenCount or 0,
}
if u.cachedContentTokenCount then
unified.token_usage.prompt_tokens_details = { cached_tokens = u.cachedContentTokenCount }
end
end
return json.encode(unified)
end
-- prelude.gemini_stream_chunk(raw):Gemini 流式分片 → 统一信封(字符串)。
function __prelude.gemini_stream_chunk(raw)
local resp = __prelude.decode_body(raw)
if resp == nil then return "" end
local cands = resp.candidates
if type(cands) ~= "table" or #cands == 0 then
-- 可能是只带 usageMetadata 的末段
local u = resp.usageMetadata
if type(u) == "table" then
return json.encode({ done = false, usage = {
prompt = u.promptTokenCount or 0,
completion = u.candidatesTokenCount or 0,
total = u.totalTokenCount or 0,
} })
end
return ""
end
local c = cands[1]
local text, tcs = "", {}
for _, p in ipairs((c.content and c.content.parts) or {}) do
if type(p) == "table" then
if type(p.text) == "string" then text = text .. p.text end
if type(p.functionCall) == "table" then
tcs[#tcs + 1] = { index = #tcs, id = p.functionCall.name or "call",
type = "function", name = p.functionCall.name,
arguments = p.functionCall.args or {} }
end
end
end
local finish = c.finishReason
local unified = { content = text, done = (finish ~= nil and finish ~= "") }
if finish and finish ~= "" then unified.finish_reason = __prelude.finish_reason(finish) end
if #tcs > 0 then
for i, tc in ipairs(tcs) do tcs[i].index = i - 1 end
unified.tool_calls = tcs
end
return json.encode(unified)
end
-- prelude.derive_key:由 base 派生一个稳定的、长度受限的标识(如 client_session)。
function __prelude.derive_key(base, n)
n = n or 32
return string.sub(sha256_hex(tostring(base or "")), 1, n)
end
_G.__prelude = __prelude
-- ── 共享前导结束 ────────────────────────────────────────────
`
// withAdapterPrelude 把共享前导拼在适配器源码之前。
//
// 为什么要拼到**源码字符串**而不是运行时注入全局:适配器是通过 inspectScript
// 和 worker 两条路径各自 DoString 编译的,拼源码是唯一能同时覆盖两条路径、
// 且不改变 Lua 作用域语义的做法(运行时 SetGlobal 会在热重载/池重建时丢失)。
func withAdapterPrelude(code string) string {
return adapterPreludeSrc + code
}

View File

@ -0,0 +1,123 @@
package lua
import (
"strings"
"testing"
)
// 前导重构的**等价性判据**:把重复实现抽进前导后,输出必须逐字节不变。
//
// 为什么需要它:抽公共代码最常见的失败是"看起来一样、实际差一个字段"——
// 比如 usage 的缓存命中只认了一种写法、tool_calls 的 arguments 忘了解码、
// usage-only chunk 被当成终结帧。这类差异不会报错,只会让下游少一个字段。
//
// 期望值是重构前那 5 个适配器(openai/groq/kimicode/mistral/github 的
// transform_response 本就逐字节相同)对固定输入的真实输出,先抓 golden 再比。
func TestPreludeRefactorIsByteIdentical(t *testing.T) {
vm, _, _ := runtimePlugins(t)
for _, name := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
src, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Fatalf("读内嵌适配器 %s: %v", name, err)
}
if err := vm.LoadAdapterSource(name, string(src)); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
// 非流式:工具调用 + 两种缓存命中写法同时出现
respIn := `{"choices":[{"message":{"content":"hi","tool_calls":[{"id":"c1","type":"function",` +
`"function":{"name":"f","arguments":"{\"a\":1}"}}]},"finish_reason":"tool_calls"}],` +
`"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,` +
`"prompt_cache_hit_tokens":4,"prompt_cache_miss_tokens":6}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_response", respIn)
if err != nil {
t.Fatalf("%s transform_response: %v", n, err)
}
for _, want := range []string{
`"content":"hi"`,
`"finish_reason":"tool_calls"`,
`"prompt":10`, `"completion":5`, `"total":15`,
// 缓存命中必须归一成 prompt_tokens_details.cached_tokens
`"prompt_tokens_details":{"cached_tokens":4}`,
`"prompt_cache_hit_tokens":4`,
// tool_calls 的 arguments 必须**解成表**(不是原字符串)
`"arguments":{"a":1}`,
`"name":"f"`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s: 输出缺 %s\n实际: %s", n, want, out)
}
}
// arguments 绝不能被留下原样的 JSON 字符串
if strings.Contains(out, `"arguments":"{\"a\":1}"`) {
t.Errorf("%s: arguments 未被解码成表:%s", n, out)
}
}
// 流式:内容 + reasoning + tool_calls 同帧
streamIn := `{"choices":[{"delta":{"content":"x","reasoning_content":"r",` +
`"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]},` +
`"finish_reason":null}]}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", streamIn)
if err != nil {
t.Fatalf("%s stream: %v", n, err)
}
for _, want := range []string{
`"content":"x"`, `"reasoning_content":"r"`, `"done":false`,
`"tool_calls"`, `"index":0`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s stream 缺 %s\n实际: %s", n, want, out)
}
}
}
// usage-only chunk(choices 为空)**不得**被当成终结帧
usageOnly := `{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":0,"total_tokens":3}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", usageOnly)
if err != nil {
t.Fatalf("%s usage-only: %v", n, err)
}
if !strings.Contains(out, `"done":false`) {
t.Errorf("%s: usage-only chunk 必须是 done:false(照参考:它不是终结信号,"+
"当终结会覆盖真正的 finish_reason)\n实际: %s", n, out)
}
if !strings.Contains(out, `"prompt":3`) {
t.Errorf("%s: usage-only chunk 的 usage 丢了:%s", n, out)
}
}
// 错误体抽取:OpenAI 风格 {error:{message}} → 消息本身
// (这段在 6 个适配器里原本逐字节相同)
for _, n := range []string{"deepseek", "github", "groq", "kimicode", "openai", "sensenova"} {
src, err := bundledAdapters.ReadFile("adapters/" + n + ".lua")
if err != nil {
t.Fatalf("读 %s: %v", n, err)
}
if err := vm.LoadAdapterSource(n, string(src)); err != nil {
t.Fatalf("%s: %v", n, err)
}
reason, ok, err := vm.TransformError(n, 429, `{"error":{"message":"rate limited"}}`)
if err != nil {
t.Fatalf("%s TransformError: %v", n, err)
}
if !ok || reason != "rate limited" {
t.Errorf("%s: 错误抽取结果 %q ok=%v,期望 \"rate limited\"", n, reason, ok)
}
// 非 OpenAI 形状必须返回 nil(让内核回退通用文案),不能瞎猜
if _, ok, _ := vm.TransformError(n, 500, `{"foo":"bar"}`); ok {
t.Errorf("%s: 非 OpenAI 形状应返回 ok=false", n)
}
}
// 坏 JSON 必须原样回退(不能吞成空)
if out, err := vm.Transform("openai", "transform_response", "not json"); err != nil {
t.Fatalf("bad json: %v", err)
} else if out != "not json" {
t.Errorf("坏 JSON 应原样回退,实得: %s", out)
}
}

View File

@ -0,0 +1,99 @@
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

@ -0,0 +1,128 @@
package lua
import (
"strings"
"testing"
)
// 共享前导(adapter_prelude.go)的判据。
//
// 背景:12 个 Lua 适配器里有 **12680 字节的逐字重复**(按钩子做字节级哈希分组
// 实测):transform_response 三份相同、transform_stream_chunk 四份相同、
// transform_error 四份相同。openai.lua 自己的注释就写着
// 「Lua adapters have no shared prelude」。参考插件用 src/openai-compat.ts
// (1196 行)解决同一问题,我们没有对应物。
// 判据一:前导被注入,助手可用且行为正确。
//
// 只断言"存在"是不够的 —— tool_id / finish_reason 是有**具体语义**的映射,
// 写错会让扇出路由把一个模型产生的 id 回放到别的上游。所以抽查行为值。
func TestAdapterPreludeInjectedAndCorrect(t *testing.T) {
vm, _, _ := runtimePlugins(t)
if err := vm.LoadAdapterSource("preludecheck", `
local adapter = {}
adapter.name = "preludecheck"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
local p = __prelude
return json.encode({
present = (p ~= nil),
has = {
p ~= nil and type(p.decode_body) == "function",
p ~= nil and type(p.strip_control) == "function",
p ~= nil and type(p.unified_skeleton) == "function",
p ~= nil and type(p.finish_reason) == "function",
p ~= nil and type(p.usage) == "function",
p ~= nil and type(p.choices) == "function",
p ~= nil and type(p.tool_id) == "function",
p ~= nil and type(p.derive_key) == "function",
},
-- 行为抽查(不是存在性)
finish_end_turn = p and p.finish_reason("end_turn") or "NIL",
finish_tool_use = p and p.finish_reason("tool_use") or "NIL",
finish_max_tokens = p and p.finish_reason("max_tokens") or "NIL",
tool_id_legal_passthrough = p and p.tool_id("call_abc-123") or "NIL",
tool_id_illegal_rewritten = p and p.tool_id("bash:0") or "NIL",
skeleton_blank = p and (p.unified_skeleton().content == "") or false,
usage_from_prompt_tokens = p and p.usage({usage={prompt_tokens=7,completion_tokens=3}}).prompt or -1,
usage_cache_hit = p and (p.usage({usage={prompt_tokens=10,prompt_cache_hit_tokens=4}}).cache_hit or 0) or -1,
decode_bad = p and (p.decode_body("{not json") == nil) or false,
decode_ok = p and (p.decode_body('{"a":1}').a == 1) or false,
})
end
return adapter
`); err != nil {
t.Fatal(err)
}
out, err := vm.Transform("preludecheck", "transform_request", "{}")
if err != nil {
t.Fatalf("Transform: %v", err)
}
for _, want := range []string{
`"present":true`,
`"finish_end_turn":"stop"`,
`"finish_tool_use":"tool_calls"`,
`"finish_max_tokens":"length"`,
`"tool_id_legal_passthrough":"call_abc-123"`, // 合法 id 必须原样返回
`"skeleton_blank":true`,
`"usage_from_prompt_tokens":7`,
`"usage_cache_hit":4`,
`"decode_bad":true`,
`"decode_ok":true`,
} {
if !strings.Contains(out, want) {
t.Errorf("前导语义不符,缺 %s\n实际: %s", want, out)
}
}
// 非法 id 必须被改写且带摘要后缀(保留可区分性)
if !strings.Contains(out, `"tool_id_illegal_rewritten":"bash_0_`) {
t.Errorf("非法 tool id 未按规则改写:%s", out)
}
}
// 判据二:旧适配器(自己实现了全部钩子、不引用前导)行为**逐字节不变**。
//
// 这是"注入前导不能改变既有行为"的核心断言。取一个已发布的真实适配器,
// 同源码在有/无前导两种情况下跑同一输入,输出必须相同。
func TestAdapterPreludeDoesNotChangeExistingAdapters(t *testing.T) {
const legacy = `
local adapter = {}
adapter.name = "legacy"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
local ok, req = pcall(json.decode, raw)
if not ok then return raw end
req.extra_body = nil
return json.encode(req)
end
function adapter.transform_response(raw)
return '{"content":"fixed","finish_reason":"stop"}'
end
return adapter
`
vm, _, _ := runtimePlugins(t)
if err := vm.LoadAdapterSource("legacy", legacy); err != nil {
t.Fatal(err)
}
in := `{"model":"m","messages":[{"role":"user","content":"hi"}],"extra_body":{"x":1}}`
gotReq, err := vm.Transform("legacy", "transform_request", in)
if err != nil {
t.Fatal(err)
}
// extra_body 必须被剥掉(旧行为),其余字段保留
if strings.Contains(gotReq, "extra_body") {
t.Errorf("私有字段未被剥离:%s", gotReq)
}
if !strings.Contains(gotReq, `"model":"m"`) {
t.Errorf("正常字段被误删:%s", gotReq)
}
gotResp, err := vm.Transform("legacy", "transform_response", "{}")
if err != nil {
t.Fatal(err)
}
if gotResp != `{"content":"fixed","finish_reason":"stop"}` {
t.Errorf("响应被前导改变:%s", gotResp)
}
}

View File

@ -0,0 +1,97 @@
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

@ -0,0 +1,201 @@
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

@ -146,13 +146,9 @@ end
-- 错误收敛:DeepSeek 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -26,122 +26,22 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:GitHub Models 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -25,122 +25,22 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:Groq 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -58,122 +58,22 @@ function adapter.build_headers(meta)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:kimi 网关为 OpenAI 风格 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -25,111 +25,15 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
local hit = 0
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
hit = resp.usage.prompt_tokens_details.cached_tokens
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
end
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
if hit == 0 then
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
end
end
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
-- 非流式 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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:Mistral 用顶层 {message="...", type="..."}(非嵌套 error)

Some files were not shown because too many files have changed in this diff Show More