fix(billing): 源层零价不再覆盖模型价(issue #1)+ default_mode 生效 + 文档/判据补齐

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。
This commit is contained in:
root
2026-10-20 17:46:52 +08:00
parent 0ae7d2c8be
commit 33cb2bdb06
8 changed files with 305 additions and 17 deletions

View File

@ -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。

View File

@ -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,
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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.

View File

@ -236,21 +236,41 @@ 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 {
//
// ⚠️ 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)
}
}
}
}
func keysOf(m map[string]interface{}) []string {
out := make([]string, 0, len(m))

View File

@ -126,6 +126,11 @@ func TestRealMutants(t *testing.T) {
},
{
name: "nested metadata no longer read",
// 锚点必须与 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",
},

View File

@ -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)
}
}

View File

@ -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