74ec1e7874
refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件
...
用户钉死了内核的职责边界,本提交把越界部分全部拆除:
内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥
## 架构纠正(用户指出的根本错误)
我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:
插件 可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
os/io/package,后续沙箱化零风险,属独立一步)
## 删除(内核越界部分)
- plugin_browser.go 内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
进程栈、回环监听器)—— 388 行全删,浏览器由插件用
os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go 登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
—— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API 登录会话列表端点
- vnc_enabled 全局开关 改为 per-plugin 授权(见下)
## 新增(内核职责内的通用机制,无任何登录/浏览器语义)
- plugin_outbound.go 通用额外出站主机授权:插件声明 plugin.hosts(请求)
→ 运维在设置页逐个批准(授权)。与 kernel actions
同一"声明≠授权"模型。内核只知道白名单加减,不知道
host 被用来干什么
- on_unload 生命周期钩子 卸载是"调度插件"的一部分:内核在关闭插件状态前调用
插件导出的 on_unload,让它自清理外部资源。内核不知道
资源是什么(进程/句柄/远端会话都行)。带进程回收回归
测试 TestOnUnloadReapsPluginProcesses
## channels.lua 全量接管
- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
→ ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)
## 验收
- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
2026-10-11 16:24:55 +08:00
fa50ec8bc7
feat(lua): 服务端浏览器登录(VNC)+ 内嵌页签 —— 回环回调的唯一自动化路径
...
用户指明的场景修正了我的一个实质错误。我之前写"VNC 不解决取凭据,凭据还在
浏览器 profile 里"——这对 cookie 类成立,但对**回环重定向**不成立:
浏览器在用户机器 → redirect 到 127.0.0.1:P → 打到**用户的**回环 → 服务端收不到 code
浏览器在服务端 → redirect 到 127.0.0.1:P → 打到**服务端的**回环 → 插件直接收到 code
后者不需要"提取凭据",code 自己流到监听方。所以对回环类登录,服务端浏览器
不是权宜之计而是**唯一自动化路径**。已实测验证(浏览器在服务端时 loopback
回调的 code 落到了服务端监听器)。
## 实现
plugin_browser.go:Xvfb(:N) ← chromium → x11vnc → websockify(noVNC) + 回环回调监听。
五个进程一起起停,因为失败模式是顺序相关的(崩掉的会话留下的 Xvfb 会让下一个
chromium 以看起来像浏览器 bug 的方式失败)。
**默认关闭,且不是"开了 VNC 就能登全部"**:每个平台单独走 vnc 流程才启动。
理由是这套东西风险最高——在服务端跑真实浏览器登录用户的第三方账号。
**边界**:插件拿到的是 view_url,**不是浏览器控制句柄**。CDP 端口不进 Lua。
否则一个插件就能读所有标签页的 cookie,而那个浏览器登录的是运维的账号。
**teardown 必须删 profile**:里面是活的第三方 session cookie,删它是安全契约
的一部分,不是收尾工作。
## viewer 反代(browser_proxy.go)
/browser/<id>/... → 该会话的 websockify。为什么不直连端口:
1. 鉴权——直连等于把已登录的浏览器暴露给任何能访问该端口的人(实测未鉴权
访问得 302 → /login,带 admin key 得 3912 字节 noVNC 页面)
2. WebSocket——noVNC 核心是 WS 升级,httputil.ReverseProxy 原生处理
3. 同源——WS 路径不需要跨域配置
## 内嵌在插件页签(用户要求)
viewer 用 iframe 内嵌进 channels 插件的登录区,而不是让人跳出去开新窗口:
- 插件 UI 拿到 view_url 就塞进 iframe src,登录完成/失效时清空 src 并隐藏
- 插件只对 `flow = "vnc"` 的平台起浏览器;其他流程继续用用户自己的浏览器
(等于白开一个高风险的浏览器是浪费)
## 插件本体:channels(同类插件)
单插件多平台,4 个平台示范四种流程(link-poll / callback / paste / vnc)。
登录 → 凭据进保险库(不进 state)→ add_source 接入内核 → 定时领额度。
声明式:plugin.kernel 申请内核动作、plugin.auth_hosts 申请登录主机,
设置页显示"申请中"由运维批准。平台表加一条就多一个平台,内核零改动。
开发中被测试抓到的三个真实缺陷(都在我自己的代码里):
1. handler 返回 `{url=…}` 没有 status/json → 内核**静默丢弃**,200 空响应、
无任何错误。已把所有 handler 统一为 {status,json},并加契约测试锁死。
2. 插件把 req.body 当表用 → 实际是 JSON **字符串**,所有平台参数都读不到。
加 bodyOf() 统一解码。
3. 顺带发现工作树里 discover.go 被改成 `return "image"`(非本次改动,像是早先
变异测试留下的),会改变模型类型推断;已还原。
测试:服务端浏览器会话(进程栈/默认拒绝/回调捕获/teardown 清理 profile)、
viewer 反代鉴权、插件 VNC 端到端、channels 插件端到端(含未授权时如实报告
source_error 而非假装成功)、handler 契约。
2026-10-11 14:26:28 +08:00
c3b858d5ba
feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
...
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。
## 为什么不是 VNC 为主路径(实测参考插件后的结论)
grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:
puppeteer / playwright / chromium / headless / vnc / selenium → 0 处
它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。
不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。
VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。
## 登录会话抽象(plugin_login.go)
四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。
- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)
## 关键阻碍与修法:登录主机不在出站白名单里
**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。
修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。
## 设置页
插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。
后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。
开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。
测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
2026-10-11 13:56:35 +08:00
a59295e826
feat(lua): 插件可调内核动作面(R4-c)+ 凭据保险库(R4-d)
...
用户指出「接口不全」——此前插件只有 __llmsproxy_adapters 三个手写闭包,
够不到内核。对照参考插件(dsh-our-free-model,13 个账号渠道)的真实能力,
缺的正是这两块:
插件自己驱动链接认证 → 存凭据 → 定时领额度 → 把源接进内核
## R4-c 内核动作面:__llmsproxy_kernel
18 个动作,全部映射到既有 Core 方法(不是新造能力):
只读 6:status / list_sources / list_models / list_adapters /
list_auto_rules / adapter_owner
写 8:reload / reset_health / probe_all / save_auto_rules /
save_key_auto / add_source / create_key / update_key
破坏性 4:remove_source / delete_key / remove_adapter / set_plugin_enabled
**add_source 是「插件注册自己的上游」的落点**——插件拿到凭据后可以自己把
端点接进网关,无需运维改 config.yaml。已端到端验证:插件调它 → 内核源列表
出现 → 模型进入可路由列表。
依赖方向:lua 不能 import core,所以接口定义在 lua、实现放 core
(kernel_actions.go),走 SetKernelAPI 注入——与 SetOutboundHosts 同一先例。
**凭据边界**:动作面**有意排除** list_keys/find_key。Core.ListKeys() 返回
明文网关密钥,交给第三方 Lua 等于交出整个部署的凭据面。有回归测试锁死
这条(TestKernelActionSurfaceExcludesPlaintextKeys)。list_sources 返回的
api_key 也强制掩码(只给 ***+后4位)。
**权限**(用户选「全开+设置页可配」):18 个动作全部可达,但**默认拒绝**,
授权落盘 .kernel-perms.json(独立文件,不并进 .access.json——后者是
map[string]string,塞进去会破坏旧二进制能读的格式)。danger 级不接受通配符。
插件可在源码 plugin.kernel={...} **申请**,设置页显示申请 vs 已授予。
**Go 侧 deadline**(用户选定):每个动作有超时预算,超时把 (nil,err) 还给
Lua。这可行的原因:Lua 只是等返回值,不占 Lua 栈执行权——与「Lua 死循环
不可中断」是两件事。
## R4-d 凭据保险库:__llmsproxy_secrets
此前插件存 token 只能写 plugin.state:0755 目录、明文、与计费累计量同文件、
会被 PUT /state 整体替换、会进 WebUI。现在独立文件:
<plugin_dir>/.secrets/<plugin>.json 文件 0600,目录 0700
临时文件**先 chmod 再 rename**,全程不存在宽权限窗口。读取身份在插件 boot
时绑定(不从 Lua 状态现读)⇒ 跨插件读不可行是机制而非约定。插件删除时清空
(DeleteAllSecrets + 启动 PurgeOrphanVaults)。
## 开发中被测试抓出的两个真实缺陷(我自己引入的)
写了 TestNoKernelActionDeadlocksFromLua 对**每个**动作断言「在插件 Lua 调用
(此时持有 p.mu)内不得死锁」,抓出:
1. **自死锁**:set_plugin_enabled → SetEnabled → p.mu.Lock(),而死锁发生在
调用者已持有的同一把锁上。
2. **更隐蔽的迟到副作用**:超时把 error 返回给插件后,动作仍在幕后执行完成——
插件以为失败,实际已生效。
修法:标注 Deferred 的动作在本次调用返回后异步执行,如实返回 accepted:true
而不是谎报成功。18 个动作全部复验无死锁,另有单独测试确认副作用真的落地
(只测「不死锁」的话,一个全空转的 dispatch 也能通过)。
测试 11 个(core 8 + lua 3):动作可用/密钥掩码/默认拒绝/授权计数/
插件注册源并变为可路由/授权落盘重启保留/未知动作名被拒/凭据面排除明文/
全动作无死锁/破坏性动作生效/Lua 侧桥接可用。
2026-10-11 13:52:29 +08:00
dafc3b3fc3
feat(lua): 插件 Lua 侧适配器桥接(R4 Lua 绑定)+ 真二进制适配器接手 e2e
...
补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。
## R4 Lua 绑定:__llmsproxy_adapters
用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。
旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。
反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量
是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM),
bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的
中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。
顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。
p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压
怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正
长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。
能力(Go 侧强制,非约定):
- install(name, source) → (生效名, nil) | (nil, err)
- update(name, source) → (true, nil) | (nil, err) 只限自己装的
- remove(name) → (true, nil) | (nil, err) 只限自己装的
- 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件
- 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门)
- 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定
**分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器
install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄
改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。
测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里
轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。
## 真二进制 e2e:适配器接手对外服务
用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内**
gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里
发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。
新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的
假上游(该上游对不符形状的请求主动报错,不做宽容接受):
- 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳
- 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE]
- 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味
压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确**
的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。
2026-10-11 13:25:11 +08:00
f4d5e779a5
chore(lua): 移除误入的临时复现脚本 zz_repro_test.go
...
上一条 commit 用 git add -A 时把工作树里一个未跟踪的临时复现脚本
(zz_repro_test.go,注释自称「临时复现」)一并扫了进去。它不属本次范围:
不对任何东西做断言(只有 t.Logf),且它探查的 anthropic 空数组问题
在 main 上同样存在,是既有行为,不由本分支引入。
顺带确认它想复现的那个形状:唯一命中的是
`"messages":{}` —— 空 Lua 数组经 luaTableToGoValue 变成 JSON 对象而非
空数组。这确实是个真问题(Anthropic 会拒),但它在 main 上就存在,属
独立缺陷,不在线格式 v2 分支的处理范围。已记入 plan.md 已知遗留。
2026-10-11 13:00:46 +08:00
61f402beef
fix(lua): 定时任务改跑在插件自己的 VM 上 —— 纠正 Phase 4 的双 VM 错误
...
用户指出的设计错误:**一个插件 = 一个 Lua VM = 一个调度器**。而 Phase 4
(4d1407b)给定时任务开了一个**私有 VM**,于是:
定时任务 refresh_token 写 plugin.state.token → 写进私有 VM 的副本
请求钩子 读 plugin.state.token → 读主 VM 的值
轮换"成功"了,对实际流量完全无效;而且私有 VM 绕过状态持久化,token 连
磁盘都到不了。这直接违背这个功能存在的理由。
## 修正
PluginRuntime 不再持有 Lua 状态。任务通过 p.state + p.mu 运行 —— 与请求
钩子**同一个 VM、同一把锁**:
- 任务写入立即可见于钩子(TestTaskWriteIsVisibleToHooks 锁死这一点)
- 任务写入经 markDirtyLocked 走同一套持久化
- 跨插件仍并行(每插件各自 VM、各自锁)
## 由此产生的行为(有意为之,非缺陷)
- 某插件的长任务会排在**它自己**的钩子前面(同一 Lua 栈,一次一个调用者)
- 卡死处置随之改变:不再"抛弃 VM 重建"(那会丢掉累计状态——计费总额、
签到历史,正是插件存在的意义),而是**停用该插件**:
- checkWedged 超过 wedgedAfter(10min) 置 disarmed
- Fire 对 disarmed 插件快速失败并记 hook_errors(否则请求会排在永不释放
的锁后面永久挂起——比停用更糟)
- Unload 检测 InFlight:有任务在跑时不取 p.mu(会永久挂起)、不 Close
状态(Lua C 层 use-after-free 直接 abort)。泄漏这一个状态是有界的,
比崩溃好。
- maxAbandons/抛弃计数因此删除:没有"重生"了,熔断由 disarmed 承担
## 测试(10 个)
主断言从"任务跑过"改为"**任务的写入对请求路径可见**"—— 旧实现会在这里
失败。另含:无触发自执行/无 schedule 无运行时/解析/at 下次触发/错误隔离/
卸载停 goroutine/卡死停用+钩子快失败/健康任务不误判/8 插件并发各自调度。
2026-10-11 12:59:36 +08:00
57fada3b0d
docs(lua): 更正 ScheduledTask.Timeout 注释 —— 超时由抛弃 generation 实现,非 Lua 指令级中断
2026-10-11 12:14:49 +08:00
b6148e6f79
chore(lua): 删除调度器里两个无人调用的诊断辅助函数
...
sortedTaskNames / PluginTasks 是开发 Phase 4 时留下的诊断辅助,最终
没有任何调用点(WebUI 走 /api/plugins 的既有结构)。连同不再需要的
sort import 一并删除,保持包面干净。
2026-10-11 11:41:08 +08:00
9016eda905
feat(lua): 卡死任务改为「抛弃整个 VM」—— 不中断,直接换一代
...
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
VM,让任务的 goroutine 跟 VM 一起被处置。这比中断干净 —— VM 重建只要
~0.1ms,抛弃比修复便宜。
## 为什么必须这样做(三次实测)
本 golua + LuaJIT 构建**无法中断**运行中的 Lua 调用:
1. SetExecutionLimit(200000) 对紧密循环 10s 未返回 —— 无效
2. 计数 hook(2000) + RaiseError 对紧密循环 fired=0 —— hook 未触发
3. 计数 hook(1000) 对「函数调用+表写」循环 fired=20971 触发,
但 RaiseError 后仍未返回 —— RaiseError 走 L.StackTrace(),
正是 vm.go 已记录的 SIGSEGV / 无法跨 C 栈路径
原因:LuaJIT 把紧密循环编译成机器码,指令计数 hook 只在解释器路径触发;
而唯一能在 C 栈上中断的 golua 入口恰好依赖 StackTrace()。
## 模型:generation 可抛弃
PluginRuntime 持有一个 runtimeGen(私有 VM + 单状态池)。任务超过
wedgedAfter(10min,因为正常情况就是慢网络调用)仍不返回时:
- 抛弃该 generation,由 ensureGeneration 在下一 tick 起一个干净的
- 卡死的 goroutine 留在旧 VM 上,永远碰不到新一代的状态
- 在飞任务被 carry 到新 generation 并重新排期(否则 abandon 计数永不前进,
熔断也就永远不触发)
## 熔断(必须有)
每次抛弃都留下一个永不返回的 goroutine + Lua 状态。没有上限的话,一个
必然卡死的任务会让运行时每 10 分钟建一个新 VM —— 由插件自身 bug 驱动的
无界泄漏。maxAbandons=5 后熔断停止重生,需人工 reload(同 systemd 单元
反复启动失败后的姿态)。
## 开发中修掉的四个缺陷
1. rt.gen 未赋值:bootGeneration 的结果只赋给局部变量,导致 checkWedged/
runDue/dropGeneration 全部读到 nil,运行时会「活着但一个任务都不跑」。
2. checkWedged 无条件抛弃:即使没有任何任务判定为 wedged 也 drop,
让健康插件不断换代。
3. startedAt 由 runDue 设置而非 runOne:任何其它 dispatch 路径的任务
都不会被追踪,checkWedged 无法区分「在跑」与「从未启动」。
4. runOne 收到 nil gen 会 panic(抛弃与派发竞争)—— 改为安全返回并清标志。
测试:13 个(无触发自动执行/真正执行/零开销/at 解析/条目校验/失败隔离/
卸载停 goroutine/压力/抛弃换代/熔断/健康任务不受影响)。
2026-10-11 10:40:58 +08:00
4d1407bbb4
feat(lua): 插件长期运行环境(R4-b)—— 内核驱动调度 + 独立 VM
...
用户需求:插件需要长期运行环境(独立 LuaVM 线程化),以支持自动签到、
token 轮换这类"没有浏览器、没有人操作时也要发生"的能力。
旧模型做不到:插件的周期工作只能靠前端 pluginAPI.call 驱动,页面没开就
一次都不跑。这正是自动签到的死穴。
新增 internal/lua/plugin_runtime.go:插件声明,内核驱动。
PLUGIN.schedule = {
{ fn = "refresh_token", every = 3600 },
{ fn = "daily_checkin", at = "03:30" },
{ fn = "poll_quota", every = 300, jitter = 30 },
}
- 每个声明了 schedule 的插件获得**私有 VM**(不是共享 plugin.state 的那个:
钩子要累加,任务要长跑且网络阻塞,两者对隔离的要求相反)
- 内核单个 ticker 驱动所有任务;每个任务**独立 goroutine**,一个长任务
不推迟另一个的 due
- `at="HH:MM"` 每日固定时刻;`jitter` 打散首触发,避免整点齐射
- 任务失败计数进入 hook_errors 面(静默坏任务必须可见)
- Unload 先停调度再关状态(顺序反了会让在跑的任务写进已关闭的 Lua 状态)
明确边界:**适配器不参与调度**。适配器仍是无状态请求级池;token 轮换靠
插件(这里)按周期调用 Phase 3 的 UpdateAdapter。
修掉两个把进程直接搞崩的缺陷:
1. 私有 VM 的池我给 sync.Cond 传了新 mutex 而非池自己的 mu →
"unlock of unlocked mutex",fatal error 而非可恢复错误。
2. Transform 硬编码 adapterGlobal,而插件模式的池把表存在 pluginGlobal →
健康的状态也报 "has no table"。加 poolGlobal(p) 按模式选择。
测试 8 个:无任何触发自动执行(核心)/真正执行了(计数读回)/无声明则零开销/
at 解析与畸形退化/不完整条目被忽略/失败隔离/卸载停 goroutine/6 插件×3 任务压力。
2026-10-11 10:26:39 +08:00
a0ab9ddda5
feat(lua): 插件适配器管理桥接(R4-a)—— 安装/改写/删除,支撑 token 轮换
...
用户需求:插件可安装、运行时修改适配器以支持复杂认证与 token 轮换;
适配器本身不需要持续运行(它是无状态请求级弹性池)。
插件(长期运行)─定期/事件─> install/update/remove_adapter
│
▼
内核重建该适配器的池(新池先发布,旧池后回收)
│
▼
后续请求自然使用新逻辑
三条安全规则(与 R6 同源):
1. 路径安全:ValidateAdapterName(无穿越/绝对路径/空白)
2. 不得影子内置:插件不能注册与内置同名适配器(运维仍可自行覆盖)
3. 归属可查:owner 表记录每个适配器由谁安装,于是"插件只能改自己装的"
是可判定的,而非靠约定。没有归属信息时,插件 A 能劫持插件 B 的认证逻辑
而无法被察觉。
修掉两个开发中暴露的真实缺陷:
1. 池替换窗口:LoadAdapterSource 原先在 v.mu 内先 shutdown 旧池再装新池,
窗口期内请求拿到 "pool closed"。改为**新池先发布、旧池后回收**。
2. 并发轮换仍失败:多个 goroutine 同时轮换时,单次重试会连续落在刚被下个
轮换关闭的池上。acquireAcrossSwap 改为按**墙钟**(2s 上限)+1ms 退避
重试新池 —— 轮换是运维的意图,落在微秒窗口里的请求不该因此失败。
上限保证适配器真消失时仍会及时报错而非空转。
3. 声明名 vs 注册键:脚本可声明与注册键不同的 name(插件按自己控制的键注册,
但适配器声明源真正引用的名字)。归属表同时记两个键,删除时一并清理;
EffectiveAdapterName 在声明名下也能查到池。
测试:11 个(安装/轮换/影子内置/跨插件窃取/碰运维适配器/路径穿越/删除/
归属列举/并发 8×20 压力/语法错误安装即拒/声明名上报)。
注:internal/gateway 的变异测试 harness 拒绝在脏工作区运行(设计如此),
故本提交后它才可运行。
2026-10-11 10:03:43 +08:00
e4111337cd
feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
...
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。
- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
(content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
system 支持字符串与 [{type:text,text}] 两种写法)。
调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
serialize_open_preamble / serialize_chunk / serialize_close,
把统一格式译回 Anthropic Messages 形状(含 content 分块、
stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。
内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。
测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。
golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00
93eeba3762
feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
...
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。
改掉的具体缺陷:
1. 私有字段泄漏归零(v1 的核心问题)
v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
适配器都认这个词:
- gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
- deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
- anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
(enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
- 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"
2. Tools/ToolChoice 改 json.RawMessage
原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。
3. 删除 MarshalJSON 后门
v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
UnifiedRequest 上一个正正经经声明的字段。
渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。
测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00
958f1f48b5
feat(lua): 适配器导入路径冲突校验(R6)——防静默覆盖与路径逃逸
...
用户要求:导入适配器时必须检查路径冲突,避免静默错误。
之前 UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua:
- name 含 "../" 或绝对路径会写到目录外
- name 与内置同名会静默覆盖,加载失败只报 "adapter X missing",
现场与病因分离
实现(internal/lua/adapter_validate.go):
- ValidateAdapterName:字符集 ^[a-zA-Z0-9_-]+$,拒绝 /、\、..、绝对路径、空白
- ValidateAdapterPath:文件必须在 adapter_dir 内
- VM.InstallPluginAdapter:插件装内置名 → ErrAdapterNameReserved 拒
- Core.UploadAdapter:在 WriteFile 之前校验(防路径逃逸)
- Core.RemoveAdapter:也校验名字(防 os.Remove 越界)
设计取舍:保留名禁止只对插件生效,不对运维生效。
- operator 放 openai.lua 覆盖内置 = 文档化的热覆盖机制,放行
- 第三方插件静默换掉内核自带的协议翻译 = 篡改发行物,拒绝
- 先误做成了全拒,Start() 加载内置适配器直接崩,测试抓住后改对
(TestLoadBundledAdapters 等锁住回归)
测试 12 个:路径穿越/绝对路径/保留段/空白/内置覆盖(允许 vs 插件拒绝)/
目录外加载/错误点名/热重载不回归。
验证:go test -tags luajit ./... 全绿。
2026-10-11 09:23:34 +08:00
9d6653a573
feat(gateway): 对外序列化移交适配器,OpenAI 格式降级为最后容错
...
内核不再硬编码对外 wire。writeChatCompletion / pumpStream 里的 OpenAI
帧拼装抽到 Serializer 接口,适配器可声明钩子完全接管。
优先级契约(用户明确要求:冲突时兜底直接让步):
适配器声明 serialize_* -> 适配器完全接管
未声明 -> 兜底 openAISerializer
「让步」的含义:适配器接管后内核绝不往其输出补任何 OpenAI 字段。混合体
(半个 Anthropic + 半个 OpenAI)比任何一个单独结果都更难排查——客户端无法
解析,而排查时会指向错误的层。
接口把「流如何结束」也纳入:OpenAI 需 usage 帧 + [DONE],Anthropic 用
message_stop,Gemini 用空 candidates 帧。DoneMarker 让适配器声明自己的
终止标记,避免擅自给严格客户端加 [DONE]。
新增能力:
- VM.HasFn(name, fn):不占 worker 探测适配器是否声明某函数
- VM.AdapterRevision(name):适配器源码每次替换 +1(接着旧值递增)
修掉两个开发中引入的真实缺陷:
1. 锁序倒置自死锁:LoadAdapterSource 里在已持有 v.mu.Lock() 时嵌套
v.mu.RLock() 取 revision,RWMutex 不可重入 -> 整个测试套件静默挂起。
症状是不报错也不超时,只是永不返回;靠 go test -timeout 的 goroutine
栈定位。
2. 缓存键漏掉 VM 维度:serializerCache 原按适配器名做键,导致同进程内两个
VM 共享判定,适配器重写后新钩子永不生效且不报错。键改为
(VM, revision, adapter),回归测试 TestSerializerCacheIsPerVM。
测试三件套:
- 功能:适配器接管 / 兜底(无适配器)/ 钩子失败退回兜底 / 缓存随改写失效
- 压力:TestSerializerForConcurrent(16 worker × 40 次)
- 回归:golden 字节级未漂移,Phase 0 安全网生效
验证:go test -tags luajit ./... 全绿(gateway 200s / lua 56s)。
2026-10-11 08:57:51 +08:00
1c6a2821e4
feat(provider): 端点机制去厂商化 —— 适配器声明 endpoint_stream
...
内核不再知道 Gemini 的流式动词。之前 provider.ChatURL 对
":generateContent" 做字符串替换(注释自认 "Gemini is the only such
adapter"),把厂商协议知识放进了调度器。
现在适配器自己声明:
adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent"
内核只比较两个不透明字符串,四级优先级:
source.endpoint_stream > source.endpoint > adapter.endpoint_stream
> adapter.endpoint
- staticInfo.endpointStream + VM.StreamEndpoint() 编译期提取
- config.Source.StreamEndpoint(yaml: endpoint_stream)
- gemini.lua 声明 endpoint_stream
- 内核代码 generateContent 计数归零(仅注释/适配器持有)
首版实现踩了一个回归并当场修掉:源自定义 endpoint 时适配器的 stream
endpoint 会错误覆盖用户的显式路径。TestEndpointPrecedence 把四级优先级
钉死,防止再犯。
验证:go test -tags luajit ./... 全绿;golden 字节级未漂移。
2026-10-11 08:30:39 +08:00
6113924655
chore(adapters): 移除非官方调用方式的适配器,保留官方适配器与模板
...
移除 4 个靠伪造客户端身份/绕过 WAF 才能工作的适配器,内核不再发行任何
非官方调用方式:
- opencodezen / opencode / opencodego:靠 x-opencode-* 客户端指纹冒充官方
客户端;zen 免费池明确要求「free tier can only be used in OpenCode」
- agentrouter:伪造 Origin: qwen.ai + Referer + X-Requested-With 绕过阿里云 WAF
保留 12 个官方适配器(openai/deepseek/anthropic/gemini/github/groq/mistral/
ollama/kimicode/qwen38/sensenova/trae),其中 sensenova/qwen38 实测零伪装特征。
本地保留两处:adapters/_local_archive/(gitignored 非递归子目录,不加载)
与线上 /etc/llmsproxy/adapters/(adapter_dir 权威副本,cp -f 不删文件)。
测试同步:删除仅服务于被移除适配器的 11 个测试函数与 usage_cost_test.go;
改写 2 处借 opencode 之名的 provider 测试为用 openai 的 HTML 错误页压缩能力
验证同一契约(适配器钩子拥有错误格式、原文不外泄)。
验证:go test -tags luajit ./... 全绿;二进制内 x-opencode-client /
QwenCode/0.2.0 / qwen.ai 等指纹字符串计数归零。
2026-10-11 08:17:56 +08:00
d3203d88fc
feat(plugins): usage 插件进仓,随包播种
...
之前只通过 POST /api/plugins 装在生产上,源码仓里没有 —— 于是
core-dist.sh 的 deb/rpm/tar.gz 与 GUI 的 electron-builder 产物都不带它,
/v1/usage 在任何拿到安装包的人那里都是 404,页面显示「后台未开放消费接口」,
看起来像坏了而不像少了个可选功能。billing.lua / quota.lua 一直都是 embed 的,
usage 与它们同级。
四个测试(internal/lua/usage_plugin_test.go)盯的是三件容易在改 Lua 时被
无声破坏的事:
1. 遮罩算法必须与 Go 的 keyID() 一致。/api/stats 的 map 以掩码(***f9a9ed)
为键,handler 收到的是完整 key;传完整 key 不报错,只是静默返回 0。实测:
?key=***f9a9ed → reqs=33847
?key=sk-gw-7ffe…9ed → reqs=0
端点于是对一个有几十万次请求的 key 报「0 次」。
2. 不得读 PLUGIN.state / prices / 任何凭据:它报的是**调用者**的用量,
读配置里的 key 会把一个租户的花费报给所有租户。
3. 取不到数据必须报「usage unavailable」,不能返回 0:0 会被渲染成
「已用 0,0 次」,那是「这个 key 从没被用过」这个不同的、且错误的断言。
4. period 默认 all:日维度看起来像每天清零。
反向验证过 roles 声明:撤掉后 TestUsagePluginShipsAndGuardsItsOwnKey 变红。
(cherry picked from commit ae2b121959 )
2026-10-07 17:37:22 +08:00
0858a747ca
fix(lua): 导出函数调用不标脏,插件状态从不落盘
...
quota 声明 hooks=[],全部工作都在导出函数里。markDirty 只从 hook 与
SetState 触发,Call 两边都不占 —— 于是探测改的内存没人写盘,状态文件要等
下一次无关的配置写入才顺带刷新。
现象是「插件看起来没写」:负 memo 要等到下一次改配置才出现。用隔离实例
逐步观察时,一度把这误读成 memo 状态机的三个缺陷,逐个查清后只有这一条
是真的:
- 「未就绪轮次累计 streak」 —— 不成立。写侧本来就对临时条目用 streak=1,
65ed1b 版本已是这个行为。
- 「读侧用旧快照,streak 永远停在 1」 —— 是退避的设计行为。单进程内第二
次探测被退避挡在写侧之前,streak 自然不再增长;真实的多轮计数在跨进程
(trace_streak.py)下是 1/2/3/4。
- 「Call 不标脏」 —— 真缺陷,已验证:去掉 markDirtyLocked,
新增的 TestOnDemandCallIsPersisted 立刻变红。
顺带保留两处无行为差异的可读性收紧:provisionalEntry 统一按「集合非空」
判断临时条目(与 copySet 空集返回 nil 的写法对齐)。
判据:TestOnDemandCallIsPersisted 用 hookless 插件走导出函数累加,
反向证明(去掉 markDirtyLocked)会红。
(cherry picked from commit 3444d2d903 )
2026-10-07 17:37:22 +08:00
7934507179
fix(quota): 未就绪适配器污染负 memo;被拒凭据被后续 404 掩盖
...
端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。
一、负 memo 语义混用
负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。
openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目:
1. 未配 control_key → 探测 → 负 memo
2. 操作员写入 key → 探测被退避拦下,adapter 根本没跑
3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时)
不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回
404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。
做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」:
- 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip)
- 条目带 unready 集合;该 vendor 配置好后条目作废重算
- 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时),
但不退避 —— 退避会拒绝那个本来能成功的探测
二、401 被下一个 adapter 的 404 掩盖
adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份
主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记
得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的
404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作
完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。
判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清:
- rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign
排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个
stub(它们读 base 而非 api_base,否则会全部传输失败)
- no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有
一道检查),预期已改正
- unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价
探活),已从清单移除
(cherry picked from commit 83f1ec0807 )
2026-10-07 17:37:22 +08:00
f042bbf4af
fix(plugins): 分片 VM 拿不到插件配置
...
页面探测走的是 fanout(分片)路径,结果写回走共享 VM。分片 VM 从同一份
源码启动,但没有 state 文件、没有插件身份,所以 PLUGIN.prices(配置,不是
累计状态)在分片里是空的。
对只读累计状态的插件无所谓;对按配置分支的插件就是致命的。OpenDesign
适配器读 prices.control_key,未播种时它对每个主机都得出「未配置」并报
skip,而同一个调用走共享 VM 是好的。也就是说:生产上死、所有调共享 VM 的
测试全绿。
pool 在每次取用时推送配置(不只是首次启动时),并把配置变化也带给池中
复用的 VM;推送是拷贝,分片拿不到共享表的引用。
这条不是在写代码时想到的,是端到端验证时撞出来的:单元测试全过,隔离实例
打真上游时 vela 报「未配置」,而 control_key 明明写进去了。
(cherry picked from commit 65075b6d44 )
2026-10-07 17:37:22 +08:00
97a6cf9224
feat(quota): OpenDesign 出站主机加入白名单
...
插件出站白名单 = 已配置源的 host。这条规则对每个 adapter 都成立,直到
OpenDesign —— 它把推理和计费拆在两个域上:推理 amr-link,计费 amr-api。
adapter 从推理主机推导计费主机,于是它请求的 host 从来不是操作员配过的
那个,请求在离开进程前就被拒,报"不是允许的出站目标",读起来像插件 bug。
core.sourceHosts 现在同时加入派生主机,且仅当源本身就在 amr-link 上
(没有 OpenDesign 源的部署不会凭空获得这条授权)。
派生规则前后端各存一份,因为 Go 建白名单、Lua 建 URL,两边不一致的表现是
「请求被拒」而不是「命名不匹配的错误」。规则改成了完整主机名匹配:
`amr-link.` 前缀写法会把 amr-link.example.com 也改写成 OpenDesign 地址。
顺带修掉一个真缺陷:Lua 侧先 `gsub("/+$")` 再匹配主机名,而 base_url 是
`https://amr-link.open-design.ai/v1 ` —— 带路径时整串拿去匹配 `^...$`
永远不命中,改写静默失效,探测打到推理主机。改成先切路径。
这个缺陷是「行为断言」抓到的:上一版判据 grep 源码里的 gsub 模式,而
gsub 模式在改前改后都"看起来对"。8/8 变异全杀。
(cherry picked from commit 4a63b9dc0b )
2026-10-07 17:37:22 +08:00
d92db3a9fd
feat(quota): 接入 OpenDesign/Vela 余额探测(controlKey)
...
vela 一直显示 skipped,因为计费端点需要第二个凭据:推理走源自己的
runtimeKey,但 amr-api.open-design.ai 的 wallet/balance 和
billing/summary 只认桌面端登录时下发的 controlKey(runtimeKey 返回
401)。全量后拿到了 controlKey,本机 /root/.amr/config.json。
测了实测行为(两个端点各 200,约 1.3s):
wallet/balance → balanceUsd / expiresAt / workspaceId
billing/summary → creditsPerUsd / membershipTier /
subscriptionCreditsUsd / totalConsumedUsd 等
实现要点:
- controlKey 走插件自己的 prices 表(插件系统本就按插件持久化一张
prices,与 state 分离),用 PUT /api/plugins/quota/state 设置;
base_url 保持推理主机,adapter 内部改写 amr-link→amr-api。
- 本账户 billingState=free,没有 codingPlan.windows,所以不设
rep.windows:页面显示"无周期数据",而不是编一个 100%。
- creditsPerUsd 缺失时置 credits_rate_missing 并跳过积分换算,不默认
10000;*Usd 字段是权威值,直接采用。
- 401 归为 error(凭据要重登),不是 skip(无计费端点),两者修复动作
不同。
- skip 时把原因带上:区分「未配置凭据」和「上游无该端点」。
新增 7 条判据 + 7 个变异全部杀死(含一条"skip 丢原因")。变异 harness
第一轮 6/6 时 no-cred-guard 存活,原因是该测试没设 api_base,探测被
出站白名单拦掉、与缺少凭据无法区分 —— 判据失效而非代码问题,已修。
(cherry picked from commit 75a0d247a4 )
2026-10-07 17:37:22 +08:00
b8386465ea
feat(adapters): qwen38 —— tokenplan/discovery-api 适配器
...
接 https://discovery-api.intern-ai.org.cn/v1(qwen3.8-27b 等 10 个模型,5 个支持
视觉)。这是一个 OpenAI 兼容端点,但有 Stock OpenAI 适配器处理不了的地方。
## 为什么需要专用适配器
**① 思考内容占绝大部分输出,且是独立字段**
实测视觉问答:completion=303,其中 reasoning=295 —— 97% 的 token 预算用在
思考上,只有 8 token 变成正文。
后果是**静默失败**:直接透传上游的 content,调用方看到的是空字符串,看起来
像模型坏了。实测 max_tokens=60 时上游返回 HTTP 200 + content=null +
finish_reason="length" —— 一个健康的请求,什么都没产出。
所以思考内容走自己的字段,绝不折进 content:折进去比丢掉更糟,页面会把模型
的内心戏当成答案显示。
**② `finish_reason: "length"` 配 `content: null` 是正常的**
不是上游故障。上报真实的 finish_reason 和已产生的部分思考,让调用方能区分
「预算用完了」和「上游挂了」。
**③ 服务端会改写模型名**
请求 `qwen3.8-27b`,返回 `model: "qwen3.8-27b-fp8"`。响应里的 model 才是
「谁真的服务了这次请求」,计费按它记账。适配器**不改回去** —— 改回去会让审计
记录撒谎。
## 踩到并修正的一个真 bug
reasoning_tokens 最初放在 `token_usage.reasoning_tokens`(顶层)。类型检查
通过,然后被丢弃。
internal/gateway/chat.go 只在一个地方读 reasoning:
resp.TokenUsage.CompletionTokensDetails.ReasoningTokens
实测证据:模型报 completion=88,网关响应里 reasoning=null。字段在正确的地方、
错误的层级,等于没有。
判据因此改成断言**路径**而不是断言「某处有个数字」,变异把顶层放回去立刻变红。
(顺带发现:deepseek adapter 根本没发这个字段,所以线上 reasoning 计费一直
是 0。那是另一个缺口,本适配器不依赖它被修。)
## 配置时要知道的一件事
远程图片 URL 由**上游自己去拉**,会稳定超时(实测 Wikimedia 图片返回
media_fetch_failed)。图片必须用 data: 内联。
## 判据(9 条,夹具全是真实响应)
- 思考与正文分离,且互不污染
- 截断响应上报 finish_reason 与部分思考
- reasoning_tokens 走正确路径,且是 completion 的子集
- tool_calls 的 arguments 从 JSON 字符串解码
- 流式:思考 chunk 不漏、正文 chunk 不丢、末尾 usage 带 reasoning
- 媒体失败带 code(media_fetch_failed),invalid_api_key 不带(免得重复)
- 带 tool_calls 的 assistant 轮补 reasoning_content(否则下一轮被拒)
- 多模态 content 数组原样透传
变异自证九次全部变红,其中三次是本次专门补的:reasoning 折进 content、
顶层 vs 嵌套路径、设成 0。
## 隔离实例实测(生产配置 + 真实上游)
非流式 completion=252 reasoning=167 ✓ 透出
流式 26 chunk / 714ms,正文 37 字符,思考 55 字符 ✓ 分离
视觉 base64 图片 → "白色。" reasoning=44 ✓ 无损
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-06 15:02:42 +08:00
a1fddf72b7
fix(quota): 十三个退避中的源被渲染成「有数据」
...
## 症状
生产 Quota 页面上 17 个源里 13 个显示「有数据」徽标 —— 而它们实际上什么都
没返回,只是刚刚被退避跳过。
## 根因(三层,逐层都静默)
**① 退避记录缺 `skipped` 标记**
`due_sources` 与 `collectSource` 返回退避记录时没有 `skipped = true`,页面的
`filter(r => !r.skipped)` 就把它们当正常记录放进卡片。卡片渲染匹配已知字段,
一条只有 `reason` 的记录什么都匹配不上,于是落到兜底分支。
**② 兜底分支文案是「有数据」**
那是个断言 —— 它在说「探测有应答」。落到这里的记录其实是:一条退避通知、
一种我们不认识的错误形状、或供应商改了响应格式。把「没有证据」说成「有数据」,
十三个死上游就被渲染成健康的了。改成「未探测」。
**③ 退避与「没有余额接口」被混为一谈**
页脚原本统一说「未暴露余额接口」。这是对上游的另一种断言,而且对死上游是错的
—— 它有接口,只是暂时没探到。
## 「只改了一处」这个坑
`render()` 改成按 `backing_off` 标记分组之后,两个调用点都必须传**记录**而不是
名字。我先改了刷新路径就部署,结果是页脚显示「未列出 13 个源:、、、……」——
症状看起来像渲染 bug,实际是两个调用点与同一个函数之间的契约不一致。
快照路径那行 `.map(function(r){ return r.name; })` 是后来才补的。
判据 `TestQuotaRenderCallSitesPassRecordsNotNames` 现在要求两处都在。
## 顺带修掉的两个真 bug
**空 Lua 表在 Go 侧解码成 `{}` 而不是 `[]`**
`plan.backing_off || []` 里 `{}` 是 truthy,空表会原样进入 `out.concat()` 并抛错,
整次刷新报失败。改用 `Array.isArray` —— 它与插件实际返回的类型匹配。
**分组靠文案匹配是个定时炸弹**
原先用 `reason.indexOf("backing off") === 0` 判断退避。理由字符串一旦本地化,
退避就会被并进「未暴露余额接口」。改用显式 `backing_off: true` 标记,
判据里也改成按标记匹配 —— 变异把文案改回英文立刻变红。
## 判据
新增两条:
- `TestQuotaBackoffRecordIsSkippedAndFlagged` —— 退避记录必须带 `skipped` 和
`backing_off` 标记,且必须有 reason(否则卡片是空的)
- `TestQuotaRenderCallSitesPassRecordsNotNames` —— 两个调用点都不得把跳过列表
映射成名字
变异自证四次全部变红:去掉 `skipped`、去掉 `backing_off`、文案改回英文、
快照路径改回 map-to-name。
## 生产实测(浏览器真实点击)
```
第1轮 1694ms: 4 个源 · 0 个读取失败
13 个源正在退避(连续探测失败,等下次重试):trae、qijiar、toter、…
第2轮 2488ms: 4 个源 · 0 个读取失败
13 个源正在退避(…):trae、qijiar、toter、…
```
页面现在只列 4 个真有数据的源,13 个退避中的归到页脚并说明原因。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-05 10:27:18 +08:00
b9a025efba
feat(quota): 判定与记录回到共享 VM,只有网络等待才分片
...
## 我引入的自相矛盾
分片是为解决这个量出来的:
单源探测(无竞争) 1.4 – 3.3 s
20 源 Promise.all 29 s
20 源串行理论值 ≈ 29 s ← 吻合
根因是 Plugins.Call 持 p.mu 到 Lua 函数返回,**含全部网络等待**;一个 Lua VM
只有一个栈,锁不能去掉。于是每个并发调用用自己的 VM 跑同一份源码(实测 8 分片
2.407s → 0.304s)。
但分片 VM 有自己的 PLUGIN.state —— 而**退避状态就存在 state 里**:
fanout(分片) → memo 0 条 → 退避永不生效,每轮都付满 12s
call(共享 VM) → memo 17 条 → 13.1s → 4.8s
我设计分片时写了「分片不碰 state」这条硬约束(防 billing 的累加在各分片分叉、
saver 写出随机一份),却没意识到探测的退避状态恰好住在那里。约束和功能打架。
## 解法:按职责切开,不搬家
把退避表移到网关(B)能解决,但 quota 的 adapter memo 也在 state 里,而且
「插件自包含」是这个插件机制的前提。搬一半比不搬更糟。
所以判定与记录留在插件里,共享 VM 负责;只有网络等待分片:
due_sources 共享 VM,纯判定,不发请求(微秒级)
fanout 只对 due 里的源分片探测
memo_record 共享 VM,把这轮结果(成功和失败都记)写回
一个分片也不碰 state —— 约束保住了。
## 实测(隔离实例,18 源,与生产同配置)
第1轮 10163 ms 退避跳过 0 分片 18 答了 4
第2轮 1299 ms 退避跳过 14 分片 4 答了 4
第3轮 1288 ms 退避跳过 14 分片 4 答了 4
第4轮 1316 ms 退避跳过 14 分片 4 答了 4
**29s → 1.3s,且 4 个有余额的源每次都探到** —— 不是靠少显示换来的快。
## 几个细节
- due_sources 的 body 里不能出现 collectSource:一旦它自己探测,过滤就没有意义
了(成本在分片之前就付掉了)。有判据守着。
- memo_record 忽略 reason 以 "backing off" 开头的行 —— 那是被跳过的探���,
把它记成失败会让 streak 在一次没发生的探测上增长。
- 成功写成 vendor 字符串,streak 自然重置,不需要显式清零。
## 判据(新增 4 条,共 11 条读实际发布的 quota.lua)
- 两个导出存在且在 exports 里
- due_sources 不调 collectSource
- memo_record 让 streak 从分片失败里累加,成功后重置
- due_sources 把退避中的 host 挡在分片之外,且零网络请求
写判据时踩了一次:两个源用了同一台 httptest server,于是被判为同一个 host,
两个都退避 —— 这是按 host 记忆的**正确**行为,是我的判据写错了。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-05 08:17:57 +08:00
7c7cf3e078
fix(quota): hostOf 用了 gmatch 而不是 match —— memo 表从未命中过任何东西
...
## 这是退避失效的真正原因,而且比 TTL 严重
查生产 state 时发现的:19 条 memo,`hostOf(key)` 命中 **0** 条。
hostOf = function(u)
return (string.gmatch(u, "^https?://([^/]+)")()) or u
end
`string.gmatch` 返回**整个匹配**,不是捕获组;而带 `^` 锚定时迭代器不会前进,
所以第一次 `()` 调用返回 **nil**,函数走 `or u` 分支返回完整 URL。
后果是彻底的、而且完全静默:
- 每次查表都是 HOST_MEMO[nil],每次写入也是 HOST_MEMO[nil]
- memo 表**对任何 host 从未命中过**
- 负 memo、PROBE_MEMO_TTL、以及刚加的指数退避,**全是看起来活着的死代码**
- 生产那 19 条历史 memo(key 是完整 URL,来自 ccquota 时代)从来没有被读过
所以「两个死上游每 30 分钟重付一次探测」的真实原因不是 TTL 到期,
而是压根没命中过 memo。
## 为什么这么久没人发现
失败模式与「memo 里没有可记的东西」完全不可区分:页面照常显示,卡片照常
skipped,没有任何报错。唯一线索是 state 里那 19 条条目 —— 它们看起来像是
在正常工作留下的痕迹。
## 修法
string.match(u, "^https?://([^/]+)")
判据 `TestQuotaMemoKeyIsTheHost` 钉住它:读**实际发布的** quota.lua,检查
用 string.match,并在运行时验四个 URL 的 host 抽取,以及同一 host 的两个不同
路径必须产出同一个 key(这正是按 host 做 memo 的意义)。
变异把 match 改回 gmatch 立刻变红,并打印出实际返回的完整 URL。
2026-10-05 08:17:57 +08:00
18525ae4c0
chore(plugins): 移除分片诊断日志
...
SHARD_DEBUG 只在排查「18 个分片为什么还是 12 秒」时用过一次,结论是
17 个 VM 真的在并行(17 VMs live),慢的是其中一个源本身。留着它就是
给热路径留一个 os.Getenv。
2026-10-05 08:17:57 +08:00
c4b37d4322
feat(quota): 探测退避改指数 —— 死上游不再每 30 分钟重付一遍
...
## 为什么 30 分钟 TTL 治不了死上游
线上有两个 host 已经**不可达十个小时**,而 PROBE_MEMO_TTL 是 1800 秒。
于是每次刷新它们的负 memo 都已经过期,每次都重付三个 adapter 的超时:
每个源 24s(预算修好前)、16s(修好后)。固定 TTL 对「已经死了很久」这个
形状完全无效 —— 它只会一直重复付。
指数退避:连续失败 streak 次,等待 1800 × 2^(streak-1),封顶 6 小时。
成功会把 memo 覆盖成 vendor 字符串,streak 自然重置。
封顶不是可选项 —— 没有它,下线一周的 host 就不再被探测,页面会**永久**
把它误报成「没有余额接口」。
## 过程中判据抓到的三个真 bug
**① 记录守卫让 streak 永远是 1**
`if rep == nil and not HOST_MEMO[host]` 只在 host **第一次**失败时为真。
之后 memo 已是 table,守卫为假,什么都不写。实测连读四次都是
`{at:... streak:1}` —— 退避退化成固定 30 分钟,正是它要解决的问题。
**② `return 0` 在 Lua 里是真值**
`if streak == nil or streak <= 0 then return 0 end` —— 只有 nil 和 false
是假值。调用点写的是 `if backoffSeconds(...) then 跳过`,于是**所有没有
streak 字段的 memo 会被永久跳过**(而那正是本改动之前写的每一个 memo)。
**③ 上限路径 `return BACKOFF_MAX` 也是真值**
21600 会被判成「仍在等待」,封顶反而变成「永远不重试」—— 恰好是封顶要防的
那个故障。现在所有路径只返回布尔。
三个都是静默的:症状只是页面少了几张卡片,没有任何报错。
## 判据
五条,全部读**实际发布的 quota.lua**(go:embed 那份),不是副本:
- 退避确实被调用(变异把调用点换成 `if false` 时会红)
- 窗口表:每个 streak 的窗口长度、窗口内跳过、窗口过期后重试、
荒谬 streak 也必须重试
- 只返回布尔(六种输入,含无 streak 字段的老 memo)
- 记录守卫会替换 memo 而非合并
- 通过真实插件:首次失败记 streak=1;**跳过不算失败**(否则一个从不被探测的
host 会无限增长 streak,再也不会被重试)
变异自证六次全部变红:`return 0`、上限返回数字、守卫不替换、调用点去掉、
去掉上限、倍数改成 1。头一次跑「守卫不替换」漏放了 —— 因为我把集成判据缩到
只验首次记录,补了一条源码断言才抓住。
## 这个文件的判据绕了远路,如实记下
`HOST_MEMO` 是进程内缓存,只在首次 collect 时从 state 恢复,之后只往回写。
所以从 Go 侧改 state 对插件不可见 —— 我先是不自知地这么试了两轮,把一个
**正确工作的退避**看成坏的。现在窗口策略直接验函数(经 shim 导出插件自己的
backoffSeconds,不是重新实现),集成判据只留真正能从外部观察的两条。
shim 也踩过:直接把 `backoffSeconds` 当 export,而 Go 侧传的是 table,
于是 at=table、streak=nil,每次都走「无 streak」分支返回 false —— 看起来
和「退避永不触发」一模一样。包一层就对了。
2026-10-05 08:17:57 +08:00
33b778faee
feat(plugins): 分片调用 —— 让阻塞型导出函数真正并行
...
## 问题
quota 页面 20 个源,刷新要 29 秒。页面用 `Promise.all` 并发发 20 个请求,
**看起来是并发的,实际不是**。
`Plugins.Call` 持 `p.mu.Lock()`,锁一直持有到 `L.Call` 返回 —— **包含 Lua 函数
内部的全部网络等待**。20 个请求到了网关后排队,串行累加。
生产实测:
单源探测(无竞争) 1.4 – 3.3 s
20 源 Promise.all 29 s 墙钟
串行理论值 ≈ 29 s ← 与实测吻合
代码里其实已经知道这个问题:quota.lua 第 104 行写着「the whole page shares ONE
Lua state under a write lock」,并为此把超时从 10s 压到 4s。但那只把 29s 变成
约 12s,没有解决「刷新不可用」。
而当初改成并发的理由(页面注释)是错的 —— 它假设并发能绕开串行。锁让它退化
回串行了。
## 不能去掉锁
一个 Lua VM 只有一个栈。两个并发调用会破坏栈。锁是必要的,答案只能是**让每个
并发调用有自己的 VM,跑同一份插件源码**。
先量,再写实现(8 分片 × 300ms 本地 HTTP 端点):
共享 VM,8 次串行 2.407 s
8 个独立 VM 并行 0.304 s → 7.9x
顺带一个测量教训:这个 spike 第一版用**忙等**而不是网络 I/O,报出 2.3x。
那是量到了 12 核机器上的 CPU 争抢(负载 8.95),和锁无关。插件做的是网络等待,
spike 就必须做网络等待 —— 和上一条提交里 os.clock 那个错误是同一类。
## 实现
- `callOn(w, fn, args)`:从 `Call` 里抽出的「在某 VM 上调一个导出函数」。`Call`
从 66 行缩到 18 行,仍负责查找、export 白名单和共享 VM 的锁。
- `shardPool`:私有 VM 池,上限 32。开机约 0.1ms,池化省不了什么但省两次刷新
的重复开销。
- `ShardCall(name, fn, argsList)`:结果与输入**按位置对齐**。
- `POST /api/plugins/{name}/fanout/{fn}`:`{"args": [...]}`,上限 64 条。
- `pluginAPI.fanout()`:页面一次请求。
- quota.lua 改用 fanout。
## 分片的硬约束(写进注释,不是靠约定)
分片 VM **不能碰 PLUGIN.state** —— state 在 VM 里,每个分片一份副本,billing
的累加会各分叉,saver 写出去的是随机某一份。分片只能用于只读、幂等的工作。
所以 `callOn` 既不查 export 白名单(`Call` 那层才是安全边界)也不落盘。
## 一个分片失败不拖垮整批
一个上游连不上,不该让 20 张卡片变成一个错误。失败的分片在自己的位置留
null,其余照常返回。**但全部分片失败时报错** —— 那不是「某个上游挂了」,
是结构性问题,报成功会把它藏在满屏红卡片后面。
## 顺手修掉一个既有缺陷:热重载是坏的
写分片源码来源时,判据直接把它顶了出来:`LoadSource` 无条件 append,而
`findLocked` 返回**第一个**匹配。实测:
LoadDir 后 1 条 → LoadSource 后 2 条
[0] version=1 [1] version=2
find 返回 version=1
也就是说,**编辑插件并保存看起来什么都没发生** —— 旧版本一直被返回,新版本
躺在旁边不生效。修法是先 Unload 再加载。这不是为分片做的,是分片正好照到了
这条路径,已单独补判据 `TestReloadReplacesRatherThanAppends`。
## 判据
七条:并行性(有界而非计时断言,8×200ms 串行是 1.6s,超过 1.0s 即红)、
位置对齐、未导出与未知插件被拒、单个失败不拖批、全失败报错、用已安装源码而
非磁盘、以及热重载替换而非追加。
全量 9 包通过。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-05 08:17:57 +08:00
345cbb30d6
fix(billing/quota): 探测预算用 os.clock 是 CPU 时间,12 秒预算从未生效
...
## 现象
线上 quota 页面点刷新后卡在「探测中…」,13 秒仍未完成,按钮一直 disabled。
## 实测根因(不是猜测,是量出来的)
单源探测,隔离实例,逐个测:
juziai 16029 ms
beizhi 16021 ms
localzen 2516 ms
frank 2028 ms
sensenova 258 ms
而插件自己写着 `PROBE_BUDGET_MS = 12000`。**修复前**这两个源各耗 **24013 ms**
—— 精确等于 4s 超时 × 2 次重试 × 3 个 adapter。预算形同虚设。
## 原因
`exhausted()` 用的是 `os.clock()`,它返回 **CPU 时间**。探测几乎全耗在网络等待上,
CPU 几乎不动,于是预算永远不触发。
判据 `TestPluginClockDuringNetworkWait` 直接量这件事:
3s blocked on HTTP: os.clock advanced 0.001s, os.time advanced 3s
**001 秒。** 一个用 CPU 时间写的预算,在 I/O 等待下永远不会响。
这个陷阱和「用忙等测锁竞争」是同一个,只是下沉了一层 —— 而且我第一版判据
正是这么写错的:用自旋循环当「网络等待」,结果 os.clock 走了 1.624s / 2s,
看起来 os.clock 跟的是墙钟,差点得出「不是这个原因」的错误结论。必须用真实
HTTP 请求重测才能拿到真相。
代码里两处同样的写法(第 434 和 466 行),一起改掉。
## 改完的效果
串行 20 源(/call/ 逐个) 84391 ms
fanout 20 源(并行) 16022 ms
⇒ 5.27x
单个死上游从 24s 降到 16s(12s 预算 + 收尾)。
## 还没解决的
16 秒仍然偏长,因为它等于**最慢那一个源**的预算。这不是并行失效,是并行
之后墙钟的必然下界。要再往下走只有两条路:把预算压到 4~6s,或者给已知死掉
的上游走缓存而不是每次重探。都是参数与策略选择,不是结构缺陷,留给下一步。
2026-10-05 08:17:57 +08:00
501ad6aac9
refactor(plugins): 统一授权词汇 —— AccessRule
...
## 问题
同一个问题「谁能看到 / 能调用什么」有两套规则、相反的默认值,并排住在两个文件里:
audienceAllows(want, role): "" / "all" → 全开 页面可见性
HTTPHandler.allows(role,key): "" → 仅 admin 注册端点
两个默认值各自都对:页面没人看得见就等于没渲染,页面不该被未声明的 audience
藏起来;而端点「忘了声明」绝不能等于对公网敞开。
危害不在默认值本身,在于**同一个问题有两套词汇**。运维配「谁能看 billing」和
「谁能调 /balance」得先学会两套规则,才能回答同一个问题。
## 改动
新增 internal/lua/plugin_access.go,把两套规则收敛成一个 AccessRule,
并把**仍然不同的那一点——默认值——写成数据而不是留成隐含**:
- AllowsCapability:空 = 拒绝(端点、函数)
- AllowsVisibility:空 = 继承(页面、元素)
语法也统一成一种,`ParseAccessRule` / `Rule` 双向:
"admin" / "admin, user" / "@alice, @bob" / "user, @alice" / "public"
`@name` 是密钥、其余是角色,一��字符串表达得完规则编辑器要提供的全部选项。
## 抽取时差点弄丢一个真实语义
`"all"` 是既有插件文件和**已保存的运维 override** 里的写法。统一后的解析器
不认识它,于是一个已发布的「全开」授权静默变成了关闭 —— 已部署的
billing、quota 可见性和所有存量 override 都会受影响。
是既有的 TestAudienceAllows 抓到的,它当场变红。这正是抽取的实质收益:
两个函数并排住着的时候,没人会想到去动那个共用字符串。
`TestAllIsAnAliasForPublic` 把它写成新类型的性质,而不是留给碰巧跑到的那
个套件去守着。
## 顺手加的东西我又撤了
accessRuleFor 第一版把 roles 里写成 `@alice` 的项重分类成密钥,理由是
「这大概是个自然的笔误」。那是在重构里偷带行为变更:会把写了莫名其妙内容的
端点的可达范围悄悄放宽。已撤,注释里写明为什么这个笔误值得修是另一件事、
需要它自己的理由和判据。
## 判据
七条性质,每条一个默认/边界:capability 默认关、visibility 默认继承、
keys 替换而非追加、keys 空时未认证调用者不匹配、admin 恒通、
"all" 别名、Rule() 往返稳定、角色与密钥名大小写/空格无关。
变异自证四次,全部变红:keys 变 OR、capability 默认全开、visibility 默认
拒绝、丢掉 "all" 别名。
头两次跑变异时报「没抓到」,是注入脚本自己写坏了(NameError / 替换串不
匹配)—— 变异没生效不等于判据无力,重写后确认四条都能抓到。
## 一条被记录下来的意外
`TestRoleAndKeyUnionAdmitsBoth` 最初断言「同时列出 role 和 key 时两者都放行」。
debug 跑出来是相反的:keys 非空就整体替换掉 role,carol 被拒。
这个意外正是不把它放在注释里的理由。判据现在断言实际语义(替换),与既有
HTTPHandler.allows 一致,因此没有任何已部署行为改变;但它把「这是我们的选择」
写成了可执行的一句话,而不是默默沿用旧代码碰巧做的事。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-05 08:17:57 +08:00
39cda89def
refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃
...
## 问题
三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:
ui = { visibilty = "admin" } -- 一个字母之差
⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分
今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。
## 改动
新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。
readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。
## 顺带修掉一个真 bug:handler 必须是名字而非函数值
`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。
## 更正一个我写了但从未核实的说法
之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:
setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
json global present when readPlugin* runs: true
luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages
真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。
一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。
## 判据
- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了
变异自证:strictKeys 短路 → 变红。
已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-05 08:17:57 +08:00
91e3013cb5
feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
...
## 影响面实测
审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849,
p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。
按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次
(deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或
免费源(localzen),记 0 是正确的,对阶梯无影响。
那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重,
而且这个偏差只会随上下文变长而变大。
## 边际而非混合平均
阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性:
缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。
混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存
存在的意义所在(本部署缓存命中率 88%)。
超出部分按缓存/新鲜的比例拆分,不重不漏。
## DSL 两种写法
供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和
倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯
会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。
两者都给时以绝对费率为准,因为那是供应商真正报出的数字。
## 判据
Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。
变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯,
分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。
规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 ——
与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit 314f4da8f2 )
2026-10-04 14:50:47 +08:00
92b81ecfd3
feat(billing): reasoning 价接入 DSL 与规则编辑器
...
上一提交把链路打通后,reasoning_price 只能靠手改 prices 生效 —— 而 prices 是
编译产物,不是人能编辑的东西。这条补上让它真正可用:
- BillingToken 加 reasoning 字段(USD/百万,与 prompt/completion 同单位)
- compile 只在规则显式声明时写 reasoning_price,缺省时字段不出现
- 规则编辑器的模型行加「思考 $/M」输入框,含新增行的模板
缺省时「不出现」是刻意的:写 0 会被 billing 插件读成「思考免费」,
每个来自该模型的请求都会静默少算。判据专门钉住这一点,变异成「缺省写 0」
即变红。
输入框 placeholder 写明「留空=按输出价计(OpenAI/DeepSeek/Anthropic 都这样)」,
因为一个没有解释的空输入框会被当成「必填但我不知道填什么」。
判据还要求 m-r 出现两次 —— 只改已有行的模板而不改「+ 模型」新建的行,
会让新加的模型在下一次保存时静默丢掉这个价。
生产验证:PUT 一个 reasoning: "1.80" 的规则,编译产物里出现
reasoning_price: 1.8000000000000001e-06(USD/token)。测完已把生产配置恢复为
6 条规则(备份 config.yaml.bak-billing-20261004-122151 为基准重建,
20 个源数量核对一致)。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit b93799a919 )
2026-10-04 14:50:47 +08:00
d119d09929
feat(billing): 记录 reasoning token,并支持按规则单独定价(默认不启用)
...
## 断掉的链路
types.go 里 ReasoningTokens 从被定义起就没人读过。适配器层早就解析并透传了
(opencodego / gemini / anthropic 都写了那段提取),网关在 chat.go 记录 usage
时读了 PromptTokensDetails.CachedTokens,却在隔壁的
CompletionTokensDetails.ReasoningTokens 上把它丢了 —— 数字到了响应里,
在唯一能留住它的地方被扔掉。
后果:任何计费规则都无法区分「思考」和「可见输出」。
## 为什么默认不启用定价
实测四个源(deepseek-v4.1-flash / claude-opus-4-8 / glm-5.3-flash /
space-bunny-free,含 anthropic thinking opt-in)全部不返回该字段。所以
reasoning_price 默认为 nil,costFor 完全跳过该分支,标准公式与改动前逐位相同
—— 这是关键:现有账单不能因为这次改动而变动。
启用时只应用「差额」:把思考部分从普通输出价重定价到思考价。完整再收一次会
对每个思考 token 双重计费,这正是判据里最容易被写出的错误实现。
clamp 到 completion_tokens:供应商报出比 completion 还大的 reasoning 是畸形
数据,不 clamp 会得到 92 倍于整个请求的费用,而且这个数字看起来足够合理,
能在仪表盘上活下来。
判据三条 + 三次变异自证(移除 clamp / 双重计费 / 默认启用)全部变红。
## 遗留
reasoning_price 未接入 BillingRule DSL —— 目前只能通过直接写 prices 生效。
DSL 字段要连带改 config 结构、编译器和规则编辑器 UI,是独立一步。
实测全部源都不返回该字段,所以这条暂时没有实际影响面。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit c36200b78b )
2026-10-04 14:50:47 +08:00
27b908d98c
fix(billing): 未定价按天统计,让「已修好」和「还在漏」不再长得一样
...
计费页只有一个终身计数器 unpriced_reqs,实测 6378 —— 占 18981 请求的 34%。
但这个数字回答不了运维唯一会问的问题:「还在漏吗?」
原因是它把两件事混在一起:加规则之前的历史欠账,和加规则之后仍在发生的漏计。
修好一个源之后终身计数纹丝不动,于是仪表盘上「未定价 6378」和一个仍在
什么都没记的系统完全无法区分。
改动:
- 钩子按 UTC 天写入 unpriced_by_day{day:{reqs, models}}。终身计数保留
(报表不该改写历史),天桶是新增的观察维度。
- 页面加「未定价(近 7 天)」一行。为 0 时额外显示「近 7 天无」——这时候
零本身就是结论:加的规则确实生效了。
- 没有天桶数据时返回 null 而不是 0。旧的 state 文件说「未知」,
不该假装有一份干净记录。
生产实测:全部视图 终身 6378 / 近 7 天 2;日视图隐藏终身计数(null)但保留
近 7 天 —— 周期视图原本把两者都置 null,因为那时根本没有天桶。
失败的请求同样计入(上游 502 的 gpt-5.5 也进了天桶):一次失败的请求照样
消耗上游配额,只是没拿到 token,单看 token 维度会漏掉。
判据:两个不同日期的请求必须落到两个天桶且终身计数不重复累加;变异成
「总记今天」即变红(只判首日会漏掉这一点)。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit ba6de89d57 )
2026-10-04 14:50:46 +08:00
ba10f03fc7
feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0
...
## 插件管理页(用户要求:权限不要只放 config.yaml)
插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改:
- 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人)
- 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public)
原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。
## 修掉的三个真缺陷
1. endpointAccess 读错了 override 的键
SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。
后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。
鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。
2. override 只在内存里
内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失,
日志里没有任何线索。现在落盘到 plugin_dir/.access.json,
插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件,
"没有覆盖"用文件不存在来诚实表达。
3. 可见性列表泄漏内部键
端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello"
并可能把它当 audience 存回去。现在按已注册端点集合过滤。
另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的
集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会
显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。
## 活跃请求:三个视图显示 0
active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。
前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃",
而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。
现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。
判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。
生产实测 day/week/month/all 均为 4。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit 120ba9e957 )
(cherry picked from commit 3b28fde22e )
2026-10-04 14:50:46 +08:00
cab3361073
fix(plugins): quota 页面加载时不再自动探测全部上游
...
首屏 19.1 秒 → 1.0 秒(生产实测)。
症状是"页面出来了但数据卡在加载"。量出来的原因不是渲染,也不是后端:
所有接口都在 0.4–2ms,而 Network 事件显示页面在头 70 毫秒内并发发出了 21 个
/api/plugins/quota/call/collect_source —— 每个源一次。CPU 采样 98% 空闲,说明
主线程不是在算,是在等。
插件页面的脚本里有 mount(); refresh();,而 refresh 会为每个源发一次探测。
探测要出网,且每次调用都持插件写锁,于是 21 次串行排队;冷启动实测 100 秒,
有缓存后 22 秒。WebUI 的首屏就这么被堵住了。
改动:
- 页面加载时不再自动探测。运维没要求的余额不值得用它换首屏。
- 改为先渲染上次的快照(pluginAPI.fetchState),标题栏标注"快照 HH:MM(点刷新
取实时)"。点刷新才真探测。这样打开页面立刻有内容,实时数字在后面更新。
- billing 页面同样有 refresh(),但它只读本机 state(1.5ms),无害,保留。
判据:首屏填充时间实测 19.1s → 1.0s,且首屏请求里不再出现 collect_source。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit c17a851ec1 )
(cherry picked from commit 9823773330 )
2026-10-04 14:50:46 +08:00
35b9422232
feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
...
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。
UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。
覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。
过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。
角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。
不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。
有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。
插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。
roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。
**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。
写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。
readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。
readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。
去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。
生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit a6c6e6e5c7 )
(cherry picked from commit 5eef5ad635 )
2026-10-04 14:50:46 +08:00
2b34c7dc8a
feat(plugins): quota 通用套餐余量插件(取代 ccquota)
...
ccquota asked one vendor one question. The shape that generalizes is two parts:
a small set of probe adapters, and a declarative override for a provider nobody
anticipated.
Three adapters, each verified against a LIVE endpoint rather than assumed:
- deepseek family: GET /user/balance →
{is_available, balance_infos:[{currency,total,granted,topped_up}]}.
Granted vs topped-up is kept separate on purpose: gift credit expiring on a
schedule is a different operational fact from prepaid money.
- OpenAI console shape: /v1/dashboard/billing/subscription +
.../billing/usage. This reports LIMITS and SPEND, not a balance — the live
provider has hard_limit_usd 1e8. There is deliberately no `remaining`:
ceiling - spend would read as "$99,999,339 left", a number nobody should
believe. It renders as spend against a ceiling instead.
- commandcode: rolling 5h / weekly / monthly windows plus billing period.
A provider with no working adapter is SKIPPED and listed by name at the bottom.
Most of these upstreams are small re-sells whose dashboard paths return an HTML
page rather than JSON; a table full of "unknown" rows teaches the operator to
ignore the page entirely. Failures that are NOT "no endpoint" — a 403 on a key we
hold — stay visible, because those are the ones worth fixing.
## Cost control, and a lock the probe runs under
First load measured 101s for 21 sources: 3 adapters x 2 attempts x 4s, times 21.
Call() holds the plugin's write lock for the whole invocation, so every
__llmsproxy_http round trip inside it blocks request_end hooks — which is how
billing accrues money. A slow upstream is not just a slow card.
- Timeout cut to 4s (from 10s).
- A host->adapter memo, persisted in plugin.state, so the dead ends are
discovered once per process instead of on every page open. Steady state is
22s.
- The memo records NEGATIVE results too, which is what actually paid: only
recording successes left every silent source re-probing forever.
- Negatives carry a timestamp and expire after 30 minutes. "This upstream has
no balance endpoint" is a fact that changes — vendors ship dashboards — and a
permanent negative makes the page quietly stop reporting a source that
started working.
- The page calls collect_source concurrently, one call per source, so a single
502 over 21 sequential probes becomes 21 independent answers.
## Two bugs the page found
- deepseek reports topped_up_balance -0.02 against a positive grant; the `v <= 0
→ "0.00"` branch flattened a real debit to zero. Negative balances now render
as -¥0.02.
- PLAN_CAPS was declared below the adapter that reads it, so Command Code hit
"attempt to index global 'PLAN_CAPS' (a nil value)" on every probe. A Lua local
is nil until its assignment runs.
Verified on production: 5 sources report, 16 skipped by name, 1 visible failure
(tabitoken 403). deepseek ¥0.32 (赠送 0.34 · 充值 -0.02), justwoker $360,865.71
spent against an ∞ ceiling, both Command Code seats with their three windows.
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit 098a4a5eb4 )
(cherry picked from commit 40636abcc2 )
2026-10-04 14:50:46 +08:00
85c81c63cc
feat(plugins): ccquota 多账户额度插件 + 页面→Lua 调用通道
...
A quota plugin cannot work on billing's model. Billing reports what the gateway
already measured, delivered on hooks during request forwarding. A plan balance
has no such arrival path: it exists only on the vendor's side, and the only way
to learn it is to ask — at the moment the operator clicks Refresh. No hook fires
on a click, and pluginAPI offered only state read/write.
So this adds the missing piece: POST /api/plugins/{name}/call/{fn}, plus
pluginAPI.call on the page side.
## Why the gate is an explicit export list
That endpoint is an HTTP surface into a Lua state. Without a declared allowlist
it would let any caller invoke the plugin's private helpers with crafted
arguments and read whatever it can see — for a plugin holding an API key, that
is handing the key to anyone who can POST. So a function is callable only if the
plugin listed it in plugin.exports. A plugin with no exports has no callable
surface; reading the missing list as "allow all" is exactly the failure mode
worth designing against. The endpoint is also admin-only.
## ccquota
One account per configured source on api.commandcode.ai, keyed by that source's
own key — two keys on one host are two seats, and both are shown. Reads
/alpha/whoami, /alpha/billing/credits, /alpha/billing/subscriptions and
/alpha/usage/summary. Each endpoint degrades independently: a vendor retiring
one route must not blank the others, and a key out of quota still reads credits
fine while inference 401s, so the report records WHICH part failed rather than
rendering a zero that reads as "no quota left".
The 401-is-everything trap: those endpoints need a bearer token plus three
headers. The token lives in the table now, per request. Passing the shared
header table straight through — my first version — yields 401 on every
endpoint, which is indistinguishable from a revoked key and sent me auditing the
account instead of the plugin.
## Also fixed: three real defects found by going live
- The bridge was registered as `httpGlobal` (the Go const's IDENTIFIER) instead
of `__llmsproxy_http` (its VALUE). Green unit tests, "attempt to call global
'__llmsproxy_http' (a nil value)" on first real request. Now a test boots a
real plugin and calls it the way a plugin does.
- The call router kept `call/` in the function name, so every call 502'd with
`does not export "call/collect_account"` while the exports list looked fine.
- readPluginExports used luaToJSON, which needs the `json` global — not yet
installed while a plugin is registering — so it silently returned an empty
list and every export looked undeclared. It uses luaTableToGoValue now.
Two tests hardcoded counts ("1 bundled plugin", "4 pages") and turned red the
moment a second bundled plugin existed. They now assert what they mean: billing
is among the bundled plugins, and the pages this test contributed all merged in
order. A count assertion is a changelog detector.
## Verified on production
commandcode 2198972886@qq.com active $0.0811 5h 0/14 wk 0.803/35 44,921 req
commmandcode2 jianf3112@gmail.com active $0.0700 5h 0/14 wk 0/35 43,827 req
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit 111dc6d53f )
(cherry picked from commit 22c9215b02 )
2026-10-04 14:50:46 +08:00
a2bb6de9fb
feat(plugins): 给 Lua 插件加受限出站 HTTP(__llmsproxy_http)
...
billing 插件从不主动出站——它报告的一切都随网关转发请求时的 hook payload
一起到达。额度插件是第一个必须自己发请求的东西:套餐余量不主动问就拿不到。
问题在于这恰好是插件模型最锋利的边。插件是 WebUI 文本框里装进来的单个
.lua 文件,跑在网关进程里:给它一个通用 HTTP 客户端,等于让每次安装都成为
一件常备的 SSFP 工具,还顺带打开了读环境变量拿凭据的第二扇门。
所以这个桥刻意做窄:
- 目标必须命中某个「已配置源」已经在通信的主机。那些是运维声明过的,
不是插件声明的——插件无法靠编一个 URL 扩大自己的触及面,而源的主机
正是本部署已经愿意交付 API key 的那批上游。
- 非 loopback 主机上拒绝明文 http。凭据只该走 HTTPS。
- loopback 免登记:它出不了本机,而 billing 规则页读网关自己的 API 正是
这么做的。
- 每请求都有超时、响应体有 4MiB 上限,插件无法占死 worker 或吃光内存。
- 绝不从环境变量读凭据。需要 key 的插件必须被显式给(state 或调用方配置),
这样授权对运维可见,而不是环境里默默存在的东西。
- 不跟随重定向:白名单是逐跳检查的,跟随就等于绕过它,3xx 原样返回。
能力只装在插件运行时(bootPluginState),不装进 setupGlobals——后者被
adapter 池共用,adapter 是运维的配置而非安装的代码。
判据 8 条钉的是拒绝路径而非快乐路径:未登记主机(含 169.254.169.254、
后缀欺骗 api.example.com.evil.test)、明文 http、空名单必须全拒、不得跟随
重定向、超时与响应体被钳制、超大响应宁可报错也不截断(截断会解析成损坏
JSON,插件会报解析错而不是真因)。
测试当场抓到一个真 bug:loopback 被白名单一起拦了,与代码注释里「loopback
免登记」自相矛盾。变异(把主机检查换成 if false)被 3 条判据抓住。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
(cherry picked from commit 0fbb8e5e88 )
(cherry picked from commit e76e1623e4 )
2026-10-04 14:50:46 +08:00
ed9baae8e4
feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
...
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。
补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。
后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。
CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 08:48:46 +08:00
400b6c35a5
fix(billing-ui): 规则表列宽锁定,消除表头/按钮重叠
...
全部靠截图 + 几何测量发现,DOM 断言当时全绿:
- table-layout:fixed 把 954px 均分成 9×106px,写在 <td> 上的每列宽度全部失效 →
相邻表头叠在一起,源 URL 输入框被压成 "https:"。
- 把宽度改到 <th> 并去掉 fixed → 浏览器改按内容算,<th> 宽度又被忽略,币种列
仍塌到 48px(输入框只剩 24px,装不下 "USD")。
- 恢复 fixed + 宽度移到 <colgroup>:fixed 下只有首行宽度生效,而「按模型价格」
格宽度随哪条规则最宽而变(实测 352px vs 206px),整行布局取决于恰好哪条规则
最宽。colgroup 一次性锁死九列,所有行一致。
- 表头双语长文案(峰段星期 UTC 1=周一)超出列宽 → nowrap + ellipsis,完整文案
留在 title。
- 删除列 58px 装不下「删除」按钮(折成两行、按钮高过整行)→ 74px,从峰段倍数
列匀出。
- 价目行 80+96+96+38≈330px 挤在 262px 格子里,× 按钮越界 78px 压在隔壁「删除」
按钮上 → 输入框改 flex:1 1 …/min-width:0 可收缩,× 保持 flex:0 0 auto。
- 顺带修上轮引入的回归:给 modelBlock 的 flex 换行后模型名不再被截断。
判据 TestRuleTableLocksColumnWidthsInAColgroup + 3 个变异(去掉 min-width:0 /
改列宽数组 / 去掉 table-layout:fixed)全部被捕获。第三个变异第一版漏放——判据用
strings.Contains(js, "table-layout:fixed") 匹配到了上方三行的解释性注释,改成匹配
style='…' 字面量后才真正失败。
2026-10-02 15:29:29 +08:00
c19b8e6394
fix(scheduler): AUTO 遇空内容响应降级到下一个 slot(客户端曾报 "no content")
...
生产故障:pi 客户端报 `model "AUTO" returned a completed response with no content`,
重试延迟 8 秒。实测 AUTO 20 次有 2 次返回空 content,全部是 claude-opus-4-8。
根因(直连上游抓包确认):思考型模型先吐 reasoning_content,max_tokens 小到
思考阶段就把预算用完时,上游返回 200 / finish_reason=length,28 个 chunk 全是
reasoning_content、content 一片空白。runTier 只看 err == nil 就当成功返回,
客户端拿到一个空响应。
修复:
- resultIsEmpty:非流式路径把「无 content、无 tool_calls、无 image」的响应当作
slot 失败继续降级。注意 ReasoningContent 不算内容——客户端要的是文本,为
另一个模型的思考阶段扣住请求比降级更糟。
- peekStream:流式路径在出现首个真实内容前缓冲 reasoning 前导,流结束仍无内容
则回报空结果,让 chainDrive 换 slot。缓冲只覆盖思考前导,拿到内容后立即
转发。tool_calls delta 算内容,agent 回合不会被误判。
- 3 条判据 + 3 个变异(恒 false / 恒 true / reasoning 算内容)全部被捕获。
同时修三个 WebUI 布局缺陷(都靠截图而非 DOM 断言发现):
- 插件侧栏项只渲染图标没有标题:btn.innerHTML 只塞 pluginIconHTML(pg.icon),
与原生页的「图标 + <span>标题</span>」不一致,侧栏是一排无名图标。
- 计费维度表 8 列挤在 465px 卡片里:table-layout:fixed 把每列压到 62px,
23/80 个单元格溢出、数字互相重叠。改为 6 列(token 细分合并为
「输入(新鲜+缓存)」,细分进 title)+ table-layout:auto,实测 0/60 溢出。
- 数字列 word-break:break-all 让每个字符独占一行(USD 0.56 竖排成 U/S/D),
改 nowrap + 容器横向滚动。
2026-10-02 15:10:01 +08:00
d9652f479a
fix(plugins): 插件 Lua 报错不再拖垮网关(生产事故修复)
...
## 事故
13:37 部署后线上 6 次 SIGSEGV 崩溃循环,8081 完全不可用,用户报大量
connect error。崩溃点固定在 internal/lua/plugins.go:invoke → L.Call →
golua StackTrace 里的 lua_getinfo。
## 根因(不是并发/GC/锁)
golua 的 callEx 在**任何** pcall 失败后无条件执行 L.StackTrace(),而
StackTrace 调 lua_getinfo,这个 LuaJIT 构建在栈够深时(带 AUTO 链轨迹的
request_end payload 正好够深)直接段错误。这是 C 层信号,Go 无法 recover,
所以一个插件的脚本错误就能带走整个进程和所有在途请求。
触发错误来自我上一轮加的 billing 日级维度:
add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), ...)
三个 bucket( 只对应两个 ),最外层 bucket() 只收到一个参数,k=nil,于是
billing.lua:141 `tbl[k] = b` 抛 "table index is nil",**每个请求都抛**。
同时还有第二个 bug:中间层用了 bucket()(返回 emptyBucket,含 cost/requests
字段)当作嵌套容器,结构也是错的。改为 dayMap() 返回纯表。
## 修法
1. billing.lua:修正括号,多层容器改用 dayMap()。
2. **pcall 守卫**(真正的架构修复):在 setupGlobals 里注册
__llmsproxy_call_hook,钩子改为经它调用。
function __llmsproxy_call_hook(fn, payload)
local ok, res = pcall(fn, payload)
if not ok then return nil, tostring(res) end
return res, nil
end
Lua 侧 pcall 在 golua 看到非零 pcall 状态之前就拦下错误,C 栈回溯路径
永远进不去。错误变成普通返回值 (nil, msg),Go 侧记进 hook_errors 并跳过
——"插件出错不影响请求转发"这条承诺对脚本错误也终于成立,而不只是对 Go panic。
## 这同时修掉了那个查了很久的间歇崩溃
同一个机制解释了此前 8/20 复现、却查不出根因的 SIGSEGV(怀疑过 janitor 竞态、
GC、LuaJIT 全局状态、VM 释放时序,全部排除)。实测对比:
TestBillingPrecedence 修复前 8/20 崩溃 → 修复后 0/20
并发建 16 个 VM 的探针 修复前 3/3 崩溃 → 修复后 0/6
全量 ./... 连跑 5 次全绿
那些崩溃本来就是一个 Lua 钩子错误在栈深时炸掉 StackTrace,时机随机所以看着
像并发问题。
## 判据
TestHookThatRaisesDoesNotCrashTheProcess:装一个每请求必崩的插件,连打 50 次,
断言进程存活 + 错误被记录 + 同状态里健康的 billing 插件照常工作。
3 个变异(守卫不 pcall / 守卫名写错 / 守卫未注册)全部被捕获,其中第一个直接
让 SIGSEGV 重现,说明守卫就是唯一防线。
## 线上验证
往生产插件目录放一个每请求必然报错的插件,连打 30 个真实流式请求:
30× HTTP 200,SIGSEGV 0 次
hook_errors 记录 count=44 且指名 zbroken-test(可观测)
billing 照常累计(2999 请求 / $0.5668)
测试插件已移除。
回滚点:/usr/local/bin/llmsproxy.bak-real-<TS>、billing.lua.bak-real-<TS>。
2026-10-02 14:07:44 +08:00
fe0764e375
feat(billing): 计费规则可在线增删改,真实落盘并注入插件
...
规则此前只能写在 config.yaml 里,重启才生效。现在 admin 可通过 API 增删改,
规则写回同一份 config.yaml、重新编译、注入 billing 插件,立即生效。
## 为什么单独开一套端点
GET/POST/PUT/DELETE /api/plugins/billing/rules
价格虽然存在插件 state 里,但它是**可评审的配置**,不是用户数据。走通用
/state 会让任意 admin key 顺手把累计账目一起重置。这里服务端掌管形状:
校验 → 落盘 → 重编译 → 注入,运维只编辑规则,插件只存数字,两者永不在同一
个 payload 里混。
"运维输入什么,config.yaml 就存什么"是刻意的:下周发现的计费错误,必须能
追溯到一个可评审的文件,而不是插件 sidecar 里的一坨 blob。
## 路由必须前置拦截
/api/plugins/billing/rules 会被 /api/plugins/ 通配路由吞掉并 404,必须在
handlePluginsAPI 之前判断。
## 两个真实缺陷(判据抓到的,不是想出来的)
1. **保存顺序反了**:先 cfg.Save() 再赋值 cfg.BillingDSL,于是每次编辑都
"成功",写回的配置里却没有 billing 段——运维的编辑在重启后消失,而 API
响应里什么异常都没有。现在先赋值、先编译(编译失败则整体回滚,不留半应用
状态)、最后落盘。
2. **GET /state 不返回生效价格**:编辑器无法显示当前真正在用的价目表,只能从
配置重建——而配置可能早已与实际漂移。新增 Plugins.Prices(),编辑页显示的
就是计费真正在用的那张表。
## 宽松编译
Compile 启动时对"URL 匹配不到任何 source"直接报错是对的(静默不计费更糟)。
但编辑器要允许存草稿:正在新建的源、正在改的 URL 不该把人堵死。新增
CompileOpts(lenient):宽松模式下该规则**留在配置里**(可评审、可恢复),
只是不进编译结果,并由 API 返回 warning 明确报出来。
## 判据(5 条 + 9 个变异)
CRUD、同 URL 重复添加必须替换而非追加(两条规则会因"先匹配先生效"而让第一条
静默失效)、未知 URL 必须警告、非法规则被拒且不落盘、admin 限制、YAML 往返
不丢价格字符串、注入的价格必须是每 token 量级(防 per-million/per-token 差
1e6 倍)。
变异验证抓出判据两处无效断言:删掉落盘、删掉注入,GET 响应都照样回显内存里
的规则,判据全绿。补了「重读磁盘配置」和「读插件实际生效价格」两条才抓住。
过程中还发现一个测试工具自身的坑:某个变异改法导致 Go 编译失败
(declared and not used),grep 匹配不到 "--- FAIL",于是被我误读成"判据漏放"。
改用可编译的变异写法后确认该变异确实被捕获。**判据报错先怀疑判据和工具。**
2026-10-02 12:55:54 +08:00
d0ddc9754d
feat(billing): 计费页支持按日/周/月/全部,与统计页同口径
...
宿主统计页已支持周期,计费页还是终身累计 —— 同一个问题在两个页面重复出现,
且两个页面口径不一致本身就是错。
## 为什么要补日级维度明细
原来只有 total 和 by_day 带时间维度,by_source/by_model/by_key 是终身累计。
只改 total 的话,页面会显示"今日开销 $0.05",下面三张表还是全量数据 ——
数字对不上,而这正是周期视图要消除的错配。所以在写入时按天折叠成本
(by_day_src/model/key)。成本在写入时就已定价,浏览器只做求和,不重新
定价,显示金额不会与持久化金额漂移。
按天为键的表不随流量增长(一年 365 项/维度),所以不设裁剪。
## 口径
UTC,与网关 dayKey 和 /api/stats 的周期窗口一致 —— 峰谷小时不会在费用
视图和用量视图落到不同一天。周为 ISO 周(周一起)。降级/未定价没有日级
计数,周期视图下**隐藏**这两张卡而不是显示终身值,那正是要消除的错配。
## 判据(7 条 + 6 个变异)
判据断言**渲染后的 DOM**,不走 IIFE 内部函数:早先版本加了测试钩子去直接
调 daysInWindow/rescale,结果 harness 里的假 Date 先后两次出问题(整体替换
构造器破坏 toISOString;子类化导致 getUTCDay 返回 NaN),症状都是
"Invalid time value",看起来完全像产品 bug。
变异验证抓到判据本身的缺陷,值得一提:
- 捕获「总账不累加」和「周起点改周日」两个变异时**全部判据仍绿**——因为
我断言的是 by_source 表格,而它由维度表独立算出,跟 total 无关。
- 补了 KPI 断言后又漏放「维度折叠不求和」——"页面里存在 60" 太弱,
60 同时出现在 KPI 和 token 列里。改成锚定 srcA 自己的金额单元格
(USD 60.0000),last-day-only 会是 30,才抓得住。
判据报错时先怀疑判据——这次三次都是判据的问题。
2026-10-02 12:41:35 +08:00