From 6c7625800a78a37afdeb8b4afbd8e869ef84241c Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 26 Jul 2026 01:56:10 +0800 Subject: [PATCH] fix(billing): price Antigravity Gemini 3.6 Flash --- .../service/account_stats_pricing_test.go | 20 +++++ backend/internal/service/billing_service.go | 14 ++++ backend/internal/service/pricing_service.go | 34 ++++++-- .../internal/service/pricing_service_test.go | 77 +++++++++++++++++++ .../model_prices_and_context_window.json | 17 ++++ 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 57ace01c22..a934a5621c 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" ) @@ -698,6 +699,25 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { require.InDelta(t, 0.2, *result, 1e-12) } +func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) { + channel := &Channel{ + ID: 1, + Status: StatusActive, + ApplyPricingToAccountStats: false, + } + cs := newTestChannelServiceForStats(t, channel, 10, "antigravity") + bs := NewBillingService(&config.Config{}, nil) + + result := resolveAccountStatsCost( + context.Background(), + cs, bs, + 1, 10, "gemini-3.6-flash-low", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, + ) + require.NotNil(t, result) + require.InDelta(t, 9.15, *result, 1e-12) +} + func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { channel := &Channel{ ID: 1, diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 98eb9c3ec3..748eaf0097 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -272,6 +272,17 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } + // Gemini 3.6 Flash (Google AI pricing: $1.50 input / $7.50 output / + // $0.15 cached input per MTok). Antigravity's -high/-low/-medium/-tiered + // aliases are matched below so unavailable remote pricing never records + // token-bearing requests at $0. + s.fallbackPrices["gemini-3.6-flash"] = &ModelPricing{ + InputPricePerToken: 1.5e-6, + OutputPricePerToken: 7.5e-6, + CacheReadPricePerToken: 0.15e-6, + SupportsCacheBreakdown: false, + } + // OpenAI GPT-5.4(业务指定价格) s.fallbackPrices["gpt-5.4"] = &ModelPricing{ InputPricePerToken: 2.5e-6, // $2.5 per MTok @@ -630,6 +641,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if strings.Contains(modelLower, "gemini-3.1-pro") || strings.Contains(modelLower, "gemini-3-1-pro") { return s.fallbackPrices["gemini-3.1-pro"] } + if strings.Contains(modelLower, "gemini-3.6-flash") || strings.Contains(modelLower, "gemini-3-6-flash") { + return s.fallbackPrices["gemini-3.6-flash"] + } // DeepSeek V4 系列:仅匹配已知 V4 Pro/Flash 与官方兼容别名 // (deepseek-chat / deepseek-reasoner → V4 Flash),未知 deepseek-* 型号不回退,避免误计价。 diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 636bd690cb..c969ec18d1 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -695,16 +695,24 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing } func (s *PricingService) buildModelLookupCandidates(modelLower string) []string { - // Prefer canonical model name first (this also improves billing compatibility with "models/xxx"). - candidates := []string{ - normalizeModelNameForPricing(modelLower), + rawCandidates := []string{ modelLower, - } - candidates = append(candidates, strings.TrimPrefix(modelLower, "models/"), lastSegment(modelLower), lastSegment(strings.TrimPrefix(modelLower, "models/")), - ) + } + 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, + // so the normalized base remains the fallback after the exact aliases. + candidates := rawCandidates + if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { + candidates = append(candidates, normalized) + } else { + // Prefer canonical model names for all other aliases (including models/xxx). + candidates = append([]string{normalized}, candidates...) + } seen := make(map[string]struct{}, len(candidates)) out := make([]string, 0, len(candidates)) @@ -752,6 +760,20 @@ func normalizeModelNameForPricing(model string) string { } return canonical } + return normalizeGeminiThinkingTierAlias(model) +} + +// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 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. +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 + } + } return model } diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 88553f0fbf..48e5159ad3 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -454,6 +454,83 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } +func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(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)) + }) + } +} + +func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(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, + }} + + require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) +} + +func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) { + svc := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000} + + 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) { + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + require.InDelta(t, 1.5, cost.InputCost, 1e-12) + require.InDelta(t, 7.5, cost.OutputCost, 1e-12) + require.InDelta(t, 0.15, cost.CacheReadCost, 1e-12) + require.InDelta(t, 9.15, cost.TotalCost, 1e-12) + }) + } +} + +func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + pricingSvc := &PricingService{} + pricingData, err := pricingSvc.parsePricingData(data) + require.NoError(t, err) + pricingSvc.pricingData = pricingData + billingSvc := NewBillingService(&config.Config{}, pricingSvc) + + for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-low", "gemini-3.6-flash-high"} { + t.Run(model, func(t *testing.T) { + pricing, err := billingSvc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 1.5e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 7.5e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 0.15e-6, pricing.CacheReadPricePerToken, 1e-12) + }) + } +} + func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) require.NoError(t, err) 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 8c3e95aabe..21180fb565 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -2339,6 +2339,23 @@ "supports_web_search": true, "web_search_billing_unit": "per_query" }, + "gemini-3.6-flash": { + "cache_read_input_token_cost": 1.5e-07, + "cache_read_input_token_cost_priority": 2.7e-07, + "input_cost_per_token": 1.5e-06, + "input_cost_per_token_priority": 2.7e-06, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 7.5e-06, + "output_cost_per_token_priority": 1.35e-05, + "source": "https://ai.google.dev/gemini-api/docs/latest-model", + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_service_tier": true + }, "gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-embedding-models",