mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按 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 降级应用(有效规则仍生效)。
403 lines
16 KiB
Go
403 lines
16 KiB
Go
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 是无人读取的死键,不应再写入")
|
||
}
|
||
}
|
||
}
|