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 != "" { 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 是无人读取的死键,不应再写入") } } }