refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持

按维护者要求(issue #1 回复方向):内核不处理任何 billing 特有逻辑;插件有完整
OS 权限,自己保存配置文件。据此不再保留任何"内核 ↔ billing 插件"的专用对接。

## 内核(删除)
- `internal/billing`(Go 编译器 + 测试):删除;
- `internal/core` 的 billing 接口/降级应用/规则加载:删除(含 `billingPath`);
- `internal/gateway` 的 `/api/plugins/billing/rules` 端点与测试:删除;
- `config` 的 `BillingDSL/Profile/Rule/Token/Peak/LongContext` 与校验、
  `billing:`/`billing_file:` 配置段:删除。系统配置里不再有 billing 段。
内核只保留所有插件通用的能力:加载 .lua、插件状态持久化、插件目录
(`__llmsproxy_plugin_dir`)、`plugin.http` 路由、插件可用的 io/os。

## 插件(billing.lua)
- 规则文件移到**插件自己的目录**:`<plugin_dir>/billing.json`,由插件用 io 自己
  读写(tmp + rename 原子替换);加载插件时自己读文件并编译;
- 规则以**源名** `source` 为键(`url:"*"` 仅表示全局模型价目表):URL→源名映射
  属于内核配置,插件不为它和内核对接;编辑页在浏览器里取 `/api/sources` 选源名;
- 规则编辑 API 改为插件自己的 `/api/plugins/http/billing/rules`
  (GET/PUT/POST/DELETE,roles=admin),写盘也在插件内完成;
- 计费账目仍在插件状态文件(内核通用持久化)`<plugin_dir>/.billing.state.json`;
- `priceFor` 落地 profile 级 `default` 策略与显式 `mode=unpriced` 的优先级。

## 迁移
生产已迁移:`config.yaml` 的 `billing:` 规则转为 `plugins/billing.json`(按源名
重写),并从系统配置中移除;规则文件与系统配置从此各自独立保存。

## 判据
- `internal/lua/billing_rules_test.go`:插件自写 billing.json 并重启恢复、
  free/subscription/token/通配符编译语义、1e6+1e6 token = 0.75 端到端、
  非法规则被拒且不覆盖旧文件、reasoning/long_context/peak;
- `internal/lua/billing_test.go`:profile default 策略三态 + 显式 unpriced 优先;
- `internal/lua/billing_ui_test.go`:规则编辑页仍挂在 billing 页内、列宽/列数守卫。
This commit is contained in:
root
2026-10-20 18:57:23 +08:00
parent 5ccf068db3
commit 7941b4110a
14 changed files with 652 additions and 1633 deletions

View File

@ -340,7 +340,7 @@ func TestBillingRuleEditorLivesInsideTheBillingPage(t *testing.T) {
for _, needle := range []string{
`id="br-body"`,
"data-act='save'", "data-act='export'", "data-act='newprofile'",
".r-url", ".r-mode",
".r-src", ".r-mode",
} {
if !strings.Contains(mount, needle) {
t.Errorf("the rule editor is missing %s", needle)
@ -1318,51 +1318,54 @@ func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) {
}
}
// 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"},
}
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 {
// profile 级 default 策略:规则以**源名**为键,编译在插件内完成;
// `default: free` 表示"没有单独规则的源一律免费"(不进 unpriced 统计),
// `unpriced`/缺省则让这些源保持可统计;显式 mode=unpriced 的源即使
// default=free 也必须仍被统计。
func TestBillingProfileDefaultPolicy(t *testing.T) {
newVM := func(t *testing.T, rules map[string]interface{}) *Plugins {
t.Helper()
ps, _ := billingVM(t)
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 _, err := ps.Call("billing", "rules_put", map[string]interface{}{"billing": rules}); err != nil {
t.Fatalf("rules_put: %v", err)
}
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": m["prices"]})
return ps
}
fire := func(t *testing.T, ps *Plugins, source string) float64 {
t.Helper()
ps.Fire(StageRequestEnd, map[string]interface{}{
"model": "some-unlisted-model", "source": "unlisted", "key": "***p", "ok": true,
"model": "some-unlisted-model", "source": source, "key": "***p", "ok": true,
"prompt_tokens": 1000.0, "completion_tokens": 10.0, "time": 1750000000000,
})
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}}
base := func(def interface{}, rules []interface{}) map[string]interface{} {
p := map[string]interface{}{"id": "p", "rules": rules}
if def != nil {
profile["default"] = def
p["default"] = def
}
if got := run(t, profile); got != 1 {
t.Errorf("default=%v: unpriced_reqs = %v, want 1(未匹配源必须可见)", def, got)
return map[string]interface{}{"active": "p", "profiles": []interface{}{p}}
}
// default=free:未列规则的源算"刻意免费"
ps := newVM(t, base("free", []interface{}{
map[string]interface{}{"source": "priced-src", "mode": "free"},
}))
if got := fire(t, ps, "unlisted-src"); got != 0 {
t.Errorf("default=free: unpriced_reqs = %v, want 0", got)
}
// default=free 但显式 unpriced 的源仍要统计
ps = newVM(t, base("free", []interface{}{
map[string]interface{}{"source": "counted-src", "mode": "unpriced"},
}))
if got := fire(t, ps, "counted-src"); got != 1 {
t.Errorf("显式 unpriced 在 default=free 下仍应统计,got %v", got)
}
// default 缺省/unpriced:未列规则的源计入 unpriced
for _, def := range []interface{}{nil, "unpriced"} {
ps := newVM(t, base(def, []interface{}{}))
if got := fire(t, ps, "unlisted-src"); got != 1 {
t.Errorf("default=%v: unpriced_reqs = %v, want 1", def, got)
}
}
}