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