From 33cb2bdb069dcac7d4520803cdfe5eb5c2576bdc Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Oct 2026 17:46:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(billing):=20=E6=BA=90=E5=B1=82=E9=9B=B6?= =?UTF-8?q?=E4=BB=B7=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BB=B7=EF=BC=88issue=20#1=EF=BC=89+=20default=5Fmode=20?= =?UTF-8?q?=E7=94=9F=E6=95=88=20+=20=E6=96=87=E6=A1=A3/=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitCode issue #1:任何按 `mode: token` 且 URL 非通配符的规则定价的源,计费恒为 0, 且 `unpriced_reqs` 也不增长 —— 完全静默。 ## 真因 - `compile.go` 给这类规则写源条目 `{prompt:0, completion:0, per_request:0}`, 本意只是"标记该源已定价"(同源未列入价目表的模型不算 unpriced); - `billing.lua` 的 priceFor() 源层在模型层之后、且 `sp.prompt ~= nil` 即覆盖; - 两者叠加 ⇒ 模型价被清成 0,而 `out.priced` 仍为 true ⇒ 连未定价计数都不涨。 ## 修法 1. `compile.go`:token 规则的源条目只写 `{per_request: 0}`(只是标记,不参与 token 计价)。`free` / `subscription` 保留全零(那里 0 本身就是语义)。 2. `billing.lua`: - `priceFor()` 的 `out` 补 `local`(此前是 VM 级全局变量,跨请求残留); - 实现 `profiles[].default` 的 `free|unpriced` 语义:`free` = 刻意免费(不进 unpriced),`unpriced`(默认)= 未定价债务。此前 compile.go 写 `default_mode` 但**无人读**,两种取值运行时完全等价。 3. `core.go`:应用 profile 时把 `unmatched_sources` 打进日志(WARNING 列出没有 规则命中的源)。该字段此前只写不读,是第二个静默少报点。 4. `docs/plugins.md`:token 价优先级改为与实际实现一致的 `keys > sources > models > default`,并写明"标记已定价只能用 per_request:0"。 ## 判据(都先红后绿) - `internal/billing`:token 规则的源条目**不含** prompt/completion;free/subscription 仍保留显式 0。 - `internal/lua`:同源同模型下旧形状 cost=0(缺陷本身)、新形状 cost=0.75 且 unpriced_reqs=0;未列入价目表的模型仍算"已定价";default_mode 三态。 - `internal/gateway`:`billing_rules_api_test` 的单位断言原本读 `cc["models"]`(恒 false,整块被跳过——从未执行过),改为 `prices["models"]`, 并新增"源条目不得带 token 价"断言。 ## 顺带修掉一个"永不执行"的判据 `discover_mutate_test.go` 的变异锚点写成 6 个空格,而 `discover.go` 已是 2 个空格 ⇒ 该变异每次都 `anchor not found`,变异 campaign 长期红着。修正锚点后, "嵌套 metadata 不再读取"这条变异真的被执行并被测试 KILL。 --- docs/plugins.md | 27 ++++- internal/billing/compile.go | 17 ++- internal/billing/wildcard_test.go | 77 +++++++++++++ internal/core/core.go | 17 +++ internal/gateway/billing_rules_api_test.go | 40 +++++-- internal/gateway/discover_mutate_test.go | 7 +- internal/lua/billing_test.go | 119 +++++++++++++++++++++ internal/lua/plugins/billing.lua | 18 +++- 8 files changed, 305 insertions(+), 17 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index c238730..e85aa81 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -505,13 +505,22 @@ end ### 7.1 计费维度 -| 维度 | 用途 | 优先级 | +| 维度 | 用途 | token 价覆盖顺序 | |---|---|---| -| `sources` | 源的固定价(如按请求计费的转发服务) | 中 | -| `models` | 模型的 per-token 价 | 高 | +| `default` | 兜底价(通常全 0) | 最低 | +| `models` | 模型的 per-token 价 | 中 | +| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 | | `keys` | 单个网关 key 的覆盖价 | **最高** | -**token 价**优先级:`keys` > `models` > `default`。 +**token 价**优先级:`keys` > `sources` > `models` > `default`。 +(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`; +所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。) + +⚠️ 正因为 `sources` 会覆盖 `models`,**"标记某源已定价"的条目只能用 +`{ "per_request": 0 }`**,不能写 `{ "prompt": 0, "completion": 0 }` —— +后者会把该源所有模型的 token 价清成 0。编译器为 `mode: token` 的具体 URL +规则生成的就是前者(GitCode issue #1)。 + **`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。 **`cache_discount`** 见 §7.7。 @@ -599,6 +608,16 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \ 仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。 +**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认): + +| `default` | 未匹配任何规则的源 | `unpriced_reqs` | +|---|---|---| +| `unpriced`(省略时) | 记 0 价,计入未定价债务 | +1 | +| `free` | 记 0 价,但视为**刻意免费** | 不增加 | + +另有 `prices.unmatched_sources`:列出没有规则命中的源,网关在应用 profile 时 +会以 `[billing] WARNING` 打到日志里 —— 看到它就去补规则(或确认 `default` 选对了)。 + 注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入** `total` 与各维度,只有金额是 0。 diff --git a/internal/billing/compile.go b/internal/billing/compile.go index 184a28b..c39d38a 100644 --- a/internal/billing/compile.go +++ b/internal/billing/compile.go @@ -245,8 +245,23 @@ func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName // model priced on its own, so the wildcard loses nothing. if rule.URL != "*" { if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok { + // ONLY the marker — deliberately no "prompt"/"completion". + // + // priceFor() applies the source dimension AFTER the model + // dimension, and treats a present source key as an override + // (`sp.prompt ~= nil` => out.prompt = sp.prompt). Writing + // zeros here therefore rewrote the model price of every model + // this rule DOES list to 0 — while out.priced stayed true, so + // unpriced_reqs never grew either. Cost 0, no warning: GitCode + // issue #1. + // + // `per_request: 0` is the marker itself: it adds nothing to a + // request's cost, but its presence makes the plugin see the + // source as priced, which is the only thing this entry is for. + // (free/subscription above keep their zero token prices: there + // the zero IS the statement.) prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{ - "prompt": 0.0, "completion": 0.0, "per_request": 0.0, + "per_request": 0.0, } } } diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go index 512bba6..6110717 100644 --- a/internal/billing/wildcard_test.go +++ b/internal/billing/wildcard_test.go @@ -212,3 +212,80 @@ func TestLongContextStepCompiles(t *testing.T) { t.Error("above=0 must be rejected") } } + +// A specific-URL token rule marks its source priced, but the entry must carry +// ONLY the "this source is priced" marker — not zero token prices. +// +// Why: `billing.lua`'s priceFor() applies the source dimension AFTER the model +// dimension and treats `sp.prompt ~= nil` as an override. A {prompt:0, +// completion:0} entry therefore zeroes the model price of every model the rule +// DOES list. The symptom is silent: cost is 0 while out.priced is true, so +// unpriced_reqs does not grow either (GitCode issue #1). +// +// The original test only asserted the KEY existed, which froze the broken shape +// into a contract; this asserts the entry CONTENT. +func TestSpecificURLTokenRuleSourceEntryHasNoZeroTokenPrices(t *testing.T) { + sources := []config.Source{{Name: "mine", BaseURL: "https://mine.example.com/v1"}} + profile := &config.BillingProfile{ + ID: "p", + Rules: []config.BillingRule{{ + URL: "https://mine.example.com/v1", Mode: "token", + Models: map[string]config.BillingToken{ + "m": {Prompt: "0.15", Completion: "0.60"}, + }, + }}, + } + prices, err := Compile(profile, sources) + if err != nil { + t.Fatalf("compile: %v", err) + } + entry, ok := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{}) + if !ok { + t.Fatal("a specific-url token rule must still mark its own source priced") + } + for _, key := range []string{"prompt", "completion"} { + if _, present := entry[key]; present { + t.Errorf("source entry carries %s=%v: priceFor() treats it as an override "+ + "and zeroes the model price (issue #1)", key, entry[key]) + } + } + // per_request: 0 is the marker itself: adding a flat fee must not change + // the total, and the key's presence is what tells the plugin "priced". + if v, present := entry["per_request"]; !present || v != 0.0 { + t.Errorf("source entry must carry per_request:0 as the priced marker, got %#v", entry) + } + // The model price must still be the real per-token value. + m, ok := prices["models"].(map[string]interface{})["m"].(map[string]interface{}) + if !ok { + t.Fatal("model price missing") + } + if m["prompt"] != 0.15e-6 { + t.Errorf("model prompt price = %v, want 1.5e-7 per token", m["prompt"]) + } +} + +// free / subscription are DELIBERATE zeros: their source entries keep the +// zero token prices, because there the zero IS the statement. +func TestExplicitZeroModesKeepZeroTokenPrices(t *testing.T) { + for _, mode := range []string{"free", "subscription"} { + profile := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ + URL: "https://mine.example.com/v1", Mode: mode, Subscription: 10, + }}} + prices, err := Compile(profile, []config.Source{ + {Name: "mine", BaseURL: "https://mine.example.com/v1"}, + }) + if err != nil { + t.Fatalf("%s compile: %v", mode, err) + } + entry, ok := prices["sources"].(map[string]interface{})["mine"].(map[string]interface{}) + if !ok { + t.Fatalf("%s: source entry missing", mode) + } + for _, key := range []string{"prompt", "completion"} { + if v, present := entry[key]; !present || v != 0.0 { + t.Errorf("%s: entry[%s] = %v (present=%v), want an explicit 0.0", + mode, key, v, present) + } + } + } +} diff --git a/internal/core/core.go b/internal/core/core.go index 8d36661..33090d9 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -1065,6 +1065,23 @@ func (c *Core) applyBillingDSL() { return } log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources)) + // unmatched_sources used to be written by the compiler and read by nobody, + // so a profile that silently left half the gateway unpriced looked applied + // and healthy. Logging it is the cheap reader: an operator who sees source + // names here knows exactly which rules to add (GitCode issue #1 follow-up). + if unmatched, ok := prices["unmatched_sources"].([]string); ok && len(unmatched) > 0 { + log.Printf("[billing] WARNING %d source(s) match no rule and fall through to the "+ + "profile default: %v (add rules, or set `default: free` if that is intended)", + len(unmatched), unmatched) + } else if raw, ok := prices["unmatched_sources"].([]interface{}); ok && len(raw) > 0 { + names := make([]string, 0, len(raw)) + for _, v := range raw { + names = append(names, fmt.Sprint(v)) + } + log.Printf("[billing] WARNING %d source(s) match no rule and fall through to the "+ + "profile default: %v (add rules, or set `default: free` if that is intended)", + len(names), names) + } } // ValidateScopeQuotas checks every scope entry's caps before they are stored. diff --git a/internal/gateway/billing_rules_api_test.go b/internal/gateway/billing_rules_api_test.go index 82cf1f0..159102c 100644 --- a/internal/gateway/billing_rules_api_test.go +++ b/internal/gateway/billing_rules_api_test.go @@ -236,19 +236,39 @@ func TestBillingRulesPersistAndReachThePlugin(t *testing.T) { if _, ok := sources["localzen"]; !ok { t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources)) } - // The injected price must be per-token, i.e. 0.15 USD/M => 1.5e-7. + // The source entry exists ONLY as the "this source is priced" marker: it + // must not carry token prices, because priceFor() applies the source + // dimension after the model dimension and would overwrite the model price + // with 0 (GitCode issue #1 — silent, unpriced_reqs does not grow either). + if entry, ok := cc.(map[string]interface{}); ok { + for _, key := range []string{"prompt", "completion"} { + if _, present := entry[key]; present { + t.Errorf("the injected source entry carries %s: it will zero the "+ + "model price (issue #1): %#v", key, entry) + } + } + } + // The injected MODEL price must be per-token, i.e. 0.15 USD/M => 1.5e-7. // Asserting the exact magnitude catches a unit error (per-million vs // per-token), which would be off by a factor of a million and still look // like "a number". - models, ok := cc.(map[string]interface{})["models"].(map[string]interface{}) - if ok { - if m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}); ok { - p, _ := m["prompt"].(float64) - if p <= 0 || p > 1e-6 { - t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+ - "a per-million value here would over-bill by 1e6x", p) - } - } + // + // ⚠️ This used to read `cc["models"]`, but `models` lives at + // prices["models"], not inside a source entry — so the type assertion was + // always false and the whole block was skipped. It had never run, which is + // how a per-million unit error could survive a test whose comment claims + // to catch exactly that. + models, ok := prices["models"].(map[string]interface{}) + if !ok { + t.Fatalf("prices has no models map: %#v", prices) + } + m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}) + if !ok { + t.Fatalf("the model price was not injected: %v", keysOf(models)) + } + if p, _ := m["prompt"].(float64); p <= 0 || p > 1e-6 { + t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+ + "a per-million value here would over-bill by 1e6x", p) } } diff --git a/internal/gateway/discover_mutate_test.go b/internal/gateway/discover_mutate_test.go index 5574f28..e1ce348 100644 --- a/internal/gateway/discover_mutate_test.go +++ b/internal/gateway/discover_mutate_test.go @@ -126,7 +126,12 @@ func TestRealMutants(t *testing.T) { }, { name: "nested metadata no longer read", - from: "\tcopyFields(m.Metadata) // nested first", + // 锚点必须与 discover.go 逐字节一致:此前这里是 6 个空格,源码已 + // 变成 2 个空格,于是这条变异永远"找不到锚点"——变异campaign 每次 + // 都以 anchor not found 失败,而它本该验证"嵌套 metadata 被丢掉"能被 + // 测试抓住。锚点漂移 = 这条变异从未真正跑过(和 issue #1 里 + // `cc["models"]` 恒 false 的测试跳过是同一种失效:断言写了但没执行)。 + from: "\tcopyFields(m.Metadata) // nested first", to: "\tcopyFields(json.RawMessage(nil)) // mutant: nested metadata dropped", }, { diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index a679f5c..6855e98 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -1236,3 +1236,122 @@ func TestLongContextComposesWithCacheDiscount(t *testing.T) { // total = 0.2325 approx(t, "step composes with cache discount", got, 0.2325) } + +// ---- 源层零价覆盖模型价(issue #1)--------------------------------------- +// +// 真实缺陷(GitCode issue #1):`compile.go` 给每条**具体 URL** 的 token 规则 +// 写一条 `{prompt:0, completion:0, per_request:0}` 的源条目,本意只是"标记该源 +// 已定价";而插件 priceFor() 的源层是**无条件覆盖**模型层:`sp.prompt ~= nil` +// 就把模型价改成 0。两条各自正当的规则叠加 ⇒ 每个请求 cost:0,且因为 +// out.priced=true,连 unpriced_reqs 都不涨 —— 完全静默。 +// +// 这里把两种条目形状都钉住:旧形状必须**真的是错的**(A 行),新形状必须 +// **保住模型价同时仍算"已定价"**(B 行)。只断言"键存在"是不够的 —— 那正是 +// 现有测试把错误形状固化成契约的原因。 +func TestBillingSourceZeroPriceMustNotZeroModelPrice(t *testing.T) { + const promptPrice = 1.5e-7 // 0.15 USD/M + const completionPrice = 6e-7 // 0.60 USD/M + const want = 0.75 // 1e6 * 1.5e-7 + 1e6 * 6e-7 + + newPrices := func(sourceEntry map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0}, + "models": map[string]interface{}{ + "m": map[string]interface{}{"prompt": promptPrice, "completion": completionPrice}, + }, + "sources": map[string]interface{}{"src": sourceEntry}, + } + } + run := func(t *testing.T, sourceEntry map[string]interface{}) (cost, unpriced float64) { + t.Helper() + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{"prices": newPrices(sourceEntry)}) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "src", "key": "***p", "ok": true, + "prompt_tokens": 1000000.0, "completion_tokens": 1000000.0, + "time": 1750000000000, + }) + st := stateOf(t, ps) + total := st["total"].(map[string]interface{}) + return total["cost"].(float64), st["unpriced_reqs"].(float64) + } + + // A 行:compile.go 修复前的形状 —— 这是缺陷本身,必须算成 0。 + cost, unpriced := run(t, map[string]interface{}{ + "prompt": 0.0, "completion": 0.0, "per_request": 0.0, + }) + approx(t, "旧形状(全零源条目) cost", cost, 0) + if unpriced != 0 { + t.Errorf("旧形状下 unpriced_reqs = %v,缺陷正是它也为 0(静默)", unpriced) + } + + // B 行:修复后的形状 —— 只带 per_request,不得覆盖模型价。 + cost, unpriced = run(t, map[string]interface{}{"per_request": 0.0}) + approx(t, "新形状(仅 per_request) cost", cost, want) + if unpriced != 0 { + t.Errorf("新形状下 unpriced_reqs = %v,want 0(该源仍算已定价)", unpriced) + } +} + +// 同一源上一个**未列入价目表**的模型:源条目(仅 per_request)让它保持 +// "已定价",所以不会被计入 unpriced_reqs —— 这正是 compile 写源条目的原意。 +func TestBillingSourceEntryStillMarksUnlistedModelPriced(t *testing.T) { + ps, _ := billingVM(t) + _ = ps.SetState("billing", map[string]interface{}{ + "prices": map[string]interface{}{ + "default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0}, + "models": map[string]interface{}{ + "priced-model": map[string]interface{}{"prompt": 1.5e-7, "completion": 6e-7}, + }, + "sources": map[string]interface{}{ + "src": map[string]interface{}{"per_request": 0.0}, + }, + }, + }) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "unlisted-model", "source": "src", "key": "***p", "ok": true, + "prompt_tokens": 1000.0, "completion_tokens": 0.0, "time": 1750000000000, + }) + st := stateOf(t, ps) + if got := st["unpriced_reqs"].(float64); got != 0 { + t.Errorf("unpriced_reqs = %v,want 0:源条目存在即为已定价源", got) + } +} + +// 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}, // 未声明 = 现有行为(未定价债务) + } + for _, tc := range cases { + ps, _ := billingVM(t) + prices := map[string]interface{}{ + "default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0}, + } + if tc.mode != "" { + prices["default_mode"] = tc.mode + } + _ = ps.SetState("billing", map[string]interface{}{"prices": prices}) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "no-rule-model", "source": "no-rule-source", "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) + } + // 无论哪种模式,成本都是 0;区别只在"是否算未定价债务"。 + approx(t, "default_mode="+tc.mode+" cost", + st["total"].(map[string]interface{})["cost"].(float64), 0) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 48ad387..4584605 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -189,7 +189,11 @@ local function priceFor(payload) -- look the same: an unpriced model silently costing 0 is the most dangerous -- failure mode a cost plugin has, because the bill still adds up and just -- quietly under-reports. It is counted separately and surfaced in the UI. - out = { + -- ★ local 是必须的:此前写的是全局 `out = {`,而 priceFor 每个请求都会调用, + -- 于是这个表挂在 VM 级全局变量上跨请求残留/泄漏(GitCode issue #1 顺带发现)。 + -- 功能上因为每次整体重建而侥幸正确,但它是共享可变状态,任何提前 return 或 + -- 后续分支忘了重建都会让上一单的价格漏到下一单 —— 计费错误不会报错。 + local out = { prompt = d.prompt or 0, completion = d.completion or 0, per_request = 0, cache_discount = d.cache_discount, peak = d.peak, -- nil = bill reasoning at the completion rate, which is what every @@ -236,6 +240,18 @@ local function priceFor(payload) if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end if kp.peak ~= nil then out.peak = kp.peak end end + + -- profile-level default for sources no rule matched. + -- + -- compile.go has always written `default_mode` ("free" | "unpriced") into the + -- prices table, but NOTHING read it — so `default: free` and + -- `default: unpriced` behaved identically at runtime (both fell through to + -- prices.default's zeros and counted as unpriced debt). That is a second + -- silent under-report (GitCode issue #1 follow-up). "free" is a DELIBERATE + -- zero, so it must be marked priced; anything else stays countable debt. + if not out.priced and p.default_mode == "free" then + out.priced = true + end return out end