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

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