refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持

按维护者要求(issue #1 回复方向):内核不处理任何 billing 特有逻辑;插件有完整
OS 权限,自己保存配置文件。据此不再保留任何"内核 ↔ billing 插件"的专用对接。

## 内核(删除)
- `internal/billing`(Go 编译器 + 测试):删除;
- `internal/core` 的 billing 接口/降级应用/规则加载:删除(含 `billingPath`);
- `internal/gateway` 的 `/api/plugins/billing/rules` 端点与测试:删除;
- `config` 的 `BillingDSL/Profile/Rule/Token/Peak/LongContext` 与校验、
  `billing:`/`billing_file:` 配置段:删除。系统配置里不再有 billing 段。
内核只保留所有插件通用的能力:加载 .lua、插件状态持久化、插件目录
(`__llmsproxy_plugin_dir`)、`plugin.http` 路由、插件可用的 io/os。

## 插件(billing.lua)
- 规则文件移到**插件自己的目录**:`<plugin_dir>/billing.json`,由插件用 io 自己
  读写(tmp + rename 原子替换);加载插件时自己读文件并编译;
- 规则以**源名** `source` 为键(`url:"*"` 仅表示全局模型价目表):URL→源名映射
  属于内核配置,插件不为它和内核对接;编辑页在浏览器里取 `/api/sources` 选源名;
- 规则编辑 API 改为插件自己的 `/api/plugins/http/billing/rules`
  (GET/PUT/POST/DELETE,roles=admin),写盘也在插件内完成;
- 计费账目仍在插件状态文件(内核通用持久化)`<plugin_dir>/.billing.state.json`;
- `priceFor` 落地 profile 级 `default` 策略与显式 `mode=unpriced` 的优先级。

## 迁移
生产已迁移:`config.yaml` 的 `billing:` 规则转为 `plugins/billing.json`(按源名
重写),并从系统配置中移除;规则文件与系统配置从此各自独立保存。

## 判据
- `internal/lua/billing_rules_test.go`:插件自写 billing.json 并重启恢复、
  free/subscription/token/通配符编译语义、1e6+1e6 token = 0.75 端到端、
  非法规则被拒且不覆盖旧文件、reasoning/long_context/peak;
- `internal/lua/billing_test.go`:profile default 策略三态 + 显式 unpriced 优先;
- `internal/lua/billing_ui_test.go`:规则编辑页仍挂在 billing 页内、列宽/列数守卫。
This commit is contained in:
root
2026-10-20 18:57:23 +08:00
parent 5ccf068db3
commit 7941b4110a
14 changed files with 652 additions and 1633 deletions

View File

@ -1,60 +0,0 @@
package core
import (
"path/filepath"
"testing"
"llmsproxy/internal/config"
)
// 一个过期的规则 URL 不得让**整个**计费 profile 失效。
//
// 真实事故(GitCode issue #1 排查中发现,生产现网):config.yaml 里
// `billing.rules[0].url` 是 `https://opencode.ai/inference/v1`,而该源的
// base_url 已是 `.../inference/openai/v1`。严格编译因此返回
// "rule url ... matches no configured source base_url",applyBillingDSL 直接
// return —— 于是**所有**计费规则都不生效:账单恒为 0、unpriced_reqs 涨到 3.7 万,
// 而唯一的痕迹是一行启动日志。这正是「静默少报」最严重的一种。
//
// 修法:严格编译失败时降级为 lenient(用能匹配的规则),并打 ERROR 说明后果。
// 一个 URL 写错不该把整个价目表一起葬送;但也不能装作成功。
func TestBillingStaleRuleURLStillAppliesOtherRules(t *testing.T) {
cfg := newTestConfig(t)
dir := t.TempDir()
cfg.PluginDir = filepath.Join(dir, "plugins")
cfg.Sources = []config.Source{
{Name: "localzen", BaseURL: "https://opencode.ai/inference/openai/v1", Adapter: "openai",
Models: []config.Model{{ID: "m"}}},
}
cfg.BillingDSL = &config.BillingDSL{
Active: "p",
Profiles: []config.BillingProfile{{
ID: "p",
Rules: []config.BillingRule{
// 过期 URL:没有任何源的 base_url 与它相等
{URL: "https://opencode.ai/inference/v1", Mode: "free"},
// 有效规则:必须仍然生效
{URL: "https://opencode.ai/inference/openai/v1", Mode: "free"},
},
}},
}
c := newTestCore(t, cfg)
c.applyBillingDSL()
raw := c.plugins.Prices("billing")
if raw == nil {
t.Fatal("billing has no price table (profile never applied)")
}
prices, ok := raw.(map[string]interface{})
if !ok {
t.Fatalf("price table shape: %#v", raw)
}
sources, ok := prices["sources"].(map[string]interface{})
if !ok {
t.Fatalf("prices has no sources: %#v", prices)
}
if _, ok := sources["localzen"]; !ok {
t.Fatalf("一条过期规则 URL 把整个 profile 弄失效了:有效的 free 规则也没有生效,"+
"结果是账单恒为 0 且没有告警。prices=%#v", prices)
}
}

View File

@ -1,84 +0,0 @@
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)
}

View File

@ -109,7 +109,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
p["name"], p["version"], p["hooks"], p["ui"])
}
c.applyBillingDSL()
c.store = config.NewStore(cfg.RuntimeFile)
// Share one box between the runtime store and config.yaml so a single
// master.key seals both files. config.Load left the config holding
@ -1009,96 +1008,6 @@ func (c *Core) Close() {
}
}
// applyBillingDSL compiles the configured billing profile and injects it into
// the billing plugin.
//
// Configured pricing replaces the hand-written JSON blob that used to be PUT
// through the state API. That path was used twice in production and both times
// it "succeeded" while being wrong: once without the required `prices` wrapper
// (silently wiping the accumulated totals), once with a peak-window shape the
// plugin does not read (peak traffic billed at off-peak rates, no error). A
// typed config with load-time validation turns both mistakes into startup
// errors naming the file and field.
//
// A DSL with no profile, or one that fails to compile, leaves the plugin's
// prices untouched: billing keeps running on whatever it had, and the problem
// is logged rather than becoming a startup failure — the gateway must forward
// even when its cost accounting is misconfigured.
func (c *Core) applyBillingDSL() {
ps := c.plugins
if ps == nil || ps.Count() == 0 {
return
}
// The billing plugin may simply not be installed; a DSL without it is a
// no-op, not an error.
var have bool
for _, row := range ps.List() {
if row["name"] == "billing" && row["loaded"] == true {
have = true
break
}
}
if !have {
return
}
dsl := c.cfg.BillingDSL
if dsl == nil || len(dsl.Profiles) == 0 {
return
}
if err := dsl.Validate(); err != nil {
log.Printf("[billing] config invalid, profile not applied: %v", err)
return
}
profile := dsl.Resolve(dsl.Active)
if profile == nil {
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
return
}
prices, staleRules, err := c.compileBillingPrices(profile, false)
if err != nil {
// ONE stale rule URL must not take the whole price table down with it.
//
// The strict compiler rejects a profile whose rule matches no source,
// which is right for catching typos — but the runtime consequence used
// to be a bare `return`: every rule stopped applying, the bill came out
// at 0 and unpriced_reqs grew forever, with a single startup log line as
// the only trace. Found in production while fixing GitCode issue #1
// (a rule still pointed at .../inference/v1 after the source moved to
// .../inference/openai/v1; 37k requests had been recorded as unpriced).
//
// 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, 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)
return
}
def := profile.Default
if def == "" {
def = "unpriced"
}
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(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))
// 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.
// A typo in a period must be rejected at write time rather than silently
// becoming a never-resetting budget — the opposite of what was typed.