refactor(billing): 计费语义下沉到 Lua(内核只解 YAML/传数据)+ 采纳 PR #2 语义

按 issue #1 的修复方向(维护者评论):内核不再持有任何计费语义,billing 逻辑
全部在 `plugins/billing.lua`;内核只负责解 config.yaml、校验 schema、把
profile + 源 base_url 作为**数据**交给插件,再把插件算出的价目通过 `prices`
侧车注入。计费账目仍持久化在插件状态文件(`plugin_dir/.billing.state.json`)。

## 变更
- **删除** `internal/billing`(compile.go 359 行 + 其 Go 测试);
- 新增 `core.compileBillingPrices`:把 profile/sources JSON 化后调用插件导出
  `compile_prices`,返回价目表 + 未匹配规则清单(供错误日志点名 URL);
- `applyBillingDSL` / 规则编辑器改为走同一个插件编译入口(编辑器 lenient,
  半成品规则可保存、可报告);
- `billing.lua` 新增 `compile_prices`:URL 归一匹配、首条命中优先、
  free/subscription/unpriced/token、USD/M→per-token、peak 窗口、
  long_context 边际价、reasoning 可选价、default 策略、未匹配清单。
  错误通过载荷 `error` 字段回传(插件导出第二返回值通道不可靠)。

## 与 PR #2(valuelesser)合并后的语义
- **token 规则不产生任何源条目**:未列价的模型落到 unpriced 统计(可见),
  不再"静默计 $0"。比 issue 正文里的 `{per_request:0}` 标记更收口;
- `default: free` 由**编译期**给未匹配源写 `{per_request:0}` 标记;
  `unpriced`/缺省不写。删除无人读取的 `default_mode` 死键;
- 保留 `unmatched_rules` 清单(只用于降级应用时点名过期 URL)。

## 判据(原 Go 单测平移到 Lua + PR #2 的用例)
- `internal/lua/billing_compile_test.go`:通配符不标记源、token 规则无源条目、
  任意源条目不得携带 token 价(根因直防)、free/subscription 保留有意零价、
  reasoning 缺席、long_context 三种形态、peak 形状、URL 归一+首条优先、
  lenient 未匹配清单、硬错误 5 例、default 两态;
- `internal/lua/billing_test.go`:编译输出驱动真实 priceFor 的 A/B 用例
  (旧形状 cost=0 / 新形状 0.75 且 unpriced=0)、default 编译期语义 e2e;
- `internal/gateway`:token 规则断言"无源条目"、模型价 per-token 量级;
- `internal/core`:过期规则 URL 降级应用(有效规则仍生效)。
This commit is contained in:
root
2026-10-20 18:33:26 +08:00
parent 442fa5e3f6
commit b4c5f0f0fc
10 changed files with 819 additions and 765 deletions

View File

@ -503,6 +503,27 @@ end
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
### 7.0 分层:计费语义在插件里,内核只给数据
`config.yaml` 的 `billing:` 是**声明**,真正的语义(规则怎么变成价目表、
覆盖顺序、未定价判定、峰谷/长上下文)全部在 `plugins/billing.lua`:
| 层 | 职责 |
|---|---|
| 内核 | 解 YAML、校验 DSL schema(`BillingProfile`/`BillingRule`);把 **profile + 每个源的 `base_url`** 作为数据交给插件;把插件算出的价目注入 `prices` 侧车;把计费记录持久化到插件状态文件 |
| 插件 | `compile_prices(profile, sources)`:URL 归一匹配、首条命中优先、`free`/`subscription`/`unpriced`/`token` 语义、USD/M → per-token、`peak` 窗口、`long_context` 边际价、`reasoning` 可选价、未匹配规则/源清单;`priceFor`/`costFor` 与账目累计 |
两条理由(GitCode issue #1):
1. 语义只写一处。曾经的 `compile.go`(Go)与 `priceFor`(Lua)各有一套覆盖
语义,组合起来把模型价静默清零 —— 账单加总正常、只是少报。
2. 插件沙箱**没有文件系统**,所以“插件直接读 config.yaml”在设计上不可行;
内核把解码后的 profile 作为钩子/接口传入,就是这里的分层形态。
**计费记录持久化**:插件状态(含累计账目)写在 `plugin_dir/.billing.state.json`
(同目录下 `.<插件名>.state.json`),随每次账目变更异步落盘,重启后恢复;
`prices` 与 `state` 分开存,改价不会抹掉历史账目。
### 7.1 计费维度
| 维度 | 用途 | token 价覆盖顺序 |
@ -516,10 +537,11 @@ end
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
⚠️ 正因为 `sources` 会覆盖 `models`,**"标记某源已定价"的条目只能用
`{ "per_request": 0 }`**,不能写 `{ "prompt": 0, "completion": 0 }` ——
后者会把该源所有模型的 token 价清成 0。编译器为 `mode: token` 的具体 URL
规则生成的就是前者(GitCode issue #1)。
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
`{prompt:0, completion:0}` 零价条目;`default: free` 给未匹配源写的是
`{per_request:0}`(只标记、不碰 token 价)。
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
**`cache_discount`** 见 §7.7。
@ -608,15 +630,14 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认):
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
它是**编译期**策略(以前被校验后写进无人读取的 `default_mode` 键,两种取值运行时
完全等价):
| `default` | 未匹配任何规则的源 | `unpriced_reqs` |
| `default` | 编译结果 | `unpriced_reqs` |
|---|---|---|
| `unpriced`(省略时) | 记 0 价,计入未定价债务 | +1 |
| `free` | 记 0 价,但视为**刻意免费** | 不增加 |
另有 `prices.unmatched_sources`:列出没有规则命中的源,网关在应用 profile 时
会以 `[billing] WARNING` 打到日志里 —— 看到它就去补规则(或确认 `default` 选对了)。
| `unpriced`(省略时) | 未匹配源不写条目 | +1 |
| `free` | 未匹配源写 `{per_request:0}` 标记(只标记、不碰 token 价) | 不增加 |
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
`total` 与各维度,只有金额是 0。