|
|
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。凡"某内容触发"的规律
必须交叉验证。
v2.0.17
|
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 |
|
|
|
482a65958b
|
docs(plugins): 补「前缀代理」章节 —— 插件自起服务挂到网关同端口
|
2026-10-15 15:02:04 +08:00 |
|
|
|
796b30f5b0
|
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 15:02:04 +08:00 |
|
|
|
7e0a428c23
|
docs(plugins): 补「前缀代理」章节 —— 插件自起服务挂到网关同端口
v2.0.16
|
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 |
|
|
|
25a525be84
|
避免部署构建占满临时文件系统
|
2026-10-15 13:01:58 +08:00 |
|
|
|
850920155a
|
避免部署构建占满临时文件系统
v2.0.15
|
2026-10-15 13:01:58 +08:00 |
|
|
|
ea8a9ac4c6
|
修复用量总计与渠道账本统计
|
2026-10-15 12:52:03 +08:00 |
|
|
|
eaac0ef6b3
|
修复用量总计与渠道账本统计
|
2026-10-15 12:43:11 +08:00 |
|
|
|
8f73f77ec2
|
chore(main): 回流 —— VNC 兜底登录真正可用(放开 browser-stack 不需要的加固 + 修 3 个插件缺陷)
按纪律先在 release/v2.0.x 提交,再内容对齐回 main。
校验:git diff release/v2.0.x = 0 行。
|
2026-10-15 08:23:25 +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 个包全绿。
v2.0.14
|
2026-10-15 08:22:57 +08:00 |
|
|
|
c36a794aa8
|
chore(main): 回流 —— 第三批逐模块对照文档
按纪律先在 release 提交再内容对齐。校验:diff vs release = 0 行。
|
2026-10-14 20:15:08 +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 |
|
|
|
0f263f895a
|
chore(main): 回流 —— 账号池落盘原子性判据
按纪律:先在 release/v2.0.x 提交,再内容对齐回 main。
校验:git diff release/v2.0.x = 0 行。
|
2026-10-14 20:12:10 +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 |
|
|
|
044ff1129b
|
chore(main): 回流 release/v2.0.x —— 插件自装适配器三连(qoder/cline)+ 自动领取 covers_today + isFree 权威来源
按发布纪律回流。这四笔原本**误提交在 main** 上(我的操作失误):
纪律要求修复先在 release/v2.0.x 提交、再回流 main。已迁移:
① release 上 cherry-pick 这四笔(干净落地);
② main reset 回 9bec158(那四笔未推送,无远端影响);
③ 内容对齐回 main —— 等价于只捡拾修复、不带版本号。
内容:
- channels-qoder 适配器:10605(排队) 与 110(额度耗尽) 分开说,厂商码留在插件域
- channels-cline 适配器:解析英文句子里的等待时长(内核按额度耗尽冷却而非 30s 猛敲)
- auto_claim:按 covers_today 记账(Qoder 10:00 刷新前不得算已处理)+ 间隔 2 小时
- isFree:改为按平台声明权威来源(cline 用远端 free[]/命名约定,opencode 用 cost)
校验:git diff release/v2.0.x = 0 行;go build 通过。
|
2026-10-14 20:01:24 +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 包全绿。
v2.0.13
|
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 |
|
|
|
9bec158d2e
|
chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
|
2026-10-14 16:52: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 个包全绿。
v2.0.12
|
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 |
|