From 3bdb869fcad8ce2c6881a3f503c2964ef23d5575 Mon Sep 17 00:00:00 2001 From: Varrge <26441281+varrge@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:54:38 +0800 Subject: [PATCH] fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices --- .../service/gateway_record_usage_test.go | 36 ++++++++++++++++ backend/internal/service/pricing_service.go | 15 +++---- .../internal/service/pricing_service_test.go | 41 +++++++++---------- 3 files changed, 64 insertions(+), 28 deletions(-) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index c14bf9c27e..ecb72f985f 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -194,6 +194,42 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_GeminiFlashThinkingTierUsesCatalogPrice(t *testing.T) { + for _, baseModel := range []string{"gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.billingService = NewBillingService(svc.cfg, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: {InputCostPerToken: 0.75e-6, OutputCostPerToken: 3.75e-6, CacheReadInputTokenCost: 0.075e-6}, + }}) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + group := &Group{ID: 27, Platform: PlatformGemini, RateMultiplier: 0.15} + model := baseModel + "-medium" + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gemini_thinking_tier", + Model: model, + UpstreamModel: model, + Usage: ClaudeUsage{InputTokens: 8498, OutputTokens: 469, CacheReadInputTokens: 159248}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, GroupID: &group.ID, Group: group}, + User: &User{ID: 601}, + Account: &Account{ID: 701, Platform: PlatformGemini, Type: AccountTypeAPIKey}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, model, usageRepo.lastLog.Model) + require.InDelta(t, 0.02007585, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.0030113775, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.0030113775, userRepo.lastAmount, 1e-12) + }) + } +} + func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index de7955e0a1..641943a162 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1190,7 +1190,7 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string normalized := normalizeModelNameForPricing(modelLower) // A tier-specific entry should take precedence when the pricing catalog gains - // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, + // one later. Antigravity's Gemini Flash thinking tiers share the base rate, // so the normalized base remains the fallback after the exact aliases. candidates := rawCandidates if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { @@ -1249,15 +1249,16 @@ func normalizeModelNameForPricing(model string) string { return normalizeGeminiThinkingTierAlias(model) } -// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash +// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini Flash // thinking-tier model IDs to the public base model. The tier controls reasoning // behavior, not the published token rate, so this keeps -high/-low/-medium and -// -tiered requests on the same price card as gemini-3.6-flash. +// -tiered requests on the corresponding base model's price card. func normalizeGeminiThinkingTierAlias(model string) string { - const baseModel = "gemini-3.6-flash" - for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { - if model == baseModel+tier { - return baseModel + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { + if model == baseModel+tier { + return baseModel + } } } return model diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index a02c74fe72..cdad4d3305 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -477,38 +477,37 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } -func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(t *testing.T) { +func TestPricingService_GeminiFlashThinkingTiersUseBasePricing(t *testing.T) { basePricing := &LiteLLMModelPricing{ InputCostPerToken: 1.5e-6, OutputCostPerToken: 7.5e-6, CacheReadInputTokenCost: 0.15e-6, } - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - }} - - for _, model := range []string{ - "gemini-3.6-flash", - "gemini-3.6-flash-high", - "gemini-3.6-flash-low", - "gemini-3.6-flash-medium", - "gemini-3.6-flash-tiered", - } { - t.Run(model, func(t *testing.T) { - require.Same(t, basePricing, svc.GetModelPricing(model)) - }) + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{baseModel: basePricing}} + for _, tier := range []string{"", "-high", "-low", "-medium", "-tiered"} { + model := baseModel + tier + t.Run(model, func(t *testing.T) { + require.Same(t, basePricing, svc.GetModelPricing(model)) + require.Same(t, basePricing, svc.GetIdentifiedModelPricing("models/"+model)) + }) + } } } -func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(t *testing.T) { +func TestPricingService_GeminiFlashTierSpecificPricingTakesPrecedence(t *testing.T) { basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6} tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6} - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - "gemini-3.6-flash-low": tierPricing, - }} + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: basePricing, + baseModel + "-low": tierPricing, + }} - require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) + require.Same(t, tierPricing, svc.GetModelPricing("models/"+baseModel+"-low")) + }) + } } func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) {