Files
ModelRouter/internal/core/billing_compile.go
root b4c5f0f0fc 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 降级应用(有效规则仍生效)。
2026-10-20 18:33:26 +08:00

85 lines
3.3 KiB
Go

package core
import (
"encoding/json"
"fmt"
"llmsproxy/internal/config"
)
// compileBillingPrices turns a billing profile into the prices table the billing
// plugin consumes — by ASKING THE PLUGIN TO DO IT.
//
// Why the kernel does not compile prices any more (GitCode issue #1, follow-up
// direction): "how a rule becomes a price table" is billing semantics, and it
// used to live in TWO places — `internal/billing/compile.go` produced source
// entries with zero token prices, while `billing.lua`'s priceFor() applied the
// source dimension after the model dimension. Each was defensible alone; together
// they silently zeroed every request (cost 0, unpriced_reqs frozen). Splitting
// the semantics across two languages is what made that possible.
//
// So the kernel keeps only what it must own — decoding config.yaml and validating
// the DSL — and hands the PROFILE plus the source base URLs to the plugin as
// plain DATA. The plugin compiles, and the kernel injects the result through the
// existing `prices` sidecar (SetState), which also keeps price-vs-ledger
// separation intact.
//
// A plugin sandbox has no filesystem access, so "the plugin reads the config
// file" is not achievable by design; passing the decoded profile is the hook /
// interface this architecture provides instead.
func (c *Core) compileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
if c.plugins == nil {
return nil, nil, fmt.Errorf("plugins not loaded")
}
if profile == nil {
return nil, nil, fmt.Errorf("no billing profile")
}
// Structs go through JSON so the Lua side receives plain data (maps/slices):
// pushGoValue is not a struct encoder, and a silent shape mismatch here would
// look exactly like "the plugin priced nothing".
rawProfile, err := json.Marshal(profile)
if err != nil {
return nil, nil, err
}
var prof map[string]interface{}
if err := json.Unmarshal(rawProfile, &prof); err != nil {
return nil, nil, err
}
sources := make([]map[string]interface{}, 0, len(c.cfg.Sources))
for _, s := range c.cfg.Sources {
sources = append(sources, map[string]interface{}{"name": s.Name, "base_url": s.BaseURL})
}
out, err := c.plugins.Call("billing", "compile_prices", map[string]interface{}{
"profile": prof, "sources": sources, "lenient": lenient,
})
if err != nil {
return nil, nil, err
}
m, ok := out.(map[string]interface{})
if !ok {
return nil, nil, fmt.Errorf("billing plugin returned an unexpected shape: %T", out)
}
if msg, hasErr := m["error"]; hasErr {
if e := fmt.Sprint(msg); e != "" && e != "<nil>" {
return nil, nil, fmt.Errorf("%s", e)
}
}
prices, ok := m["prices"].(map[string]interface{})
if !ok {
return nil, nil, fmt.Errorf("billing plugin returned no prices table")
}
var unmatched []string
if arr, ok := m["unmatched_rules"].([]interface{}); ok {
for _, v := range arr {
unmatched = append(unmatched, fmt.Sprint(v))
}
}
return prices, unmatched, nil
}
// CompileBillingPrices is compileBillingPrices for callers outside the package
// (the rules editor validates an edit before persisting it).
func (c *Core) CompileBillingPrices(profile *config.BillingProfile, lenient bool) (map[string]interface{}, []string, error) {
return c.compileBillingPrices(profile, lenient)
}