fix(billing): dedup fallback pricing warn to stop per-request log spam (#3394)

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) <noreply@anthropic.com>
This commit is contained in:
haruka
2026-06-21 07:46:52 -07:00
co-authored by Claude Opus 4.8
parent 6936687870
commit 7c2fee6c99
3 changed files with 79 additions and 2 deletions
+11 -1
View File
@@ -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
}
@@ -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()
+2 -1
View File
@@ -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))
}
}