diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b65f55c71c..8b6497a2cb 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -3071,6 +3071,77 @@ func TestOpenAIGatewayServiceRecordUsage_ShadowUsesParentCredentialTierContract( require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) } +func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + nil, + ) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + groupID := int64(77) + serviceTier := "priority" + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + inputPrice := 0.001 + outputPrice := 0.002 + fastMultiplier := 3.0 + apiKey := &APIKey{ + ID: 1020, + GroupID: &groupID, + Group: &Group{ + ID: groupID, Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, RateMultiplier: 0.5, FreeOpenAIFast: true, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.6-sol"}, + BillingMode: BillingModeToken, + InputPrice: &inputPrice, + OutputPrice: &outputPrice, + FastMultiplier: &fastMultiplier, + }}, + }, + } + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_free_fast", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: OpenAIUsage{InputTokens: tokens.InputTokens, OutputTokens: tokens.OutputTokens}, + Model: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2020}, + Account: &Account{ID: 3020, Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier) + require.InDelta(t, 0.5, usageRepo.lastLog.RateMultiplier, 1e-12) + + standardTotal := float64(tokens.InputTokens)*inputPrice + float64(tokens.OutputTokens)*outputPrice + require.InDelta(t, standardTotal*fastMultiplier, usageRepo.lastLog.TotalCost, 1e-10) + require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10) +} + +func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) { + apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}} + + require.True(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + "priority", + )) + require.False(t, groupBillsOpenAIFastAtStandard( + apiKey, + &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + "priority", + )) +} + func TestOpenAIGatewayServiceRecordUsage_ServiceTierNeverRaisedByUpstreamResponse(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 6fb688353a..cb998b55fa 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -132,6 +132,24 @@ func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { return timezone.Now() } +func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool { + if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast { + return false + } + if account == nil || !account.IsOpenAI() { + return false + } + if !groupSupportsOpenAIFast(apiKey.Group.Platform) { + return false + } + switch normalizeBillingServiceTier(serviceTier) { + case "priority", "fast": + return true + default: + return false + } +} + // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { if input == nil { @@ -277,6 +295,32 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } } + // Free Fast changes only the customer charge. Keep priority TotalCost and + // service_tier for upstream accounting, but evaluate ActualCost once more at + // the Standard tier using the same channel, peak, and long-context policy. + if groupBillsOpenAIFastAtStandard(apiKey, billingAccount, serviceTier) { + standardCost, standardErr := s.calculateOpenAIRecordUsageCost( + ctx, + result, + apiKey, + billingModels, + multiplier, + imageMultiplier, + videoMultiplier, + baseMultiplier, + tokens, + "", + longContextBillingGate, + pricingAt, + ) + if standardErr != nil { + return standardErr + } + if cost != nil && standardCost != nil { + cost.ActualCost = standardCost.ActualCost + } + } + // Determine billing type isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() billingType := BillingTypeBalance