refactor(billing): 计费语义下沉到 Lua(内核只解 YAML/传数据)+ 采纳 PR #2 语义

按 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 降级应用(有效规则仍生效)。
This commit is contained in:
root
2026-10-20 18:33:26 +08:00
parent 442fa5e3f6
commit b4c5f0f0fc
10 changed files with 819 additions and 765 deletions

View File

@ -16,7 +16,6 @@ import (
"sync/atomic"
"time"
"llmsproxy/internal/billing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/provider"
@ -1055,7 +1054,7 @@ func (c *Core) applyBillingDSL() {
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
return
}
prices, err := billing.Compile(profile, c.cfg.Sources)
prices, staleRules, err := c.compileBillingPrices(profile, false)
if err != nil {
// ONE stale rule URL must not take the whole price table down with it.
//
@ -1070,7 +1069,7 @@ func (c *Core) applyBillingDSL() {
// So: apply the rules that DO match, and say exactly what was dropped.
// The profile still fails loudly — it just no longer fails silently for
// everything else.
lenient, lerr := billing.CompileOpts(profile, c.cfg.Sources, true)
lenient, stale, lerr := c.compileBillingPrices(profile, true)
if lerr != nil {
log.Printf("[billing] profile %q not applied: %v (lenient retry also failed: %v)",
profile.ID, err, lerr)
@ -1083,34 +1082,21 @@ func (c *Core) applyBillingDSL() {
log.Printf("[billing] ERROR profile %q has %d rule URL(s) matching no configured "+
"source: %v — applied the remaining %d rule(s); unmatched traffic falls through "+
"to default=%q and keeps counting in unpriced_reqs. FIX THE RULE URL: a stale URL "+
"used to zero out the entire price table.", profile.ID,
len(billing.UnmatchedRuleURLs(profile, c.cfg.Sources)),
billing.UnmatchedRuleURLs(profile, c.cfg.Sources),
len(profile.Rules)-len(billing.UnmatchedRuleURLs(profile, c.cfg.Sources)), def)
prices = lenient
"used to zero out the entire price table.", profile.ID, len(stale), stale,
len(profile.Rules)-len(stale), def)
prices, staleRules = lenient, stale
}
_ = staleRules
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
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)
}
// Sources that no rule matched are encoded by the COMPILER (the plugin):
// `default: free` writes a {per_request:0} marker, `unpriced` leaves them out
// so the plugin's counters see them. The old `unmatched_sources` key was
// written by the compiler and read by nobody — deleted rather than kept as
// decoration (GitCode issue #1 / PR #2).
}
// ValidateScopeQuotas checks every scope entry's caps before they are stored.