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

@ -1318,40 +1318,51 @@ func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
}
}
// profile 级 default(compile.go 写入的 default_mode)必须真的生效。
//
// 真实缺陷(GitCode issue #1 顺带发现):compile.go:54 写 `default_mode`、
// :104 写 `unmatched_sources`,但全仓库没有任何读取方 —— 于是 profile 的
// `default: free|unpriced` 在运行时完全等价,区别只存在于 YAML 里。
// "free" 是**刻意定价为 0**,不该进未定价统计;"unpriced" 才该。
func TestBillingProfileDefaultMode(t *testing.T) {
cases := []struct {
mode string
wantUnpriced float64
}{
{"free", 0},
{"unpriced", 1},
{"", 1}, // 未声明 = 现有行为(未定价债务)
// profile 级 default 策略必须真的生效 —— 且它是**编译期**语义(PR #2):
// `default: free` 由 compile_prices 给未匹配的源写 {per_request:0} 标记;
// `unpriced`/缺省不写。旧实现把 default 写进没人读的 default_mode 键,
// 两种取值运行时完全等价。
func TestBillingProfileDefaultIsDecidedAtCompileTime(t *testing.T) {
sources := []map[string]interface{}{
{"name": "listed", "base_url": "https://listed.example/v1"},
{"name": "unlisted", "base_url": "https://unlisted.example/v1"},
}
for _, tc := range cases {
tokenRule := map[string]interface{}{
"url": "https://listed.example/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "2"}},
}
run := func(t *testing.T, profile map[string]interface{}) float64 {
t.Helper()
ps, _ := billingVM(t)
prices := map[string]interface{}{
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": false,
})
if err != nil {
t.Fatalf("compile_prices: %v", err)
}
if tc.mode != "" {
prices["default_mode"] = tc.mode
m := out.(map[string]interface{})
if e, bad := m["error"]; bad && fmt.Sprint(e) != "" {
t.Fatalf("compile: %v", e)
}
_ = ps.SetState("billing", map[string]interface{}{"prices": prices})
_ = ps.SetState("billing", map[string]interface{}{"prices": m["prices"]})
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "no-rule-model", "source": "no-rule-source", "key": "***p", "ok": true,
"model": "some-unlisted-model", "source": "unlisted", "key": "***p", "ok": true,
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
})
st := stateOf(t, ps)
if got := st["unpriced_reqs"].(float64); got != tc.wantUnpriced {
t.Errorf("default_mode=%q: unpriced_reqs = %v, want %v", tc.mode, got, tc.wantUnpriced)
return stateOf(t, ps)["unpriced_reqs"].(float64)
}
free := map[string]interface{}{"id": "p", "default": "free", "rules": []interface{}{tokenRule}}
if got := run(t, free); got != 0 {
t.Errorf("default=free: unpriced_reqs = %v, want 0(刻意免费,不该计入债务)", got)
}
for _, def := range []interface{}{"unpriced", nil} {
profile := map[string]interface{}{"id": "p", "rules": []interface{}{tokenRule}}
if def != nil {
profile["default"] = def
}
if got := run(t, profile); got != 1 {
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
}
// 无论哪种模式,成本都是 0;区别只在"是否算未定价债务"。
approx(t, "default_mode="+tc.mode+" cost",
st["total"].(map[string]interface{})["cost"].(float64), 0)
}
}