diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1fbbe1cc5e..c13908443e 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) // APIKeyRateLimitCacheData holds rate limit usage data cached in Redis. @@ -843,9 +844,39 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { return s.fallbackPrices["grok-build-0.1"] } + // Unknown Grok text IDs (grok-5, dated snapshots, provider-prefixed) inherit + // the current default text card so a new model cannot ship unbilled. + if pricing := s.grokUnknownTextFamilyFallback(modelLower); pricing != nil { + return pricing + } + return nil } +func (s *BillingService) grokUnknownTextFamilyFallback(model string) *ModelPricing { + if s == nil || !isGrokUnknownTextFamilyModel(model) { + return nil + } + return s.fallbackPrices["grok-4.5"] +} + +func isGrokUnknownTextFamilyModel(model string) bool { + native := strings.ToLower(strings.TrimSpace(xai.StripGrokProviderPrefix(model))) + switch { + case native == "grok", native == "grok-latest": + return true + case strings.HasPrefix(native, "grok-build"), + strings.HasPrefix(native, "grok-composer"), + strings.HasPrefix(native, "composer-"): + return true + case len(native) > 5 && strings.HasPrefix(native, "grok-"): + rest := native[len("grok-"):] + return rest[0] >= '0' && rest[0] <= '9' + default: + return false + } +} + // HasIdentifiedTokenPricing 判断模型能否在价格表中被"确定性识别"出 token 价格。 // // 与 GetModelPricing 的关键区别:本函数拒绝按子串猜系列的兜底。GetModelPricing 会 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 30a91fda2a..d1ea67645c 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1185,6 +1185,61 @@ func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) { } } +func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing.T) { + svc := newTestBillingService() + resolver := NewModelPricingResolver(nil, svc) + tokens := UsageTokens{InputTokens: 250000, OutputTokens: 1000} + + off := &Group{LongContextPricingEnabled: false} + disabled, err := svc.CalculateCostUnified(CostInput{ + Model: "grok-4.5", Group: off, Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + + on := &Group{LongContextPricingEnabled: true} + enabled, err := svc.CalculateCostUnified(CostInput{ + Model: "grok-4.5", Group: on, Tokens: tokens, RateMultiplier: 1, Resolver: resolver, + }) + require.NoError(t, err) + + require.False(t, disabled.LongContextBillingApplied) + require.True(t, enabled.LongContextBillingApplied) + require.InDelta(t, disabled.InputCost*2, enabled.InputCost, 1e-12) + require.InDelta(t, disabled.OutputCost*2, enabled.OutputCost, 1e-12) +} + +func TestGetModelPricing_UnknownGrokTextFallsBackToGrok45(t *testing.T) { + svc := newTestBillingService() + baseline, err := svc.GetModelPricing("grok-4.5") + require.NoError(t, err) + + for _, model := range []string{"grok-5", "grok-5-latest", "x-ai/grok-7", "grok-4.7-beta"} { + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err, "model %s", model) + require.InDelta(t, baseline.InputPricePerToken, pricing.InputPricePerToken, 1e-12, model) + require.InDelta(t, baseline.OutputPricePerToken, pricing.OutputPricePerToken, 1e-12, model) + require.InDelta(t, baseline.CacheReadPricePerToken, pricing.CacheReadPricePerToken, 1e-12, model) + } + + for _, model := range []string{ + "grok-imagine-image-3.0", + "grok-imagine-video-2", + "grok-voice-latest", + "grok-web-search", + "grok-x-search", + "grok-speech-1", + } { + _, err := svc.GetModelPricing(model) + require.Error(t, err, "non-text grok family %s must not inherit grok-4.5 token rates", model) + require.ErrorIs(t, err, ErrModelPricingUnavailable) + } + + // Known cards stay on their own rate, not the 4.5 family floor. + build, err := svc.GetModelPricing("grok-build-0.1") + require.NoError(t, err) + require.InDelta(t, 1e-6, build.InputPricePerToken, 1e-12) +} + func TestGetModelPricing_GrokCatalogFallbacks(t *testing.T) { svc := newTestBillingService()