mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(billing): 过期规则 URL 不再让整个 profile 失效(降级应用 + 点名 URL)
生产现网发现(issue #1 排查):billing.rules 里一条指向 https://opencode.ai/inference/v1 的规则,在该源 base_url 变成 .../inference/openai/v1 之后匹配不到任何源;严格编译因此让**整个** profile 不生效 —— 账单恒 0、unpriced_reqs 涨到 3.7 万,唯一痕迹是一行启动日志。 - 严格编译失败时降级为 lenient:应用能匹配的规则,其余按 profile default; - 日志用 ERROR 点名**具体哪条 URL** 写错(新增 billing.UnmatchedRuleURLs), 并说明未匹配流量的记账去向; - 判据 TestBillingStaleRuleURLStillAppliesOtherRules:一条过期规则 + 一条有效 规则 ⇒ 有效规则必须仍然生效。
This commit is contained in:
@ -106,6 +106,27 @@ func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenien
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// UnmatchedRuleURLs lists the non-wildcard rule URLs that match no configured
|
||||
// source. Compile() treats such a URL as a hard error (a typo'd rule prices
|
||||
// nothing), but the CALLER needs the list to say which rule to fix instead of
|
||||
// just "compile failed" — and to explain what the degraded table dropped.
|
||||
func UnmatchedRuleURLs(profile *config.BillingProfile, sources []config.Source) []string {
|
||||
if profile == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for i := range profile.Rules {
|
||||
rule := &profile.Rules[i]
|
||||
if rule.URL == "*" {
|
||||
continue
|
||||
}
|
||||
if len(matchSources(rule.URL, sources)) == 0 {
|
||||
out = append(out, rule.URL)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchSources returns the source names whose base_url matches pattern.
|
||||
// "*" matches every source (used as a catch-all default rule).
|
||||
func matchSources(pattern string, sources []config.Source) []string {
|
||||
|
||||
@ -289,3 +289,28 @@ func TestExplicitZeroModesKeepZeroTokenPrices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnmatchedRuleURLs 是给调用方的"哪条规则写错了"清单:严格编译只会说
|
||||
// "compile failed",而运维需要知道具体 URL;降级应用时也要能说明丢了什么。
|
||||
func TestUnmatchedRuleURLs(t *testing.T) {
|
||||
sources := []config.Source{{Name: "mine", BaseURL: "https://mine.example.com/v1"}}
|
||||
profile := &config.BillingProfile{
|
||||
ID: "p",
|
||||
Rules: []config.BillingRule{
|
||||
{URL: "https://mine.example.com/v1", Mode: "free"},
|
||||
{URL: "https://stale.example.com/v1", Mode: "free"},
|
||||
{URL: "*", Mode: "token", Models: map[string]config.BillingToken{
|
||||
"m": {Prompt: "1", Completion: "1"},
|
||||
}},
|
||||
{URL: "https://mine.example.com/v1/", Mode: "free"}, // 尾斜杠归一后匹配
|
||||
},
|
||||
}
|
||||
got := UnmatchedRuleURLs(profile, sources)
|
||||
if len(got) != 1 || got[0] != "https://stale.example.com/v1" {
|
||||
t.Fatalf("unmatched = %v, want only the stale URL (wildcard and trailing-slash "+
|
||||
"variants must not be reported)", got)
|
||||
}
|
||||
if nil != UnmatchedRuleURLs(nil, sources) {
|
||||
t.Error("nil profile must yield nil")
|
||||
}
|
||||
}
|
||||
|
||||
60
internal/core/billing_apply_test.go
Normal file
60
internal/core/billing_apply_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1057,8 +1057,37 @@ func (c *Core) applyBillingDSL() {
|
||||
}
|
||||
prices, err := billing.Compile(profile, c.cfg.Sources)
|
||||
if err != nil {
|
||||
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
|
||||
return
|
||||
// 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, lerr := billing.CompileOpts(profile, c.cfg.Sources, 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(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
|
||||
}
|
||||
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
|
||||
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
|
||||
|
||||
Reference in New Issue
Block a user