Merge pull request #3401 from StarryKira/fix/issue-3394-fallback-pricing-log-spam

fix: stop per-request fallback-pricing log spam for unknown models (#3394)
This commit is contained in:
Wesley Liddick
2026-06-29 09:19:44 +08:00
committed by GitHub
5 changed files with 83 additions and 6 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 创建计费服务实例
@@ -722,7 +728,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 baseInput 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))
}
}
+2 -2
View File
@@ -2408,8 +2408,8 @@ export default {
deleteError: 'Failed to delete channel',
nameRequired: 'Please enter a channel name',
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range",
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range",
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.",
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.",
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
columns: {
name: 'Name',
+2 -2
View File
@@ -2484,8 +2484,8 @@ export default {
deleteError: '删除渠道失败',
nameRequired: '请输入渠道名称',
duplicateModels: '模型「{0}」在多个定价条目中重复',
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠",
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。",
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。",
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
columns: {
name: '名称',