mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
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:
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user