From 9b63f9d84c740c250fe2b4ec071b9763ae1eb766 Mon Sep 17 00:00:00 2001 From: Octopus Date: Fri, 3 Apr 2026 10:18:48 +0800 Subject: [PATCH] fix: use BillingModel and upstream fallback for GPT billing in OpenAI gateway (fixes #1430) When a GPT account's model mapping uses a non-standard name (e.g. "gpt" without version), RecordUsage could not find pricing and silently billed zero. Similarly, the Anthropic Messages compat path set BillingModel to the correct GPT model but RecordUsage ignored it, billing at Claude rates instead. Two changes to OpenAIGatewayService.RecordUsage: 1. Prefer result.BillingModel when set (non-empty), falling back to the existing forwardResultBillingModel logic. This ensures the Messages compat path (CC to GPT) uses the correct GPT pricing. 2. When the resolved billing model has no pricing configured, fall back to result.UpstreamModel before giving up. The upstream model is always normalised to a known model (e.g. "gpt-5.1"), so this prevents billing from silently dropping to zero for non-standard model names. --- .../openai_gateway_record_usage_test.go | 69 +++++++++++++++++++ .../service/openai_gateway_service.go | 13 +++- 2 files changed, 81 insertions(+), 1 deletion(-) 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} }