diff --git a/internal/billing/compile.go b/internal/billing/compile.go index c39d38a..4fdde50 100644 --- a/internal/billing/compile.go +++ b/internal/billing/compile.go @@ -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 { diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go index 6110717..df62718 100644 --- a/internal/billing/wildcard_test.go +++ b/internal/billing/wildcard_test.go @@ -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") + } +} diff --git a/internal/core/billing_apply_test.go b/internal/core/billing_apply_test.go new file mode 100644 index 0000000..ba5c9d8 --- /dev/null +++ b/internal/core/billing_apply_test.go @@ -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) + } +} diff --git a/internal/core/core.go b/internal/core/core.go index 33090d9..55ee2e2 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -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)