From c8ad9f4cc3942157c07c89ab0cef191e019d0246 Mon Sep 17 00:00:00 2001 From: feeeei Date: Fri, 28 Aug 2026 23:47:48 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(billing):=20=E9=95=BF=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E9=98=B6=E6=A2=AF=E8=AE=A1=E4=BB=B7=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BB=B7=E6=A0=BC=E7=9B=AE=E5=BD=95=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 GPT/Gemini 系列写死的阶梯规则,阶梯一律由目录数据驱动: - 解析层折算 LiteLLM 的 *_above_XXXk_tokens 绝对价字段为阈值+倍率 (阈值取自字段名,倍率 = above/基础价,cache 侧跟随输入倍率, xAI 按 provider 用达到即进高档语义);显式 long_context_* 字段 按"字段存在"优先,显式 threshold=0 或 multiplier=1 可关闭阶梯 - 删除 OpenAI 272K 常量与 applyModelSpecificPricingPolicy 的长上下文 强补分支、fallbackPrices/静态兜底条目的 LongContext 字段、Gemini 旧 边际规则全链路(CalculateCostWithLongContext / RecordUsageWithLongContext / 阶梯表 marginal 基准);保留 fastRatio 与 GPT-5.6 cache_write 1.25x 防御分支、grok 静态兜底阶梯 - 渠道配置定价区间时完全以渠道区间为准,不再叠加目录阶梯 - 长上下文倍率 ≤0 在应用点按 1 计,防止显式字段残缺把分项乘 0 免费 - 目录重载时对比新旧数据,原有阶梯丢失打 WARN 哨兵告警 行为变化:Gemini 由边际 2x(仅 /v1beta、全模型)改为整单换档(数据 驱动、仅带 above_200k 的 pro 系),渠道平价之上叠加目录阶梯;Claude sonnet-4/4.5 随目录数据获得 200K 整单阶梯(与官方定价一致);模型广场 官方参考价列与实付列同源展示目录阶梯。 --- backend/internal/config/config.go | 2 +- backend/internal/handler/endpoint.go | 2 +- .../internal/handler/gemini_v1beta_handler.go | 44 ++-- .../service/billing_context_schedule.go | 52 ++--- .../service/billing_context_schedule_test.go | 82 ++++---- backend/internal/service/billing_service.go | 175 ++++------------ .../internal/service/billing_service_test.go | 156 +++----------- .../service/billing_token_cost_request.go | 52 +---- .../billing_token_cost_request_test.go | 103 +++++---- .../service/gateway_record_usage_test.go | 39 ---- .../internal/service/gateway_usage_billing.go | 108 ++-------- .../service/model_plaza_service_test.go | 16 +- .../openai_gateway_record_usage_test.go | 18 +- backend/internal/service/pricing_service.go | 146 +++++++++++-- .../internal/service/pricing_service_test.go | 196 +++++++++++++++++- .../service/pricing_stub_helpers_test.go | 33 +++ deploy/config.example.yaml | 4 +- 17 files changed, 597 insertions(+), 631 deletions(-) create mode 100644 backend/internal/service/pricing_stub_helpers_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8c856cbf6136..390c1bba1890 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2279,7 +2279,7 @@ func setDefaults() { viper.SetDefault("rate_limit.overload_cooldown_minutes", 10) viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10) - // Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移) + // Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据 viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json") viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256") viper.SetDefault("pricing.data_dir", "./data") diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index d13bbee47a62..b45b101d890e 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc { // ────────────────────────────────────────────────────────── // Context helpers — used by handlers before building -// RecordUsageInput / RecordUsageLongContextInput. +// RecordUsageInput. // ────────────────────────────────────────────────────────── // GetInboundEndpoint returns the canonical inbound endpoint stored by diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 07840b7ea4cc..fc01d51725f0 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { forceCacheBilling := fs.ForceCacheBilling quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) - // 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。 - var longContextThreshold int - var longContextMultiplier float64 - if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil { - longContextThreshold = rule.Threshold - longContextMultiplier = rule.Multiplier - } + // 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。 h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { - if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ - Result: result, - QuotaPlatform: quotaPlatform, - APIKey: apiKey, - User: apiKey.User, - Account: account, - Subscription: subscription, - PricingAt: pricingAt, - InboundEndpoint: inboundEndpoint, - UpstreamEndpoint: upstreamEndpoint, - UserAgent: userAgent, - IPAddress: clientIP, - RequestPayloadHash: requestPayloadHash, - LongContextThreshold: longContextThreshold, - LongContextMultiplier: longContextMultiplier, - ForceCacheBilling: forceCacheBilling, - APIKeyService: h.apiKeyService, - SessionID: sessionID, - ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), + if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ + Result: result, + QuotaPlatform: quotaPlatform, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + PricingAt: pricingAt, + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + ForceCacheBilling: forceCacheBilling, + APIKeyService: h.apiKeyService, + SessionID: sessionID, + ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel), }); err != nil { logger.L().With( zap.String("component", "handler.gemini_v1beta.models"), diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index 05f2a181b1c4..777302250495 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -9,15 +9,12 @@ import ( "time" ) -// ContextPricingBasis 阶梯的计价基准。 +// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的 +// Gemini 边际口径("marginal")已随平台旧规则一并移除。 type ContextPricingBasis string -const ( - // ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。 - ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" - // ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。 - ContextPricingBasisMarginal ContextPricingBasis = "marginal" -) +// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。 +const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" // ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。 // nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。 @@ -73,8 +70,8 @@ const contextProbeDelta = 1000 // ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。 // // 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价, -// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。 -// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值), +// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。 +// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值), // 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等 // 规则变更无需同步到这里;相邻同价段会合并。 // @@ -103,22 +100,13 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso return nil, nil } - var legacy *LegacyLongContextRule - if in.Group != nil { - legacy = s.LegacyLongContextRule(in.Platform) - } - if !legacyLongContextApplies(resolved, in.Group, legacy) { - legacy = nil - } - req := TokenCostRequest{ - Ctx: ctx, - Model: in.Model, - Group: in.Group, - RateMultiplier: 1, - Resolver: resolver, - Resolved: resolved, - LegacyLongContext: legacy, + Ctx: ctx, + Model: in.Model, + Group: in.Group, + RateMultiplier: 1, + Resolver: resolver, + Resolved: resolved, } probe := func(tokens UsageTokens) (*CostBreakdown, error) { r := req @@ -126,7 +114,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso return s.CalculateTokenCostForRequest(r) } - plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy) + plan := s.contextPricingBreakpoints(resolver, resolved, in.Model) segments := buildContextSegments(plan.bounds) tiers := make([]ContextPricingTier, 0, len(segments)) @@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso tiers = mergeEqualContextTiers(tiers) applyContextTierLabels(tiers, plan) - basis := ContextPricingBasisWholeRequest - if legacy != nil { - basis = ContextPricingBasisMarginal - } - return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil + return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil } // resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。 @@ -210,14 +194,8 @@ type contextBreakpointPlan struct { } // contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。 -func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan { +func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan { plan := contextBreakpointPlan{} - if legacy != nil { - plan.bounds = []int{legacy.Threshold} - plan.thresholdBound = legacy.Threshold - plan.threshold = legacy.Threshold - return plan - } if !resolved.longContextPricingEnabled { return plan } diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index b13606da9fcc..77a0195cf4a8 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) { require.InDelta(t, *want, *got, 1e-15, field) } +// mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。 +func mustCatalogFromJSON(body string) *PricingService { + s := &PricingService{} + data, err := s.parsePricingData([]byte(body)) + if err != nil { + panic(err) + } + s.pricingData = data + return s +} + +func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) } + func scheduleScenarios() []scheduleScenario { p := testPtrFloat64 return []scheduleScenario{ { name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, - group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) @@ -92,7 +105,7 @@ func scheduleScenarios() []scheduleScenario { }, { name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, - group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest, + group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 1) requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) @@ -100,7 +113,7 @@ func scheduleScenarios() []scheduleScenario { }, { name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI, - group: nil, wantBasis: ContextPricingBasisWholeRequest, + group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6)) @@ -210,6 +223,7 @@ func scheduleScenarios() []scheduleScenario { group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), }}}, + catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) @@ -219,46 +233,55 @@ func scheduleScenarios() []scheduleScenario { }, }, { - name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, - group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal, + name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) - requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6)) }, }, { - name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, - group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 1) }, }, { - name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, - group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), + name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), channel: []ChannelModelPricing{{ Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6), }}, wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { - require.Len(t, s.Tiers, 1) + require.Len(t, s.Tiers, 2) requirePrice(t, p(3e-6), s.Tiers[0].Input, "input") + requirePrice(t, p(6e-6), s.Tiers[1].Input, "input") }, }, { - name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini, - group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, + name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini, + group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 1) }, }, + { + name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini, + group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, + check: func(t *testing.T, s *ContextPricingSchedule) { + require.Len(t, s.Tiers, 2) + }, + }, { name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite, group: enabledGroup(PlatformComposite), channel: []ChannelModelPricing{{ Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6), }}, + catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) @@ -283,9 +306,11 @@ func scheduleScenarios() []scheduleScenario { { name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI, group: enabledGroup(PlatformOpenAI), - catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ - "gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6}, - }), + catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}}`), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) @@ -398,22 +423,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier { } // expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用: -// 整单基准取所在档单价 × 全量;边际基准逐段累加。 +// 整单基准取所在档单价 × 全量。 func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 { - if s.Basis == ContextPricingBasisMarginal { - total := 0.0 - for _, tier := range s.Tiers { - if contextTokens <= tier.MinTokens { - break - } - upper := contextTokens - if tier.MaxTokens != nil && *tier.MaxTokens < upper { - upper = *tier.MaxTokens - } - total += float64(upper-tier.MinTokens) * tierPrice(tier, kind) - } - return total - } return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind) } @@ -443,17 +454,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) { pricingInput.GroupID = &gid } resolved := resolver.Resolve(ctx, pricingInput) - var legacy *LegacyLongContextRule - if sc.group != nil { - legacy = bs.LegacyLongContextRule(sc.platform) - } - if !legacyLongContextApplies(resolved, sc.group, legacy) { - legacy = nil - } cost := func(tokens UsageTokens) float64 { bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1, - Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy, + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) return bd.ActualCost diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 7002fc09ccf7..ed4856e961d0 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -116,12 +116,6 @@ type ModelPricing struct { ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } -const ( - openAIGPT54LongContextInputThreshold = 272000 - openAIGPT54LongContextInputMultiplier = 2.0 - openAIGPT54LongContextOutputMultiplier = 1.5 -) - func normalizeBillingServiceTier(serviceTier string) string { return strings.ToLower(strings.TrimSpace(serviceTier)) } @@ -395,9 +389,6 @@ func (s *BillingService) initFallbackPricing() { CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok SupportsCacheBreakdown: false, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } // OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。 // Source: https://platform.openai.com/docs/pricing @@ -405,24 +396,18 @@ func (s *BillingService) initFallbackPricing() { InputPricePerToken: 5e-6, OutputPricePerToken: 30e-6, // 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。 - CacheCreationPricePerToken: 5e-6, - CacheReadPricePerToken: 0.5e-6, - SupportsCacheBreakdown: false, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.5e-6, + SupportsCacheBreakdown: false, }, 2.5) // GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。 s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{ InputPricePerToken: 30e-6, OutputPricePerToken: 180e-6, // 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。 - CacheCreationPricePerToken: 30e-6, - CacheReadPricePerToken: 30e-6, - SupportsCacheBreakdown: false, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, + CacheCreationPricePerToken: 30e-6, + CacheReadPricePerToken: 30e-6, + SupportsCacheBreakdown: false, } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 @@ -435,9 +420,6 @@ func (s *BillingService) initFallbackPricing() { CacheCreationPricePerTokenPriority: 12.5e-6, CacheReadPricePerToken: 0.5e-6, CacheReadPricePerTokenPriority: 1e-6, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{ InputPricePerToken: 2e-6, @@ -448,9 +430,6 @@ func (s *BillingService) initFallbackPricing() { CacheCreationPricePerTokenPriority: 5e-6, CacheReadPricePerToken: 0.2e-6, CacheReadPricePerTokenPriority: 0.4e-6, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{ InputPricePerToken: 0.2e-6, @@ -461,9 +440,6 @@ func (s *BillingService) initFallbackPricing() { CacheCreationPricePerTokenPriority: 0.5e-6, CacheReadPricePerToken: 0.02e-6, CacheReadPricePerTokenPriority: 0.04e-6, - LongContextInputThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier, } s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{ @@ -1125,10 +1101,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { CacheCreation1hPrice: price1h, SupportsCacheBreakdown: enableBreakdown, LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold, - LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, - LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, - ImageInputPricePerToken: litellmPricing.InputCostPerImageToken, - ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, + // xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。 + LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"), + LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, + LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, + ImageInputPricePerToken: litellmPricing.InputCostPerImageToken, + ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, }), nil } } @@ -1375,15 +1353,18 @@ func (s *BillingService) computeTokenBreakdown( var baselineCost *CostBreakdown if longContextPricingEligible { baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false) - inputPrice *= pricing.LongContextInputMultiplier - outputPrice *= pricing.LongContextOutputMultiplier + // 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧), + // 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。 + longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier) + inputPrice *= longCtxInputMultiplier + outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier) // 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率; // 否则 cache hit 越多,少计的费用越多(见 #2293)。 - cacheReadPrice *= pricing.LongContextInputMultiplier + cacheReadPrice *= longCtxInputMultiplier // 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h) // 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里 // 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。 - cacheCreationMultiplier = pricing.LongContextInputMultiplier + cacheCreationMultiplier = longCtxInputMultiplier } bd := &CostBreakdown{} @@ -1568,10 +1549,13 @@ func (s *BillingService) calculateCostInternalWithPolicy( return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil } -// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、 -// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡 -// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的 -// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。 +// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价 +// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority +// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文 +// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段) +// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部) +// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx +// 关闭强制,保留运营者配置。 func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing { return s.applyModelSpecificPricingPolicyEx(model, pricing, true) } @@ -1607,16 +1591,10 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) - usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized) - if !isGPT56 && !usesLegacyLongContextPricing { - return pricing - } - needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) && - (pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0) needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 { + if !needsCacheCreationPolicy && fastRatio <= 0 { return pricing } cloned := *pricing @@ -1628,17 +1606,6 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25 } } - if isGPT56 || usesLegacyLongContextPricing { - if cloned.LongContextInputThreshold <= 0 { - cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold - } - if cloned.LongContextInputMultiplier <= 0 { - cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier - } - if cloned.LongContextOutputMultiplier <= 0 { - cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier - } - } if fastRatio > 0 { enforceOpenAIFastPricingRatio(&cloned, fastRatio) } @@ -1678,6 +1645,14 @@ func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) { } } +// longContextMultiplierOrOne 把未配置(≤0)的长上下文倍率归一为 1。 +func longContextMultiplierOrOne(m float64) float64 { + if m <= 0 { + return 1 + } + return m +} + func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool { if pricing == nil || pricing.LongContextInputThreshold <= 0 { return false @@ -1692,10 +1667,6 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens return totalInputTokens > pricing.LongContextInputThreshold } -func usesOpenAILegacyLongContextPricing(normalized string) bool { - return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro" -} - // CalculateCostWithConfig 使用配置中的默认倍率计算费用 func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) { multiplier := s.cfg.Default.RateMultiplier @@ -1705,82 +1676,6 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken return s.CalculateCost(model, tokens, multiplier) } -// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费 -// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费 -// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍) -// -// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0 -// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k) -// 范围内正常计费,范围外 × 2 计费 -func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { - // 未启用长上下文计费,直接走正常计费 - if threshold <= 0 || extraMultiplier <= 1 { - return s.CalculateCost(model, tokens, rateMultiplier) - } - - // 计算总输入 token(缓存读取 + 新输入) - total := tokens.CacheReadTokens + tokens.InputTokens - if total <= threshold { - return s.CalculateCost(model, tokens, rateMultiplier) - } - - // 拆分成范围内和范围外 - var inRangeCacheTokens, inRangeInputTokens int - var outRangeCacheTokens, outRangeInputTokens int - - if tokens.CacheReadTokens >= threshold { - // 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入 - inRangeCacheTokens = threshold - inRangeInputTokens = 0 - outRangeCacheTokens = tokens.CacheReadTokens - threshold - outRangeInputTokens = tokens.InputTokens - } else { - // 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入 - inRangeCacheTokens = tokens.CacheReadTokens - inRangeInputTokens = threshold - tokens.CacheReadTokens - outRangeCacheTokens = 0 - outRangeInputTokens = tokens.InputTokens - inRangeInputTokens - } - - // 范围内部分:正常计费 - inRangeTokens := UsageTokens{ - InputTokens: inRangeInputTokens, - OutputTokens: tokens.OutputTokens, // 输出只算一次 - CacheCreationTokens: tokens.CacheCreationTokens, - CacheReadTokens: inRangeCacheTokens, - CacheCreation5mTokens: tokens.CacheCreation5mTokens, - CacheCreation1hTokens: tokens.CacheCreation1hTokens, - ImageOutputTokens: tokens.ImageOutputTokens, - } - inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier) - if err != nil { - return nil, err - } - - // 范围外部分:× extraMultiplier 计费 - outRangeTokens := UsageTokens{ - InputTokens: outRangeInputTokens, - CacheReadTokens: outRangeCacheTokens, - } - outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier) - if err != nil { - return inRangeCost, fmt.Errorf("out-range cost: %w", err) - } - - // 合并成本 - return &CostBreakdown{ - InputCost: inRangeCost.InputCost + outRangeCost.InputCost, - ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost, - OutputCost: inRangeCost.OutputCost, - ImageOutputCost: inRangeCost.ImageOutputCost, - CacheCreationCost: inRangeCost.CacheCreationCost, - CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost, - TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost, - ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost, - LongContextBillingApplied: outRangeCost.ActualCost > 0, - }, nil -} - // ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配) func (s *BillingService) ListSupportedModels() []string { models := make([]string, 0) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 99b8ebd8d9f7..8bb351c55976 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -33,6 +33,11 @@ func newTestBillingService() *BillingService { return NewBillingService(&config.Config{}, nil) } +func newTestBillingServiceWithOpenAILadderCatalog(t *testing.T) *BillingService { + t.Helper() + return NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) +} + func TestCalculateCost_BasicComputation(t *testing.T) { svc := newTestBillingService() @@ -200,9 +205,21 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) { require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12) require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) + // 静态兜底价不携带长上下文阶梯:阶梯一律由目录数据(above_272k 折算)驱动。 + require.Zero(t, pricing.LongContextInputThreshold) + require.Zero(t, pricing.LongContextInputMultiplier) + require.Zero(t, pricing.LongContextOutputMultiplier) +} + +func TestGetModelPricing_CatalogAboveTierFieldsDriveLongContext(t *testing.T) { + svc := newTestBillingServiceWithOpenAILadderCatalog(t) + + pricing, err := svc.GetModelPricing("gpt-5.4") + require.NoError(t, err) require.Equal(t, 272000, pricing.LongContextInputThreshold) require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于") } func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { @@ -215,8 +232,8 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { cacheRead float64 longContext int }{ - {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000}, - {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 0}, + {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 0}, {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0}, {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0}, } @@ -247,7 +264,7 @@ func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) { } func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) tokens := UsageTokens{ InputTokens: 300000, @@ -267,7 +284,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t } func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) cost, err := svc.calculateCostWithServiceTierPolicy( "gpt-5.4-2026-03-05", @@ -283,7 +300,7 @@ func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t } func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) tokens := UsageTokens{ InputTokens: 300000, @@ -331,7 +348,7 @@ func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) { // 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。 // 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。 func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值 tokens := UsageTokens{ @@ -359,7 +376,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *tes // 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。 func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文 tokens := UsageTokens{ @@ -381,7 +398,7 @@ func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *test // 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此 // 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。 func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值 tokens := UsageTokens{ @@ -402,7 +419,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t // 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。 func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) { - svc := newTestBillingService() + svc := newTestBillingServiceWithOpenAILadderCatalog(t) // InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文 tokens := UsageTokens{ @@ -921,114 +938,6 @@ func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) { require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15) require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)") } -func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) { - svc := newTestBillingService() - - tokens := UsageTokens{ - InputTokens: 50000, - OutputTokens: 1000, - CacheReadTokens: 100000, - } - // 总输入 150k < 200k 阈值,应走正常计费 - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0) - require.NoError(t, err) - - normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.NoError(t, err) - - require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10) -} - -func TestCalculateCostWithLongContext_AboveThreshold_CacheExceedsThreshold(t *testing.T) { - svc := newTestBillingService() - - // 缓存 210k + 输入 10k = 220k > 200k 阈值 - // 缓存已超阈值:范围内 200k 缓存,范围外 10k 缓存 + 10k 输入 - tokens := UsageTokens{ - InputTokens: 10000, - OutputTokens: 1000, - CacheReadTokens: 210000, - } - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0) - require.NoError(t, err) - - // 范围内:200k cache + 0 input + 1k output - inRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{ - InputTokens: 0, - OutputTokens: 1000, - CacheReadTokens: 200000, - }, 1.0) - - // 范围外:10k cache + 10k input,倍率 2.0 - outRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{ - InputTokens: 10000, - CacheReadTokens: 10000, - }, 2.0) - - require.InDelta(t, inRange.ActualCost+outRange.ActualCost, cost.ActualCost, 1e-10) -} - -func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *testing.T) { - svc := newTestBillingService() - - // 缓存 100k + 输入 150k = 250k > 200k 阈值 - // 缓存未超阈值:范围内 100k 缓存 + 100k 输入,范围外 50k 输入 - tokens := UsageTokens{ - InputTokens: 150000, - OutputTokens: 1000, - CacheReadTokens: 100000, - } - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0) - require.NoError(t, err) - - require.True(t, cost.ActualCost > 0, "费用应大于 0") - - // 正常费用不含长上下文 - normalCost, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用") -} - -func TestCalculateCostWithLongContext_MarkerRequiresActualCostIncrease(t *testing.T) { - svc := newTestBillingService() - tokens := UsageTokens{InputTokens: 300000} - - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 0, 200000, 2.0) - - require.NoError(t, err) - require.Zero(t, cost.ActualCost) - require.False(t, cost.LongContextBillingApplied) -} - -func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) { - svc := newTestBillingService() - - tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0} - - // threshold <= 0 应禁用长上下文计费 - cost1, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 0, 2.0) - require.NoError(t, err) - - cost2, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.NoError(t, err) - - require.InDelta(t, cost2.ActualCost, cost1.ActualCost, 1e-10) -} - -func TestCalculateCostWithLongContext_ExtraMultiplierLessEqualOne(t *testing.T) { - svc := newTestBillingService() - - tokens := UsageTokens{InputTokens: 300000} - - // extraMultiplier <= 1 应禁用长上下文计费 - cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 1.0) - require.NoError(t, err) - - normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) - require.NoError(t, err) - - require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10) -} - func TestCalculateImageCost(t *testing.T) { svc := newTestBillingService() @@ -1178,19 +1087,6 @@ func TestForceUpdatePricing_NilService(t *testing.T) { require.Contains(t, err.Error(), "not initialized") } -func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) { - // 使用空的 fallback prices 让 GetModelPricing 失败 - svc := &BillingService{ - cfg: &config.Config{}, - fallbackPrices: make(map[string]*ModelPricing), - } - - tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0} - _, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0) - require.Error(t, err) - require.Contains(t, err.Error(), "pricing not found") -} - func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go index efd8aa660678..6a59be16a539 100644 --- a/backend/internal/service/billing_token_cost_request.go +++ b/backend/internal/service/billing_token_cost_request.go @@ -5,33 +5,6 @@ import ( "time" ) -// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。 -// -// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier, -// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。 -// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用; -// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。 -type LegacyLongContextRule struct { - Threshold int - Multiplier float64 -} - -const ( - geminiLegacyLongContextThreshold = 200000 - geminiLegacyLongContextMultiplier = 2.0 -) - -// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。 -func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule { - if platform == PlatformGemini { - return &LegacyLongContextRule{ - Threshold: geminiLegacyLongContextThreshold, - Multiplier: geminiLegacyLongContextMultiplier, - } - } - return nil -} - // TokenCostRequest 通用网关 token 计费请求。 type TokenCostRequest struct { Ctx context.Context @@ -44,27 +17,12 @@ type TokenCostRequest struct { Resolver *ModelPricingResolver // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 Resolved *ResolvedPricing - // LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。 - LegacyLongContext *LegacyLongContextRule -} - -// legacyLongContextApplies 判定请求是否走旧长上下文规则: -// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。 -func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool { - if rule == nil || rule.Threshold <= 0 { - return false - } - if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { - return false - } - return group == nil || group.LongContextPricingEnabled } // CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用: -// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中); -// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费; -// 3. 否则有解析器与分组 → 统一计费(内置目录定价); -// 4. 否则按模型目录直接计费。 +// 1. 分组/渠道显式定价,或有解析器与分组 → 统一计费 +// (区间、分组卡、目录长上下文阶梯均在其中,阶梯由目录数据驱动); +// 2. 否则按模型目录直接计费。 // // 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。 func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) { @@ -72,10 +30,6 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } - if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) { - return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier, - req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier) - } if req.Resolver != nil && req.Group != nil { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 8392b8cf49f5..ccb5b73642dd 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -28,6 +28,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo return bs, NewModelPricingResolver(cs, bs) } +// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。 func geminiCatalogStub() *PricingService { return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{ "gemini-2.5-pro": { @@ -39,22 +40,29 @@ func geminiCatalogStub() *PricingService { }) } -func TestLegacyLongContextRule_OnlyGemini(t *testing.T) { - bs := NewBillingService(&config.Config{}, nil) - rule := bs.LegacyLongContextRule(PlatformGemini) - require.NotNil(t, rule) - require.Equal(t, 200000, rule.Threshold) - require.InDelta(t, 2.0, rule.Multiplier, 1e-12) - for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} { - require.Nil(t, bs.LegacyLongContextRule(platform), platform) - } +// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价, +// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。 +const geminiLadderCatalogJSON = `{ + "gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 3.125e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 6.25e-07} +}` + +func geminiLadderCatalogStub(t *testing.T) *PricingService { + t.Helper() + return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON) } -func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) { +// 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致, +// 超阈值整单按渠道价 × 目录倍率。 +func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) { bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{ Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6), - }}, geminiCatalogStub()) + }}, geminiLadderCatalogStub(t)) group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} gid := group.ID resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) @@ -63,23 +71,41 @@ func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testin tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, - Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini), + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) - want, err := bs.CalculateCostUnified(CostInput{ - Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, - RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, + require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9) + require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9) + require.True(t, got.LongContextBillingApplied) +} + +// 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。 +func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{ + Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, + Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}}, + }}, geminiLadderCatalogStub(t)) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceChannel, resolved.Source) + require.NotEmpty(t, resolved.Intervals) + + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) - require.Equal(t, want, got) - // 渠道平价 10e-6 × 300K,旧规则未叠加 - require.InDelta(t, 3.0, got.InputCost, 1e-9) + require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9) + require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9) + require.False(t, got.LongContextBillingApplied) } -func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) { - bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) +// 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。 +func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t)) tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} - rule := bs.LegacyLongContextRule(PlatformGemini) for _, enabled := range []bool{true, false} { group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled} @@ -89,32 +115,39 @@ func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, - Resolver: resolver, Resolved: resolved, LegacyLongContext: rule, + Resolver: resolver, Resolved: resolved, }) require.NoError(t, err) if enabled { - want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier) - require.NoError(t, err) - require.Equal(t, want, got) - // 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。 - // 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。 - require.InDelta(t, 0.51, got.ActualCost, 1e-9) + // 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015 + require.InDelta(t, 0.765, got.ActualCost, 1e-9) require.True(t, got.LongContextBillingApplied) } else { - want, err := bs.CalculateCostUnified(CostInput{ - Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens, - RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved, - }) - require.NoError(t, err) - require.Equal(t, want, got) require.InDelta(t, 0.385, got.ActualCost, 1e-9) require.False(t, got.LongContextBillingApplied) } } } +// 目录条目没有阶梯字段时,开关开启也不产生阶梯。 +func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.InDelta(t, 0.385, got.ActualCost, 1e-9) + require.False(t, got.LongContextBillingApplied) +} + func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) { - bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil) + bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true} gid := group.ID tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000} diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index 0be04770290c..c14bf9c27ebb 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -456,45 +456,6 @@ func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testi require.Equal(t, 1, quotaSvc.quotaCalls) } -func TestGatewayServiceRecordUsageWithLongContext_BillingUsesDetachedContext(t *testing.T) { - usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: context.DeadlineExceeded} - userRepo := &openAIRecordUsageUserRepoStub{} - subRepo := &openAIRecordUsageSubRepoStub{} - quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{} - svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo) - - reqCtx, cancel := context.WithCancel(context.Background()) - cancel() - - err := svc.RecordUsageWithLongContext(reqCtx, &RecordUsageLongContextInput{ - Result: &ForwardResult{ - RequestID: "gateway_long_context_detached_ctx", - Usage: ClaudeUsage{ - InputTokens: 12, - OutputTokens: 8, - }, - Model: "claude-sonnet-4", - Duration: time.Second, - }, - APIKey: &APIKey{ - ID: 502, - Quota: 100, - }, - User: &User{ID: 602}, - Account: &Account{ID: 702}, - LongContextThreshold: 200000, - LongContextMultiplier: 2, - APIKeyService: quotaSvc, - }) - - require.NoError(t, err) - require.Equal(t, 1, usageRepo.calls) - require.Equal(t, 1, userRepo.deductCalls) - require.NoError(t, userRepo.lastCtxErr) - require.Equal(t, 1, quotaSvc.quotaCalls) - require.NoError(t, quotaSvc.lastQuotaCtxErr) -} - func TestGatewayServiceRecordUsage_UsesFallbackRequestIDForUsageLog(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 4d47720f05c8..069758baaee4 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -596,13 +596,6 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage } } -// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。 -type recordUsageOpts struct { - // 长上下文计费(仅 Gemini 路径需要) - LongContextThreshold int - LongContextMultiplier float64 -} - // RecordUsage 记录使用量并扣费(或更新订阅用量) func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error { return s.recordUsageCore(ctx, &recordUsageCoreInput{ @@ -622,54 +615,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu APIKeyService: input.APIKeyService, QuotaPlatform: input.QuotaPlatform, ChannelUsageFields: input.ChannelUsageFields, - }, &recordUsageOpts{}) -} - -// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费) -type RecordUsageLongContextInput struct { - Result *ForwardResult - APIKey *APIKey - User *User - Account *Account - Subscription *UserSubscription // 可选:订阅信息 - PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义 - InboundEndpoint string // 入站端点(客户端请求路径) - UpstreamEndpoint string // 上游端点(标准化后的上游路径) - UserAgent string // 请求的 User-Agent - IPAddress string // 请求的客户端 IP 地址 - SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联 - RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险 - LongContextThreshold int // 长上下文阈值(如 200000) - LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0) - ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换) - APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选) - QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform) - - ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析) -} - -// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini) -func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error { - return s.recordUsageCore(ctx, &recordUsageCoreInput{ - Result: input.Result, - APIKey: input.APIKey, - User: input.User, - Account: input.Account, - Subscription: input.Subscription, - PricingAt: input.PricingAt, - InboundEndpoint: input.InboundEndpoint, - UpstreamEndpoint: input.UpstreamEndpoint, - UserAgent: input.UserAgent, - IPAddress: input.IPAddress, - SessionID: input.SessionID, - RequestPayloadHash: input.RequestPayloadHash, - ForceCacheBilling: input.ForceCacheBilling, - APIKeyService: input.APIKeyService, - QuotaPlatform: input.QuotaPlatform, - ChannelUsageFields: input.ChannelUsageFields, - }, &recordUsageOpts{ - LongContextThreshold: input.LongContextThreshold, - LongContextMultiplier: input.LongContextMultiplier, }) } @@ -767,9 +712,8 @@ func logResponseModelBillingApplied(component string, account *Account, requestI slog.Info("billing.response_model_applied", attrs...) } -// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。 -// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。 -func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error { +// recordUsageCore 是 RecordUsage 的核心实现。 +func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error { result := input.Result apiKey := input.APIKey user := input.User @@ -839,7 +783,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } // 计算费用 - cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts) + cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt) // response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。 // 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing // + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的 @@ -851,7 +795,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts) + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt) baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, @@ -872,7 +816,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 创建使用日志 accountRateMultiplier := account.BillingRateMultiplier() usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription, - requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts) + requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost) // 计算账号统计定价费用(使用最终上游模型匹配自定义规则) if apiKey.GroupID != nil { @@ -932,7 +876,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage return nil } -// calculateRecordUsageCost 根据请求类型和选项计算费用。 +// calculateRecordUsageCost 根据请求类型计算费用。 func (s *GatewayService) calculateRecordUsageCost( ctx context.Context, result *ForwardResult, @@ -941,12 +885,11 @@ func (s *GatewayService) calculateRecordUsageCost( multiplier float64, imageMultiplier float64, pricingAt time.Time, - opts *recordUsageOpts, ) *CostBreakdown { // 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费 if result.ImageCount > 0 { if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken { - return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) + return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt) } return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } @@ -970,7 +913,7 @@ func (s *GatewayService) calculateRecordUsageCost( } // Token 计费;SearchCount 为叠加 surcharge(不替代 token)。 - tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts) + tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt) if result.SearchCount > 0 { price := groupSearchPricePer1kFromAPIKey(apiKey) if price != nil && *price == 0 { @@ -1122,7 +1065,7 @@ func (s *GatewayService) calculateImageCost( return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier) } -// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价) +// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价) // 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。 func (s *GatewayService) calculateTokenCost( ctx context.Context, @@ -1131,7 +1074,6 @@ func (s *GatewayService) calculateTokenCost( billingModel string, multiplier float64, pricingAt time.Time, - opts *recordUsageOpts, ) *CostBreakdown { tokens := UsageTokens{ InputTokens: result.Usage.InputTokens, @@ -1148,22 +1090,17 @@ func (s *GatewayService) calculateTokenCost( gid := apiKey.Group.ID resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group}) } - var legacy *LegacyLongContextRule - if opts.LongContextThreshold > 0 { - legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier} - } cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{ - Ctx: ctx, - Model: billingModel, - Group: apiKey.Group, - Tokens: tokens, - RateMultiplier: multiplier, - PricingAt: pricingAt, - ServiceTier: optionalStringValue(result.ServiceTier), - Resolver: s.resolver, - Resolved: resolved, - LegacyLongContext: legacy, + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) @@ -1172,14 +1109,6 @@ func (s *GatewayService) calculateTokenCost( return cost } -// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。 -func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule { - if s == nil || s.billingService == nil { - return nil - } - return s.billingService.LegacyLongContextRule(platform) -} - // buildRecordUsageLog 构建使用日志并设置计费模式。 func (s *GatewayService) buildRecordUsageLog( ctx context.Context, @@ -1196,7 +1125,6 @@ func (s *GatewayService) buildRecordUsageLog( billingType int8, cacheTTLOverridden bool, cost *CostBreakdown, - opts *recordUsageOpts, ) *UsageLog { durationMs := int(result.Duration.Milliseconds()) requestID := resolveUsageBillingRequestID(ctx, result.RequestID) diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index d54aca70ad77..4a588ecb0f27 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -362,7 +362,8 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) { {ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true}, {ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false}, } - svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil) + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, + newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 2) @@ -395,26 +396,27 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) { } } -func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) { +func TestListGroups_GeminiCatalogLadderShownWholeRequest(t *testing.T) { channels := []Channel{{ ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}}, }} groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}} - svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub()) + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, + newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)) out, err := svc.ListGroups(context.Background()) require.NoError(t, err) require.Len(t, out, 1) m := out[0].Models[0] - require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis) + require.Equal(t, ContextPricingBasisWholeRequest, m.LongContextBasis) require.Len(t, m.Pricing.Intervals, 2) require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel) require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel) require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15) - require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15) - // 官方参考不套用站内旧规则 + require.InDelta(t, 15e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15) + // 官方参考价与实付同源:都来自目录数据的阶梯字段 require.NotNil(t, m.OfficialPricing) - require.Empty(t, m.OfficialPricing.Intervals) + require.Len(t, m.OfficialPricing.Intervals, 2) } func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index c3bca27176b5..1066cde083c9 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1182,11 +1182,21 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGrou require.Equal(t, 1, userRepo.deductCalls) } +// swapInOpenAILadderCatalog 给测试服务换上带 above_272k 阶梯字段的目录: +// 静态兜底价已不带阶梯,长上下文相关测试需要目录数据。 +func swapInOpenAILadderCatalog(t *testing.T, svc *OpenAIGatewayService) { + t.Helper() + cfg := &config.Config{} + cfg.Default.RateMultiplier = 1.1 + svc.billingService = NewBillingService(cfg, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON)) +} + func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} subRepo := &openAIRecordUsageSubRepoStub{} svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ @@ -1227,6 +1237,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test t.Run("group on account off", func(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true), @@ -1242,6 +1253,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test t.Run("group off account on", func(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{RequestID: "resp_and_group_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false), @@ -1260,6 +1272,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test t.Run("group on account on", func(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true), @@ -1352,6 +1365,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett &openAIRecordUsageSubRepoStub{}, nil, ) + swapInOpenAILadderCatalog(t, svc) svc.accountRepo = accountRepo parentID := int64(4016) @@ -2784,7 +2798,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun 0.15, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) @@ -2824,7 +2837,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier( 1.0, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) @@ -2857,7 +2869,6 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI 1.0, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) @@ -2921,7 +2932,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis 1.0, 1.0, time.Time{}, - nil, ) require.NotNil(t, cost) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 6ae24dd39ed9..a1abcbad1674 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -10,6 +11,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" "time" @@ -22,18 +24,19 @@ import ( ) var ( - openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`) - openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`) + openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`) + openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`) + // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名 + // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。 + // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。 + aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) openAIGPT54FallbackPricing = &LiteLLMModelPricing{ - InputCostPerToken: 2.5e-06, // $2.5 per MTok - OutputCostPerToken: 1.5e-05, // $15 per MTok - CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok - LongContextInputTokenThreshold: 272000, - LongContextInputCostMultiplier: 2.0, - LongContextOutputCostMultiplier: 1.5, - LiteLLMProvider: "openai", - Mode: "chat", - SupportsPromptCaching: true, + InputCostPerToken: 2.5e-06, // $2.5 per MTok + OutputCostPerToken: 1.5e-05, // $15 per MTok + CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, @@ -44,9 +47,6 @@ var ( CacheCreationInputTokenCostPriority: 1.25e-05, CacheReadInputTokenCost: 5e-07, CacheReadInputTokenCostPriority: 1e-06, - LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", @@ -61,9 +61,6 @@ var ( CacheCreationInputTokenCostPriority: 5e-06, CacheReadInputTokenCost: 2e-07, CacheReadInputTokenCostPriority: 4e-07, - LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", @@ -78,9 +75,6 @@ var ( CacheCreationInputTokenCostPriority: 5e-07, CacheReadInputTokenCost: 2e-08, CacheReadInputTokenCostPriority: 4e-08, - LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold, - LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier, - LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier, SupportsServiceTier: true, LiteLLMProvider: "openai", Mode: "chat", @@ -408,6 +402,7 @@ func (s *PricingService) downloadPricingData() error { // 更新内存数据 s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, data) s.pricingData = data s.lastUpdated = time.Now() s.localHash = syncHash @@ -500,6 +495,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel pricing.InputCostPerImageToken = *entry.InputCostPerImageToken } + hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil || + entry.LongContextInputCostMultiplier != nil || + entry.LongContextOutputCostMultiplier != nil + if !hasExplicitLongContext { + deriveLongContextFromAboveTierFields(rawEntry, pricing) + } + result[modelName] = pricing } @@ -514,6 +516,81 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel return result, nil } +// deriveLongContextFromAboveTierFields 把 LiteLLM 目录的 *_above_XXXk_tokens 绝对价字段 +// 折算成 long_context_* 阈值+倍率(sub2api 计费机制的内部表达):阈值取自字段名, +// 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由 +// 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该 +// 模型的阶梯。多个阈值并存时取最小阈值。 +// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算; +// 现网 OpenAI/Claude 条目(含 5m/1h 分档)的 cache above 档均恰为基础价 × 输入倍率, +// Gemini pro 系存在无基础价的孤儿 cache_creation above 字段(数据缺陷,计费两侧均按 0)。 +func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) { + if pricing == nil || + pricing.LongContextInputTokenThreshold > 0 || + pricing.LongContextInputCostMultiplier > 0 || + pricing.LongContextOutputCostMultiplier > 0 { + return + } + if !bytes.Contains(rawEntry, []byte("_above_")) { + return + } + var fields map[string]any + if err := json.Unmarshal(rawEntry, &fields); err != nil { + return + } + type tierPrices struct{ input, output float64 } + tiers := make(map[int]*tierPrices) + for key, value := range fields { + m := aboveTierPricePattern.FindStringSubmatch(key) + if m == nil { + continue + } + price, ok := value.(float64) + if !ok || price <= 0 { + continue + } + thousands, err := strconv.Atoi(m[2]) + if err != nil || thousands <= 0 { + continue + } + threshold := thousands * 1000 + tp := tiers[threshold] + if tp == nil { + tp = &tierPrices{} + tiers[threshold] = tp + } + if m[1] == "input" { + tp.input = price + } else { + tp.output = price + } + } + if len(tiers) == 0 { + return + } + threshold := 0 + for t := range tiers { + if threshold == 0 || t < threshold { + threshold = t + } + } + tp := tiers[threshold] + inputMultiplier, outputMultiplier := 1.0, 1.0 + if tp.input > 0 && pricing.InputCostPerToken > 0 { + inputMultiplier = tp.input / pricing.InputCostPerToken + } + if tp.output > 0 && pricing.OutputCostPerToken > 0 { + outputMultiplier = tp.output / pricing.OutputCostPerToken + } + // above 价不高于基础价时视为无附加费,不生成阶梯。 + if inputMultiplier <= 1 && outputMultiplier <= 1 { + return + } + pricing.LongContextInputTokenThreshold = threshold + pricing.LongContextInputCostMultiplier = inputMultiplier + pricing.LongContextOutputCostMultiplier = outputMultiplier +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -533,6 +610,7 @@ func (s *PricingService) loadPricingData(filePath string) error { hashStr := hex.EncodeToString(hash[:]) s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, pricingData) s.pricingData = pricingData s.localHash = hashStr @@ -579,6 +657,34 @@ func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelP return data } +// warnDroppedLongContextLadders 对比新旧目录数据:原本带长上下文阶梯的条目在新数据里 +// 丢失阈值时打 WARN。阶梯已完全数据驱动(无代码兜底),数据源一次误提交就会把阶梯 +// 静默变成基础价少收(07-14~08-21 漏收事故的形态),这里是唯一的哨兵。 +// 调用方需持有 s.mu 写锁。 +func warnDroppedLongContextLadders(old, next map[string]*LiteLLMModelPricing) { + if len(old) == 0 { + return + } + var dropped []string + for name, prev := range old { + if prev == nil || prev.LongContextInputTokenThreshold <= 0 { + continue + } + if cur, ok := next[name]; ok && (cur == nil || cur.LongContextInputTokenThreshold <= 0) { + dropped = append(dropped, name) + } + } + if len(dropped) == 0 { + return + } + sort.Strings(dropped) + total := len(dropped) + if total > 20 { + dropped = append(dropped[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Long-context ladder dropped for %d model(s) after reload: %s (verify catalog/override data if unintended)", total, strings.Join(dropped, ", ")) +} + // useFallbackPricing 使用回退价格文件 func (s *PricingService) useFallbackPricing() error { fallbackFile := s.cfg.Pricing.FallbackFile diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 1a933aec107d..96cc63ba55ad 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -109,9 +109,8 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing. require.NoError(t, err) require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12) - require.Equal(t, 272000, pricing.LongContextInputThreshold) - require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) - require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + // 阶梯由目录数据驱动:条目无 above/long_context 字段时不再由策略强补。 + require.Zero(t, pricing.LongContextInputThreshold) tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100} standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "") @@ -129,6 +128,32 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing. } } +// gpt56LadderCatalogJSON 三个 5.6 模型的目录条目:above_272k 绝对价 + priority 平价, +// cache_write 缺失由策略按 1.25 倍输入价补齐。 +const gpt56LadderCatalogJSON = `{ + "gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1e-05, + "output_cost_per_token": 3e-05, "output_cost_per_token_priority": 6e-05, + "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 1e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}, + "gpt-5.6-terra": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-06, "input_cost_per_token_priority": 4e-06, + "output_cost_per_token": 1.2e-05, "output_cost_per_token_priority": 2.4e-05, + "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token_above_272k_tokens": 4e-06, + "output_cost_per_token_above_272k_tokens": 1.8e-05, + "cache_read_input_token_cost_above_272k_tokens": 4e-07}, + "gpt-5.6-luna": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-07, "input_cost_per_token_priority": 4e-07, + "output_cost_per_token": 1.2e-06, "output_cost_per_token_priority": 2.4e-06, + "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_priority": 4e-08, + "input_cost_per_token_above_272k_tokens": 4e-07, + "output_cost_per_token_above_272k_tokens": 1.8e-06, + "cache_read_input_token_cost_above_272k_tokens": 4e-08} +}` + func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testing.T) { models := []struct { name string @@ -157,7 +182,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi for _, model := range models { for _, tier := range tiers { t.Run(model.name+"/"+tier.name, func(t *testing.T) { - svc := NewBillingService(&config.Config{}, nil) + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON)) serviceTier := "" if tier.name != "standard" { serviceTier = tier.name @@ -174,7 +199,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi } func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) { - svc := NewBillingService(&config.Config{}, nil) + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON)) tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 100000, CacheReadTokens: 72000, OutputTokens: 10} cost, err := svc.CalculateCost("gpt-5.6-sol", tokens, 1) @@ -284,9 +309,8 @@ func assertGPT56FallbackPricing(t *testing.T, pricing *ModelPricing, input, cach require.InDelta(t, cached, pricing.CacheReadPricePerToken, 1e-12) require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12) require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12) - require.Equal(t, 272000, pricing.LongContextInputThreshold) - require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) - require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + // 静态兜底只兜基础价;阶梯由目录数据(above_272k 折算或显式字段)驱动。 + require.Zero(t, pricing.LongContextInputThreshold) } func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) { @@ -436,9 +460,8 @@ func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T) require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12) require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 1e-12) - require.Equal(t, 272000, got.LongContextInputTokenThreshold) - require.InDelta(t, 2.0, got.LongContextInputCostMultiplier, 1e-12) - require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12) + // 静态兜底只兜基础价,不携带长上下文阶梯(阶梯由目录数据驱动)。 + require.Zero(t, got.LongContextInputTokenThreshold) } func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { @@ -719,3 +742,154 @@ func TestListModelNamesByProvider_EmptyCatalog(t *testing.T) { require.NotNil(t, got) require.Empty(t, got) } + +// --- above_XXXk 绝对价字段折算为阈值+倍率 --- + +func TestParsePricingData_DerivesLongContextFromAboveTierFields(t *testing.T) { + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gpt-above": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token_above_272k_tokens_flex": 5e-06, + "output_cost_per_token_above_272k_tokens_flex": 2.25e-05}, + "gemini-above": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05}, + "explicit-wins": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "long_context_input_cost_multiplier": 1, + "long_context_output_cost_multiplier": 1, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05}, + "no-surcharge": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "cache-only-above": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}, + "multi-threshold": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06, + "input_cost_per_token_above_128k_tokens": 2e-06, + "input_cost_per_token_above_272k_tokens": 4e-06} + }`)) + require.NoError(t, err) + + openai := data["gpt-above"] + require.Equal(t, 272000, openai.LongContextInputTokenThreshold, "阈值取自字段名(_flex 变体不参与)") + require.InDelta(t, 2.0, openai.LongContextInputCostMultiplier, 1e-12) + require.InDelta(t, 1.5, openai.LongContextOutputCostMultiplier, 1e-12) + + gemini := data["gemini-above"] + require.Equal(t, 200000, gemini.LongContextInputTokenThreshold) + require.InDelta(t, 2.0, gemini.LongContextInputCostMultiplier, 1e-12) + require.InDelta(t, 1.5, gemini.LongContextOutputCostMultiplier, 1e-12) + + explicit := data["explicit-wins"] + require.Zero(t, explicit.LongContextInputTokenThreshold, "显式 long_context_* 字段优先,不做折算") + require.InDelta(t, 1.0, explicit.LongContextInputCostMultiplier, 1e-12) + + require.Zero(t, data["no-surcharge"].LongContextInputTokenThreshold, "above 价不高于基础价视为无附加费") + require.Zero(t, data["cache-only-above"].LongContextInputTokenThreshold, "仅 cache 侧 above 字段不构成阶梯") + require.Equal(t, 128000, data["multi-threshold"].LongContextInputTokenThreshold, "多阈值取最小") +} + +func TestGetModelPricing_XAIThresholdInclusive(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "grok-4.5": {"litellm_provider": "xai", "mode": "chat", + "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05} + }`)) + pricing, err := svc.GetModelPricing("grok-4.5") + require.NoError(t, err) + require.Equal(t, 200000, pricing.LongContextInputThreshold) + require.True(t, pricing.LongContextThresholdInclusive, "xAI 阈值语义为达到即进高档") +} + +// F3:显式 long_context 字段以"字段存在"为准——显式 0 也能压住 above 折算,关闭阶梯。 +func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) { + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gpt-5.5": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "long_context_input_token_threshold": 0, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05} + }`)) + require.NoError(t, err) + require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold) + require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier) +} + +// F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。 +func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) { + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} + + t.Run("only input multiplier", func(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "partial-in": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0} + }`)) + cost, err := svc.CalculateCost("partial-in", tokens, 1.0) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 300000*2e-6*2, cost.InputCost, 1e-10) + require.InDelta(t, 1000*1e-5, cost.OutputCost, 1e-10, "缺失的 output 倍率按 1 计,不得为 0") + require.InDelta(t, 10000*2e-7*2, cost.CacheReadCost, 1e-10) + }) + + t.Run("only output multiplier", func(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "partial-out": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_output_cost_multiplier": 1.5} + }`)) + cost, err := svc.CalculateCost("partial-out", tokens, 1.0) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 300000*2e-6, cost.InputCost, 1e-10, "缺失的 input 倍率按 1 计,不得为 0") + require.InDelta(t, 1000*1e-5*1.5, cost.OutputCost, 1e-10) + require.InDelta(t, 10000*2e-7, cost.CacheReadCost, 1e-10, "cache_read 跟随 input 倍率,同样按 1 计") + }) +} + +// 行为声明:目录带 above_200k 的 Claude sonnet 条目同样获得数据驱动的整单阶梯 +// (与 Anthropic 官方 1M 长上下文定价一致),受分组长上下文开关约束。 +func TestCalculateCost_ClaudeSonnetCatalogLadderIsDataDriven(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{ + "claude-sonnet-4-5": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token_above_200k_tokens": 6e-06, + "output_cost_per_token_above_200k_tokens": 2.25e-05, + "cache_read_input_token_cost_above_200k_tokens": 6e-07} + }`)) + + pricing, err := svc.GetModelPricing("claude-sonnet-4-5") + require.NoError(t, err) + require.Equal(t, 200000, pricing.LongContextInputThreshold) + require.False(t, pricing.LongContextThresholdInclusive, "anthropic 为严格大于") + + over := UsageTokens{InputTokens: 250000, OutputTokens: 1000} + cost, err := svc.CalculateCost("claude-sonnet-4-5", over, 1.0) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 250000*3e-6*2, cost.InputCost, 1e-10) + require.InDelta(t, 1000*1.5e-5*1.5, cost.OutputCost, 1e-10) + + under := UsageTokens{InputTokens: 200000, OutputTokens: 1000} + cost, err = svc.CalculateCost("claude-sonnet-4-5", under, 1.0) + require.NoError(t, err) + require.False(t, cost.LongContextBillingApplied, "恰好 200000 不进高档(严格大于)") +} diff --git a/backend/internal/service/pricing_stub_helpers_test.go b/backend/internal/service/pricing_stub_helpers_test.go new file mode 100644 index 000000000000..66bef43797a9 --- /dev/null +++ b/backend/internal/service/pricing_stub_helpers_test.go @@ -0,0 +1,33 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// openAILadderCatalogJSON 镜像真实同步目录的形态:长上下文用 above_272k 绝对价字段表达, +// 由解析层折算成阈值+倍率。静态 Go 兜底价不再携带阶梯,阶梯计费一律走目录数据。 +const openAILadderCatalogJSON = `{ + "gpt-5.4": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 2.5e-07, "cache_creation_input_token_cost": 2.5e-06, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 2.25e-05, + "cache_read_input_token_cost_above_272k_tokens": 5e-07}, + "gpt-5.5-pro": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 3e-05, "output_cost_per_token": 1.8e-04, + "input_cost_per_token_above_272k_tokens": 6e-05, + "output_cost_per_token_above_272k_tokens": 2.7e-04} +}` + +// newStubPricingServiceFromJSON 用与生产一致的解析路径(含 above_XXXk 阶梯折算) +// 从原始目录 JSON 构造目录 stub。无 build tag:带 unit 标签与默认构建的测试文件都会用到。 +func newStubPricingServiceFromJSON(t *testing.T, body string) *PricingService { + t.Helper() + s := &PricingService{} + data, err := s.parsePricingData([]byte(body)) + require.NoError(t, err) + s.pricingData = data + return s +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 033cb2990865..bd31c3421e80 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1054,8 +1054,8 @@ plugins: # 定价数据源(可选) # ============================================================================= pricing: - # URL to fetch model pricing data (default: pinned model-price-repo commit) - # 获取模型定价数据的 URL(默认:固定 commit 的 model-price-repo) + # URL to fetch model pricing data (default: model-price-repo main branch) + # 获取模型定价数据的 URL(默认:model-price-repo main 分支) remote_url: "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/refs/heads/main//model_prices_and_context_window.json" # Hash verification URL (optional) # 哈希校验 URL(可选) From 2d28c886e5ec3f844240fd21f55df4aba3146320 Mon Sep 17 00:00:00 2001 From: feeeei Date: Sun, 30 Aug 2026 16:55:28 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(pricing):=20=E6=96=B0=E5=A2=9E=20overr?= =?UTF-8?q?ide=5Ffile=20=E4=BB=B7=E6=A0=BC=E7=9B=AE=E5=BD=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 pricing.override_file 配置(默认关闭):稀疏补丁文件按 JSON 字段 浅合并覆盖目录/回退数据,作为最高优先级数据源,用于本地自定义官方 目录条目(如显式 long_context_input_token_threshold=0 关闭某模型阶梯) 而无需自建价格镜像: - applyPricingOverrides 挂在 parsePricingData 解析入口,只修补已存在 条目(补丁字段覆盖同名字段,值为 null 删除字段);目录、回退与灾备 路径都经该入口,覆盖语义三处一致 - mergeOverrideOnlyModels 在回退合并后把目录/回退都没有的模型作为 独立条目并入(复用主解析路径,须自带价格字段通过有效性过滤); 不在主解析时抢先建条目,避免纯补丁挡住回退完整条目、其余分项价 静默变 0 - 最终未生效的条目(模型名拼错、纯补丁落在不存在的模型上)打 WARN 哨兵;override 文件缺失/损坏仅跳过合并,不影响目录加载 - 生效时机为重启或下次目录下载,与 fallback_file 一致;fallback_file 只补缺失模型的语义不变 --- backend/internal/config/config.go | 3 + backend/internal/service/pricing_service.go | 122 +++++++++++ .../service/pricing_service_override_test.go | 201 ++++++++++++++++++ deploy/config.example.yaml | 8 + 4 files changed, 334 insertions(+) create mode 100644 backend/internal/service/pricing_service_override_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 390c1bba1890..5d04ab5306e3 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -667,6 +667,8 @@ type PricingConfig struct { DataDir string `mapstructure:"data_dir"` // 回退文件路径 FallbackFile string `mapstructure:"fallback_file"` + // 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高 + OverrideFile string `mapstructure:"override_file"` // 更新间隔(小时) UpdateIntervalHours int `mapstructure:"update_interval_hours"` // 哈希校验间隔(分钟) @@ -2284,6 +2286,7 @@ func setDefaults() { viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256") viper.SetDefault("pricing.data_dir", "./data") viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json") + viper.SetDefault("pricing.override_file", "") viper.SetDefault("pricing.update_interval_hours", 24) viper.SetDefault("pricing.hash_check_interval_minutes", 10) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index a1abcbad1674..f3b9339cdc74 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -382,6 +383,7 @@ func (s *PricingService) downloadPricingData() error { return fmt.Errorf("parse pricing data: %w", err) } data = s.mergeFallbackPricingData(data) + data = s.mergeOverrideOnlyModels(data) // 保存到本地文件 pricingFile := s.getPricingFilePath() @@ -419,6 +421,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if err := json.Unmarshal(body, &rawData); err != nil { return nil, fmt.Errorf("parse raw JSON: %w", err) } + rawData = s.applyPricingOverrides(rawData) result := make(map[string]*LiteLLMModelPricing) skipped := 0 @@ -591,6 +594,124 @@ func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *Lit pricing.LongContextOutputCostMultiplier = outputMultiplier } +// applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退 +// 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补 +// 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后 +// 统一并入——若在此处抢先建条目,纯 override 条目会挡住回退文件中同名完整条目的合并。 +func (s *PricingService) applyPricingOverrides(rawData map[string]json.RawMessage) map[string]json.RawMessage { + overrides := s.loadPricingOverrideEntries() + if len(overrides) == 0 { + return rawData + } + for name, patch := range overrides { + base, ok := rawData[name] + if !ok { + continue + } + merged, valid := mergePricingOverrideEntry(base, patch) + if !valid { + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override entry %q skipped: not a JSON object", name) + continue + } + rawData[name] = merged + } + return rawData +} + +// loadPricingOverrideEntries 读取 override 文件的原始条目。未配置返回 nil; +// 读取或解析失败打日志并跳过,不影响目录加载。 +func (s *PricingService) loadPricingOverrideEntries() map[string]json.RawMessage { + if s == nil || s.cfg == nil { + return nil + } + path := strings.TrimSpace(s.cfg.Pricing.OverrideFile) + if path == "" { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err) + return nil + } + var entries map[string]json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override merge skipped: %v", err) + return nil + } + return entries +} + +// mergePricingOverrideEntry 在 JSON 字段层浅合并:patch 字段覆盖 base 同名字段, +// 值为 null 的 patch 字段从结果中删除,base 为空时结果即 patch 本身。 +// patch 不是 JSON 对象时返回 ok=false。 +func mergePricingOverrideEntry(base, patch json.RawMessage) (json.RawMessage, bool) { + var patchFields map[string]any + if err := json.Unmarshal(patch, &patchFields); err != nil || patchFields == nil { + return nil, false + } + merged := make(map[string]any, len(patchFields)) + if len(base) > 0 { + // base 非对象时忽略,仅以 patch 为准。 + if err := json.Unmarshal(base, &merged); err != nil { + merged = make(map[string]any, len(patchFields)) + } + } + for k, v := range patchFields { + if v == nil { + delete(merged, k) + continue + } + merged[k] = v + } + out, err := json.Marshal(merged) + if err != nil { + return nil, false + } + return out, true +} + +// mergeOverrideOnlyModels 把 override 中目录/回退两层都不存在的模型作为独立条目并入 +// (条目须自带价格字段才能通过有效性过滤),并对最终仍未生效的条目打 WARN: +// 模型名拼错、或纯补丁条目落在不存在的模型上时会被静默丢弃,让"已改价/已关阶梯" +// 的运营预期与实际计费脱节,这里是唯一的哨兵。 +func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPricing) map[string]*LiteLLMModelPricing { + overrides := s.loadPricingOverrideEntries() + if len(overrides) == 0 { + return data + } + if data == nil { + data = make(map[string]*LiteLLMModelPricing) + } + leftover := make(map[string]json.RawMessage) + for name, patch := range overrides { + if _, ok := data[name]; !ok { + leftover[name] = patch + } + } + if len(leftover) == 0 { + return data + } + // 复用主解析路径(含 above_XXXk 折算与有效性过滤);applyPricingOverrides + // 对已存在条目做的自我修补是幂等的,不会二次改值。 + if body, err := json.Marshal(leftover); err == nil { + if parsed, err := s.parsePricingData(body); err == nil { + maps.Copy(data, parsed) + } + } + var missing []string + for name := range leftover { + if _, ok := data[name]; !ok { + missing = append(missing, name) + } + } + if len(missing) == 0 { + return data + } + sort.Strings(missing) + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: override had no effect for %d model(s): %s (unknown model name, or patch-only entry without price fields)", len(missing), strings.Join(missing, ", ")) + return data +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -604,6 +725,7 @@ func (s *PricingService) loadPricingData(filePath string) error { return fmt.Errorf("parse pricing data: %w", err) } pricingData = s.mergeFallbackPricingData(pricingData) + pricingData = s.mergeOverrideOnlyModels(pricingData) // 计算哈希 hash := sha256.Sum256(data) diff --git a/backend/internal/service/pricing_service_override_test.go b/backend/internal/service/pricing_service_override_test.go new file mode 100644 index 000000000000..ab0dcf205cfb --- /dev/null +++ b/backend/internal/service/pricing_service_override_test.go @@ -0,0 +1,201 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +// gpt55OverrideCatalogJSON 镜像真实目录形态:长上下文以 above_272k 绝对价字段表达。 +const gpt55OverrideCatalogJSON = `{ + "gpt-5.5": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1.25e-05, + "output_cost_per_token": 3e-05, "output_cost_per_token_priority": 7.5e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 1e-06}, + "gpt-5.4": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token_above_272k_tokens": 5e-06, + "output_cost_per_token_above_272k_tokens": 2.25e-05} +}` + +func newPricingServiceWithOverride(t *testing.T, overrideJSON string) *PricingService { + t.Helper() + path := filepath.Join(t.TempDir(), "overrides.json") + require.NoError(t, os.WriteFile(path, []byte(overrideJSON), 0644)) + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.OverrideFile = path + return svc +} + +// override 的旗舰用例:显式 threshold=0 压住 above 折算,把目录条目的阶梯关成标准价。 +func TestPricingOverride_ExplicitZeroThresholdDisablesCatalogLadder(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.5": {"long_context_input_token_threshold": 0}}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + + patched := data["gpt-5.5"] + require.NotNil(t, patched) + require.Zero(t, patched.LongContextInputTokenThreshold) + require.Zero(t, patched.LongContextInputCostMultiplier) + require.InDelta(t, 5e-6, patched.InputCostPerToken, 1e-12, "补丁不得影响基础价") + require.InDelta(t, 3e-5, patched.OutputCostPerToken, 1e-12) + require.Equal(t, 272000, data["gpt-5.4"].LongContextInputTokenThreshold, "未覆盖的模型保持目录阶梯") + + svc.pricingData = data + billing := NewBillingService(&config.Config{}, svc) + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} + cost, err := billing.CalculateCost("gpt-5.5", tokens, 1) + require.NoError(t, err) + require.False(t, cost.LongContextBillingApplied) + require.InDelta(t, 300000*5e-6, cost.InputCost, 1e-10) + require.InDelta(t, 1000*3e-5, cost.OutputCost, 1e-10) + require.InDelta(t, 10000*5e-7, cost.CacheReadCost, 1e-10) +} + +func TestPricingOverride_FieldLevelMergeKeepsOtherFields(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.4": {"input_cost_per_token": 3e-06}}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + + patched := data["gpt-5.4"] + require.InDelta(t, 3e-6, patched.InputCostPerToken, 1e-12) + require.InDelta(t, 1.5e-5, patched.OutputCostPerToken, 1e-12, "未覆盖字段保持目录值") + require.Equal(t, "openai", patched.LiteLLMProvider) + require.Equal(t, 272000, patched.LongContextInputTokenThreshold, "above 折算仍生效") + // 折算发生在合并之后:above 价不变、基础价被补丁改小,倍率随之变化。 + require.InDelta(t, 5.0/3.0, patched.LongContextInputCostMultiplier, 1e-9) +} + +func TestPricingOverride_NullFieldValueRemovesField(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.5": { + "input_cost_per_token_above_272k_tokens": null, + "output_cost_per_token_above_272k_tokens": null, + "cache_read_input_token_cost_above_272k_tokens": null}}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold, "above 字段删除后不再折算阶梯") + require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12) +} + +// 完整加载管线:纯补丁不得抢在回退合并前建条目(否则回退完整条目被跳过、 +// 其余分项价变 0 少收);目录/回退都没有的模型作为独立条目并入。 +func TestPricingOverride_LoadPipelineAddsNewModelAndPatchesFallbackOnly(t *testing.T) { + dir := t.TempDir() + catalogPath := filepath.Join(dir, "catalog.json") + require.NoError(t, os.WriteFile(catalogPath, []byte(`{ + "remote-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06} + }`), 0644)) + fallbackPath := filepath.Join(dir, "fallback.json") + require.NoError(t, os.WriteFile(fallbackPath, []byte(`{ + "fallback-only-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 8e-06, + "cache_read_input_token_cost": 4e-07} + }`), 0644)) + overridePath := filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(overridePath, []byte(`{ + "fallback-only-model": {"input_cost_per_token": 9e-06}, + "override-new-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 1e-05} + }`), 0644)) + + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.FallbackFile = fallbackPath + svc.cfg.Pricing.OverrideFile = overridePath + require.NoError(t, svc.loadPricingData(catalogPath)) + + patched := svc.pricingData["fallback-only-model"] + require.NotNil(t, patched) + require.InDelta(t, 9e-6, patched.InputCostPerToken, 1e-12) + require.InDelta(t, 8e-6, patched.OutputCostPerToken, 1e-12, "回退条目的其余字段必须保留") + require.InDelta(t, 4e-7, patched.CacheReadInputTokenCost, 1e-12) + + added := svc.pricingData["override-new-model"] + require.NotNil(t, added) + require.InDelta(t, 5e-6, added.InputCostPerToken, 1e-12) + require.InDelta(t, 1e-5, added.OutputCostPerToken, 1e-12) + + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) +} + +// 拼错模型名(或纯补丁落在不存在的模型上)会被有效性过滤丢弃,必须有哨兵 WARN。 +func TestPricingOverride_IneffectiveEntryWarns(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + dir := t.TempDir() + catalogPath := filepath.Join(dir, "catalog.json") + require.NoError(t, os.WriteFile(catalogPath, []byte(`{ + "remote-model": {"litellm_provider": "test", "mode": "chat", "input_cost_per_token": 1e-06} + }`), 0644)) + overridePath := filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(overridePath, []byte(`{ + "typo-model": {"long_context_input_token_threshold": 0} + }`), 0644)) + + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.OverrideFile = overridePath + require.NoError(t, svc.loadPricingData(catalogPath)) + + require.NotContains(t, svc.pricingData, "typo-model") + require.True(t, logSink.ContainsMessageAtLevel("override had no effect for 1 model(s): typo-model", "warn")) +} + +func TestPricingOverride_NonObjectEntryKeepsCatalogEntry(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{"gpt-5.5": "oops"}`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold, "非法补丁忽略,目录条目原样保留") + require.InDelta(t, 5e-6, data["gpt-5.5"].InputCostPerToken, 1e-12) +} + +func TestPricingOverride_MissingOrInvalidFileIsIgnored(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.OverrideFile = filepath.Join(t.TempDir(), "absent.json") + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold) + }) + + t.Run("invalid json", func(t *testing.T) { + svc := newPricingServiceWithOverride(t, `{invalid`) + data, err := svc.parsePricingData([]byte(gpt55OverrideCatalogJSON)) + require.NoError(t, err) + require.Equal(t, 272000, data["gpt-5.5"].LongContextInputTokenThreshold) + }) +} + +// 对真实出厂目录快照关闭 gpt-5.5 阶梯:计费视角阈值归零、基础价不变, +// 其他模型(gpt-5.4)的目录阶梯不受影响。 +func TestPricingOverride_DisablesGPT55LadderOnDefaultCatalog(t *testing.T) { + body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + svc := newPricingServiceWithOverride(t, `{ + "gpt-5.5": {"long_context_input_token_threshold": 0}, + "gpt-5.5-2026-04-23": {"long_context_input_token_threshold": 0} + }`) + data, err := svc.parsePricingData(body) + require.NoError(t, err) + svc.pricingData = data + billing := NewBillingService(&config.Config{}, svc) + + for _, model := range []string{"gpt-5.5", "gpt-5.5-2026-04-23"} { + pricing, err := billing.GetModelPricing(model) + require.NoError(t, err) + require.Zero(t, pricing.LongContextInputThreshold, model) + require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12, model) + } + + pricing, err := billing.GetModelPricing("gpt-5.4") + require.NoError(t, err) + require.Equal(t, 272000, pricing.LongContextInputThreshold, "其他模型的目录阶梯不受影响") +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index bd31c3421e80..c8798ee171da 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1066,6 +1066,14 @@ pricing: # Fallback pricing file # 备用定价文件 fallback_file: "./resources/model-pricing/model_prices_and_context_window.json" + # Override patch file (optional, highest priority): entries are shallow-merged + # field-by-field over catalog/fallback data; a null field value removes that field. + # Model names must match catalog keys exactly. Edits take effect on restart or + # the next catalog download. + # 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据, + # 字段值为 null 表示删除该字段。模型名须与目录键完全一致。 + # 修改后重启或下次目录下载时生效。 + # override_file: "./data/model_pricing_overrides.json" # Update interval in hours # 更新间隔(小时) update_interval_hours: 24 From 54e5874ac35ac81b3dddc72a6f25213fc0bf394f Mon Sep 17 00:00:00 2001 From: feeeei Date: Mon, 31 Aug 2026 20:07:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(pricing):=20=E7=9B=AE=E5=BD=95=20cache?= =?UTF-8?q?=20=E4=BE=A7=E5=AD=97=E6=AE=B5=E5=A5=91=E7=BA=A6=E5=93=A8?= =?UTF-8?q?=E5=85=B5=E4=B8=8E=20Gemini=20pro=20=E7=BC=93=E5=AD=98=E5=86=99?= =?UTF-8?q?=E5=85=A5=E4=BB=B7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶梯计价数据驱动后,目录条目的字段缺失或版本拼接会直接变成计费偏差, 解析层补两道数据契约哨兵,并修正出厂快照中 Gemini pro 系的缓存写入价: - orphanCacheTierFields / warnOrphanCacheTierFields:条目带 cache 侧 above 档(含 _priority/_flex 变体与 above_1hr 形态)却没有基础价时打 WARN——cache 侧 above 档不参与计费取值,按"基础价 × 输入倍率"计,基础价 缺失即该分项按 0 计;变体缺自身基础价时沿 变体→1h→标准 回落链任一存在 即不算孤儿,与计费回落一致 - isLopsidedLongContextLadder / warnLopsidedLongContextLadders:above 档 折算出只有一侧带附加费的阶梯时打 WARN(基础价与 above 档来自不同价格 版本的形态),显式 long_context_* 字段不告警 - deriveLongContextFromAboveTierFields 注释改为陈述契约:cache above 档 须恰为基础价 × 输入倍率 - 出厂快照 6 个 Gemini pro 条目补 cache_creation_input_token_cost(= 标准 输入价,Google 对缓存写入不另收费)、above_200k 改为输入 above 价、带 priority 输入价的 4 条补 priority 孪生,与 model-price-repo 数据修正同值 测试: - 解析层:孤儿字段 WARN(含 priority/1h 孤儿正例、1h 回落 5m 反例)、单侧 阶梯 WARN、出厂快照契约(全条目无孤儿字段、无单侧阶梯、Gemini pro 缓存 写入价 = 输入价) - 用量契约:extractGeminiUsage 与 Antigravity 非流式/流式转换均不产生 cache_creation token(Gemini usageMetadata 无该类别) - 计费:gemini-2.5-pro 阶梯对 cache_read / cache_creation 分项生效,阈值 计入缓存写入 token;阶梯表场景与夹具同步为现行目录值 --- .../pkg/antigravity/usage_contract_test.go | 40 ++++++ .../service/billing_context_schedule_test.go | 5 +- .../billing_token_cost_request_test.go | 46 ++++++- .../gemini_messages_compat_service_test.go | 5 + backend/internal/service/pricing_service.go | 94 +++++++++++++- .../internal/service/pricing_service_test.go | 120 ++++++++++++++++++ .../model_prices_and_context_window.json | 26 +++- 7 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 backend/internal/pkg/antigravity/usage_contract_test.go diff --git a/backend/internal/pkg/antigravity/usage_contract_test.go b/backend/internal/pkg/antigravity/usage_contract_test.go new file mode 100644 index 000000000000..832bffbfb2f7 --- /dev/null +++ b/backend/internal/pkg/antigravity/usage_contract_test.go @@ -0,0 +1,40 @@ +//go:build unit + +package antigravity + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入的 token 类别: +// 两条转换路径产出的 Claude usage 中 cache_creation_input_tokens 恒为 0,缓存命中计入 +// cache_read_input_tokens 并从 input_tokens 中扣除。 +func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) { + const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` + + `"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":5}}` + + t.Run("non-stream", func(t *testing.T) { + _, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview") + require.NoError(t, err) + require.NotNil(t, usage) + require.Equal(t, 70, usage.InputTokens) + require.Equal(t, 25, usage.OutputTokens) + require.Equal(t, 30, usage.CacheReadInputTokens) + require.Zero(t, usage.CacheCreationInputTokens) + }) + + t.Run("stream", func(t *testing.T) { + p := NewStreamingProcessor("gemini-3.1-pro-preview") + out := p.ProcessLine(`data: {"response":` + geminiBody + `}`) + require.True(t, strings.Contains(string(out), `"message_start"`)) + require.NotContains(t, string(out), `"cache_creation_input_tokens"`, "message_start 的 usage 不应携带缓存写入分项") + _, usage := p.Finish() + require.NotNil(t, usage) + require.Equal(t, 70, usage.InputTokens) + require.Equal(t, 30, usage.CacheReadInputTokens) + require.Zero(t, usage.CacheCreationInputTokens) + }) +} diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index 77a0195cf4a8..dfe43f1265ac 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -237,8 +237,9 @@ func scheduleScenarios() []scheduleScenario { group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest, check: func(t *testing.T, s *ContextPricingSchedule) { require.Len(t, s.Tiers, 2) - requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6)) - requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6)) + // 缓存写入按标准输入价,与 input 一同整单换档 + requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6)) + requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6)) }, }, { diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index ccb5b73642dd..9d282de0614e 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -40,15 +40,18 @@ func geminiCatalogStub() *PricingService { }) } -// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价, -// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。 +// geminiLadderCatalogJSON 镜像真实目录的 gemini-2.5-pro 条目(含 cache_creation 基础价修正后的 +// 数值):above_200k 绝对价由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5;cache 侧 above 档 +// 恰为基础价 × 输入倍率(缓存写入按标准输入价,Google 对缓存写入不另收费)。 const geminiLadderCatalogJSON = `{ "gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, - "cache_read_input_token_cost": 3.125e-07, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "output_cost_per_token_above_200k_tokens": 1.5e-05, - "cache_read_input_token_cost_above_200k_tokens": 6.25e-07} + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06} }` func geminiLadderCatalogStub(t *testing.T) *PricingService { @@ -129,6 +132,41 @@ func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing } } +// 目录阶梯对缓存分项同样生效:cache_read / cache_creation 随输入倍率整单换档, +// 阈值判定计入全部输入侧 token(input + cache_creation + cache_read)。 +func TestCalculateTokenCostForRequest_GeminiLadderAppliesToCacheItems(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t)) + group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group}) + require.Equal(t, PricingSourceLiteLLM, resolved.Source) + + calc := func(tokens UsageTokens) *CostBreakdown { + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1, + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + return got + } + + // 输入侧合计 90K + 100K + 20K = 210K > 200K:所有分项按高档计。 + // 不计 cache_creation 时只有 110K,不会过阈值——用例同时守住"缓存写入 token 计入阈值"。 + above := calc(UsageTokens{InputTokens: 90000, CacheCreationTokens: 100000, CacheReadTokens: 20000, OutputTokens: 1000}) + require.True(t, above.LongContextBillingApplied) + require.InDelta(t, 90000*1.25e-6*2, above.InputCost, 1e-9) + require.InDelta(t, 100000*1.25e-6*2, above.CacheCreationCost, 1e-9) + require.InDelta(t, 20000*1.25e-7*2, above.CacheReadCost, 1e-9) + require.InDelta(t, 1000*1e-5*1.5, above.OutputCost, 1e-9) + + // 输入侧合计 50K + 100K + 40K = 190K ≤ 200K:按基础价计 + below := calc(UsageTokens{InputTokens: 50000, CacheCreationTokens: 100000, CacheReadTokens: 40000, OutputTokens: 1000}) + require.False(t, below.LongContextBillingApplied) + require.InDelta(t, 50000*1.25e-6, below.InputCost, 1e-9) + require.InDelta(t, 100000*1.25e-6, below.CacheCreationCost, 1e-9) + require.InDelta(t, 40000*1.25e-7, below.CacheReadCost, 1e-9) +} + // 目录条目没有阶梯字段时,开关开启也不产生阶梯。 func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) { bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub()) diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index e9ee74e3beee..c6f5a46d08a5 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -898,6 +898,11 @@ func TestExtractGeminiUsage(t *testing.T) { if got.CacheReadInputTokens != tt.wantUsage.CacheReadInputTokens { t.Errorf("CacheReadInputTokens: 期望 %d,实际 %d", tt.wantUsage.CacheReadInputTokens, got.CacheReadInputTokens) } + // Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入 + // 的 token 类别:cache_creation_input_tokens 恒为 0,计费侧不会产生缓存创建分项。 + if got.CacheCreationInputTokens != 0 { + t.Errorf("CacheCreationInputTokens: 期望 0,实际 %d", got.CacheCreationInputTokens) + } }) } } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index f3b9339cdc74..826f6a7803b4 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -30,7 +30,12 @@ var ( // aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名 // (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。 // 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。 - aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) + aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`) + // cacheTierPricePattern 匹配 cache 侧的长上下文绝对价字段名 + // (cache_creation_input_token_cost_above_200k_tokens、cache_read_input_token_cost_above_272k_tokens_priority、 + // cache_creation_input_token_cost_above_1hr_above_200k_tokens 等)。 + // 组 1 为基础价字段名主干,组 2 为 1h 缓存时长段(可为空),组 3 为服务档后缀(可为空)。 + cacheTierPricePattern = regexp.MustCompile(`^(cache_(?:creation|read)_input_token_cost)(_above_1hr)?_above_\d+k_tokens((?:_[a-z]+)?)$`) openAIGPT54FallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 2.5e-06, // $2.5 per MTok OutputCostPerToken: 1.5e-05, // $15 per MTok @@ -425,6 +430,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel result := make(map[string]*LiteLLMModelPricing) skipped := 0 + var orphanCacheTiers, lopsidedLadders []string for modelName, rawEntry := range rawData { // 跳过 sample_spec 等文档条目 @@ -503,6 +509,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel entry.LongContextOutputCostMultiplier != nil if !hasExplicitLongContext { deriveLongContextFromAboveTierFields(rawEntry, pricing) + if isLopsidedLongContextLadder(pricing) { + lopsidedLadders = append(lopsidedLadders, fmt.Sprintf("%s(input x%.2f, output x%.2f)", modelName, + pricing.LongContextInputCostMultiplier, pricing.LongContextOutputCostMultiplier)) + } + } + if orphans := orphanCacheTierFields(rawEntry); len(orphans) > 0 { + orphanCacheTiers = append(orphanCacheTiers, modelName+"("+strings.Join(orphans, ",")+")") } result[modelName] = pricing @@ -511,6 +524,8 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if skipped > 0 { logger.LegacyPrintf("service.pricing", "[Pricing] Skipped %d invalid entries", skipped) } + warnOrphanCacheTierFields(orphanCacheTiers) + warnLopsidedLongContextLadders(lopsidedLadders) if len(result) == 0 { return nil, fmt.Errorf("no valid pricing entries found") @@ -524,9 +539,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel // 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由 // 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该 // 模型的阶梯。多个阈值并存时取最小阈值。 -// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算; -// 现网 OpenAI/Claude 条目(含 5m/1h 分档)的 cache above 档均恰为基础价 × 输入倍率, -// Gemini pro 系存在无基础价的孤儿 cache_creation above 字段(数据缺陷,计费两侧均按 0)。 +// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算: +// 目录条目的 cache above 档须恰为基础价 × 输入倍率;缺基础价的 cache above 字段 +// 无法参与计费,由 orphanCacheTierFields 哨兵告警。 func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) { if pricing == nil || pricing.LongContextInputTokenThreshold > 0 || @@ -594,6 +609,77 @@ func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *Lit pricing.LongContextOutputCostMultiplier = outputMultiplier } +// isLopsidedLongContextLadder 判断折算出的阶梯是否只有一侧带附加费。官方阶梯(OpenAI、 +// Google、Anthropic、xAI)都同时抬高 input 与 output;单侧附加费意味着条目的基础价与 +// above 档来自不同价格版本(如基础价被手工 pin、above 档随上游更新),折算出的倍率失真。 +func isLopsidedLongContextLadder(pricing *LiteLLMModelPricing) bool { + if pricing == nil || pricing.LongContextInputTokenThreshold <= 0 { + return false + } + return (pricing.LongContextInputCostMultiplier > 1) != (pricing.LongContextOutputCostMultiplier > 1) +} + +// warnLopsidedLongContextLadders 对单侧附加费的折算阶梯打 WARN:应成组修正该条目的 +// 基础价与 above 档(目录或 pricing.override_file)。 +func warnLopsidedLongContextLadders(entries []string) { + if len(entries) == 0 { + return + } + sort.Strings(entries) + total := len(entries) + if total > 20 { + entries = append(entries[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) derive a one-sided long-context ladder (surcharge on only input or only output); base prices and above-tier prices likely come from different price versions: %s", total, strings.Join(entries, ", ")) +} + +// orphanCacheTierFields 返回条目中没有对应基础价的 cache 侧 above 档字段名。 +// cache 侧 above 档不参与计费取值,计费按"基础价 × 输入倍率";基础价缺失或为 0 时, +// 该缓存分项在整个阶梯上都按 0 计。计费对变体有回落:服务档变体(_priority/_flex) +// 缺自身基础价时用标准基础价,1h 缓存写入缺 above_1hr 价时全部按 5m 价——因此沿 +// 回落链任一基础价存在即不算孤儿。 +func orphanCacheTierFields(rawEntry json.RawMessage) []string { + if !bytes.Contains(rawEntry, []byte("_above_")) { + return nil + } + var fields map[string]any + if err := json.Unmarshal(rawEntry, &fields); err != nil { + return nil + } + positive := func(key string) bool { + price, ok := fields[key].(float64) + return ok && price > 0 + } + var orphans []string + for key := range fields { + m := cacheTierPricePattern.FindStringSubmatch(key) + if m == nil || !positive(key) { + continue + } + stem, hourly, tier := m[1], m[2], m[3] + if positive(stem+hourly+tier) || positive(stem+hourly) || positive(stem+tier) || positive(stem) { + continue + } + orphans = append(orphans, key) + } + sort.Strings(orphans) + return orphans +} + +// warnOrphanCacheTierFields 对带 cache 侧 above 档却没有基础价的条目打 WARN: +// 该缓存分项按 0 计费,目录或 pricing.override_file 补上基础价即可消除。 +func warnOrphanCacheTierFields(entries []string) { + if len(entries) == 0 { + return + } + sort.Strings(entries) + total := len(entries) + if total > 20 { + entries = append(entries[:20], "...") + } + logger.LegacyPrintf("service.pricing", "[Pricing] Warning: %d model(s) carry cache above-tier prices without a base cache price; that cache item bills at $0 until the catalog/override supplies the base: %s", total, strings.Join(entries, ", ")) +} + // applyPricingOverrides 把 override 文件的条目逐字段修补进原始目录数据。目录与回退 // 文件的解析都经过 parsePricingData,因此 override 是最高优先级的数据源。这里只修补 // 已存在的条目:目录/回退里都没有的模型由 mergeOverrideOnlyModels 在两层数据合并后 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 96cc63ba55ad..a02c74fe7215 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -827,6 +827,126 @@ func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) { require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier) } +// cache 侧 above 档随输入倍率计费、不单独折算;缺基础价的 cache above 字段无法参与计费, +// 该缓存分项按 0 计,属于数据契约违规,必须有哨兵 WARN。服务档变体缺基础价时回落 +// 标准基础价,不算孤儿。 +func TestParsePricingData_WarnsOrphanCacheTierFields(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "gemini-orphan": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07}, + "gemini-complete": {"litellm_provider": "vertex_ai-language-models", "mode": "chat", + "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, + "cache_read_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost": 1.25e-06, + "input_cost_per_token_above_200k_tokens": 2.5e-06, + "output_cost_per_token_above_200k_tokens": 1.5e-05, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06}, + "priority-variant-without-own-base": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token_above_272k_tokens": 1e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06}, + "priority-variant-orphan": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05}, + "hourly-tier-with-5m-base": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_creation_input_token_cost": 3.75e-06, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05}, + "hourly-tier-orphan": {"litellm_provider": "anthropic", "mode": "chat", + "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05} + }`)) + require.NoError(t, err) + + require.Equal(t, 200000, data["gemini-orphan"].LongContextInputTokenThreshold, "孤儿 cache 字段不影响 input/output 阶梯折算") + require.Zero(t, data["gemini-orphan"].CacheCreationInputTokenCost) + require.InDelta(t, 1.25e-6, data["gemini-complete"].CacheCreationInputTokenCost, 1e-12) + + require.True(t, logSink.ContainsMessageAtLevel("gemini-orphan(cache_creation_input_token_cost_above_200k_tokens)", "warn")) + require.True(t, logSink.ContainsMessage("priority-variant-orphan(cache_creation_input_token_cost_above_272k_tokens_priority)")) + require.True(t, logSink.ContainsMessage("hourly-tier-orphan(cache_creation_input_token_cost_above_1hr_above_200k_tokens)")) + require.False(t, logSink.ContainsMessage("gemini-complete")) + require.False(t, logSink.ContainsMessage("priority-variant-without-own-base")) + require.False(t, logSink.ContainsMessage("hourly-tier-with-5m-base"), "1h 档缺 above_1hr 基础价时计费回落 5m 价,不算孤儿") +} + +// 基础价与 above 档来自不同价格版本时(如基础价被手工 pin、above 档随上游更新)会折算出 +// 只有一侧带附加费的阶梯,必须有哨兵 WARN;显式 long_context_* 字段是部署方意图,不告警。 +func TestParsePricingData_WarnsLopsidedLongContextLadder(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + svc := &PricingService{} + data, err := svc.parsePricingData([]byte(`{ + "mixed-versions": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, + "input_cost_per_token_above_272k_tokens": 8e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "consistent": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05, + "input_cost_per_token_above_272k_tokens": 8e-06, + "output_cost_per_token_above_272k_tokens": 3e-05}, + "explicit-input-only": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2} + }`)) + require.NoError(t, err) + + require.Equal(t, 272000, data["mixed-versions"].LongContextInputTokenThreshold, "单侧阶梯仍按折算结果计费,只告警不丢弃") + require.InDelta(t, 1.6, data["mixed-versions"].LongContextInputCostMultiplier, 1e-12) + require.True(t, logSink.ContainsMessageAtLevel("mixed-versions(input x1.60, output x1.00)", "warn")) + require.False(t, logSink.ContainsMessage("consistent")) + require.False(t, logSink.ContainsMessage("explicit-input-only")) +} + +// 出厂回退快照必须满足数据契约:没有孤儿 cache above 字段、没有单侧阶梯,且 Gemini pro 系的 +// 缓存写入基础价等于标准输入价(含 priority 变体)。快照是随目录同步刷新的文本,这里防止刷新时静默回退。 +func TestDefaultCatalogSnapshot_CacheTierContract(t *testing.T) { + logSink, restore := captureStructuredLog(t) + defer restore() + + body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + var rawEntries map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &rawEntries)) + for name, raw := range rawEntries { + require.Empty(t, orphanCacheTierFields(raw), "快照条目 %s 带孤儿 cache above 字段", name) + } + + svc := &PricingService{} + data, err := svc.parsePricingData(body) + require.NoError(t, err) + require.False(t, logSink.ContainsMessage("carry cache above-tier prices"), "快照不应触发孤儿 cache 字段哨兵") + require.False(t, logSink.ContainsMessage("one-sided long-context ladder"), "快照不应触发单侧阶梯哨兵") + for _, model := range []string{ + "gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview", + "gemini-3.1-pro-high", "gemini-3.1-pro-low", "gemini-3.1-pro-preview-customtools", + } { + pricing := data[model] + require.NotNil(t, pricing, model) + require.Positive(t, pricing.InputCostPerToken, model) + require.InDelta(t, pricing.InputCostPerToken, pricing.CacheCreationInputTokenCost, 1e-15, "%s 缓存写入基础价应等于标准输入价", model) + require.Equal(t, 200000, pricing.LongContextInputTokenThreshold, model) + if pricing.InputCostPerTokenPriority > 0 { + require.InDelta(t, pricing.InputCostPerTokenPriority, pricing.CacheCreationInputTokenCostPriority, 1e-15, "%s priority 缓存写入价应等于 priority 输入价", model) + } + } +} + // F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。 func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) { tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 94790cdba112..7fea80856127 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -1414,7 +1414,8 @@ ] }, "gemini-2.5-pro": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06, "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, @@ -1702,7 +1703,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3-pro-preview": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2031,7 +2035,10 @@ "supports_web_search": true }, "gemini-3.1-pro-high": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2095,7 +2102,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-low": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2159,7 +2169,10 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, + "cache_creation_input_token_cost_above_200k_tokens_priority": 7.2e-06, + "cache_creation_input_token_cost_priority": 3.6e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, @@ -2223,7 +2236,8 @@ "web_search_billing_unit": "per_query" }, "gemini-3.1-pro-preview-customtools": { - "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, + "cache_creation_input_token_cost": 2e-06, + "cache_creation_input_token_cost_above_200k_tokens": 4e-06, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "input_cost_per_token": 2e-06,