mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user