From 7c2fee6c9906de2613771953c99802804a051c5f Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 21 Jun 2026 07:46:52 -0700 Subject: [PATCH] fix(billing): dedup fallback pricing warn to stop per-request log spam (#3394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "[Billing] Using fallback pricing for model: X" warn was emitted on every request for any model not in LiteLLM but matched by getFallbackPricing (e.g. "glm-5.2" substring-matches the "glm-5" fallback price). Via the stdlib log bridge it is inferred as WARN and persisted to ops_system_logs, producing tens of thousands of rows/day. It fired even when channel pricing already overrides the price, and from several call sites (resolver, account-stats pricing, non-channel billing, admin lookup). Dedup the warn at the source (BillingService.GetModelPricing) via a sync.Map keyed by the already-lowercased model name, so each model logs at most once per process while keeping one audit line. Billing amounts are unchanged. Also clarify the model/mapping pattern conflict error to state that names are matched case-insensitively, so an existing entry (e.g. "GLM-5.2") already covers all case variants and the lowercase variant need not be added — the behavior the issue mistook for a case-sensitivity bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/service/billing_service.go | 12 +++- .../internal/service/billing_service_test.go | 66 +++++++++++++++++++ backend/internal/service/channel_service.go | 3 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 74055a8151..7c929fc066 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "strings" + "sync" "time" "github.com/Wei-Shaw/sub2api/internal/config" @@ -168,6 +169,11 @@ type BillingService struct { cfg *config.Config pricingService *PricingService fallbackPrices map[string]*ModelPricing // 硬编码回退价格 + + // fallbackWarnSeen 记录已打过 fallback 警告日志的(已小写化)模型名, + // 让 "[Billing] Using fallback pricing" 每个模型每进程最多打一条, + // 避免热路径上每请求刷屏(issue #3394)。零值即可用,无需在构造函数初始化。 + fallbackWarnSeen sync.Map } // NewBillingService 创建计费服务实例 @@ -699,7 +705,11 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { // 2. 使用硬编码回退价格 fallback := s.getFallbackPricing(model) if fallback != nil { - log.Printf("[Billing] Using fallback pricing for model: %s", model) + // 按模型名去重:每个模型每进程最多打一条 warn,避免热路径每请求刷屏(issue #3394)。 + // model 在函数入口已 ToLower,故 GLM-5.2 / glm-5.2 视为同一条目。 + if _, seen := s.fallbackWarnSeen.LoadOrStore(model, struct{}{}); !seen { + log.Printf("[Billing] Using fallback pricing for model: %s", model) + } return s.applyModelSpecificPricingPolicy(model, fallback), nil } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 9541047d25..62792dc5df 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -3,13 +3,32 @@ package service import ( + "bytes" + "log" "math" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) +// captureStdLog 重定向 stdlib log 输出到 buffer,返回该 buffer;通过 t.Cleanup 还原。 +// 用于断言 GetModelPricing 的 fallback warn(log.Printf)打了几次。 +func captureStdLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prevOut := log.Writer() + prevFlags := log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(prevOut) + log.SetFlags(prevFlags) + }) + return &buf +} + func newTestBillingService() *BillingService { return NewBillingService(&config.Config{}, nil) } @@ -105,6 +124,53 @@ func TestGetModelPricing_CaseInsensitive(t *testing.T) { require.Equal(t, p1.InputPricePerToken, p2.InputPricePerToken) } +// issue #3394: fallback warn 应按模型名去重,每个模型每进程最多打一条, +// 避免热路径每请求刷屏 ops_system_logs。 +func TestGetModelPricing_FallbackWarnLoggedOncePerModel(t *testing.T) { + svc := newTestBillingService() + buf := captureStdLog(t) + + // glm-5.2 不在 LiteLLM,经 strings.Contains 命中 glm-5 兜底价 → 触发 fallback warn。 + for i := 0; i < 5; i++ { + pricing, err := svc.GetModelPricing("glm-5.2") + require.NoError(t, err) + require.NotNil(t, pricing) + } + + got := strings.Count(buf.String(), "Using fallback pricing for model: glm-5.2") + require.Equal(t, 1, got, "同一模型的 fallback warn 应只打一条,实际日志:\n%s", buf.String()) +} + +// 去重按"每模型"而非全局:不同模型各打一条;大小写变体经入口 ToLower 归一,视为同一条目。 +func TestGetModelPricing_FallbackWarnPerModelNotGlobal(t *testing.T) { + svc := newTestBillingService() + buf := captureStdLog(t) + + for i := 0; i < 3; i++ { + _, _ = svc.GetModelPricing("glm-5.2") + _, _ = svc.GetModelPricing("GLM-5.2") // 与上一行同模型(ToLower 后),去重后不再打 + _, _ = svc.GetModelPricing("glm-4.6") + } + + out := buf.String() + require.Equal(t, 1, strings.Count(out, "model: glm-5.2"), out) + require.Equal(t, 1, strings.Count(out, "model: glm-4.6"), out) + require.Equal(t, 0, strings.Count(out, "model: GLM-5.2"), out) // 大写经 ToLower 归一,不应单独成行 +} + +// 回归:glm-5.2 仍解析到 glm-5 兜底价(计费金额不变,防止日志改动掩盖未来计费回归)。 +func TestGetModelPricing_GLM52FallsBackToGLM5Price(t *testing.T) { + svc := newTestBillingService() + + got, err := svc.GetModelPricing("glm-5.2") + require.NoError(t, err) + require.NotNil(t, got) + + // glm-5 base:Input 1e-6 / Output 3.2e-6(见 TestGetFallbackPricing_FamilyMatching)。 + require.InDelta(t, 1e-6, got.InputPricePerToken, 1e-12) + require.InDelta(t, 3.2e-6, got.OutputPricePerToken, 1e-12) +} + func TestGetModelPricing_UnknownClaudeModelFallsBackToSonnet(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 18bd4d5324..5272fe0e93 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -983,7 +983,8 @@ func detectConflicts(entries []modelEntry, platform, errCode, label string) erro for j := i + 1; j < len(entries); j++ { if conflictsBetween(entries[i], entries[j]) { return infraerrors.BadRequest(errCode, - fmt.Sprintf("%s '%s' and '%s' conflict in platform '%s': overlapping match range", + fmt.Sprintf("%s '%s' and '%s' conflict in platform '%s': overlapping match range "+ + "(model names are matched case-insensitively, so an existing entry already covers all case variants)", label, entries[i].pattern, entries[j].pattern, platform)) } }