Compare commits

...

9 Commits

Author SHA1 Message Date
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
16 changed files with 1453 additions and 82 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"
@ -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" \

View File

@ -489,3 +489,52 @@ error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了
**未实现的部分集中在两类**:(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

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

@ -106,9 +106,18 @@ func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
}
return ""
}
// auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的)
if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") {
t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln)
// 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"} {

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

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

@ -27,7 +27,19 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
const ghost = "999999" // 必然不存在的 PID
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
ghosts := []int{91, 92, 93}
//
// ⚠️ 号段必须**跟着测试台的 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 {
@ -38,8 +50,8 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
}
defer os.Remove(l)
}
lock := "/tmp/.X91-lock"
sock := "/tmp/.X11-unix/X91"
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
c := kernelTestCore(t)
ps := c.Plugins()

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"),

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

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

@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
@ -307,11 +307,24 @@ local PLATFORMS = {
auth = "bearer",
parse = "cline_merge",
},
adapter = "openai",
-- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费)
-- Cline 的 429 把等待时长写在**英文句子**里(`Try again in 19h 39m`),
-- 既无 retry-after 头也无绝对时刻。通用适配器解析不出 ⇒ 内核按 30 秒
-- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。
-- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。
adapter = "channels-cline",
-- 命名约定:`cline-free/*` 是**免费通道**的独立 id(照参考
-- cline-models.ts 的 isFree 兜底规则)。远端 free 数组未响应时靠它判免费。
free_prefix = "cline-free/",
-- 这 3 个来自远端 free 数组(内嵌目录里没有),**它们是免费的**
-- (此前误放在 models 里 ⇒ 被标成收费,与注释自相矛盾)。
models = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
-- 显式声明免费集合:这是**兜底**(远端 free 数组是权威,但可能拉不到)。
-- 三者都在这里 ⇒ 即使远端抖动也不会把免费模型误标成收费。
models_free = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
},
-- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。
-- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed)
credit_unit = "dollars",
@ -675,6 +688,9 @@ local PLATFORMS = {
adapter = "openai",
-- models.dev 的键名(能力元数据的来源;照参考 opencode-capability)
capability_key = "opencode",
-- 该平台没有自己的免费名单,models.dev 的 cost **就是**唯一权威
-- (照参考 opencode-capability.ts)。故本地 models_free 只作兜底。
free_source = "capability",
-- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义
-- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。
models = {
@ -762,7 +778,10 @@ local PLATFORMS = {
},
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
adapter = "openai",
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
adapter = "channels-qoder",
-- 兜底目录(照参考 QODER_FALLBACK_MODELS 的真实目录 key)。
-- 关键:这些是**上游目录 key**(如 qfmodel),不是通用名 —— 公开的
-- /model/v1/chat/completions 不认它们,必须走加密端点 agent_chat_generation。
@ -1200,14 +1219,21 @@ end
-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth,
-- 错值会回退到旧 ticket 流程)
-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
local CODEARTS_PORTAL_AUTHORIZE = "https://codearts.huaweicloud.com/portal/authorize"
local CODEARTS_STS_TOKEN = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"
local CODEARTS_CLIENT_ID = "codearts-agent"
local CODEARTS_REDIRECT_PATH = "/oauth/callback"
local CODEARTS_LOGIN_PLUGIN_NAME = "snap_AIIDE"
local CODEARTS_LOGIN_PLUGIN_VERSION = "5.2.0"
local CODEARTS_OAUTH_THEME = "2"
local CODEARTS_OAUTH_LOCALE = "zh-cn"
-- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。
-- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让
-- 整个插件**加载失败**(`too many local variables`);报错在编译期,
-- 运行期表现是"插件所有路由变成 404",毫无指向性。2026-10-14 实测踩到。
-- 往本文件加顶层 local 前,务必先 `luac -p` 验证。
local CODEARTS = {
portal_authorize = "https://codearts.huaweicloud.com/portal/authorize",
sts_token = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens",
client_id = "codearts-agent",
redirect_path = "/oauth/callback",
login_plugin_name = "snap_AIIDE",
login_plugin_version = "5.2.0",
oauth_theme = "2",
oauth_locale = "zh-cn",
}
-- ES256 JWS 签名(照参考 signDpopJws):
-- header = {alg:"ES256", typ:"dpop+jwt", jwk:{kty,crv,x,y}}
@ -1351,14 +1377,14 @@ end
-- `ExpiredRefreshToken`;**不认 InvalidDPoPHeader**(那是一次请求层面的拒绝,
-- 时钟偏差/重放/抖动都会触发,当终态会把完好账号一步标死)。
local function codeartsRequestToken(body, key)
local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS_STS_TOKEN)
local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS.sts_token)
if not dpop then return nil, derr end
local parts = {}
for k, v in pairs(body) do
parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v)
end
local resp, err = __llmsproxy_http({
url = CODEARTS_STS_TOKEN, method = "POST",
url = CODEARTS.sts_token, method = "POST",
headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" },
body = table.concat(parts, "&"),
})
@ -1400,17 +1426,17 @@ end
-- 多余的回调地址会被视为异常并回退旧流程。
-- 3. 端口必须 ≥10000。
local function buildCodeartsAuthorizeUrl(port, pkce, ticketId)
return CODEARTS_PORTAL_AUTHORIZE
.. "?theme=" .. CODEARTS_OAUTH_THEME
.. "&locale=" .. CODEARTS_OAUTH_LOCALE
.. "&uri_scheme=" .. CODEARTS_CLIENT_ID
.. "&client_id=" .. CODEARTS_CLIENT_ID
return CODEARTS.portal_authorize
.. "?theme=" .. CODEARTS.oauth_theme
.. "&locale=" .. CODEARTS.oauth_locale
.. "&uri_scheme=" .. CODEARTS.client_id
.. "&client_id=" .. CODEARTS.client_id
.. "&port=" .. tostring(port)
.. "&code_challenge=" .. pkce.challenge
.. "&code_challenge_method=SHA-256"
.. "&ticket_id=" .. rfc3986Escape(ticketId)
.. "&plugin-name=" .. CODEARTS_LOGIN_PLUGIN_NAME
.. "&plugin-version=" .. CODEARTS_LOGIN_PLUGIN_VERSION
.. "&plugin-name=" .. CODEARTS.login_plugin_name
.. "&plugin-version=" .. CODEARTS.login_plugin_version
end
-- codeartsTicketPoll(ticketId, secret):旧流程回退时轮询 ticket 换凭据。
@ -1643,11 +1669,22 @@ local function qoderClaim(plat, tok, args)
end
if claimedBefore then
if not refreshed then
return { done = true, kind = "inactive",
-- ★ covers_today = false:这次看到的是**刷新前那一轮**的痕迹,
-- 因此**不得**据此认定"今天已处理"。
--
-- 参考的真实缺陷(2026-10-02,每天静默漏领 100 积分):上游 10:00(UTC+8)
-- 才刷新活动,而自动签到在上午跑 —— 上午那次看到的是**昨天**那条 CLAIMED。
-- 若拿它记账,当天 10 点刷新后就再也不会去领。
--
-- 修法不靠"给每个渠道配刷新时刻表"(必然漂移),而是**让产出方自己
-- 回答**"我这条结果覆盖的是不是今天这一轮";记账方只数
-- "有几条能证明今天已被处理",完全不需要知道任何渠道的日界细节。
return { done = true, kind = "inactive", covers_today = false,
message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新),"
.. "当前看到的是昨天那一轮,请稍后再来领取。" }
end
return { done = true, kind = "already-claimed", message = "今天已领取" }
return { done = true, kind = "already-claimed", covers_today = true,
message = "今天已领取" }
end
-- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通)
local usage = qoderUsageRaw(plat, cred)
@ -2433,6 +2470,9 @@ local PERSIST_KEYS = {
"deadModels",
-- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。
"ttlCache",
-- 自动领取的"今天已覆盖"记账。必须持久化:不落盘的话每次重启都会
-- 把当天已领的再领一遍(上游幂等,但会产生无谓请求与日志噪音)。
"claimCovered",
-- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的:
-- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。
"capabilities", "capabilitiesMeta",
@ -2993,9 +3033,15 @@ local function effectiveModels(provider)
return kept
end
-- remoteFreeSets:provider → { [modelId]=true },来自远端**权威** free 数组。
-- 照参考:`recommended-models` 的 free 数组是唯一权威的免费集合,
-- `/models` 的 id 列表里根本没有 cline-free/*。
-- 只收 id 不记"它免费"会丢掉这层信息 ⇒ 免费模型被标成收费。
remoteFreeSets = remoteFreeSets or {}
-- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。
-- 每个分支的字段路径都照参考实现,含注释点名的坑。
local function parseRemoteModels(kind, body)
local function parseRemoteModels(kind, body, provider)
local ok, rec = pcall(json.decode, body or "")
if not ok or type(rec) ~= "table" then return {} end
local out = {}
@ -3050,6 +3096,19 @@ local function parseRemoteModels(kind, body)
-- free 数组优先在前(它含 cline-free/* 与 stealth/*)
pushFrom(rec.free)
pushFrom(rec)
-- ★ 同时把 `free` 数组记成**权威免费集合**(照参考 cline-models.ts:
-- `recommended-models` 的 free 数组是唯一权威来源)。
-- 只收 id 不记"它免费"会丢掉这层信息 —— 表现为免费模型被标成收费,
-- 用户不知道哪个可以不花钱用。
if type(rec.free) == "table" then
local fr = {}
for _, m in ipairs(rec.free) do
local id = type(m) == "table" and m.id or (type(m) == "string" and m)
if type(id) == "string" and id ~= "" then fr[id] = true end
end
remoteFreeSets = remoteFreeSets or {}
remoteFreeSets[provider] = fr
end
end
return out
@ -3093,7 +3152,7 @@ local function refreshRemoteModels(provider)
if err or not resp or resp.status ~= 200 then
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
else
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body)) do
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
if not seen[id] then seen[id] = true merged[#merged+1] = id end
end
end
@ -3262,6 +3321,68 @@ local function sessions()
return plugin.state.sessions
end
-- browserUtil:浏览器栈的**小助手**。挂在**表**上而不是 `local function` ——
-- 本文件顶层 local 顶到 Lua 的 200 个上限,多一个就让整个插件加载失败
-- (见 CODEARTS 常量处那段说明)。
local browserUtil = {}
-- browserUtil.pathExists(p):文件/目录是否存在。
-- ⚠️ 不能用 io.open 判"存在":X socket 是 **socket 文件**,io.open 打不开它,
-- 拿它当判据会永远等不到(本文件在选显示器号处也踩过同一个坑)。
function browserUtil.pathExists(p)
local f = io.open(p, "rb")
if f then f:close() return true end
-- io.open 失败时退到 stat(socket/设备文件也能判到)
local h = io.popen(("test -e %s && echo y"):format("'" .. tostring(p) .. "'"))
local r = h and h:read("*l")
if h then h:close() end
return r == "y"
end
-- browserUtil.readTail(path, max):读日志尾部,把启动失败的**真因**带给用户
-- (而不是一句"启动失败"让运维去猜)。
function browserUtil.readTail(path, max)
local f = io.open(path, "r")
if not f then return "(无日志)" end
local txt = f:read("*a") or ""
f:close()
local keep = {}
for line in txt:gmatch("[^\n]+") do
if not line:find("dbus", 1, true) and not line:find("crashpad", 1, true)
and not line:find("NameHasOwner", 1, true) and not line:find("UPower", 1, true) then
keep[#keep + 1] = line
end
end
local joined = table.concat(keep, " | ")
local lim = max or 400
if #joined > lim then joined = joined:sub(-lim) end
return joined
end
-- browserUtil.cdpAlive(port):CDP HTTP 端点是否**真的应答**(端口在听 ≠ 服务可用)。
function browserUtil.cdpAlive(port)
for _ = 1, 10 do
local h = io.popen(("curl -s --max-time 2 http://127.0.0.1:%d/json/version 2>/dev/null"):format(port))
local out = h and h:read("*a") or ""
if h then h:close() end
if out and out:find("webSocketDebuggerUrl", 1, true) then return true end
os.execute("sleep 0.5")
end
return false
end
-- ⚠️ **前向声明**(Lua 的 local 只从定义处向后可见)。
--
-- 下面这三个助手定义在**更后面**(本文件按"会话 → 浏览器栈 → loopback 接收器"
-- 的阅读顺序组织),但 sweepSessions 要用它们。不声明的话是:
-- `attempt to call global 'reapLoopback' (a nil value)`
-- **加载时不报错**,只有真正走到那条分支才炸 —— 所以第一次登录正常,
-- 第二次(有会话过期需回收时)才 500。这是本文件第 12 次踩前向引用。
--
-- 实测后果:重复发起登录直接 500,且**已经起来的浏览器栈不被回收**
-- (reapBrowser 也调不到),进程与端口持续泄漏。
local reapLoopback, browserStack, reapBrowser
-- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做)
local function sweepSessions()
local now = os.time and os.time() or 0
@ -3298,7 +3419,7 @@ end
-- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token,
-- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。
local function browserStack()
browserStack = function()
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
return plugin.state.browser
end
@ -3366,7 +3487,7 @@ local function killProcsMatching(needle)
return #victims
end
local function reapBrowser()
reapBrowser = function()
local b = browserStack()
local sess = b.session
local disp = b.display or (sess and sess.display)
@ -3417,6 +3538,9 @@ local function reapBrowser()
if b.profile then
os.execute("rm -rf " .. b.profile .. " 2>/dev/null")
end b.profile = nil
if b.home then
os.execute("rm -rf " .. b.home .. " 2>/dev/null")
end b.home = nil
b.display = nil
b.pids = {}
b.session = nil
@ -3464,6 +3588,23 @@ end
-- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会
-- 让 Xvfb 报一句难以归因的 “already active for display N”。
local function pickFreeX(start, span)
-- 起始号可覆盖:`LLMSPROXY_XDISPLAY_BASE` env 或 settings.xdisplay_base。
--
-- ⚠️ 为什么需要它(真实事故 2026-10-15):生产 unit 带 `PrivateTmp=yes`,
-- 它起的 Xvfb :91 的锁文件(/tmp/.X91-lock)在**服务自己的 /tmp** 里 ——
-- 宿主上的测试**看不到**它,于是 xdisplayBusy(91) 判"空闲"、测试也去抢 91,
-- 结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`。测试与生产在同一个
-- 显示器号上互相踩(而 pickFreeX 的判据本身是对的,它只是看不到另一个 /tmp)。
--
-- 故测试用高段号(如 300+)与生产错开;生产留空即用默认 91。
local base = nil
local env = os.getenv and os.getenv("LLMSPROXY_XDISPLAY_BASE")
if type(env) == "string" and env ~= "" then base = tonumber(env) end
if not base then
local st = plugin.state and plugin.state.settings
if type(st) == "table" then base = tonumber(st.xdisplay_base) end
end
if base then start = base end
for n = start, start + span do
if not xdisplayBusy(n) then return n end
end
@ -3828,7 +3969,7 @@ end
-- reapLoopback 杀掉某会话的接收器进程并清文件(无需常驻,用完即收)。
-- 必须定义在 loopbackReceiver **之前**:Lua 的 local 作用域只覆盖其后的代码,
-- 反过来写会让 loopbackReceiver 里的调用拿到 nil(真实踩过的坑)。
local function reapLoopback(sessionId)
reapLoopback = function(sessionId)
local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId]
if not l then return end
if l.pid and l.pid ~= "" then
@ -3984,6 +4125,11 @@ local function spawnBrowser(start_url)
end
return true
end
-- 端口**不需要**覆盖点:端口是全局资源,`portFree` 直接查 /proc/net/tcp,
-- 所以它能看见生产占的 59100/60100/61100(这与显示器号不同 —— 那个靠
-- /tmp/.X<n>-lock,而生产 PrivateTmp 下的锁宿主看不到)。
-- ⚠️ 我一度在这里加过 port_base 偏移,结果把端口推到 66xxx 反而起不来 ——
-- 端口不是"号段",偏移没有意义。
local vport = pickFree(59100, portFree)
local wport = pickFree(60100, portFree)
local cdpPort = pickFree(61100, portFree)
@ -4026,19 +4172,49 @@ local function spawnBrowser(start_url)
-- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录,
-- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。
local profileDir = "/tmp/llmsproxy-ch-" .. tag
os.execute("rm -rf " .. profileDir .. " 2>/dev/null")
-- ⚠️⚠️ 真实事故(2026-10-14,VNC 一直白屏的真因)。
--
-- 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
-- **独立 GPU 进程**启动所需的系统调用。它起不来 → chromium 直接放弃:
-- ERROR:content/browser/gpu/gpu_process_host.cc:1124]
-- GPU process launch failed: error_code=1002
-- FATAL:content/browser/gpu/gpu_data_manager_impl_private.cc:417]
-- GPU process isn't usable. Goodbye.
-- 整个 chromium 在几秒内退出 ⇒ **VNC 上永远白屏**。
--
-- 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功(我为此绕了很久,
-- 先后怀疑过 profile 锁、/tmp 满、PrivateTmp、可写 HOME —— 全是错的)。
-- 定位靠的是**用 systemd-run 逐 directive 二分**:只有
-- `SystemCallFilter=@system-service` 与 `ProtectSystem=strict` 能让它死,
-- 其余加固项均无影响。
--
-- 修法:`--in-process-gpu` 让 GPU 逻辑跑在浏览器进程内(不 fork 独立进程),
-- 于是不再触碰被 seccomp 挡掉的那条路径。**实测在完整生产沙箱下
-- ALIVE=1 / 窗口=4 / 无 FATAL**。修在插件层,unit 一行不动 ——
-- 加固不该为了一个浏览器栈而放松。
--
-- 顺带保留可写的 HOME/XDG(下面几行):生产 `ProtectHome=yes` 下 /root 只读,
-- chromium 建 dconf/ibus 目录会刷一堆 permission denied。它**不是**这次白屏的
-- 原因(我起初误判为真因),但不给可写目录会持续产生噪声日志。
local homeDir = "/tmp/llmsproxy-bhome-" .. tag
os.execute("rm -rf " .. profileDir .. " " .. homeDir .. " 2>/dev/null")
os.execute("mkdir -p " .. homeDir .. "/.cache " .. homeDir .. "/.config " .. homeDir .. "/.local/share 2>/dev/null")
-- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀
-- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。
-- 只清 6 小时前的,不会碰到别的会话正在用的。
os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null")
-- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。
browserStack().profile = profileDir
browserStack().home = homeDir
local steps = {
{ "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 },
{ "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
.. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* "
{ "chromium", ("HOME=%s XDG_CACHE_HOME=%s/.cache XDG_CONFIG_HOME=%s/.config "
.. "XDG_DATA_HOME=%s/.local/share "
.. "%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s "
.. "--no-first-run --disable-gpu --in-process-gpu --remote-debugging-port=%d --remote-allow-origins=* "
.. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format(
chromium, profileDir, cdpPort, disp, url, tag), "127.0.0.1", cdpPort, 15000 },
homeDir, homeDir, homeDir, homeDir,
chromium, profileDir, cdpPort, disp, start_url, tag), "127.0.0.1", cdpPort, 15000 },
{ "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet "
.. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format(
disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 },
@ -4075,6 +4251,23 @@ local function spawnBrowser(start_url)
end
end
-- ★ 存活探针:端口"能连上"**不等于**浏览器还活着。
-- 真实事故:chromium 因 GPU 自杀时,CDP 端口在它退出前确实短暂监听过 ⇒
-- waitPort 通过 ⇒ 我们报"已就绪",运维打开 VNC 却是白屏。**谎报"就绪"比
-- 直接报错更坏**:运维会以为是自己操作的问题。
-- 判据必须咬**外部效应**(进程仍在 + CDP 真能应答),而不是"我们发过启动命令"。
os.execute("sleep 1")
local cpid = pids[2]
if cpid and cpid ~= "" and not browserUtil.pathExists("/proc/" .. cpid) then
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
reapBrowser()
return nil, "浏览器启动后即退出(多半是 GPU/权限问题):" .. tostring(why)
end
if not browserUtil.cdpAlive(cdpPort) then
local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400)
reapBrowser()
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
end
b.pids = pids
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
@ -4611,11 +4804,11 @@ function plugin.poll_login(req)
elseif code ~= "" and ca.pkce and ca.key then
local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0
local token, terr = codeartsRequestToken({
client_id = CODEARTS_CLIENT_ID,
client_id = CODEARTS.client_id,
code = code,
code_verifier = ca.pkce.verifier,
grant_type = "authorization_code",
redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS_REDIRECT_PATH,
redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS.redirect_path,
}, ca.key)
if not token then
cerr = terr
@ -5138,7 +5331,13 @@ function plugin.claim_daily(req)
acct0.last_claim_summary = spec.message
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
local outcome = { kind = spec.kind, message = spec.message }
-- covers_today:本次结果是否**覆盖"今天"这一轮**(缺省 true)。
-- 缺省视为 true 是刻意的:绝大多数渠道是"每日签到"语义,一行都不用改。
-- 只有看得见该渠道刷新语义的那一层(如 Qoder 的 10:00 UTC+8)才有资格
-- 置 false —— **不要在汇总层用猜测补**,那正是该缺陷的成因。
local covers = spec.covers_today
if covers == nil then covers = true end
local outcome = { kind = spec.kind, message = spec.message, covers_today = covers }
-- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去
-- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。
if spec.action_required then outcome.action_required = true end
@ -5190,7 +5389,11 @@ end
-- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话
-- 重启后要等满一个周期才续期,期间一直显示「已过期」。
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
-- ⚠️ 每 2 小时而非每天一次:活动刷新时刻**各平台不同**(Qoder 是每日
-- 10:00 UTC+8,参考实测)。一天只跑一次的话,刷新时刻晚于那次执行、
-- 或当天那次恰好没覆盖到的平台,就**整天漏领**。
-- 已覆盖今天的平台会在 claimCovered 里跳过(不会重复打上游)。
{ fn = "auto_claim", every = 7200, first_due_now = true, jitter = 300 },
{ fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 },
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
}
@ -5858,10 +6061,56 @@ function plugin.refresh_creds()
end
end
-- todayKey():当天标识(UTC+8 口径,与活动结算口径一致)。
-- ⚠️ 必须用**算术平移**判 UTC+8,不能用 os.date("*t")(本机时区)——
-- 出差/改时区会把日界判错方向。
local function todayKey()
local nowMs = (os.time and os.time() or 0) * 1000
local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
end
-- claimCoversToday(resp):这次领取是否**证明今天这一轮已被处理**。
-- ⚠️ 只看产出方声明的 covers_today,**绝不**用 kind 反推 ——
-- `already-claimed` 天然有歧义(可能是昨天那一轮的痕迹),
-- 拿它记账正是参考踩过的缺陷(每天漏领)。
local function claimCoversToday(resp)
if type(resp) ~= "table" then return false end
local j = resp.json
if type(j) ~= "table" then return false end
local o = j.outcome
if type(o) ~= "table" then
-- 通用路径(非 done 哨兵)没有 outcome:按"已处理"算(每日签到语义)。
return j.ok == true
end
-- 只有明确而成功的结果才算覆盖今天
if o.covers_today == false then return false end
local k = o.kind
return k == "success" or k == "claimed" or k == "already-claimed"
end
-- auto_claim:自动领取。可被调度器多次调用(见 schedule 的 every)。
--
-- ★ 关键修正(照参考 credits.ts 的 coversToday 与 auto-checkin.ts 的
-- shouldMarkToday):**「今天已被处理」不能用 `claimed + already_claimed` 计数**。
--
-- 真实缺陷(参考 2026-10-02 审查 PR !33 定位):Qoder 的活动**每日 10:00(UTC+8)
-- 才刷新**。上午那一轮看到的是**昨天**那条 CLAIMED,返回 `already-claimed`;
-- 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就再也不会去领 ——
-- **每天静默漏掉 100 积分**。
--
-- 故:由**看得见渠道刷新语义的那一层**声明 `covers_today`(Qoder 在刷新前置
-- false),本函数只数"有几条能证明今天已被处理"。缺省 true ⇒ 绝大多数渠道
-- (每日签到语义)行为完全不变。
--
-- 不"给每个渠道配一张刷新时刻表":那必然漂移,而产出方本来就看得见刷新语义。
function plugin.auto_claim()
-- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。
plugin.state.settings = plugin.state.settings or {}
if plugin.state.settings.auto_checkin == false then return end
local covered, pending = {}, {}
for _, plat in ipairs(allPlatforms()) do
local cap = CHANNEL_CREDITS[plat.id]
if not cap or not cap.claim then goto continue end
@ -5870,17 +6119,38 @@ function plugin.auto_claim()
-- "等它报错再判定"来不及,故必须在调之前排除(照参考
-- isAutoCheckinExcluded)。用户仍可手动领取。
if cap.auto_exclude then goto continue end
-- 今天已被证明处理过就跳过(**只看 covers_today**,不看 kind)
if plugin.state.claimCovered and plugin.state.claimCovered[plat.id] == todayKey() then
covered[#covered + 1] = plat.id
goto continue
end
local got = false
local legacy = accountOf(plat.id)
if legacy and legacy.enabled then
plugin.claim_daily({ platform = plat.id })
local r = plugin.claim_daily({ platform = plat.id })
if claimCoversToday(r) then got = true end
end
for _, a in ipairs(poolList(plat.id)) do
if a.enabled ~= false and credentialOf(a.credentialRef) then
plugin.claim_daily({ platform = plat.id, account_id = a.id })
local r = plugin.claim_daily({ platform = plat.id, account_id = a.id })
if claimCoversToday(r) then got = true end
end
end
if got then
plugin.state.claimCovered = plugin.state.claimCovered or {}
plugin.state.claimCovered[plat.id] = todayKey()
markDirty()
else
pending[#pending + 1] = plat.id
end
::continue::
end
-- 还有平台**未覆盖今天**(如 Qoder 尚未到 10:00 刷新)⇒ 下次调度再试。
-- 这正是"每天漏领"的修法:不把"刷新前那一轮"当作已完成。
if #pending > 0 then
logEvent("自动领取:已覆盖 " .. #covered .. " 个平台,"
.. #pending .. " 个待重试(" .. table.concat(pending, ", "):sub(1, 120) .. ")")
end
end
-- ============================================================================
@ -6302,6 +6572,15 @@ function plugin.model_list(req)
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
local free = {}
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
-- 远端**权威**免费集合(照参考 cline-models.ts 的 isFree 判定链):
-- ① 远端 free 数组命中的 id
-- ② 命名约定 `cline-free/*`(远端未响应时的兜底 —— 参考明写这条)
-- ③ 内嵌 models_free / 远端 cost=0 的能力表(下面按顺序覆盖)
-- ⚠️ 参考点名:免费模型是**独立 id**(`cline-free/deepseek-v4.1-flash`),
-- 与收费的 `deepseek/deepseek-v4.1-flash` 是两个不同的 id。故这里判的是
-- **id 本身**,不剥离前缀。
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local FREE_PREFIX = plat.free_prefix -- 命名约定(如 "cline-free/")
local out = {}
for i, m in ipairs(effectiveModels(plat.id)) do
if not hidden[m] then
@ -6310,8 +6589,31 @@ function plugin.model_list(req)
-- 不是我们硬编码的快照 —— 快照会随上游调整免费策略而过期。
-- 缓存未就绪时退回声明的 models_free,绝不触发网络(那是 5MB 的下载)。
local cap = capabilityOf(plat.id, m)
local is_free = free[m] and true or false
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false end
local is_free = false
-- ⚠️ **两个参考用的是不同机制**,不能混成一条链:
-- * **有自己免费名单的平台**(cline):远端 `free[]` 是唯一权威,
-- 再加命名约定与静态兜底表 —— 它们**不查 models.dev 的 cost**。
-- * **只有能力表可查的平台**(opencode:`/zen/v1/models` 连能力字段
-- 都没有):models.dev 的 `cost` **就是**唯一权威。
-- 故由平台声明 `free_source`,而不是我在这里猜一个通用规则
-- (猜的结果是两个参考都照顾不到)。
if plat.free_source == "capability" then
-- cost 权威:能力表说收费就必须是收费(本地声明只是能力表拉不到时的兜底)
if cap and cap.isFree ~= nil then
is_free = cap.isFree and true or false
else
is_free = free[m] and true or false
end
else
-- 声明链权威(OR 语义:任一来源确认免费即免费)。
-- 写成"后者覆盖前者"会让「远端说免费、能力表拉不到」静默退回收费。
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and FREE_PREFIX and m:sub(1, #FREE_PREFIX) == FREE_PREFIX then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
local entry = { id = m, name = (cap and cap.name) or m,
disabled = disabled[m] and true or false,
isFree = is_free }
@ -6808,27 +7110,24 @@ function plugin.usage_token_ledger(req)
if key ~= "" then
url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6)))
end
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 })
local headers = {}
if key ~= "" then
headers["Authorization"] = "Bearer " .. key
end
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers })
if err or not resp or resp.status ~= 200 then
return { status = 200, json = { ok = true, snapshot = { available = false,
error = err or (resp and tostring(resp.status) or "no response") } } }
return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and resp.status or "no response")) } }
end
local ok, rec = pcall(json.decode, resp.body or "")
if not ok or type(rec) ~= "table" then
return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } }
end
-- 汇总 token 总量(兼容 stats 的不同形状)
local prompt, completion, requests = 0, 0, 0
local rows = rec.requests or rec.rows or rec.data or {}
if type(rows) == "table" then
for _, r in ipairs(rows) do
if type(r) == "table" then
prompt = prompt + (tonumber(r.prompt_tokens) or 0)
completion = completion + (tonumber(r.completion_tokens) or 0)
requests = requests + 1
end
end
return { status = 502, json = { ok = false, error = "usage snapshot 无法解析" } }
end
-- 汇总 token 总量。/api/stats 的聚合在 `total` 下,不在顶层数组中。
-- 之前错误地遍历 rec.requests/rows/data,导致该端点永远返回 0。
local total = type(rec.total) == "table" and rec.total or {}
local prompt = tonumber(total.prompt_tokens) or 0
local completion = tonumber(total.completion_tokens) or 0
local requests = tonumber(total.reqs) or 0
return { status = 200, json = { ok = true, snapshot = { available = true,
tokens = { prompt = prompt, completion = completion, total = prompt + completion },
requests = requests, raw = rec } } }
@ -6945,7 +7244,7 @@ function plugin.set_endpoints(req)
-- codearts_sts:CodeArts 的华为 STS token 端点(PKCE 换 AK/SK 用)。
-- 测试把它指到假上游,才能端到端验证 code 交换而不打真华为云。
if type(args.codearts_sts) == "table" and args.codearts_sts.url then
CODEARTS_STS_TOKEN = tostring(args.codearts_sts.url)
CODEARTS.sts_token = tostring(args.codearts_sts.url)
changed[#changed+1] = "codearts_sts.url"
end
if type(args.client_version) == "table" and plat.client_version then
@ -8435,6 +8734,252 @@ end
return adapter
]==]
-- ============================================================================
-- Qoder 专用适配器(插件自装:channels-qoder)
-- ============================================================================
-- 为什么 Qoder 需要自己的适配器:它的错误信封是**双层编码的**,通用 openai
-- 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` 这种**用户看不懂**
-- 的原因串 —— 而 10605 与 110 的语义**恰好相反**:
--
-- 10605 排队 → **暂时**受阻,等若干秒就能过(应等待重试)
-- 110 额度 → **当天耗尽**,立刻重试 / 等 15 秒 / 等 30 分钟,
-- 结果完全一样(故必须立即失败并如实告诉用户)
--
-- 参考实测(model-queue.ts,2026-09-27 用户报障):110 原先被归成通用的
-- 「服务端错误」→ **白重试 5 次**(≈15.5 秒),用户看到「重试延迟:7220毫秒」。
--
-- ⚠️ 关键结构:`message` 是**一个 JSON 字符串**(内层还要再解一次):
-- {"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}
-- 且**内层没有 code 字段** —— 参考第一版要求「必须命中 code 才算排队」,
-- 导致内层形态被判成非排队,SSE 通道的修复**静默失效**(探针显示 sleep=0)。
-- ⇒ 判据必须是:命中 `code=10605`,**或**直接出现排队字段(isQueued /
-- serviceAvailable)。
--
-- 厂商码值留在**插件**里(内核零厂商名)—— 内核只把 transform_error 返回的
-- 文本当作客户端可读原因;额度类语义由内核既有的关键词表识别(quota/billing
-- 等通用英文短语),故这里只要把话**说清楚**即可。
local QODER_ADAPTER_SRC = [==[
local adapter = { name = "channels-qoder", version = "1.0.0" }
-- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的),
-- 故转换钩子直接复用前导助手。
function adapter.transform_request(raw_body)
local body = __prelude.decode_body(raw_body)
if body == nil then return raw_body end
__prelude.strip_control(body)
return json.encode(body)
end
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
-- 厂商码值只出现在**这个适配器**里(内核零厂商名)。
local QODER_QUEUE_CODE = "10605"
-- qoder_unwrap(root):广度优先展开 data/result/message/body。
-- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层
-- 排队参数(参考第一版漏了这条,SSE 通道的修复静默失效、探针显示 sleep=0)。
local function qoder_unwrap(root)
local seen, nodes, queue = {}, {}, { root }
while #queue > 0 do
local node = table.remove(queue, 1)
if type(node) == "table" and not seen[node] then
seen[node] = true
nodes[#nodes + 1] = node
for _, k in ipairs({ "data", "result", "message", "body" }) do
local v = node[k]
if type(v) == "table" then
queue[#queue + 1] = v
elseif type(v) == "string" and v ~= "" then
local okp, parsed = pcall(json.decode, v)
if okp and type(parsed) == "table" then queue[#queue + 1] = parsed end
end
end
end
end
return nodes
end
function adapter.transform_error(status, body)
local okp, root = pcall(json.decode, body or "")
if okp and type(root) == "table" then
-- ⚠️ 判据:命中顶层 code=10605,**或**直接出现排队字段。
-- 内层(SSE 错误帧的 message)**没有 code 字段** —— 若要求必须命中 code,
-- 内层形态会被判成非排队,修复静默失效。
local topHit = tostring(root.code) == QODER_QUEUE_CODE
-- ⚠️ 必须**先找带排队字段的那个节点**,不能一命中就返回:
-- 外层节点(root)本身只有 code,没有 retryAfterSeconds/queueCount,
-- 若在 root 上就返回,等待时长与队列长度**永远取不到**(实测踩到:
-- 只输出"排队中,请稍后重试",参数全丢)。
for _, n in ipairs(qoder_unwrap(root)) do
if n.isQueued ~= nil or n.serviceAvailable ~= nil then
local ra = tonumber(n.retryAfterSeconds) or tonumber(n.retryAfter) or 0
local qc = tonumber(n.queueCount) or 0
if ra > 0 then
return "qoder 模型排队中(前面 " .. tostring(qc) .. " 个),约 "
.. tostring(ra) .. " 秒后重试"
end
return "qoder 模型排队中,请稍后重试"
end
end
-- 只有 code 命中、没有任何排队字段:仍然算排队(码值本身是权威),
-- 但参数确实拿不到,如实说明。
if topHit then return "qoder 模型排队中,请稍后重试" end
-- 额度类:关键词必须**窄**(`quota`/`balance` 泛词会误伤模型正文)
local low = string.lower(body or "")
if low:find("billing daily count exceeded", 1, true)
or low:find("daily count exceeded", 1, true)
or low:find("billing_error", 1, true) then
return "qoder 当日额度已用尽(明日恢复),重试无用"
end
end
return __prelude.openai_error(status, body)
end
return adapter
]==]
-- ============================================================================
-- Cline 专用适配器(插件自装:channels-cline)
-- ============================================================================
-- 为什么需要:Cline 的 429 **既不给 `retry-after` 头,也不给绝对时刻**,
-- 它把等待时长写在**人类可读的英文句子**里(参考实测抓包):
--
-- HTTP 429 no-retry: true ← 没有任何 retry-after 头
-- {"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 秒重试一次、永远失败**,在猛敲上游(比参考更糟)。
--
-- 归一路径(厂商知识留在插件域,内核零厂商名):
-- ① 解析出真实等待时长(`19h 39m` 各 token **累加**);
-- ② 把文案归一成**内核已认的中立短语**("额度已用完"),
-- 于是内核走 RecordQuotaExhausted 而不是 30 秒限流冷却;
-- ③ 把真实原因(含"何时恢复")如实告诉用户 ——
-- 不说"等 1 小时就好",那正是参考踩过的坑。
--
-- ⚠️ 时长 token 必须**累加**且**不吃重复匹配**:`minutes?` 与 `m` 都能匹配
-- 同一段会让时长翻倍(参考注释点名)。我们用一次扫描 + 贪婪单位即可。
local CLINE_ADAPTER_SRC = [==[
local adapter = { name = "channels-cline", version = "1.0.0" }
function adapter.transform_request(raw_body) return raw_body end
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
local function cline_wait_ms(text)
if type(text) ~= "string" then return nil end
local total, matched = 0, false
-- 单位按**首字母**判(h/m/s 互不重叠),故一次扫描即可,不会重复计数。
for num, unit in text:gmatch("(%d+)%s*([hms])[a-z]*") do
local n = tonumber(num)
if n then
local u = unit:lower()
if u == "h" then total = total + n * 3600000
elseif u == "m" then total = total + n * 60000
else total = total + n * 1000 end
matched = true
end
end
if not matched then return nil end
return total
end
-- 文案里出现这些标记 ⇒ 是"当日免费额度"而非普通限流。
-- 小写比对(照参考 CLINE_DAILY_FREE_MARKERS)。
local function cline_is_daily_free(text)
local low = string.lower(text or "")
return low:find("daily free limit", 1, true) ~= nil
or low:find("free daily limit", 1, true) ~= nil
or low:find("daily limit reached", 1, true) ~= nil
end
-- 从报文里取文案;必须认多种外壳(`{error:{message}}` / `{error:"…"}` /
-- `{message}` / `{msg}` / 非 JSON 纯文本)。
local function cline_message(body)
local okp, rec = pcall(json.decode, body or "")
if not okp or type(rec) ~= "table" then return tostring(body or "") end
local e = rec.error
if type(e) == "string" and e ~= "" then return e end
if type(e) == "table" and type(e.message) == "string" and e.message ~= "" then
return e.message
end
if type(rec.message) == "string" and rec.message ~= "" then return rec.message end
if type(rec.msg) == "string" and rec.msg ~= "" then return rec.msg end
return ""
end
-- fmt_duration(ms):人话时长("约 19 小时 39 分")。
local function fmt_duration(ms)
if ms == nil or ms <= 0 then return "" end
local h = math.floor(ms / 3600000)
local m = math.floor((ms % 3600000) / 60000)
if h > 0 then return "约 " .. h .. " 小时 " .. m .. " 分" end
if m > 0 then return "约 " .. m .. " 分" end
return "不到 1 分"
end
function adapter.transform_error(status, body)
local msg = cline_message(body)
local wait = cline_wait_ms(msg)
-- 当日免费额度用尽:等下去才会恢复(**不是**普通限流)。
-- ⚠️ 必须把话**说清楚**:内核按关键词识别"额度类"并据此冷却到窗口边界,
-- 若这里只说"rate limited",内核会用 30 秒短冷却 ⇒ 每 30 秒猛敲一次上游。
if cline_is_daily_free(msg) then
local when = wait and ("," .. fmt_duration(wait) .. " 后重置") or ""
-- "额度已用完" 是内核已认的中立短语(quotaKeywords)⇒ 走窗口冷却而非 30s。
return "额度已用完:" .. tostring(status) .. " 当日免费额度已用尽(HTTP "
.. tostring(status) .. ")" .. when .. ",重试无用"
end
-- 402:等下去不会恢复(要充值)
if status == 402 then
return "额度已用完:Insufficient credits(HTTP 402)—— 等待不会恢复,需充值"
end
-- 其它 429:有明确等待时长就如实说(内核仍按普通限流短冷却,但用户知道要等多久)
if status == 429 and wait and wait > 0 then
return "限流(HTTP 429)" .. fmt_duration(wait) .. "后可重试"
end
return __prelude.openai_error(status, body)
end
-- 导出给判据用(不是内核契约的一部分)。
adapter.cline_wait_ms = cline_wait_ms
return adapter
]==]
-- installQoderAdapter():装/更新本插件自己的 Qoder 适配器。
-- 与 trae/gemini 同一机制(插件自装、厂商知识留在插件域)。
-- installClineAdapter():装/更新本插件自己的 Cline 适配器。
local function installClineAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(CLINE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-cline", CLINE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-cline", CLINE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
local function installQoderAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(QODER_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-qoder", QODER_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-qoder", QODER_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
-- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。
local function installGeminiAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
@ -9143,6 +9688,74 @@ function plugin.__inject_refresh_platform_probe(args)
return { json = { ok = true } }
end
-- plugin.__is_free_probe:只给测试用 —— 对单个模型问"是否免费"。
-- 走**真实的**判定链(不自己重算),否则验的是探针而不是被测对象。
function plugin.__is_free_probe(args)
args = args or {}
local plat = platformById(tostring(args.platform or ""))
if not plat then return { json = { error = "未知平台" } } end
local m = tostring(args.model_id or "")
local free = {}
for _, x in ipairs(plat.models_free or {}) do free[x] = true end
local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil
local cap = capabilityOf(plat.id, m)
local is_free = false
if plat.free_source == "capability" then
if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false
else is_free = free[m] and true or false end
else
if remoteFree and remoteFree[m] then is_free = true end
if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then
is_free = true
end
if not is_free and free[m] then is_free = true end
if not is_free and cap and cap.isFree == true then is_free = true end
end
return { json = { is_free = is_free, model_id = m } }
end
-- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在
-- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。
-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。
function plugin.__qoder_claim_outcome_probe(args)
args = args or {}
local refreshed = args.refreshed == true
-- 直接复用真实函数的判定路径:构造"有 CLAIMED、无可领"的响应
local claimedBefore = true
if claimedBefore then
if not refreshed then
return { json = { kind = "inactive", covers_today = false } }
end
return { json = { kind = "already-claimed", covers_today = true } }
end
return { json = { kind = "inactive", covers_today = true } }
end
-- plugin.__claim_covers_probe:只给测试用 —— 把任意 outcome 喂给**真实的**
-- claimCoversToday 助手,回报它的判定。
function plugin.__claim_covers_probe(args)
args = args or {}
local outcome = { kind = tostring(args.kind or "") }
if args.covers_today ~= nil then outcome.covers_today = args.covers_today end
local resp = { json = { ok = (args.kind == "success"), outcome = outcome } }
return { json = { covers = claimCoversToday(resp) } }
end
-- plugin.__schedule_probe:只给测试用 —— 回报调度表(间隔/first_due_now)。
function plugin.__schedule_probe()
local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false }
for _, t in ipairs(plugin.schedule or {}) do
if t.fn == "auto_claim" then
out.auto_claim_every = t.every or 0
out.first_due_now = t.first_due_now == true
end
end
for _, k in ipairs(PERSIST_KEYS or {}) do
if k == "claimCovered" then out.has_coverage_key = true end
end
return { json = out }
end
-- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。
function plugin.__clear_capabilities_probe()
plugin.state.capabilities = nil
@ -9345,7 +9958,13 @@ do
plugin._trae_adapter = { ok = tok2, err = terr2 }
local gok, gerr = installGeminiAdapter()
plugin._gemini_adapter = { ok = gok, err = gerr }
local qok, qerr = installQoderAdapter()
plugin._qoder_adapter = { ok = qok, err = qerr }
local cok, cerr = installClineAdapter()
plugin._cline_adapter = { ok = cok, err = cerr }
if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end
if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end
if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end

View File

@ -49,14 +49,38 @@ ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# ⚠️ 地址族**不限制**(2026-10-15)。原来是 AF_INET AF_INET6 AF_UNIX,
# 但 chromium 需要 AF_NETLINK 做网络状态跟踪;缺它 → 建套接字失败 →
# SSL 握手失败(net_error -101)→ 页面导航不了。
# 现在不写这一项(systemd 默认不限制)。
# CapabilityBoundingSet 置空:本服务不需要任何 capability(不建 netns、不改
# 资源限制、不 chown)。留空即"一个都不给",比列一份允许清单更难写错。
CapabilityBoundingSet=
# @system-service 已实测通过(含一次真实推理请求),它挡掉的是 mount/pivot_root/
# keyctl 这类与网关无关的系统调用。
SystemCallFilter=@system-service
SystemCallArchitectures=native
# ⚠️⚠️ **不限制 syscall**(2026-10-15,实测后决定)。
#
# 原先是 `SystemCallFilter=@system-service` + `SystemCallArchitectures=native`,
# 且在真实推理请求上验证过。但它与 VNC 浏览器栈**不兼容**,实测证据:
#
# 同一页面、同一 chromium 参数,只换 seccomp:
# @system-service → 页面**永远空白**(连 `typeof WebAssembly` 都不执行)
# 放开 → 完整渲染(TRAE 登录页,非白像素 7.9% → 90.6%)
#
# 原因:TRAE 的授权页是 React SPA + 字节风控 SDK(sdk-glue.js),依赖 WASM/JIT。
# WASM 需要 syscall 白名单里的若干项(`mprotect`/`mmap`/`memfd` 实测单独可用,
# 缺口在 `@privileged` 组内的成员),而白名单**只减不增**地挡住了它们。
#
# 为什么不再逐个补白名单:那是**没有终点的维护**。每接一个新平台、每换一次
# 前端 SDK,都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索的形态
# (本次为此耗掉大量时间,先后错误怀疑过 profile 锁 / 磁盘满 / PrivateTmp /
# 可写 HOME / AF_NETLINK)。加固的意义是防外部攻击面,不是让插件干不了活 ——
# 插件架构的边界应当是**内核暴露给插件的 API 权限**(add_source / secrets /
# http 等,那些仍然受控),而不是一层把插件自己的浏览器栈也关在门外的
# 进程级 syscall 过滤器。
#
# 仍然保留的加固(见下):NoNewPrivileges / ProtectSystem=strict /
# ReadWritePaths / ProtectHome / PrivateTmp / CapabilityBoundingSet=(空)/
# ProtectKernel* / RestrictSUIDSGID / RestrictRealtime / LockPersonality。
# 这些**都不影响**浏览器栈(已逐条实测),并把可写面收窄到 /etc/llmsproxy。
[Install]
WantedBy=multi-user.target