Commit Graph

18 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
30064696b3 perf(plugin): 去掉钩子热路径的 JSON 往返 + 同 stage 跨插件并行
## 1. 去掉 JSON 往返(快路径)
实测单次 Fire 14.6µs,其中 json.Marshal 4.0 + json.Unmarshal 5.6 = 9.6µs,
**67% 花在把 map[string]interface{} 序列化再反序列化**,而紧接着的
pushGoValue 本来就能直接遍历这两种类型。改为按类型直接转换(fastvalue.go),
只对不认识���类型才回落 JSON —— 陌生字段仍然会被送到插件,而不是消失。

快路径与 JSON 路径逐字节等价由 TestFastPathMatchesJSONPath 锁住(8 组载荷,
覆盖 int/uint/float 各宽度、嵌套、slice、map[string]string、未知类型)。
还有一条专门防止「优化悄悄失效」:TestFastPathIsActuallyUsed 用真实的
request_end 载荷断言它确实走快路径。

同一份代码 A/B 实测:JSON 往返 56.4µs → 快路径 35.3µs(省 37%)。

## 2. 同 stage 跨插件并行
参照 /home/program/TrueAgent 的 StageHost.RunStage:
  - **快照后释放锁**再并行 —— 它记录过一次自死锁(p.Stop → onExit → ReclaimOwner
    要拿 registry 锁,持锁并行即死锁)。这里同理:钩子可能经 admin API 增删插件,
    那条路径要拿 ps.mu 写锁,所以并行段内不持任何 ps 锁。
  - 每个 goroutine recover。
  - 单插件走直连路径,不付 goroutine 代价(生产就是这种配置)。

**与 TrueAgent 不同的一点**:它可以放心并行,因为 handler 只写 ctx.Response 并有
IsResponded() 仲裁;我们的钩子返回 table 会合并进 payload,而
docs/plugins.md 明确承诺「payload 原样传给下一个插件」。所以合并**按插件加载
顺序**执行,结果确定,不依赖调度;代价是钩子之间不再互相可见 —— 这是一处
**契约变化**,已在文档里写明,并说明随核心发布的 billing 从不返回任何值
(代码注释就写着 "nobody downstream would read a return value")。

实测收益(真实二进制,三实例对照,3000 请求):

              无插件     1 插件      4 插件
  稳态并发32    849 rps   768 (-9.5%) 741 (-12.7%)
  突发并发64   1524-1893  1182-1676  1064-1443

4 插件只降 10-20%,而并行前实测 4 插件是 63.8µs vs 单插件 14.6µs(-300%)。

## ★ 我自己造成的两次性能事故
**① 持久化把热路径拖慢 26 倍。** 最初的快照在钩子路径上做:走 luaValueToGo +
json.Marshal + json.Unmarshal 三重转换,每请求 264µs,Fire 从 14.6µs 变成 385µs。
改成 saver 按自己节奏拉取(钩子只标记 dirty,flush 时才快照),385µs → 25.7µs。
**这里还踩了第二次 use-after-free**:让后台 goroutine 去读 Lua 表,vm.Stop() 后
那是已释放内存(SIGSEGV)。安全性现在由「Plugins.Close 等 saver 的最后一次
flush 完成后,调用方才停 VM」保证。

**② 基准被自己的后台写入污染。** 关掉 markDirty 反而测出 36µs、比开着还慢,
方向完全反了 —— 是 saver 每 2 秒写盘混进了计时。加了 DisableStatePersistence
后数据才可信。

## 判据(11 项,全部变异验证)
快路径等价/确实生效/不别名输入 + 并行与单插件路径合并一致 + 合并顺序确定 +
抛异常的钩子不拖累同伴 + 每插件恰好执行一次 + 并发 Fire 安全 + Fire 期间不持
注册表锁 + 真实 billing 在并行下正常 + 持久化 7 项。

变异:改坏合并顺序 → 红;去掉单插件路径的合并 → 红(3 个既有测试同时抓到)。
★ 「删掉 recover」这个变异**没有**让判据变红,查下去发现 golua 把 error()、
nil 索引、调用 nil、深递归全部转成 error RETURN,不产生 Go panic —— 那个测试
根本没测到 recover。已改名 TestThrowingHook 并在注释里写明 recover() 当前无法
被 Lua 触达,保留它是为了守 Go 侧。留一个「看起来有覆盖」的断言比没有更糟。

## 端到端(真实二进制 + 真实 billing)
20 万请求全 200,rps 1870,p99 96ms,RSS 37.9→42MB 有界;
负载停止后四个插件计数**完全一致**(231745),hook_errors 为空;
systemctl restart 后 billing 仍是 231745 —— 并行与持久化同时生效。
381 个测试全绿,含 -race。
2026-10-02 10:45:20 +08:00
ce2032435a fix(plugin): 插件 state 持久化 —— 重启不再丢账
## 问题(压测实测)
插件 state 活在 Lua VM 里,进程一死就没了。实测线上量级:
  重启前 {"requests":218241,"prompt_tokens":26188920,...}
  重启后 {"requests":0,"prompt_tokens":0,...}
对计费插件来说这不是舍入误差,是功能本身没生效 —— 它存在的意义就是那个
不断累加的数字,而一次 systemctl restart 就能把它抹掉。

## 设计
- prices(配置)与 state(累计历史)**分开存**在同一个文件里但不同字段。
  SetState 在内存里已经这么分,磁盘必须同意:合并会让改价看起来像清零,
  或让恢复历史时顺带复活过期价格。
- 原子写(临时文件 + rename):写一半崩掉时上一份仍可读,而不是留下一个
  解析失败的半截 JSON —— 那等于这次也丢。
- 损坏文件只警告不阻断启动。转发不能依赖插件的账本活着。
- 防抖后台刷:钩子路径只标记,真正的写在一个 goroutine 里合并进行。
  计费插件每请求都改 state,同步写会把一次 JSON 编码 + 文件写放到热路径上
  (实测钩子本身已经 14.6µs,写会盖过它)。
- Core.Close 必须先刷插件再停 VM:flush 要读 Lua 表,vm.Stop() 之后读的是
  已释放的内存。

## ★ 实现中踩的四个坑(都由测试或崩溃直接暴露,不是推测)
1. **后台 goroutine 碰 Lua = use-after-free**。最初让 flush 线程去读 Lua 状态,
   vm.Stop() 后那是已释放内存 —— 表现为 golua 里的 SIGSEGV,不是干净报错。
   改成:钩子路径(VM 必然存活、已持 p.mu)取快照,后台只写文件。
2. **自死锁**:markDirtyLocked 被 invoke 调用,而 invoke 全程持 p.mu,
   再 Lock 一次就是死锁。lua 包测试直接挂到超时。
3. **luaToJSON 独占整个栈**(每条路径结尾都 SetTop(0))。连续调两次读两个
   字段时第二次访问的是不存在的槽位 —— 这个绑定不 panic,直接 SIGABRT。
   改为每次重建栈。中间还因为提前 return 没 Pop 而让栈逐次错位。
4. **快照顺序**:先快照后读返回值,会把钩子的返回值清掉,于是每个"有意见"的
   插件静默变成"没意见",而文档承诺的"返回 table 合并进 payload"就废了,
   且没有任何报错。

## 判据(7 项,全部变异验证过)
重启后总计保留 / prices 与 state 分离 / 纯 prices 更新也持久化 /
钩子返回值不被快照吃掉 / 损坏文件降级不阻断 / 500 次变更合并成个位数次写 /
Close 刷出尾部。

变异结果:
  关掉 mark          → TestStateSurvivesRestart + TestPricesAndStateAreSeparate 红
  Close 不等 flush   → TestCloseFlushesTail 红
  prices-only 不写盘 → TestPricesOnlyUpdatePersists 红
  还原快照顺序       → TestHookReturnValueSurvivesSnapshot 红
★ 第一次跑「关掉 mark」时判据没报错,原因是我的变异脚本写出未使用变量导致
  编译失败 —— go test 根本没跑测试,我却读成了"通过"。换成 _, _ = 后如期变红。

## 端到端
隔离实例发 12 次请求 → systemctl restart → requests 仍为 12,token 数不变。
371 个测试全绿。
2026-10-02 10:17:07 +08:00
a78f7cb6c5 feat(plugin): 启用/禁用 + 磁盘列表 + 峰谷定价 + 随核心发布
## 插件管理后端
- PUT /api/plugins/{name} {"enabled":bool}   启用/禁用
- GET /api/plugins/{name}                    读源码(编辑器用,与 /state 区分)
- GET /api/plugins 的 on_disk 字段            列出目录里所有 .lua 及其加载态
- validPluginName 提取为共享函数,install/remove/read 三处共用,防止检查漂移

禁用是**运行态开关,不删文件**:插件把线上网关搞坏了、但离修好只差一行时,
运维需要把它移出请求路径而不丢失它(同 systemd mask 而非 remove 的道理)。
它**不跨重启保留**——一个悄悄比操作者意图活得更久的"禁用"本身就是个意外。

Builtin 的判定是「加载的源码与内嵌版本逐字节相同」,而不是「名字匹配」:
被改过的 billing.lua 不能被标成 builtin,否则 UI 会提供覆盖用户改动的操作。

on_disk 列表包含**加载失败**的插件。否则一个语法错误的插件在 UI 上直接消失,
运维看到的现象是"插件不见了"而不是"插件报错了"。

## 峰谷 / 时段定价
commandcode 的 DeepSeek V4 系列就是高峰 01-04 & 06-10 UTC 工作日 2 倍价
(非高峰 17h/天)。静态价目表达不了,而算错方向是**静默**的。

价目条目可带 peak = {multiplier, windows=[{days, hours}]}。命中任一窗口即乘。
★ 用 `os.date("!%H")` 取 **UTC** 小时:provider 费率表按 UTC 标注,而网关跑在
本地时区(本机 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8 小时,
白天算成夜间——比不做峰谷还糟。

## ★ 实现与注释不一致,被判据抓住
applyPeak 最初直接 `price.prompt = price.prompt * m`,注释写「缓存读不翻倍」。
但 costFor 里**缓存读价是从 price.prompt 派生的**,所以原地翻倍会把缓存读
也翻倍——两个折扣被叠在一起,而 provider 从没打算叠。
改成 applyPeak 只**记录**乘数,由 costFor 分段应用:fresh prompt 与 completion
翻倍,cache read 那一项不动。
只靠注释说明意图是不够的:TestBillingPeakDoesNotDoubleCacheRead 立刻红了
(0.006 vs 期望 0.003)。变异回原实现仍是红的。

## 判据(21 个计费测试全绿,新增 5 个峰谷)
  窗口恒命中 ×2 / 窗口永不命中保持静态价 / 星期不匹配不命中
  (这条正是防"用本地时区整体偏移 8 小时")/ 无 peak 规则向后兼容
  / 缓存读不随峰谷翻倍

后端部分:构建/vet/gofmt 干净,8 个包全绿。
2026-10-02 08:33:41 +08:00
42764bc99e feat(plugin): AUTO 调度轨迹可见(chain_step stage)
被问"还有 auto 调度相关 stage 呢?"问出来的真实缺口。

## 问题
chainDrive 只返回 (resp, src, model, err),调用方只知道**最终哪个槽位赢了**。
遍历过程中算出来又丢掉的东西——哪些档被跳过、为什么跳过、哪些槽位硬失败、
哪档全忙——一律不可见。ChainErr 里其实有这些,但**只在全部失败时**才填,
而它是 error 返回值不是记录。于是:

    "tier 1 冷却所以降级到 tier 3"  ==  "tier 1 正常接单"

对插件而言 tier 只是个常量 -2("resolved by the chain"),信息量为零。而这
恰恰是优先级链存在的全部理由,也是"我那个贵模型为什么没被用"的答案。

## 做法(scheduler 侧零新依赖)
新增 TraceEvent / TraceSink,chainDrive 多一个可选 sink 参数:

  - TraceEvent 是本包的普通 struct,sink 是 func 参数 ⇒ **不新增 import**,
    scheduler 仍然可独立测试
  - sink 为 nil 时每次 emit 只多一次 nil 判断;没有插件的网关在 AUTO 热路径上
    零开销(gateway 的 chainTraceSink 直接返回 nil)
  - 事件是纯观测:scheduler 不基于它做任何分支,gateway 也不把它喂回路由/
    冷却/配额

四种 kind:tier_skip / slot_fail / tier_busy / selected,selected 每次成功
遍历恰好一次且是最后一步。顺序保证所有 step 在 routed 之前。

## 暴露给插件
新增 chain_step stage(逐个步骤),并在 request_end 载荷里加三个便于做报表的
字段:chain_walk(上限 12 步,防审计记录膨胀)、degraded、tier_served。

## ★ 计费口径(我按推荐的做,已写进文档,需要你确认)
**按实际服务的模型计费**:降级到 tier 3 仍按 tier 3 的价算,轨迹只作观测。
理由与 §7.5 的边界一致——插件只报表不执法,两套口径混在一起会引出"降级该不该
多收钱"这种无法从代码判断的争议。若要改成"按本该用的档计价",需要在 models
价目里允许按 tier 定价,这我没做,因为那是个产品决策。

## 计费插件同步消费
by_tier_served / skip_reasons / degraded_reqs 三个新维度。skip_reasons 的等待
时长做了归一(`no free slot within <wait>`),否则 busy-wait 文案一变就多一行。
降级次数在 request_end 里计而不是在 chain_step 里计:一次降级的请求要走多步,
按步计会重复计数。

## 判据(346 个测试全绿,新增 15 个)
  scheduler  6 个:正常路径只发一个 selected / 跳档+降级可见 / 硬失败与跳档
                严格区分(不可混为一谈,否则抖动上游看起来像空闲上游)/
                nil sink 安全 / 全失败时轨迹与 ChainErr 并存且不互相破坏 /
                空链不发事件
  gateway    1 个端到端:tier 1 全 500 → 插件收到 slot_fail(tier 1) +
                selected(tier 2),request_end 的 tier_served=2 且 degraded=true
  lua        2 个:降级计数与按实际模型计价 / 跳过原因归一聚合
  lua        1 个:chain_step 是真 stage 且顺序正确

3 个变异都红:去掉 slot_fail(3 个判据红)/ 去掉 tier_skip(1 个)/
去掉 degraded 字段(1 个)。
2026-10-02 01:03:39 +08:00
a51a6811a6 feat(plugin): Lua 插件机制 + 计费插件 + 插件文档
插件 = plugin_dir 下的单个 .lua 文件,做两件事:挂请求流水线的钩子、在启动时
贡献 WebUI 界面(整页或往现有页面追加组件)。两者独立。

## 流水线 stage(三个)
  request_start  已解析鉴权、未选源
  routed         已选定 (source, model)、未发往上游
  request_end    每请求恰好一次,带最终计量
request_end 挂在 gateway.writeRec——四条入口路径(直连/AUTO × 流式/非流式)的
唯一汇合点:既不漏(流式 token 只有流结束才知道)也不重。

## 计费插件(plugins/billing.lua,默认 seed,开箱可用)
源 / 模型 / 密钥三个维度定价。token 价优先级 keys > models > default;per_request
固定价是**叠加**的(生图模型可以既算 token 又收固定费)。单位是 USD/单 token,
即各家 provider 的公布口径。累计 total / by_source / by_model / by_key / by_day。
失败请求保留 token 费用、丢弃固定费(可经 count_failures 翻转)。
界面 = 一个独立页 + 状态页顶部一块总开销 tile。

## 一个明确的设计边界
计费插件**只报表,不执法**。网关自己的配额会计(stats.go,入口强制)才是限额
权威,插件不参与任何路由/配额决策。两套独立会计若对不上,比一套功能略少的
更糟。

## ★ 中途改掉的一个根本设计错误
最初让插件复用适配器的**弹性 worker 池**(多状态)。这对适配器是对的(它们无
状态),对插件是错的:计费插件往 plugin.state 累加,多状态意味着总量被劈成
几份;而 SetState 写价格只写进其中一个 worker,钩子恰好跑到另一个时**所有请求
按 0 计费**。改为**单状态 + 互斥锁**。代价写进文档:钩子必须短、同步、不阻塞,
卡住的钩子会卡住所有插件的钩子。
这个 bug 是测试逼出来的——先写了 SetState+Fire 的用例,数字全是 0 才挖出来。

另一个连带缺陷:只带 prices 的 PUT 会整体替换 state,把累计量清零。改为
prices/state 分离——prices 是配置、state 是历史,改价不动账。

## 撞到的三个 Lua 绑定的坑(都写进注释)
  - SetGlobal **会 pop 栈**:连着调两次,第二次从空栈取,赋成 nil
  - GetField 索引越界是 **SIGABRT 整个进程**,不是 panic,recover 救不了
  - Call(nargs, n) **不接受函数索引**,它调的是 nargs 个参数正下方那个;
    传索引会调到参数上("attempt to call a table value")
另外 GetField/SetField 用绝对索引,SetTop(0) 之后必须重取。

## 错误隔离
钩子 error() 不影响转发:捕获 → 记进 hook_errors → 跳下一个插件。适配器出错
会让源进冷却,插件出错**零惩罚**——插件是可选功能。/api/plugins 的 hook_errors
让"坏掉的插件"可见而不是静默消失。

## 界面注入
GET /api/ui-inject 一次返回所有插件的扩展(侧栏需要全部 page 才能建好)。
WebUI 在首次 render **之前** await 注入:先插 HTML 再重建 <script> 让它执行
(innerHTML/template 插入的 script 不会执行,这正是要的效果——避免脚本跑在
自己 DOM 之前)。注入失败不影响仪表盘。
browser 侧 pluginAPI 暴露 fetchState / postState / onTabShown。

## 文档
docs/plugins.md —— 快速上手、加载与热更新、三个 stage 的完整字段表、界面扩展、
状态与 HTTP API、运行时约束(单状态/异常隔离/内置函数)、计费插件的定价与
计费策略、排错表、与适配器的对比表。

## 判据(328 个测试全绿,插件相关 33 个)
  - 计费断言的是**具体金额**(0.00625 / 0.0402 / 0.0075…),不是"能加载"
  - 4 个变异都红:钩子异常不隔离 / prices 清空累计 / 忽略 key 优先级 /
    毫秒时间戳不换算
  - UI 侧 6 个判据把注入顺序、script 执行时机、pluginAPI 名称、tab 路由、
    anchor 四种形式、失败非致命全钉住
  - 鉴权:state 读任意角色、写仅 admin
2026-10-02 00:37:29 +08:00