diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index b8bb555a77..e2046dd979 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 创建计费服务实例 @@ -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 } 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)) } } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 808cb36013..1c70619981 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8413804e2c..12d474329e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '名称',