Files
ModelRouter/internal/lua/billing_compile_test.go
root b4c5f0f0fc 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 降级应用(有效规则仍生效)。
2026-10-20 18:33:26 +08:00

403 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"fmt"
"strings"
"testing"
)
// 规则 → 价格表的编译现在**在插件里**(GitCode issue #1 的建议方向:内核只提供
// 钩子/接口,计费语义下沉到 Lua)。这些判据从原 internal/billing 的 Go 单测
// 平移而来,并补上 issue #1 暴露的两个形状陷阱。
//
// 为什么必须保留这些判据:语义一旦分裂回两处,就会出现"源层零价覆盖模型价"
// 这类静默归零 —— 账单加总正常、只是少报,且 unpriced_reqs 也不涨。
func compilePricesForTest(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) map[string]interface{} {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": lenient,
})
if err != nil {
t.Fatalf("compile_prices: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("compile_prices returned %T", out)
}
if e, hasErr := m["error"]; hasErr && fmt.Sprint(e) != "" {
t.Fatalf("编译失败: %v", e)
}
if p, ok := m["prices"].(map[string]interface{}); ok {
return p
}
t.Fatalf("no prices in %#v", m)
return nil
}
// 编译失败通过载荷里的 error 字段回传(内核桥的第二个返回值通道在插件导出上
// 不可靠;{error=...} 是本仓库的通行约定)。返回错误文本供断言。
func compilePricesErr(t *testing.T, profile map[string]interface{}, sources interface{}, lenient bool) string {
t.Helper()
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": sources, "lenient": lenient,
})
if err != nil {
return err.Error()
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("compile_prices returned %T", out)
}
if e, hasErr := m["error"]; hasErr {
if s := fmt.Sprint(e); s != "" && s != "<nil>" {
return s
}
}
t.Fatalf("预期编译失败,实得 %#v", m)
return ""
}
func srcList(base string) []map[string]interface{} {
return []map[string]interface{}{{"name": "mine", "base_url": base}}
}
// 通配符 token 规则**不能**给源写零价条目:源层覆盖模型层,会把该源所有未列
// 价模型悄悄算成 0(原 Go 测试 TestWildcardTokenRuleDoesNotMarkPaidSources)。
func TestBillingCompileWildcardTokenDoesNotMarkSources(t *testing.T) {
sources := []map[string]interface{}{
{"name": "paid1", "base_url": "https://paid1.example.com/v1"},
{"name": "paid2", "base_url": "https://paid2.example.com/v1"},
{"name": "free1", "base_url": "https://free.example.com/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://free.example.com/v1", "mode": "free"},
map[string]interface{}{"url": "*", "mode": "token", "models": map[string]interface{}{
"only-catalogued-model": map[string]interface{}{"prompt": "1.0", "completion": "2.0"},
}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
srcs := prices["sources"].(map[string]interface{})
if _, ok := srcs["free1"]; !ok {
t.Error("显式 free 规则的源条目丢了(free 的 0 是语义)")
}
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("通配符 token 规则把 %q 标成已定价/0 价 —— 其未列价模型会算成 0 而不是进入 unpriced", name)
}
}
models := prices["models"].(map[string]interface{})
if _, ok := models["only-catalogued-model"]; !ok {
t.Error("通配符规则没有发布模型价")
}
}
// token 规则**不得**产生源条目(PR #2 的结论,比 issue 正文的 per_request 标记更收口)。
//
// 只写 {per_request:0} 虽然不会清零模型价,但同源上未列入 rule.Models 的模型仍被
// 视为"已定价"、静默计 0、unpriced 计数不涨 —— 仍是静默少报。统一收口后它们
// 落到 unpriced 统计里(可见);真正"边际成本为零"用 free/subscription。
func TestBillingCompileTokenRuleEmitsNoSourceEntry(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.60"},
},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
srcs := prices["sources"].(map[string]interface{})
if entry, ok := srcs["mine"]; ok {
t.Errorf("token 规则产生了源条目 %#v:源层会覆盖模型价 / 静默计 0", entry)
}
// per-million → per-token:0.15 USD/M = 1.5e-7
m := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if m["prompt"].(float64) != 1.5e-7 {
t.Errorf("prompt = %v, want 1.5e-7(per-token)", m["prompt"])
}
if m["completion"].(float64) != 6e-7 {
t.Errorf("completion = %v, want 6e-7", m["completion"])
}
}
// 根因直防:token 规则的源条目不得携带 token 价。
// (free/subscription 是唯一合法的"有意零价",由下一个测试单独守住。)
func TestBillingCompileNoSourceEntryEverCarriesTokenPrices(t *testing.T) {
sources := []map[string]interface{}{
{"name": "a", "base_url": "https://a.example/v1"},
{"name": "b", "base_url": "https://b.example/v1"},
{"name": "c", "base_url": "https://c.example/v1"},
{"name": "d", "base_url": "https://d.example/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://a.example/v1", "mode": "token",
"models": map[string]interface{}{"m1": map[string]interface{}{"prompt": "1", "completion": "2"}}},
map[string]interface{}{"url": "https://b.example/v1", "mode": "unpriced"},
map[string]interface{}{"url": "*", "mode": "token",
"models": map[string]interface{}{"m2": map[string]interface{}{"prompt": "3", "completion": "4"}}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
for name, raw := range prices["sources"].(map[string]interface{}) {
entry, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("源 %q 条目不是表: %#v", name, raw)
}
if _, bad := entry["prompt"]; bad {
t.Errorf("源 %q 携带 prompt=%v:会覆盖该源所有模型价", name, entry["prompt"])
}
if _, bad := entry["completion"]; bad {
t.Errorf("源 %q 携带 completion=%v:会覆盖该源所有模型价", name, entry["completion"])
}
}
}
// free / subscription 的零价是**语义**,必须保留 prompt/completion=0。
func TestBillingCompileExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
for _, mode := range []string{"free", "subscription"} {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": mode, "subscription": 10,
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
for _, key := range []string{"prompt", "completion"} {
if v, present := entry[key]; !present || v != 0.0 {
t.Errorf("%s: entry[%s] = %v (present=%v), want 显式 0", mode, key, v, present)
}
}
}
}
// reasoning 未配置时必须**缺席**(不是 0)。写 0 等于"思考免费",会静默少报
// 所有把 reasoning 按输出计价的 provider。
func TestBillingCompileReasoningPriceOmittedUnlessSet(t *testing.T) {
base := map[string]interface{}{"prompt": "1.0", "completion": "2.0"}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"plain": base,
"reasoning": map[string]interface{}{"prompt": "1.0", "completion": "2.0", "reasoning": "3.0"},
},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
models := prices["models"].(map[string]interface{})
if _, present := models["plain"].(map[string]interface{})["reasoning_price"]; present {
t.Error("未配置 reasoning 的模型不应出现 reasoning_price(0 会被当成免费)")
}
rp := models["reasoning"].(map[string]interface{})["reasoning_price"].(float64)
if rp != 3e-6 {
t.Errorf("reasoning_price = %v, want 3e-6", rp)
}
_ = base
}
// long_context:绝对值优先;只给倍数时按基础价的倍数;两者都缺要报错。
func TestBillingCompileLongContextRates(t *testing.T) {
cases := []struct {
name string
lc map[string]interface{}
want float64
wantErr bool
}{
{"absolute", map[string]interface{}{"above": 128000, "prompt": "0.30"}, 3e-7, false},
{"multiplier", map[string]interface{}{"above": 128000, "multiplier": 2}, 3e-7, false},
{"both-absolute-wins", map[string]interface{}{"above": 128000, "prompt": "0.30", "multiplier": 5}, 3e-7, false},
{"neither", map[string]interface{}{"above": 128000}, 0, true},
{"above-zero", map[string]interface{}{"above": 0, "multiplier": 2}, 0, true},
}
for _, tc := range cases {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{
"m": map[string]interface{}{"prompt": "0.15", "completion": "0.6",
"long_context": tc.lc},
},
}},
}
if tc.wantErr {
compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false)
continue
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
lc := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["long_context"].(map[string]interface{})
if lc["prompt"].(float64) != tc.want {
t.Errorf("%s: long_context.prompt = %v, want %v", tc.name, lc["prompt"], tc.want)
}
if lc["above"].(float64) != 128000 {
t.Errorf("%s: above = %v", tc.name, lc["above"])
}
}
}
// peak 的形状是插件唯一读取的 {multiplier, windows=[{days,hours}]}。
func TestBillingCompilePeakShape(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{map[string]interface{}{
"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}},
"peak": map[string]interface{}{"multiplier": 2,
"weekdays": []interface{}{1, 2}, "hours": []interface{}{[]interface{}{1, 4}}},
}},
}
prices := compilePricesForTest(t, profile, srcList("https://mine.example.com/v1"), false)
pk := prices["models"].(map[string]interface{})["m"].(map[string]interface{})["peak"].(map[string]interface{})
if pk["multiplier"].(float64) != 2 {
t.Errorf("multiplier = %v", pk["multiplier"])
}
wins, ok := pk["windows"].([]interface{})
if !ok || len(wins) != 1 {
t.Fatalf("windows = %#v", pk["windows"])
}
win := wins[0].(map[string]interface{})
if len(win["days"].([]interface{})) != 2 || len(win["hours"].([]interface{})) != 1 {
t.Errorf("window = %#v", win)
}
}
// URL 归一:尾斜杠与大小写不应影响匹配;首条命中优先。
func TestBillingCompileURLMatchingAndFirstWins(t *testing.T) {
sources := []map[string]interface{}{
{"name": "mine", "base_url": "https://Mine.Example.com/v1"},
}
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://mine.example.com/v1/", "mode": "free"},
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "1", "completion": "1"}}},
},
}
prices := compilePricesForTest(t, profile, sources, false)
entry := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{})
if entry["prompt"].(float64) != 0 {
t.Errorf("首条规则(free)应优先,实得 %#v", entry)
}
if _, ok := prices["models"].(map[string]interface{})["m"]; ok {
t.Error("第二条规则不应再被应用(首条命中优先,避免顺序歧义)")
}
}
// 严格模式:无源匹配的 URL 是硬错误;lenient 模式跳过并回报具体 URL。
func TestBillingCompileUnmatchedRules(t *testing.T) {
profile := map[string]interface{}{
"id": "p",
"rules": []interface{}{
map[string]interface{}{"url": "https://mine.example.com/v1", "mode": "free"},
map[string]interface{}{"url": "https://stale.example.com/v1", "mode": "free"},
},
}
if msg := compilePricesErr(t, profile, srcList("https://mine.example.com/v1"), false); msg == "" {
t.Fatal("严格模式必须报错")
}
ps, _ := billingVM(t)
out, err := ps.Call("billing", "compile_prices", map[string]interface{}{
"profile": profile, "sources": srcList("https://mine.example.com/v1"), "lenient": true,
})
if err != nil {
t.Fatalf("lenient: %v", err)
}
m := out.(map[string]interface{})
rules := m["unmatched_rules"].([]interface{})
if len(rules) != 1 || rules[0] != "https://stale.example.com/v1" {
t.Errorf("unmatched_rules = %#v", rules)
}
prices := m["prices"].(map[string]interface{})
if _, ok := prices["sources"].(map[string]interface{})["mine"]; !ok {
t.Error("lenient 模式下有效规则仍须生效")
}
}
// 硬错误:未知 mode、token 无模型、负价、非数字、profile 不是表。
func TestBillingCompileHardErrors(t *testing.T) {
base := func(rule map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"id": "p", "rules": []interface{}{rule}}
}
url := "https://mine.example.com/v1"
src := srcList(url)
cases := []struct {
name string
profile map[string]interface{}
wantSub string
}{
{"unknown mode", base(map[string]interface{}{"url": url, "mode": "wat"}), "unknown mode"},
{"token no models", base(map[string]interface{}{"url": url, "mode": "token"}), "requires at least one model"},
{"negative price", base(map[string]interface{}{"url": url, "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "-1", "completion": "1"}}}), "negative price"},
{"not a number", base(map[string]interface{}{"url": url, "mode": "token",
"models": map[string]interface{}{"m": map[string]interface{}{"prompt": "abc", "completion": "1"}}}), "not a number"},
{"profile not table", nil, "no billing profile"},
}
for _, tc := range cases {
msg := compilePricesErr(t, tc.profile, src, false)
if !strings.Contains(msg, tc.wantSub) {
t.Errorf("%s: error = %q, want substring %q", tc.name, msg, tc.wantSub)
}
}
}
// profile.default 是"没有规则命中的源"的策略,以前被校验后写进无人读的
// default_mode 键,运行时行为与 unpriced 完全一样(PR #2 修复)。
func TestBillingCompileProfileDefault(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"}},
}
// default: free ⇒ 未匹配源写 {per_request:0}(只标记、不碰 token 价)
profile := map[string]interface{}{
"id": "p", "default": "free",
"rules": []interface{}{tokenRule},
}
prices := compilePricesForTest(t, profile, sources, false)
srcs := prices["sources"].(map[string]interface{})
un, ok := srcs["unlisted"].(map[string]interface{})
if !ok {
t.Fatalf("default: free 没有标记未匹配源: %#v", srcs)
}
if _, bad := un["prompt"]; bad {
t.Errorf("default-free 标记携带 prompt=%v:会清零该源模型价", un["prompt"])
}
if _, ok := srcs["listed"]; ok {
t.Error("default: free 不应标记已被规则命中的源")
}
if _, ok := prices["models"].(map[string]interface{})["m"]; !ok {
t.Error("token 规则的模型价丢了")
}
// default: unpriced / 缺省 ⇒ 不写任何标记(让 unpriced 计数看得见)
for _, def := range []interface{}{"unpriced", nil} {
profile := map[string]interface{}{"id": "p", "rules": []interface{}{}}
if def != nil {
profile["default"] = def
}
prices := compilePricesForTest(t, profile, sources, false)
if n := len(prices["sources"].(map[string]interface{})); n != 0 {
t.Errorf("default=%v 标记了 %d 个源;它们应保持未定价以便计数", def, n)
}
if _, present := prices["default_mode"]; present {
t.Error("default_mode 是无人读取的死键,不应再写入")
}
}
}