diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 7a636afad1..74a9c1b965 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -984,3 +984,72 @@ func TestOpenAIGatewayServiceRecordUsage_SimpleModeSkipsBillingAfterPersist(t *t require.Equal(t, 0, userRepo.deductCalls) require.Equal(t, 0, subRepo.incrementCalls) } + +// TestOpenAIGatewayServiceRecordUsage_UnknownBillingModelFallsBackToUpstream verifies that +// when the billing model has no configured pricing (e.g. "gpt" without version), RecordUsage +// falls back to the upstream model's pricing rather than silently billing zero. +func TestOpenAIGatewayServiceRecordUsage_UnknownBillingModelFallsBackToUpstream(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + usage := OpenAIUsage{InputTokens: 10, OutputTokens: 5} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_unknown_billing_model", + Usage: usage, + Model: "gpt", // no pricing for bare "gpt" + BillingModel: "gpt", // same non-standard name + UpstreamModel: "gpt-5.1", // normalized model with known pricing + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1001, Group: &Group{RateMultiplier: 1}}, + User: &User{ID: 2001}, + Account: &Account{ID: 3001}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + // Cost must be non-zero: upstream model fallback should have been used. + require.Greater(t, usageRepo.lastLog.ActualCost, 0.0, "expected non-zero cost after upstream model fallback") + require.Greater(t, userRepo.lastAmount, 0.0, "expected non-zero deduction after upstream model fallback") +} + +// TestOpenAIGatewayServiceRecordUsage_BillingModelPreferredOverRequestedModel verifies that +// when BillingModel is explicitly set (Messages/Anthropic compat path), it is used for billing +// instead of the client-visible model name. +func TestOpenAIGatewayServiceRecordUsage_BillingModelPreferredOverRequestedModel(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + usage := OpenAIUsage{InputTokens: 20, OutputTokens: 8} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_billing_model_preferred", + Usage: usage, + Model: "claude-opus-4-6", // client-visible Claude model (no GPT pricing) + BillingModel: "gpt-5.1-codex", // actual billing model set by Messages compat path + UpstreamModel: "gpt-5.1-codex", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1002, Group: &Group{RateMultiplier: 1}}, + User: &User{ID: 2002}, + Account: &Account{ID: 3002}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + + // Cost should use gpt-5.1-codex pricing, not claude-opus pricing. + expectedCost, calcErr := svc.billingService.CalculateCost("gpt-5.1-codex", UsageTokens{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + }, 1.0) + require.NoError(t, calcErr) + require.InDelta(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index e85f0705aa..d15ab1084a 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -4152,12 +4152,23 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } - billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) + // Prefer BillingModel when explicitly set (e.g., Anthropic Messages compat path where + // the requested model is a Claude model but billing should use the mapped GPT model). + billingModel := result.BillingModel + if billingModel == "" { + billingModel = forwardResultBillingModel(result.Model, result.UpstreamModel) + } serviceTier := "" if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) } cost, err := s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, serviceTier) + if err != nil && result.UpstreamModel != "" && result.UpstreamModel != billingModel { + // Fallback: try upstream model pricing when billing model has no configured price. + // This handles cases where billingModel is a non-standard name (e.g. "gpt" without + // version) while upstreamModel is always normalized to a known model (e.g. "gpt-5.1"). + cost, err = s.billingService.CalculateCostWithServiceTier(result.UpstreamModel, tokens, multiplier, serviceTier) + } if err != nil { cost = &CostBreakdown{ActualCost: 0} }