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:
root
2026-10-20 18:04:43 +08:00
parent 33cb2bdb06
commit 0caa37ea6a
4 changed files with 137 additions and 2 deletions

View File

@ -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 {

View File

@ -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")
}
}

View 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)
}
}

View File

@ -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)