diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index f72553167a..5134ffeb42 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -3001,6 +3001,50 @@ func TestOpenAIGatewayServiceRecordUsage_CodexDefaultEchoKeepsFastBilling(t *tes } } +func TestOpenAIGatewayServiceRecordUsage_ShadowUsesParentCredentialTierContract(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + nil, + ) + parentID := int64(9001) + accountRepo := &openAIRecordUsageAccountRepoStub{account: &Account{ + ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + }} + svc.accountRepo = accountRepo + serviceTier := "priority" + tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_shadow_codex_default_echo", + ServiceTier: &serviceTier, + UpstreamResponseServiceTier: "default", + Usage: OpenAIUsage{InputTokens: tokens.InputTokens, OutputTokens: tokens.OutputTokens}, + Model: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1020}, + User: &User{ID: 2020}, + Account: &Account{ + ID: 3020, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + ParentAccountID: &parentID, + }, + }) + + require.NoError(t, err) + require.Equal(t, 1, accountRepo.calls) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ServiceTier) + require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier) + + fastCost, calcErr := svc.billingService.CalculateCostWithServiceTier("gpt-5.6-sol", tokens, 1.0, "priority") + require.NoError(t, calcErr) + require.InDelta(t, fastCost.TotalCost, usageRepo.lastLog.TotalCost, 1e-10) +} + 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 7949febee1..02aafbd8d0 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -143,10 +143,14 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec user := input.User account := input.Account subscription := input.Subscription + billingAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return err + } if !isGrokVideoUsageResult(result, nil) { ApplyOpenAIImageBillingResolution(result) } - logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(account, result)) + logServiceTierBillingDowngrade("service.openai_gateway", account, result.RequestID, ApplyOpenAIServiceTierBillingResolution(billingAccount, result)) // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。 // 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。 @@ -183,7 +187,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec videoMultiplier := resolveVideoRateMultiplier(apiKey, baseMultiplier) var cost *CostBreakdown - var err error billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) if result.BillingModel != "" { billingModel = strings.TrimSpace(result.BillingModel) @@ -207,13 +210,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) } - billingAccount := account - if account.IsShadow() { - billingAccount, err = resolveCredentialAccount(ctx, s.accountRepo, account) - if err != nil { - return err - } - } longContextBillingGate := openAILongContextBillingGate(billingAccount) cost, err = s.calculateOpenAIRecordUsageCost( ctx,