a21d0ca5a1
chore(gui): 版本号 1.9.2 → 1.9.3
...
v1.9.3 收一处误导性错误与编辑器提示:
- AUTO 被密钥模型范围拦下时返回 model_not_found(像说 AUTO 没配置),
改为 model_not_allowed 并说明修法
- per-key AUTO 链编辑器在该密钥范围不含 AUTO 时给出警告
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 21:50:20 +08:00
cf14f66ad4
fix(gateway): AUTO 被密钥模型范围拦下时报错误导 + 编辑器给出提示
...
一个密钥的模型范围不含 AUTO 时,它用不了 AUTO —— 范围过滤发生在链之前。
但这两条路径都不说真话:
1. 请求侧:AUTO 走的是通用分支,返回 model_not_found "model \"AUTO\" is
not configured",读起来像 AUTO 没配置。非 AUTO 模型早就有 model_not_allowed
的专门提示,AUTO 漏了。补上,并说明修法(把 AUTO 加进该密钥的 models,
或去掉范围限制)。
2. 编辑器侧:per-key AUTO 链编辑器可以正常配链、保存也成功,看起来一切正常,
但该密钥的每个请求都会 403。管理员无从得知。现在弹窗顶部在检测到冲突时
显示警告,并列出当前范围。
刻意不做的事:不自动把 AUTO 加进该密钥的模型范围。那等于悄悄授予运营
没要求的访问权,比一个显眼的警告更糟。
判据 1 条,除确认提示出现外还断言该函数体内没有 PUT/POST/fetch/api ——
它只报告,不得写回。变异(去掉 AUTO 判断)判红。
CDP 实测四种场景:范围含 AUTO → 无提示;范围不含 → 警告并列出范围;
空范围(不受限)→ 无提示;范围含 AUTO → 无提示。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 21:23:02 +08:00
4e3b905b58
chore(gui): 版本号 1.9.1 → 1.9.2
...
v1.9.2 收两处 UI 修复:
- 统计区间文案在日视图恒为空(bucket 是小时粒度,盲目拼 T00:00:00Z 使
toISOString 抛 RangeError,整句赋值丢失),且天数按小时桶数误报
- per-key AUTO 链默认复制全局链作为编辑起点,而不是空白画布
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 13:36:11 +08:00
130b3f2e8d
feat(ui): per-key AUTO 链默认复制全局链作为编辑起点
...
此前给没有独立链的密钥打开编辑器是空白画布,管理员要从 226 个模型里
一条条重新挑。真实任务是"这个用户拿全局链,但去掉他不该碰的两个源",
所以起点应该是全局链本身。
- 无独立链时读 /api/auto 的 rules 预填(实测 4 泳道 / 10 块,与全局链
一致),不是 discovery 兜底(那会把每个源的每个模型都堆进来)。
- 提示区分三种状态,因为它们在画布上长得一样、点取消的后果完全不同:
复制来的起点 / 该密钥已有的独立链 / 清空后保存将恢复跟随全局。
- seeded 标记随关闭清理,避免下次的提示说错来源。
CDP 实测:打开无独立链的密钥 → 画布 4 泳道 10 块 + 提示"已复制全局链
作为编辑起点";删一个块服务端仍 inherits:true;保存后 inherits:false
n=9、弹窗关闭;重开读回 4 泳道 9 块且提示消失(它现在有自己的链了)。
横向滚动可达性一并验证:scrollLeft 172 = scrollWidth-clientWidth,
滚到底后无残留不可达块。
判据 2 条 + 变异(恢复空白起点 / 去掉 seeded 标记均判红)。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 13:36:11 +08:00
dac0b4ce93
fix(ui): 统计区间文案在日视图恒为空、天数按小时桶误报
...
v1.9.0 加的「统计区间:起 → 止 (Nd)」正是用来解释"周 > 月"的,但它自己
在日视图上从来不出现在屏幕上。
原因:区间终点从最后一个 bucket 的标签拼出来,而 bucket 粒度随视图变化
—— 日视图按小时("2026-10-03T09"),周/月按天("2026-10-01")。代码无条件
拼 "T00:00:00Z",日视图就成了 "2026-10-03T09T00:00:00Z",Date 拒绝该值,
toISOString() 抛 RangeError,整个赋值语句丢失。页面无任何报错,只是那行
字不见了——恰好是用户最常看的"今日"视图。
第二个问题同源:天数直接用了 st.buckets.length。日视图 5 个桶是 5 个小时,
界面却显示 "(5d)"。改为按窗口起止算天数。
CDP 实测(修复后):
day 统计区间: 2026-10-03 → 2026-10-03 (1d) ← 原为空,且曾误报 5d
week 统计区间: 2026-09-28 → 2026-10-03 (6d)
month 统计区间: 2026-10-01 → 2026-10-03 (3d)
all (空,正确:终身累计没有窗口)
判据一条 + 变异:恢复原来的盲目拼接 → 判红。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 13:36:11 +08:00
97864895d4
chore(gui): 版本号 1.9.0 → 1.9.1
...
v1.9.1 收 v1.9.0 之后发现并修复的四个 UI/统计缺陷:
- per-key 编辑器画布画进后台标签页的全局画布,弹窗空白且污染全局链
- per-key 保存成功却不关弹窗,toast 文案还把已保存说成会失败
- 首页日/周/月视图请求记录表恒为空,滚动加载新旧行错位
- per-key 弹窗复用共享 #modal-wrap id、编辑即落盘(取消撤不回)、
无独立链时画布填满全部 226 个模型
v1.9.0 的 tag 与附件保持不动,作为发布时点的凭据。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 11:48:01 +08:00
f9cd0770a9
fix(ui): per-key 弹窗复用共享弹窗ID、编辑即落盘、空画布显示226个模型
...
截图看渲染 + 交互实测后发现的四个问题,都出在我自己新写的代码里。
一、弹窗用了全站共享的 #modal-wrap。
这个 id 是 App 内所有弹窗共用的,且允许多个同时存在("添加档位"选择器就
开在本弹窗之上)。既有代码靠 .closest() 或 closeTopModal() 从按钮往上找,
是有效的——但本弹窗还要从画布侧被查(sortCanvasEl / keyAutoClose /
keyAutoInherit),那里没有按钮可走。实测点"添加档位"后 DOM 里同时有
modal-wrap=1 和 key-auto-modal=1,getElementById 会命中文档里的第一个,
也就是用户没在看的那一个:取消关掉错误的框,画布也可能解析到选择器上。
改为独立 id #key-auto-modal。
二、编辑即落盘,保存/取消形同虚设。
scrAddFromForm / sortScopeSave / scrDelSlot 等 6 处编辑后立刻调
persistAuto()。全局页这样是有意的(改动立即生效),但它和自己页面上那句
"点击保存排序后生效"的提示自相矛盾;在 per-key 弹窗里更严重——加一个槽位
就已经写进那个用户的链了,取消也无法撤销。收敛到 afterChainEdit():
有 scope 时只提示、不落盘,保存是唯一写入口。
三、无独立链时画布显示全部 226 个模型。
注释写的是"显示空画布",实现却是 buildLanes(idx, [], 'chat')——空 rules
会走 discovery 兜底,把每个源的模型全填进去。用户打开弹窗看到 226 个色块,
会以为这些就是这个密钥的链。改为空 rules 直接给 []。
四、"使用全局链"提示不随画布变化。
加完槽位后仍显示"该密钥使用全局 AUTO 链",与旁边的色块直接矛盾。改为由
afterChainEdit 触发、按画布是否有内容决定去留。
另:批量替换时正则的缩进假设错了一次,把 afterChainEdit 自己改成了自调用
(无限递归),node --check 抓到后修掉。这类批量替换必须先过语法检查。
判据两条 + 变异:弹窗回到共享 id → 判红;去掉 scope 分支 → 判红。
CDP 实测:弹窗 id 唯一、添加后画布 0→1 块、添加后服务端 inherits=true
(未落盘)、取消后仍 inherits=true、选择器与弹窗并存互不干扰。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 11:20:29 +08:00
6311913812
fix(stats): 日/周/月视图请求记录表空白,滚动加载顺序错乱
...
首页记录表由 paintRecords(st.records) 渲染,而 /api/stats 的 period 分支
手写了一份响应 map,压根没有 records 字段 —— 只有 all 分支走 Snapshot()
才带得上。实测切到日/周/月,记录表恒为空。
两处数据源也不一致:
| 端点 | 数据源 | 排序 |
|---|---|---|
| /api/stats(all)| 内存ring(几百条)| oldest-first |
| /api/stats/records(翻页)| 审计文件(全部)| newest-first |
| /api/stats(period)| 无 | 无 |
前端靠 paintRecords 里一句 .reverse() 把 all 的 oldest-first 转成 newest-first,
翻页端点则本来就是 newest-first。首屏和翻页方向相反,滚动时新旧行会错位
拼接;且 all 分支不带 next_cursor,表永远停在第一屏。
修法:两个分支都改用 AuditPage(与翻页端点同一份代码路径),记录方向统一
由它决定,前端去掉 .reverse()。附带好处:记录深度不再受 maxRecs 限制,
"全部"视图真的能翻到底。
判据三条,变异验证:period 分支返回 nil records → 前两条判红;all 分支不
覆盖 records → "no next_cursor"判红。变异过程中我自己也犯了两次错:第一版
判据硬编码 newest-first,而AuditPage 在单文件(整块读完)与多文件(分块反向
读)下方向不同,改为断言两处方向一致;第三版想测 admin 过滤,但
newTestGateway 的 sk-test 不是 admin,改为测真正的越权边界(非admin 不能
用 ?key= 看别人的记录)。
CDP 实测:四个视图各 100 行、时间从新到旧,滚动加载 100→200 行且新末行
时间更旧(顺序衔接正确)。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 10:32:51 +08:00
aa003e84d3
fix(ui): per-key 编辑器画布画错容器、保存成功却不关弹窗
...
CDP 实测发现两个真 bug,都是复用画布时引入的。
一、画布画进了后台标签页。sortCanvasEl() 原本按文档顺序找,先命中
#scr-canvas(全局优先级页的画布)。而弹窗是挂在设置页之上的,两个画布
同时存在于 DOM —— 于是 per-key 的 36 条泳道被画进隐藏的全局画布,弹窗
里空空如也,回���全局页还会发现自己的链被覆盖了。
改为按 sortState.scope 解析:有 scope 就用 #key-auto-canvas,否则用
#scr-canvas。
二、保存成功却弹窗不关。!j.slots 分支里我写了 return,跳过了
keyAutoClose。写确实成功了(服务端已存),但用户看到弹窗还开着、按钮
还是禁用状态,会反复点保存。而且那条 toast 文案写成"保存后 AUTO 请求
会失败",读起来像保存失败了 —— 一并改成"已保存,但槽位无法解析…"。
判据两条,变异验证:恢复按文档顺序查找 → 第一条判红;!slots 分支加
return → 第二条判红。第二条最初只查 keyAutoClose() 是否存在,变异加了
return 仍然通过,属于判据漏放,已改为扫描 try 块内 close 调用之前的
所有 return 语句。
CDP 复测(先开全局页制造干扰,再开 per-key 弹窗):
- 弹窗画布 36 泳道,全局画布保持 4 泳道未被污染
- 保存 gpt-5.2@xinjianya 后弹窗关闭、服务端存储正确、scope 清空
- 重开弹窗读回 [["gpt-5.2@xinjianya"]]
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 10:32:51 +08:00
28149bdd1a
merge: release/v1.7.x → main(per-key AUTO 链 + GUI 插件管理 + UI 复用)
...
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0
- 13e2397 fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
- ed9baae feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
v1.9.0
2026-10-03 08:50:55 +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
13e2397e9c
fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口
...
两件事。
一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列,
比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。
这是重复实现且必然漂移。改为复用同一套泳道画布:
- 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor,
全局编辑器与 per-key 编辑器共用。
- sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto
按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。
- 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。
- keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。
- 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。
判据改为断言"复用"而非断言具体实现,并新增两条:
- 关闭弹窗不清 scope(全局编辑器会存错目标)
- per-key 编辑器不再挂载共享画布
二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始,
所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03
(周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03
(3 天,4.0B tokens)。
真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD
(Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到
月内(一个看起来很自然的"修复")会被两条判据同时抓住。
CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示
"2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个
块,与全局编辑器同款。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 08:26:57 +08:00
0cd1cd51f6
merge: release/v1.7.x → main(per-key AUTO 链 + 部署 v1.9.0)
...
- 22decf2 feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
- c66e1b5 test(keys): per-key AUTO 链的并发与压力验证
- 9dc2b64 chore(gui): 版本号 1.8.0 → 1.9.0
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 08:15:00 +08:00
9dc2b64013
chore(gui): 版本号 1.8.0 → 1.9.0
...
per-key AUTO 链(22decf2)与并发判据(c66e1b5)是 v1.8.0 发布后的新特性,
v1.8.0 的 tag 已存在,不能复用。package-lock.json 里剩下的一处 1.8.0 是
@electron/fuses 依赖自身的版本,不应改动。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 08:08:37 +08:00
c66e1b5955
test(keys): per-key AUTO 链的并发与压力验证
...
AutoChainFor 在请求热路径上无锁读 keyAutoChains,而 SaveKeyAuto 与
rebuildRegistry 会整表替换它。顺序调用测不到任何交错,所以补并发判据。
- 8 读者 + 2 写者并发改链,断言读到的链必属于 {自身链, 全局链} 这个合法
集合,不断言固定值(写者本来就在合法地来回切换,钉死值会在正确代码上
失败)。
- 源增删期间读者保持可用链。churn 源用 "churn" 前缀,避免与链依赖的 s1
重名——删掉链引用的源会合法地清空该链,拿它当断言等于测虚构。
- 500 个 key 的查表不退化。
变异验证:把 rebuildKeyAutoChains 改成原地清空重填(破坏原子替换),
-race 报 DATA RACE,判据有效。
真实压力(20 key 网关 + 并发 AUTO + 后台持续改链):
- 40 并发 25s:9616 请求 / 382 QPS / 零 panic / 零 DATA RACE
- 出现 111 次 503,根因是 mock 上游被打爆后 "no free slot within 2s",
属预期降级而非缺陷:并发降到 6 时 1395/1395 全 200。
- 每次压测都断言"未改链的用户始终命中自己该走的模型",未改链的 19 个
key 全程零错分。
2026-10-03 08:01:37 +08:00
22decf2bd3
feat(keys): 每个密钥可配独立 AUTO 链,管理员代配
...
此前 AUTO 链是全局单值(cfg.Auto + Core.AutoChain()),所有用户共用一条链。
管理员无法为某个用户单独指定调度链。
按 per-key 覆盖 + 全局兜底实现:
- config.GWKey 增加 Auto 字段与 HasOwnAuto()。未配置即继承全局链,
存量部署零改动,新密钥天然继承全局链。
- Core 把 buildAutoChain 的归一化逻辑抽成 chainForRules,全局链、
生图链、per-key 链共用同一套编译,避免两处漂移。
- Core 增加 keyAutoChains 缓存 + AutoChainFor(key)。请求路径读缓存不
加锁,与全局链查询一致。缓存整表原子替换,不会看到半成品。
- 请求侧 chat.go 改用 AutoChainFor(reqKey)。冷却仍在 Provider 上按
model+source 共享:两条链指向同一个 slot 时共用冷却,与今天单链行为
相同,也避免为 per-key 维度重构冷却而改变现有可观测语义。
- API:GET/PUT/DELETE /api/keys/{key}/auto(admin),GET
/api/keys/me/auto(任意角色,只能读自己的)。空 PUT 与 DELETE 等价于
"恢复继承",无法持久化一条会 503 的空链。写入时回报解析出的槽位数,
让管理员当场看到模型名写错,而不是等用户下次请求 503。
- 源变更时一并重编译 per-key 链,加源后无需重启即可生效。
判据 18 条,5 个变异全部被抓住:AutoChainFor 忽略 key、清空后不重建
缓存、源变更不重建、空 PUT 落盘成空链、me/auto 误要求 admin。
UI 判据做变异时发现漏放:只查函数定义存在,删掉按钮后仍通过。已改为
断言 keyCanvasHtml 内的调用点。
端到端实测(真实 HTTP + 两个 mock 上游):admin 与 bob 初始同为 m-fast,
给 bob 配 m-cheap 后两者分流,重启后仍分流,DELETE 后 bob 回到 m-fast。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev >
2026-10-03 07:45:54 +08:00
4b07be67b4
Merge commit 'ba01da9' into HEAD
2026-10-03 00:16:21 +08:00
ba01da937b
fix(packaging): 打包带上计费插件,并修复一个让所有产物校验形同虚设的缺陷
...
发 v1.8.0 时发现包里没有 billing.lua —— 而 v1.8.0 的主打特性就是计费插件,
发布说明明写「随核心发布的示例插件(billing)」。
功能本身没坏:internal/lua/vm.go 用 //go:embed plugins/*.lua 把插件编进二进制,
writeBundledPlugins 在 plugin_dir 不存在时把它写出来。实测确认(用正确格式的
配置):全新 plugin_dir 启动后自动生成 billing.lua 70031 字节,插件正常加载。
所以这是产物内容与发布说明不符,不是功能缺失 —— 运维解包检查时看不到它,
只能等首次启动后才发现。
三处改动:
1) tar.gz 现在带 plugins/。让运维能在安装前读它、改它,而不是启动后才知道。
2) 修复 dpkg-deb 那行。`dpkg-deb -I "$DIST"/llmsproxy_*.deb` 的 glob 展开成三个
.deb(dist 里堆着 1.5.9 / 1.6.0 / 本次产物),dpkg-deb 只认第一个归档,其余
参数被当成 control component 名,退出码 2。脚本是 set -euo pipefail,于是
在这里直接终止 —— 「done」从未打印,我加在后面的产物内容校验从来没有执行过,
真正跑过的只有上面那道 100KB 大小下限。改为取最新的那个 deb。
这个缺陷早于本次改动,是被它暴露出来的。
3) 新增产物内容校验:tar.gz 必须含 llmsproxy / config.example.yaml /
llmsproxy.service / plugins/billing.lua,adapters 至少 10 个 .lua。
大小下限抓不到这个问题 —— 少 70KB 仍然远超 100KB,而首次启动自动 seed 的
行为会在运行时把它藏起来,运维看到插件正常工作就以为包是完整的。
目录按内容而非名字匹配:tar 列出的是 …/adapters/openai.lua,不存在裸
…/adapters 条目,第一版按名字 grep 把完整包判成了坏包。
两个变异验证:去掉 plugins 拷贝 → 「archive is missing plugins/billing.lua」
退出 1;只留 1 个 adapter → 「archive has only 1 adapters」退出 1。
v1.8.0
2026-10-02 23:31:29 +08:00
913974bb6c
Merge commit 'c8edea2' into HEAD
2026-10-02 20:16:18 +08:00
c8edea23f5
chore(version): 1.7.6 -> 1.8.0
...
打 v1.8.0 时发现清单里的版本号还停在 v1.7.6:main 上有一份 1.8.0 的
(4b37e1a),发布线没有,于是 merge 时按前者取值、tag 指向发布线,产物自相矛盾
(tag v1.8.0 配一份声明 1.7.6 的 package.json)。
功能上无影响:grep 确认没有任何代码读 package.json 的 version,llmsproxy
也没有 --version(只有 -check / -config / -show-secrets),所以这只是清单
自述与 tag 不一致,不影响任何回显路径。但发版纪律要求「版本号正确、与 Tag
一致」,发布前把它对齐。
三处:package.json 一处、package-lock.json 两处(顶层与 packages."")。
api.go 注释里引用的 v1.7.6 未动——那是在说明一条规则的出处,不是版本声明。
改后用 json.load 验证两个文件仍合法且两处版本一致。
2026-10-02 20:13:20 +08:00
ffb2b8f2f2
Merge release/v1.7.x into main for v1.8.0
...
main had fallen a full feature generation behind: 13739 lines and 29 files
that main did not contain at all (the whole Lua plugin mechanism, the billing
package, stats_period, plugins_api, docs/plugins.md). v1.7.x contains
everything main has and nothing main lacks, so this is a one-way merge rather
than two divergent lines.
Eight conflicts, each read before resolving — the script that does it lives at
.probe/resolve_merge_conflicts.py and aborts rather than guess:
* cmd/gui/package.json, cmd/gui/package-lock.json — main already carries 1.8.0
(4b37e1a ); the release line carries 1.7.6. Kept 1.8.0. Taking 1.7.6 would
ship tag v1.8.0 next to a manifest claiming 1.7.6.
* packaging/config.example.yaml (2 hunks) — release side is a superset both
times: it documents the `auto:` field, and it replaces the older one-line
runtime_file note with a paragraph that also states where templates and
deleted-markers live and warns that the AUTO chain, gateway keys and sources
are in config.yaml. HEAD's wording is strictly less.
* internal/config/config.go, internal/core/core.go, internal/gateway/api.go,
internal/gateway/apiv1.go, internal/gateway/stats.go (6 hunks) — HEAD is
zero-length in every one and the release side is the new code: BillingDSL
types, applyBillingDSL, the pointer-semantics source upsert, the
optional_fields doc string, the AUTO chain-walk field. The resolver asserts
the empty-HEAD property instead of assuming it.
Verified before committing: no residual conflict markers, both manifests read
1.8.0, `go build -tags luajit ./...` clean, `go test -tags luajit ./...` all nine
packages pass.
2026-10-02 19:38:42 +08:00
689c8cb383
test(deploy): 给 deploy.sh 的备份裁剪与回滚点补判据
...
发版前核验:deploy.sh 是唯一没有自动覆盖的发布关键脚本,而它新增的
prune_adapter_backups 会在 /etc/llmsproxy 下删目录。deploy.sh 本身路径硬编码
(会 mv 覆盖 /usr/local/bin/llmsproxy 与 config.yaml 并重启服务),不能在生产
试跑,所以把函数抽到临时目录实测。
两条判据:
- TestDeployPruneAdapterBackups:在 t.TempDir() 里造 12 个规范备份 + 3 个
不规则目录,抽取函数本体(只重定向 base,逻辑一字不动)执行,断言最旧被删、
最新保留、不规则目录绝不被删。
- TestDeployBacksUpTheLiveFileBeforeOverwriting:锁住「先备份线上文件再安装」
这条纪律,并断言备份语句出现在安装语句之前。
写判据时踩的三个坑(都是判据自身的错,不是被测代码的错):
1. 备份名位数。守卫是 ^[0-9]{14}$,第一版造了 13 位和 15 位的名字,全部被
「名字不规范」跳过,看起来像「什么都没删」的假通过。这和记忆里线上那次
15 位时间戳的坑是同一个。
2. KEEP_ADAPTER_BACKUPS=5 写在了 shim 里,等于覆盖被测脚本自己的配置——把
deploy.sh 里的 KEEP 改成 0 判据照样通过。改为从被测脚本正则读取该值。
3. 存活数期望写错。KEEP 计入不规则目录的数量却永不删除它们,所以实际存活
是 KEEP + 不规则目录数(实测 7)。这是「宁可多留也不删人工目录」的正确
取舍,判据改为断言这个语义。
变异验证 3/3 被捕获:去掉两处名字正则(误删人工目录)、KEEP 改 0、备份来源
换成新文件。第三个变异第一版用 sed 只改到第一个匹配点、漏掉真正的删除点,
误报成「判据漏放」——是变异脚本没改到位。
2026-10-02 15:44:50 +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
09cb215208
fix(billing): 通配符 token 规则不再把付费源标成「已定价 0」+ 迁移现有价格为规则
...
## 迁移后立刻发现的设计缺陷
把 .billing.state.json 里的现行价格迁成 URL 规则后(免费源 4 个 + 一个 `*`
模型价目表),注入插件的价格表里出现了 21 个 source 条目,全部 {0,0,0}——
包括 commandcode、alittokenplan 这些**付费**源。
原因在 Compile 的 token 分支:规则匹配到的每个 source 都会补一个 {0,0,0}
条目,注释写的理由是「否则该 URL 上没列进 rule.Models 的模型会掉回默认 0」。
但 `*` 匹配所有 source,于是这条为「特定 URL」设计的兜底变成了「给所有源盖
已定价 0 的章」。
后果正是插件本身要防的那个失效:priceFor 只要 source 有条目就置 priced=true,
priced=true 的请求不进 unpriced_reqs。所以付费源上未列出的模型全部记成
「已定价 $0」,账单看着加得起来,实际静默少算——commandcode 的 DeepSeek
正是这种情况(54k 请求的 deepseek/deepseek-v4.1-flash 在旧 sidecar 里
压根没定价)。
## 修法
通配符规则不再补 source 条目。`*` 的语义是模型目录:「这几个模型 id wherever
从哪来都这个价」,它不是「这些源都免费」。模型查表 p.models[payload.model]
本身就会把列出的模型标为已定价,所以通配符去掉 source 条目不丢任何东西。
特定 URL 的 token 规则保留 source 条目(它确实为该 URL 声明了定价)。
## 判据(2 条 + 3 个变异)
- 通配符不得标记任何未显式声明的源为已定价(点名 commandcode 场景)
- 特定 URL 规则仍必须标记自己的源(反向约束,防止把兜底整体删掉)
变异 M1(通配符也标记)、M2(只有通配符标记)、M3d(模型价发布到错 key)
均被捕获。M3 前两版「漏放」是我的变异脚本改坏了编译(unused variable),
grep 匹配不到 "--- FAIL"——和之前一样的工具陷阱,判据本身没问题。
## 线上状态
config.yaml 已写入 billing 段(active: current,5 条规则,无 warning),
生效价格表只把 4 个免费源标为 priced 0,付费源保持未标记⇒其未列出模型会
正确计入 unpriced 而非静默 0 元。
真实请求验证:3 个 deepseek-v4.1-flash 流式请求 200,计费从 0.56679
涨到 0.56702,走的是新规则注入的价格。全量测试连跑 5 次全绿。
2026-10-02 14:21:40 +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
c241a19b51
feat(stats): 用量按日/周/月/全部统计(审计文件聚合)
...
统计页原来只有一个视图——进程启动以来的累计。早上没人和一整周没人看起来
一模一样。加日/周/月/总四个周期。
## 口径与实现
周期视图必须走审计文件,不能走内存聚合:内存 byModel/byKey 等是终身累计,
而 recs 环形缓冲只有 500 条(defaultRingSize)。读环会把任何超过几百个
请求的周期悄悄少算——这正是要消除的那类错数。
- 日/周/月 = UTC 日历窗口(今日 / ISO 周周一 00:00 / 本月 1 日)。
刻意不用滚动 24h:滚动窗口会让"今天"和"最近一天"边界不同,同一个数字
随查看时刻在两张卡片间跳。UTC 也和 billing 的峰段计算同口径,峰谷小时
不会在费用视图和用量视图里落到不同一天。
- 全部 = 复用现有 Snapshot(内存聚合),无审计文件时依然可用。
- 时间桶:日→每小时(今天内部的尖峰要看得见),周/月→每天(否则一周是
7×24 个点、一个月 31×24)。全部视图无时间线(终身总量没有有意义的
时间轴,硬画 500 个滚动小时点是另一种撒谎)。
- key 过滤在所有维度生效;非法 period 返回 400 而不是静默回落"全部"——
书签里的手误应当报错,而不是悄悄换成终身数字。
## 判据(10 条 + 8 个变异全部被捕获)
窗口边界(含"周日必须回到上一个周一"这个 Go Weekday() 陷阱)、旧记录不
计入、维度独立聚合且 by_model 求和等于 total、日桶按小时且有序、key 隔离、
全部视图走终身、空窗口标记 truncated、period 校验、query 解析。
变异验证时 by_status 假绿了一次:禁用状态码聚合后判据全过,查下去是我
**根本没测 by_status**(零覆盖)。补 TestPeriodStatusDimension 后该变异
立即被捕获。判据报假问题时,先怀疑判据——这次确实是我错了。
## 真实流量核对
生产审计文件手算 vs 后端(含轮转文件):
day 手算 3559 / 后端 3532
week 手算 43240 / 后端 35034
month 手算 13696 / 后端 13670
差异是核对快照与请求之间的新流量,量级一致。
一个必须说明的发现:审计文件里混着两种记录 —— Req(type/model/
prompt_tokens)和访问日志(lat_ms/status/path)。46026 行里 33450 行是
访问日志,Go 侧按 r.Type=="" 跳过。这不是 bug(CSV 导出同样如此),但
意味着任何按行数手算都必须过滤,否则会差一个数量级。
CDP 实测四周期切换:reqs 3,571 / 35,075 / 13,710 / 196,712,与后端一致,
无控制台错误,localStorage 持久化生效。
2026-10-02 12:26:26 +08:00
d1da40e493
fix(billing): 页面文字溢出卡片 + 数字千分位
...
用户报 billing 页"文字超出展示框"。真因是三处叠加,都不是文案问题:
1. KPI 卡是 CSS grid,grid item 默认 min-width:auto。2e8 级的 prompt
tokens(实测 270,149,209)无法收缩,于是把 grid 轨道撑出容器 → 整页横向
溢出。修法是 min-width:0 + overflow:hidden(不给 min-width:0 的话,
grid 子项永远不肯收缩,这是 grid 最常见的溢出坑)。
2. 表格写死 min-width:560px,窄视口下必然溢出。改 width:100% +
table-layout:fixed,列宽由布局分配而不是由内容撑开。
3. 长名称(deepseek-v4.1-flash 这类模型 id)撑宽单元格。名称列改 ellipsis +
title 悬停看全名;数字列 word-break:break-all 在列宽内换行。
顺手:所有 token/请求数走 toLocaleString 千分位。原始 9 位数字读起来要数
零位数,分组后 270,149,209 一眼可读,也顺带缩短了字符串宽度。
CDP 实测(820px 窄视口,逼出溢出条件):
main/body 横向溢出 = no
table=338 < card=366(修复前 min-width:560 必然 > 366)
KPI 输入 tokens = 270,149,209
billing 页无控制台错误
残留(不影响布局):表头 TH 在固定布局下 48>42 轻微超出自身格,因为
word-break 不拆单个长词;表格整体仍在容器内,未产生页面滚动。
全量测试全绿(8 包)。
2026-10-02 12:16:40 +08:00
aa10ee8c27
fix(billing): 插件页不可达(真·空白根因)+ i18n + 溢出 + 状态页 tile
...
## ★ 用户报告「Billing 页还是空白」—— 上一轮的验证有漏洞
上一轮我用 goTab('billing') 直接调用验证,显示"有数据、无错误"就下了结论。
但用户是**点侧栏按钮**。真实点击路径走 goTab,而 goTab 只遍历硬编码的 TABS
常量来切换 `hidden` 类 —— 插件页不在 TABS 里,所以 #tab-billing 的 hidden
**永远不会被移除**。内容一直躺在 DOM 里(KPI/表格都填好了),只是不可见。
这个 bug 没有任何报错:注入正确、数据正确、API 200,唯一的问题是宿主页的
路由逻辑没把插件页纳入。而它是上一轮「TABS 收敛为单一常量」时留下的:
收敛让三处共用一个常量,却没让插件页进入它。
修法:goTab 同时遍历 PLUGIN_PAGES。PLUGIN_PAGES 从 const 改为 var 并**提前到
goTab 之前声明** —— const 在文件后部声明的话,goTab 的读取落在 TDZ 里,
第一次点击插件页就会抛 ReferenceError(同类问题这个文件里已是第二次)。
判据 TestPluginPagesAreReachableByGoTab 锁两件事:goTab 遍历插件页集合 +
声明在使用之前。变异验证:删掉遍历 → 红;var 改回 const → 红。
## 插件 UI 不跟随多语言
宿主的 applyI18n/data-i 只覆盖**宿主渲染的标记**;插件注入的 HTML 对它不可见,
所以整个 UI 切中文时 Billing 页还是英文。
pluginAPI 增加 lang(getter,实时值)与 onLangChange(切换回调)。
billing 页所有文案改走双语字典:KPI、表头(fresh/cache/cache%)、区块标题
(占位后由脚本填)、空态、状态页 tile 标签。切换时立即重渲染标题,
不用等下一次 fetch。
## 部分页面超出 UI 区域
#main 只有 overflow-y,插件页内容(8 列表格 min-width、长字符串)会横向撑破。
两层修:插件 pane 统一 min-width:0/max-width:100%/overflow-x:auto(第三方
任意 HTML 的兜底,与原生 pane 一致);billing 的宽表格在自身容器内滚动。
## 状态页 tile 的 TypeError(每次重绘都报)
tile 的 tick() 在 await 之后直接 getElementById(...).textContent = ...,
但状态页每次刷新都整体重建 pane,元素可能已不存在 → null 属性赋值。
await 之后重新取元素并判空。
## 顺手补的缺口
上一轮加了缓存表格列,但 KPI 卡片漏了(那次替换 assert 失败后重试只重做了
表格)—— 缓存命中率在表格里有、KPI 里没有。本次补上。
## 验证
真实浏览器(禁缓存、真实点击侧栏按钮):pane 可见、KPI 9 项、表头双语、
语言双向切换正确(Per source ⇄ 按源)、无水平溢出、无 billing 控制台错误。
生产数据:Total USD 0.566798 / 732 请求 / 降级 231 / 2.09 亿 prompt tokens。
387+ 测试全绿。
## DSL(进行中,未完)
config.BillingDSL(active + profiles + rules,rule 按 url 匹配 mode=free/
token/subscription/unpriced)与 internal/billing.Compile(url 规则 → 插件
prices 表,含峰谷窗口的形状编译 —— 之前手写 JSON 两次弄错的正是这个形状)
已落地并通过校验/编译;core 启动接线已写。profile 切换 API 与 WebUI 选择器
未做,生产 config.yaml 也尚未写 billing 段 —— 下一轮继续。
2026-10-02 11:47:24 +08:00
fbdf0dea10
fix(billing): 缓存命中统计缺失 + Billing 页空白 + 侧栏图标
...
三个问题都来自生产实测,不是代码审阅。
## 1. 缓存命中被计费却不被统计
网关确实从上游 usage 提取了 prompt_cache_hit_tokens(审计里能看到
cache_hit_tokens: 270104 / cache_reported: true,占 prompt 的 99.9%),
costFor() 也用它给缓存段定价了 —— 但**没有任何 bucket 记录它**。
结果:一个 99.88% 命中率的网关,报表显示 prompt_tokens 却看不出其中
多少是缓存读,也无从按源/模型/key 看命中率。
每个 bucket 现在多三个字段:
cache_hit_tokens 命中数(按上游上报)
cache_fresh_tokens 未命中的 prompt
cache_reported_reqs 上游确实上报了缓存数的请求数
第三个字段是刻意的:**「零命中」与「上游根本不上报」在命中总量里完全一样**,
而它们在「缓存折扣有没有生效」这个问题上含义相反。没有它就无法区分,
只能猜。
chat.go 的 payload 之前**没有** cache_reported(审计有、插件没有),
所以任何插件侧的缓存统计都只能猜 —— 已补上。
旧 state 文件的 bucket 没有这些字段:Lua 里 nil + number 会抛错,而钩子抛错
会让**该请求完全不记账**(一个统计缺口会变成静默缺口)。add() 里做了回填。
UI 增加 fresh/cache/cache% 三列 + Cache hit rate KPI;未上报的显示 n/r 而不是 0%。
## 2. Billing 页空白:render() 引用了未定义的 s
`render(st)` 里两处 KPI 写成 `s.degraded_reqs`,ReferenceError 让整个渲染
中断,所有表格停在初始的空 innerHTML。症状是「页面加载了但什么都没有」,
而 /api/plugins/billing/state 返回 200 且有真实数据 —— 载荷完全正确,
DOM 是空的。
更糟的是 refresh() 里的 `catch (e) { /* never break the page */ }` 把错误
**静默吞掉**了:网络面板一切正常,页面什么都没有。现在 catch 会
console.error(仍然不抛,装饰性组件不该拖垮宿主页,但必须留痕)。
## 3. 侧栏图标
billing 声明 icon = "💰 ",而原生 tab 全是内联 SVG(stroke: currentColor)。
emoji 尺寸不对、不跟随主题。
WebUI 增加 pluginIconHTML:插件图标可以是文本,也可以是内联 SVG。
**SVG 走严格白名单**(tag + 属性都是 allowlist,不是 denylist)——
插件是在运维者浏览器里跑的第三方代码,不能"信任插件";但也不能直接拒绝
SVG,因为那是唯一能和原生 tab 视觉一致的方式。
用真实 Chromium 验证 12 个用例,全部挡住,包括 foreignObject 里嵌 HTML
命名空间 <img onerror> 这个经典绕过(整体丢弃,所以 img/onerror 也没了)。
★ node 里没有 DOMParser/jsdom,所以没法在单测里跑这个过滤器 —— 用正则近似
会得到一个"测试通过但浏览器里失效"的过滤器,这比没有测试更糟。
顺带修了过滤器的两个真缺陷:输出里嵌套了空 `<svg></svg>`,且 viewBox
是从包装元素读的(永远是 null)而不是插件自己的,所以任何自定义 viewBox
的图标都会丢失。
## 判据(新增 7 项,全部变异验证)
写「注入脚本能否正常执行」这个守卫时我错了四次:
1. 静态扫「已声明的名字」→ 把 HTML 字符串里的 CSS 类名(class/div/td)
全报成未定义
2. 用 CSS 选择器解析器查样式表 → 报样式表本身坏了
3. 只挂 process 的 uncaughtException → 脚本在 IIFE 里异步跑,错误是
unhandledRejection,判据对原 bug 全绿
4. 只查「有没有抛错」→ render() 开头是 `if (!st) return`,传错字段是
**静默 no-op**:不抛、不打日志、不报错,只是页面空白
最终判据是:在 node 里用 DOM stub 真跑一遍,同时要求「无异常」且
「至少写进一个容器」,并监听 console.error。变异验证:还原 s → 红;
render 收到 undefined 字段 → 红。
表头/行列数一致性也有守卫:row() 加了缓存列而表头没加时,表格会整体错位
(cache% 落到 completion 列下)—— 渲染正常、有数据、但要仔细看才发现。
## 生产验证
重启后价目表与累计账完整保留(1.17 亿 prompt tokens)。
新请求缓存统计生效:cache_hit 947,436 / cache_fresh 888,
cache_reported_reqs 7 / 395(其余来自旧 state,正是该字段存在的意义)。
真实浏览器:表格 3 行、KPI 7 项、表头 name/cost/reqs/prompt/fresh/cache/cache%/completion、
SVG 图标 currentColor 渲染、控制台无 billing 错误。391 个测试全绿。
## 另发现一个无关 bug(未修)
首页 stats 图表抛 IndexSizeError: arc 半径为负(-2),在 ui/index.html 的
paintStats 附近。属状态页图表,不在本次范围。
2026-10-02 11:16:15 +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
8de1499c40
fix(ui): 插件元素注入被宿主页面重建擦除 —— 元素型注入从未真正生效
...
## 现象
部署示例后打开 WebUI:侧栏有 Billing 页,但**状态页上没有任何计费组件**。
插件明明声明了 elements,/api/ui-inject 也确实返回了 mount(1570 字节)。
## 根因(不是缺功能)
七个宿主页面的渲染函数都用 `pane.innerHTML = ...` **整体替换**自己的 DOM。
`renderStatus` 在 `injectPluginUI()` 之后由 refresh() 立刻调用,于是刚挂上的
plugin-el 连同整个 pane 一起被下一次赋值销毁。
时序上它**从没有过"显示一帧"的机会**:注入 → refresh("status") → innerHTML 覆盖。
所以症状是"元素从来没出现过",而不是"刷新后消失"——这正是我先前据
/api/ui-inject 返回值判定"注入正常"而漏掉的地方:**载荷到达 ≠ DOM 存活**。
Billing 页不受影响,因为它属于 PLUGIN_PAGES,走插件自有 DOM,不经宿主重建。
于是看起来像"页面注入有效、元素注入无效",把排查引向插件声明本身。
## 修法
- mountPluginElements 改成具名可重入函数,并注册进 PLUGIN_MOUNT_HOOKS
- refresh() 在**唯一出口**统一调 remountPluginElements(),而不是给七个渲染函数
各加一次调用——后者是多一处会忘的地方,而忘记的后果是静默的
- 每个挂载点按 data-idx 幂等:宿主重绘时若该 pane 已有该元素就直接返回,
否则插件的 <script> 会每次重绘都跑一遍,计数器静默翻倍
## ★ 验证方式换了:真实浏览器,而不是 payload
静态测试和 curl 都看不出这个 bug(载荷完全正确)。用 CDP 连本机共享浏览器实测:
修复后:首屏 tile=1,页面重建后=1,连续重建 5 次仍=1,console 无错误
回退后:tile 全程=0
对照二进制(把 done 改成空函数重编译)实测首屏就是 0,
**证明"从未显示过",不是"显示后消失"**。
## 判据与变异
TestPluginElementsSurviveHostRebuild 锁住:remountPluginElements 存在、
PLUGIN_MOUNT_HOOKS 在使用之前声明(const TDZ 会让首屏直接抛错)、refresh 挂了重挂、
挂载按 data-idx 幂等。
三个变异全部被抓住:撤掉 refresh 的重挂 / 去掉幂等守卫 / 把 const 声明移到 push 之后。
★ 第一次跑第三个变异时**判据正确地没报**,因为我的替换脚本命中了注释里的同名文本,
真正的 const 没被移动——是变异无效,不是判据有洞。换按行定位后如期变红。
## 同时补上部署示例
packaging/config.example.yaml 里补 plugin_dir 说明(之前只有 online 部署路径踩过)。
实测升级路径本身是好的:给已有配置加 plugin_dir 后,首次启动会自动 seed 内置
billing 插件,无需手工放置文件。
2026-10-02 09:04:47 +08:00
1c690611f8
feat(gui): WebUI 与 Electron 壳的插件安装/删除/禁用/编辑
...
## WebUI:新增「插件」页
- 列表来自 on_disk(不是 loaded 集合)——**加载失败的插件也必须显示并带错误**,
否则一个语法错误看起来和"插件没装"完全一样
- 启用/禁用(PUT {"enabled":bool})、删除、编辑源码、安装/覆盖
- 显示 hook_errors:插件抛异常在别处毫无痕迹,没有这一栏的症状就是
"功能就是不work"
- 插到 dropzone 与代码编辑器都做了泛型化(bindDropzone / openCodeModal),
适配器与插件共用一份,而不是复制第二份只改 4 个 id 的函数
## TABS 收敛为单一常量
tab 清单原本是字面量散在三处:goTab、refresh()、admin-only 隐藏列表。
加一个 tab 意味着三处都要记得改,漏一处就是"路由认得但界面不显示"——
和今天早些时候 chain_step 漏报同一类静默缺口。现在只有 const TABS。
## Electron 壳:设置面板里的插件管理
渲染进程不能直连内嵌核心(没有 key、不知道端口),所以走 IPC:
renderer → plugins:proxy → main → HTTP /api/plugins
代理是 (method, path, body) 透传而不是固定命令表:固定表每加一个端点就要扩,
而"按钮存在但什么都不做"比"没有这个按钮"更糟。透传让渲染层能调用核心将来
新增的任何 /api/plugins 路由,路径在主进程校验。
## ★ GUI 此前零测试,而本次改动就引入了三类"看起来没事"的问题
1. 引用了不存在的 CSS 类(.tag / .sm)——渲染成无样式文本
2. 引用了不存在的 helper(esc / escAttr)——那是 WebUI 的,renderer/app.js
是独立文档,点击时 ReferenceError
3. .ghost/.primary 只在 .form .actions 作用域内生效,插件按钮在 .pl-acts 里
于是是无样式裸按钮
补 4 个静态判据(不启动 Electron,守卫的正是"打开应用才看得见"那一类):
TestGUICSSClassesExist 用到的类必须在样式表里定义
TestGUIHelperFunctionsAreDefined 被调用的函数必须有定义
TestGUIPluginPanelIsReachable 面板在 overlay 内、按钮已绑定、打开设置会加载
TestGUIIPCPathIsConstrained 代理必须限定 /api/plugins 前缀并拒绝路径穿越
写第一个判据时我错了三次:CSS 解析器先丢最后一个 selector、再把变量块当
selector、最后漏掉复合选择器(.tb-btn.tb-close)。两次"判据自己坏了"的
教训和本项目一贯一致——**判据出错的信号是它报了一个假问题**。现在改用宽松的
token 提取 + 显式的 guiKnownUnstyled 豁免表(blob/tgl/rail 是既有无样式类,
不是本次引入,失败它们只会让判据对新工作失去意义)。
## 变异验证
改坏唯一的 CSS 定义(.pl-empty)→ TestGUICSSClassesExist 红
改坏 helper 名 → TestGUIHelperFunctionsAreDefined 红
★ 第一次变异我改了 .pl-broken,判据**正确地没报**——因为它还被另一条规则定义。
这是变异选错目标,不是判据有洞;换 .pl-empty 后如期变红。
363 个测试全绿。
2026-10-02 08:47:08 +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
d0c7465130
fix(plugin): /api/ui-inject 的 stages 漏掉 chain_step + 忽略临时构建目录
...
部署到线上时用隔离实例(独立端口 18099 + 独立 config/runtime/adapter 目录)
发真实请求验证,抓到的第三个 bug。
## bug:discovery 文档漏掉新 stage
handlePluginUI 的响应里 stages 是**字面写死的三个**。加 chain_step 时只改了
lua.AllStages,没改这里,于是插件作者读 GET /api/ui-inject 会看到
["request_start","routed","request_end"],**合理地得出结论:没有 chain_step 这个
stage**。stage 本身是注册好的、也确实在触发,只是没被声明。
改成从 lua.AllStages 派生——AllStages 是唯一定义顺序的地方,让它保持唯一。
★ 而我原来的测试断言 `len(view.Stages) != 3`,**断言本身是 bug 的保护伞**:
它把"三个"固化成了期望值,于是新增第四个 stage 时测试全绿、bug 上线。
现在断言改为「与 AllStages 等长且逐项相同」,并显式要求 chain_step 在其中。
新增 stage 而忘了声明,这类问题会立刻红。
## 顺带:.gitignore 补上临时构建目录
.probe/ 和 .build-work/ 是我调试时当 GOTMPDIR 和临时二进制用的,之前每轮
手工删,这轮差点提交进去 9.5MB 的二进制。
## 部署验证留档
隔离实例跑真实 chat(158 prompt / 13 completion / 128 cache_hit),计费插件
算出 0.000688,与手算 (158-128)*1e-5 + 128*1e-5*0.1 + 13*2e-5 **逐位吻合**。
★ 第一次手算我按全价算成 0.00184,一度以为插件算错了——查审计记录才看到
cache_hit_tokens。**算钱不对时先查输入再怀疑实现**,而我忘的恰是刚修的折扣。
## 验证
351 个测试全绿;变异(stages 改回硬编码三个)被 TestUIInjectServesPluginUI
抓住,3 条断言同时红。
2026-10-02 06:24:34 +08:00
cb6df0a3f0
fix(billing): 缓存命中按全价计 + 未定价流量静默记 0
...
部署前审计计费插件时自己找到的两个真缺陷,都会直接算错钱。
## ★ 缺陷 1:缓存命中按全价计(高估约 10 倍)
costFor 只看 prompt_tokens,不区分其中多少是缓存命中。实测(审计脚本,非推演):
1M prompt token 里 900k 是 cache_hit → **算出 10 USD**,而缓存读通常只要 1/10
价,正确值 ~1.9。agent 流量反复重放长前缀,正是缓存要让它便宜的那类流量,所以
这个偏差恰好落在最高频的流量上。
改为拆分:
fresh = prompt_tokens - cache_hit_tokens → 全价
cached = cache_hit_tokens → 全价 × cache_discount
cache_discount 默认 0.1(DeepSeek/Qwen/Kimi 的量级),可按条目覆盖——**折扣率是
每个 provider 的事实、不是自然常数**,所以 0.1 只是默认值而不是硬编码常量。
另外把 cache_hit 钳到 prompt 以内:适配器报出比 prompt 还大的缓存命中数时,
fresh 会变负数,凭空产生负计费 token。
## ★ 缺陷 2:未定价模型静默记 0(最危险)
没有任何价目覆盖的请求,成本记 0,而 **requests 和 token 数照常计入 total**。
于是账单看起来完全正常,只是 quietly 少报——没有任何报错,没有任何异常。
比多算危险得多:多算你会去查,少算你不会知道。
新增两个维度把这件事变成显式信号:
unpriced_reqs 未定价请求数
unpriced_models 按模型点名,直接告诉你价目表缺哪一行
仪表盘加一张 "Unpriced" 卡片,**这个数应该是 0**。
任何维度(source / model / key)覆盖了就算 priced。
## 修这两个时自己踩的坑
第一版把未定价统计块写在了 `local s = plugin.state` **之前十行**,
在一个全新插件上 hook 直接抛 "attempt to index global 's'",于是
**整条请求什么都没记**——计费插件能有的最坏失败方式。
是 TestBillingZeroPricesIsSafe 的 "requests = 0" 抓到的。
代价:一个计费插件静默失效,而网关日志里只有一行 hook error。
## 判据(351 个测试全绿,计费相关 16 个)
新增 5 个,全部是**具体金额**断言:
TestBillingCacheHitsAreDiscounted 1M/900k 命中 → 1.9
TestBillingCacheDiscountIsPerModel 覆盖为 0 / 1 两种极端
TestBillingCacheHitClampedToPrompt 荒谬的命中数不产生负费用
TestBillingCountsUnpricedTraffic 只数未定价的那个,且流量仍计入 total
TestBillingAnyDimensionCountsAsPriced 源维度定价也算 priced
2026-10-02 01:14:33 +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
8c18e0c3d7
fix(plugin): request_start 在直连与生图路径上根本没触发
...
被"你确定功能全部正常了?你全部测试了?"问出来的。之前所有插件测试都是直接调
Plugins.Fire(),只证明 Lua 运行时没问题,**完全没验证网关有没有真的触发**——
把 handleChat 里的三处调用删掉,整个套件照样全绿,而线上一个钩子都不会跑。
补上走真实 HTTP 的端到端判据后,立刻抓到两个真 bug:
## bug 1:直连路径完全跳过 request_start
fireStart 只写在 handleChat 的 AUTO 分支里,任何指定了具体模型的请求(也就是
绝大多数请求)都不触发。修法是挪到 isAuto 判断之前,两条路径共用一次调用。
顺带修正位置语义:它在配额/模型范围闸门**之前**触发,所以插件能统计到被网关
拒绝的请求;否则插件永远只能报"被服务的请求数",算不出真实请求率。
## bug 2:生图路径三个 stage 全断
handleImage 是第三个入口,有自己的 handler 和自己的调度调用。"聊天能用"对它
毫无证明力。而生图是计费流量,计费插件看不到就等于少报。
已补 fireImageStart + 两处 fireRouted(direct -1 / AUTO -2)。
它写独立函数而不是复用 fireStart 传空 chatRequest:image 请求没有 messages
和 tools,传一个为聊天设计的零值结构会诱导后来者去读不存在的字段。
## 端到端判据(6 个,全部走真实 handler)
TestHooksFireOnRealDirectChat 直连:三个 stage 顺序 + 真实 source/model/tokens
TestHooksFireOnRealStreamChat 流式是另一条路径(记录由 defer 在流结束后写)
TestHooksFireOnAutoRequest AUTO 链:start 报 "AUTO"、routed 报**解析后**的模型
TestHooksFireOnFailedRequest 失败请求:routed 不触发(没选到源)、
request_end **必须**触发(否则计费看不到失败流量)
TestHooksFireOnRealImageRequest 生图:type=image,第三个入口
TestRejectedChatStillFiresRequestStart 404 拒绝也要触发 start(顺序决定的钉子)
TestBrokenPluginDoesNotBreakForwarding 插件每 stage 都抛异常时聊天仍返回 200
## 变异验证
把 fireStart 挪回 AUTO 分支(= 重现我犯的错)→ 4 个判据红:DirectChat /
StreamChat / FailedRequest / RejectedChat。恢复后 336 个测试全绿。
这两个 bug 都属于"读代码看不出来"的类型:fireStart 那一行就在 handleChat 里,
看着挺像那么回事,只有真的发一个请求才知道它没被调到。
2026-10-02 00:49:32 +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
a2e1adc2d8
fix(gemini): endpoint 自相矛盾导致预置模板必失败
...
gemini.lua 里 adapter.endpoint = "/v1/models",而它自己的注释写的是
POST /v1/models/{model}:generateContent
两者矛盾,而 Go 侧是静态拼接(provider.URL = base_url + endpoint),拼不出
模型名。预置模板 "Google Gemini"(base_url=.../v1beta)于是会 POST 到
https://generativelanguage.googleapis.com/v1beta/v1/models
既多一段 /v1,又缺 :generateContent——那是 Gemini 的模型**列表**端点,
对 POST 返 405。所以任何用户从模板建这个源,拿到的都是必定失败的源。
实测确认影响范围:线上 21 个源里没有 gemini(openai×15 / trae / sensenova /
opencodezen / deepseek / anthropic / agentrouter),所以是潜伏缺陷。
修法:endpoint 改成模板 `/v1beta/models/{model}:generateContent`,新增
provider.ChatURL(model, stream):
- 用 **PathEscape** 替换 {model}——模型 id 进的是 URL 路径,不转义的话
一个 "/" 就会静默指向另一个资源(判据里用 RequestURI 而非 URL.Path
断言,因为后者是解码后的,看不出 %2F);
- 流式把 ":generateContent" 换成 ":streamGenerateContent"(同一个路径、
不同动词,也在路径里)。替换刻意只认这个精确后缀,免得别的适配器
仅仅提到这个词就被改写;
- source 自己设的 endpoint: 仍然优先,模板被整体跳过。
Chat / ChatStream / probeChat 三处调用点改为传本次请求真实的 model——AUTO
按槽位把 req.Model 钉死,所以 URL 必须跟随**请求**的模型,用源默认模型会让
多模型源每次都打同一个(还记到别的模型的账上)。
判定静态 endpoint 的其他 10 个适配器零影响(TestNonGeminiEndpointsAreUntouched)。
顺带:Stats 的 mutex 不是可重入的,导出方法自己加锁、*Locked 后缀要求调用
方持锁。持锁调导出方法会死锁——我的探针真卡死过一次(直到 10 分钟超时)。
补上 LOCKING 注释,并加判据把这条规则钉住(含一个 20 秒上限的行为判据,
让未来的重构撞死锁时快速失败而不是拖满整个套件)。
2026-10-01 23:37:17 +08:00
7082ffb723
fix(packaging): 去掉重复的加固块,并同步线上单元的确切内容
...
上一版把线上单元拷进来后又追加了一份英文注释的加固指令,导致
NoNewPrivileges / ProtectSystem / SystemCallFilter 等在同一个 unit 里
出现两次。systemd 对重复指令取最后一个,行为上不会坏,但文件本身是错的
(读的人会以为有两套加固),且 packaged 与 deployed 不再一致。
现在 packaged/llmsproxy.service 与线上 /etc/systemd/system/llmsproxy.service
逐字节相同,每条指令只出现一次。
2026-10-01 20:59:34 +08:00
5e723b5aa5
feat(packaging): systemd unit 加固(逐条实测,非照抄模板)
...
原单元只有内存调优两行环境变量,加固项一个都没有,且以 root 运行。补上
一组经验证的加固指令。
关键决定:**仍然以 root 运行**。本服务要读 master.key(0600 root)。实测加
User=llmsproxy 直接起不来,且失败方式隐蔽——
[config] secrets disabled: open /etc/llmsproxy/master.key: permission denied
只是一行日志,服务会带着「敏感值以明文落盘」继续跑。也就是说在当前文件
权限下降权不是加固而是把密钥降级。要降权得先把 key 交给服务用户、统一
/etc/llmsproxy 属主,那是独立的、需要回滚预案的变更,不混进来。
每条指令都在一个独立探针单元(临时端口 + 独立 runtime_file/adapter_dir,
拷了真实适配器)上验证过:
- 鉴权 401/200 正常;
- 一次真实 /v1/chat/completions 走通(证明 SystemCallFilter=@system-service
没打断 LuaJIT 适配器的 JIT 代码路径——这是最容易被 seccomp 搞坏的地方);
- 审计文件可写可轮转(ReadWritePaths=/etc/llmsproxy 够用);
- 连续重启 3 次都 active + http 200,kill -9 行为符合预期。
systemd 对非法指令值不报错只「忽略」,所以逐条实测是唯一可靠做法。
读路径全在 /etc/llmsproxy;运行时写入经核对只有 config.yaml / runtime.json /
*.audit.jsonl / adapters/*.lua / master.key,全在该目录下,故 ProtectSystem=strict
+ ReadWritePaths=/etc/llmsproxy 即可。CapabilityBoundingSet 置空(本服务不需要
任何 capability,留空比写允许清单更难出错)。
已部署到线上 /etc/systemd/system/llmsproxy.service(原单元已备份为 .bak-*),
restart 后服务 active、监听 8081、WebUI 可达、审计继续写入;本仓库的
packaging/llmsproxy.service 与线上一致(去掉了部署机特有的 RSS 实测数字)。
2026-10-01 20:58:23 +08:00
e10bfbb278
fix(deploy): 适配器备份只保留最近 5 份,不再无限堆积
...
sync_adapters 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到
最近一次($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人
清理——线上实测已积累 71 个,把 /etc/llmsproxy 顶到 122M。
prune_adapter_backups:
- 按时间保留最近 KEEP_ADAPTER_BACKUPS=5 份(sort 升序,删前面较旧的);
- 另给目录数一个硬上限(5×4),防一次误配置在几秒内造出成百上千个目录;
- 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,手工放的
adapters.bak.MANUAL 之类一律跳过不删(宁可留着也不误伤)。
本地用真实形状的名字(14 位 YYYYMMDDHHMMSS)跑了 5 个用例验证:
12 份→留最新 5 且手工目录/无关目录不动、恰好 5 份→全留、40 份→走硬上限、
0 份→不报错、只有手工目录→原样保留。
线上已手动按同一规则清到 5 份(122M→114M;剩下的大头是 runtime.json 的
审计 .old,由 auditKeepOld=16 自行限额,不在本次范围)。
2026-10-01 20:52:23 +08:00
ad54616bee
docs: 对齐分支命名实际用法 + 修正示例配置的存储位置说明
...
三处都是"文档说的"与"仓库实际做的"不一致,读文档的人会被误导。
1. 分支命名:文档写 release/vX.Y.Z 且举例 release/v1.4.2,实际从
release/v1.4.x 起一律用字面 x(release/v1.5.x / v1.7.x)。改成实际情况,
并说明"一条 minor 分支跨多个 patch"是刻意的:patch 是同批功能的修订,
hotfix 落同一条分支,回流 main 时不必处理多条 release 分支间的依赖。
2. 退役规则与实践不符:文档说"下个版本发布就删上一个 release 分支",但
release/v1.4.x / v1.5.x 至今仍在。保留无害(hotfix 已回流),但与规则
矛盾。两份文档都如实记下这个出入,并记录特性分支**确实**在清理——本次
删除的四个分支都逐提交用 git patch-id 核对过,工作已全部进入 main
(唯一 patch-id 不同的是 bf0657b 的发布前变体,diff 过 api.go 改动逐字节
相同)。留着只是给下个版本制造 cherry-pick/merge 陷阱。
3. 存储位置:示例配置说"WebUI 新增/编辑的源会写入 runtime_file",且默认
模型注释说 AUTO"按各源 priority 自动选最高可用源"。两处都与实现相反——
上游源、网关密钥、AUTO 链都在 config.yaml(AddSource 走
UpsertSourceInYAML,密钥与链走 cfg.Save);runtime.json 只剩源模板、
删除标记和预置模板名单。已核实 store.Upsert(runtime 源)已无调用点,
store 里的 Keys/Auto 只被加解密、从不写入,是遗留字段。
priority 本身不是完全没用,所以注释保留并说清它的两个真实用途:首次启动
seedAuto 的初值,以及 AUTO 生图链未配置时挑"最佳模型"(bestChatModel /
bestImageModel 按 priority 取最大)。
2026-10-01 20:41:57 +08:00
18e422a943
fix(sources): 源编辑不再清零 proxy_url / api_key_env / timeout
...
bf0657b 修好了 api_key,但 upsert 仍会重写整个源,于是请求无法表达的字段
一律被重置为零值。这四个字段的后果都不是"少个配置项":
- api_key_env 丢失 ⇒ 盘上无明文密钥的源变成无凭据源,写操作返回 200,
下一次调用上游才 401。而 README 恰恰把这个特性当作卖点在宣传。
- proxy_url 丢失 ⇒ 一个走代理的上游变成直连(或反之),且完全无声。
- timeout / queue_timeout 丢失 ⇒ 退回默认 120s / 60s。
触发路径不是只有脚本:WebUI 的 saveSource() 发的 payload 只含表单上的
11 个字段,而 editSource() 表单里根本没有这 4 项 ⇒ 运维在界面上改个并发数
就会静默清掉它们。
修法用「存在性」语义而不是「空即继承」:
- 不传 → 保留已存值(部分更新的客户端要的就是这个)
- 传了 → 覆盖,包括传空串表示清空
api_key 刻意保留它原有的「空即继承」规则,不跟着改成指针:该规则已随
v1.7.6 发布,脚本依赖它;而凭据丢失比代理丢失严重得多。两个字段的失败
模式相反,所以规则相反——这一条写进了两处注释。
另一处是差一点的:config.Source 把 Timeout/QueueTimeout 标成 json:"-",
所以 reveal 接口的结构体序列化**根本不返回它们**。表单读不到 → 输入框
恒空 → 而输入框每次都回传 → 每存一次就把 timeout 清零。等于把刚修好的
丢字段换个方向又造了一个。因此 reveal 分支现在显式返回 duration 字符串。
判据:
- TestWebUIEditPayloadPreservesRoutingFields 用的是 WebUI 真实 payload
的逐字节副本,并同时断言"确实改动的字段生效",否则"什么都不写"也能过
- TestSourceEditPreservesAPIKeyEnv 单独盯 api_key_env(唯一造成凭据丢失的)
- CanBeSet / CanBeCleared 分别盯两个方向:只有"空即继承"的实现过不了
CanBeCleared(清空代理框会永远保留旧代理)
- 持久化判据**重新加载 config.yaml 并按语义比对**:300s 会被重新序列化成
5m0s,按字符串匹配是假红(我自己先踩了一次)
- TestSourcePayloadCoversEveryEditableField 用反射卡住"这一类":新增
Source 字段而没接到 API 上时立刻变红。反射查结构体而非 marshal 结果,
因为指针 + omitempty 会合法地从序列化输出里消失,那正是"未提及"信号
- 三个 UI 契约判据把 JS 侧也钉住(表单必须回传、必须从 reveal 读)
变异验证(每次都先确认 build 通过,再数红格):
1. 去掉覆盖逻辑 → 7 个判据红
2. 改成"空即继承" → CanBeCleared + ClearIsScoped 红
3. reveal 不返回 duration → TestSourceRevealExposesDurations 红
4. 表单不回传 api_key_env → TestUIEditFormRoundTrips... 红
顺带修正 /api/v1 索引:DELETE /api/keys 的路径段写的是 {name},实际是 key
本身;PUT /api/keys/{key} 实现了却没列。
全量 + vet + race 全绿;WebUI 内联脚本过 node --check。
2026-10-01 20:36:48 +08:00
980f4a0e40
fix(gui): 缓存解封结果,否则每个请求都要 spawn 一个进程
...
The auth rule calls readAdminKey() on every outbound request so injection
never depends on ordering. Once the sealed-config path shells out to the
core, that turns each request into a process spawn: 200 simulated requests
took 1012ms and launched 200 cores.
Cache the unsealed key against config.yaml's mtime. Editing the config still
invalidates it, which is what the auth rule actually needs -- the port
rewrite, the first write, and a user edit all change mtime. Measured: 200
requests now cost 10ms and one spawn.
Only a successful unseal is cached. Caching a failure would pin an empty key
until the config next changes, turning a momentary spawn error into a locked
out user.
v1.7.6
2026-10-01 19:23:32 +08:00
1fe379f630
fix(gui): 缓存解封结果,否则每个请求都要 spawn 一个进程
...
The auth rule calls readAdminKey() on every outbound request so injection
never depends on ordering. Once the sealed-config path shells out to the
core, that turns each request into a process spawn: 200 simulated requests
took 1012ms and launched 200 cores.
Cache the unsealed key against config.yaml's mtime. Editing the config still
invalidates it, which is what the auth rule actually needs -- the port
rewrite, the first write, and a user edit all change mtime. Measured: 200
requests now cost 10ms and one spawn.
Only a successful unseal is cached. Caching a failure would pin an empty key
until the config next changes, turning a momentary spawn error into a locked
out user.
2026-10-01 19:23:20 +08:00