diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 449acf1597..d3b609974d 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1476,6 +1476,8 @@ type CostInput struct { Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool + // DisableModelLongContextPricing 豁免模型价卡的长上下文附加费,不改变自定义区间价格。 + DisableModelLongContextPricing bool } // CalculateCostUnified 统一计费入口,支持三种计费模式。 @@ -1491,7 +1493,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, if err != nil { return nil, err } - breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongContextBilling) + breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongContextBilling && !input.DisableModelLongContextPricing) applyCostBreakdownMultiplier(breakdown, reasoningEffortBillingMultiplier(input.ReasoningEffort, pricing.ReasoningEffortMultipliers)) return breakdown, nil } @@ -1578,7 +1580,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos } // 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。 - applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled + applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled && !input.DisableModelLongContextPricing breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) diff --git a/backend/internal/service/gateway_image_reasoning_pricing_test.go b/backend/internal/service/gateway_image_reasoning_pricing_test.go index 39309153ba..322f3cb849 100644 --- a/backend/internal/service/gateway_image_reasoning_pricing_test.go +++ b/backend/internal/service/gateway_image_reasoning_pricing_test.go @@ -131,7 +131,7 @@ func TestCalculateRecordUsageCost_MediaReasoningPricing(t *testing.T) { } var err error cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, - []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, time.Time{}) + []string{model}, 0.5, 0.5, 0.5, 0.5, UsageTokens{}, "", nil, false, time.Time{}) require.NoError(t, err) } require.NotNil(t, cost) diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index b8c6236d9d..3674efba09 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -51,7 +51,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 即使 token 倍率(含高峰,3.0)更高也不采用。 apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} - cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), time.Time{}) + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false), false, time.Time{}) require.NoError(t, err) require.Equal(t, string(BillingModePerRequest), cost.BillingMode) require.InDelta(t, 0.01, cost.TotalCost, 1e-12) @@ -59,7 +59,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 分组配置单价 0.005 apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), time.Time{}) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false), false, time.Time{}) require.NoError(t, err) require.InDelta(t, 0.005, cost.TotalCost, 1e-12) require.InDelta(t, 0.005, cost.ActualCost, 1e-12) @@ -67,7 +67,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, // 证明回落到了 token 路径而不是被按次分支吞掉)。 result.WebSearchCalls = 0 - _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), time.Time{}) + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false), false, time.Time{}) require.Error(t, err) } diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index da532536b1..1ab002af89 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -74,7 +74,7 @@ func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *t _, err := svc.calculateOpenAIRecordUsageCost( context.Background(), nil, apiKey, nil, - 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, time.Time{}, + 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 100}, "", nil, false, time.Time{}, ) require.Error(t, err) require.True(t, isUsagePricingUnavailableError(err), diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go index d2d069ff5c..ed9aed830c 100644 --- a/backend/internal/service/openai_gateway_search_surcharge_test.go +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -38,6 +38,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + false, time.Time{}, ) require.NoError(t, err) @@ -69,6 +70,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing. UsageTokens{}, "", boolPtr(false), + false, time.Time{}, ) require.NoError(t, err) @@ -115,6 +117,7 @@ func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", boolPtr(false), + false, time.Time{}, ) require.Error(t, err) diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 824d2b94dd..ce2e276a7e 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "go.uber.org/zap" ) @@ -236,6 +237,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec serviceTier = strings.TrimSpace(*result.ServiceTier) } longContextBillingGate := openAILongContextBillingGate(billingAccount) + modelLongContextExempt := openAISubscriptionLongContextExempt(billingAccount, result) cost, err = s.calculateOpenAIRecordUsageCost( ctx, result, @@ -248,6 +250,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec tokens, serviceTier, longContextBillingGate, + modelLongContextExempt, pricingAt, ) if err != nil { @@ -281,7 +284,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel)) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, modelLongContextExempt, pricingAt, ) // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 @@ -312,6 +315,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec tokens, "", longContextBillingGate, + modelLongContextExempt, pricingAt, ) if standardErr != nil && !isUsagePricingUnavailableError(standardErr) { @@ -484,7 +488,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, tokens, cost.TotalCost, pricingAt, - accountStatsLongContextPricingEnabled(longContextBillingGate), + accountStatsLongContextPricingEnabled(longContextBillingGate) && !modelLongContextExempt, ) } @@ -557,6 +561,20 @@ func openAILongContextBillingGate(account *Account) *bool { return &enabled } +// openAISubscriptionLongContextExempt 按实际凭据和最终发送模型判断订阅豁免。 +// GPT-6.1 Sol 的订阅请求不再收取模型长上下文附加费;API Key 仍使用 API 价卡。 +// 使用发送模型,避免客户别名、渠道计费模型或响应模型计费切换改变上游订阅规则。 +func openAISubscriptionLongContextExempt(account *Account, result *OpenAIForwardResult) bool { + if !account.IsOpenAIOAuthLike() || result == nil { + return false + } + model := strings.TrimSpace(result.UpstreamModel) + if model == "" { + model = result.Model + } + return openai.IsGPT61SolModelSpelling(model) +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, @@ -569,6 +587,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( tokens UsageTokens, serviceTier string, longContextBillingGate *bool, + modelLongContextExempt bool, pricingAt time.Time, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) @@ -625,6 +644,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( serviceTier, optionalStringValue(result.ReasoningEffort), longContextBillingGate, + modelLongContextExempt, ) if err == nil { tokenCost = cost @@ -719,6 +739,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( serviceTier string, reasoningEffort string, longContextBillingGate *bool, + modelLongContextExempt bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID @@ -726,7 +747,8 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, - LongContextBillingEnabled: longContextBillingGate, + LongContextBillingEnabled: longContextBillingGate, + DisableModelLongContextPricing: modelLongContextExempt, }) } return s.billingService.calculateCostWithServiceTierPolicy( @@ -734,7 +756,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( tokens, multiplier, serviceTier, - longContextBillingGate == nil || *longContextBillingGate, + (longContextBillingGate == nil || *longContextBillingGate) && !modelLongContextExempt, ) } diff --git a/backend/internal/service/openai_subscription_long_context_test.go b/backend/internal/service/openai_subscription_long_context_test.go new file mode 100644 index 0000000000..bdf5b9549b --- /dev/null +++ b/backend/internal/service/openai_subscription_long_context_test.go @@ -0,0 +1,238 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAISubscriptionLongContextExempt(t *testing.T) { + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + for _, tt := range []struct { + name string + account *Account + result *OpenAIForwardResult + want bool + }{ + {name: "空账号", result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}}, + {name: "空结果", account: oauth}, + {name: "订阅", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}, want: true}, + {name: "别名", account: oauth, result: &OpenAIForwardResult{Model: "openai/GPT_6.1_SOL-max"}, want: true}, + {name: "压缩", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol-openai-compact"}, want: true}, + {name: "映射到目标", account: oauth, result: &OpenAIForwardResult{Model: "custom", UpstreamModel: "gpt-6.1-sol"}, want: true}, + {name: "映射离开目标", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol", UpstreamModel: "gpt-6-sol"}}, + {name: "仅计费标签相同", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6-sol", BillingModel: "gpt-6.1-sol"}}, + {name: "仅响应标签相同", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6-sol", UpstreamResponseModel: "gpt-6.1-sol"}}, + {name: "其他模型", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6-astra"}}, + {name: "相似名称", account: oauth, result: &OpenAIForwardResult{Model: "gpt-6.1-sol-preview"}}, + {name: "API账号", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}}, + {name: "其他平台", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}}, + {name: "订阅访问令牌", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}, result: &OpenAIForwardResult{Model: "gpt-6.1-sol"}, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, openAISubscriptionLongContextExempt(tt.account, tt.result)) + }) + } +} + +// 同时覆盖远程目录更新、静态兜底、旧计费入口和分组入口,校验真实扣款与账号成本。 +func TestOpenAIGatewayServiceRecordUsage_GPT61SolSubscriptionLongContext(t *testing.T) { + for _, catalog := range []bool{false, true} { + for _, groupMode := range []string{"无分组", "分组关闭", "分组开启"} { + for _, accountEnabled := range []bool{false, true} { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey} { + for _, tier := range []string{"", "priority"} { + name := fmt.Sprintf("目录=%t/%s/账号开关=%t/%s/%s", catalog, groupMode, accountEnabled, accountType, tier) + t.Run(name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + if catalog { + svc.billingService = NewBillingService(svc.cfg, newStubPricingServiceFromJSON(t, `{ + "gpt-6.1-sol": {"litellm_provider": "openai", "mode": "chat", + "input_cost_per_token": 2e-6, "output_cost_per_token": 10e-6, + "cache_read_input_token_cost": 0.1e-6, "cache_creation_input_token_cost": 2.5e-6, + "input_cost_per_token_above_272k_tokens": 4e-6, + "output_cost_per_token_above_272k_tokens": 15e-6} + }`)) + } + apiKey := &APIKey{ID: 1} + if groupMode != "无分组" { + apiKey = openAIRecordUsageAPIKeyWithGroup(svc, 1, groupMode == "分组开启") + apiKey.GroupID = i64p(1) + apiKey.Group.RateMultiplier = 1.1 + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI) + } + account := &Account{ID: 3, Platform: PlatformOpenAI, Type: accountType, + Extra: map[string]any{openAILongContextBillingEnabledKey: accountEnabled}} + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: name, Model: "gpt-6.1-sol", ServiceTier: &tier, Duration: time.Second, + // 普通输入只有 200k,由缓存使总上下文越过 272k。 + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000, CacheReadInputTokens: 80000, CacheCreationInputTokens: 20000}, + }, + APIKey: apiKey, User: &User{ID: 2}, Account: account, + }) + require.NoError(t, err) + wantLong := accountType == AccountTypeAPIKey && (accountEnabled || groupMode == "分组开启") + inputFactor, outputFactor, tierFactor := 1.0, 1.0, 1.0 + if wantLong { + inputFactor, outputFactor = 2, 1.5 + } + if tier == "priority" { + tierFactor = 2 + } + log := usageRepo.lastLog + require.NotNil(t, log) + require.Equal(t, wantLong, log.LongContextBillingApplied) + require.InDelta(t, 200000*2e-6*inputFactor*tierFactor, log.InputCost, 1e-12) + require.InDelta(t, 80000*0.1e-6*inputFactor*tierFactor, log.CacheReadCost, 1e-12) + require.InDelta(t, 20000*2.5e-6*inputFactor*tierFactor, log.CacheCreationCost, 1e-12) + require.InDelta(t, 2000*10e-6*outputFactor*tierFactor, log.OutputCost, 1e-12) + wantTotal := (0.458*inputFactor + 0.02*outputFactor) * tierFactor + require.InDelta(t, wantTotal, log.TotalCost, 1e-12) + require.InDelta(t, wantTotal*1.1, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, wantTotal*1.1, userRepo.lastAmount, 1e-12) + if groupMode != "无分组" { + wantStats := 0.478 * tierFactor + if accountType == AccountTypeAPIKey && accountEnabled { + wantStats = (0.458*2 + 0.02*1.5) * tierFactor + } + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, wantStats, *log.AccountStatsCost, 1e-12) + } + }) + } + } + } + } + } +} + +func TestOpenAIGatewayServiceRecordUsage_GPT61SolUsesCredentialAndUpstreamModel(t *testing.T) { + for _, tt := range []struct { + name string + model string + upstream string + parentType string + wantTotal float64 + wantLong bool + }{ + {name: "客户别名映射到目标", model: "custom", upstream: "gpt-6.1-sol", parentType: AccountTypeOAuth, wantTotal: 0.62}, + {name: "请求目标映射到旧模型", model: "gpt-6.1-sol", upstream: "gpt-6-sol", parentType: AccountTypeOAuth, wantTotal: 1.23, wantLong: true}, + {name: "其他订阅模型", model: "gpt-6-sol", parentType: AccountTypeOAuth, wantTotal: 1.23, wantLong: true}, + {name: "订阅母账号", model: "gpt-6.1-sol", parentType: AccountTypeOAuth, wantTotal: 0.62}, + } { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + parentID := int64(4) + svc.accountRepo = &openAIRecordUsageAccountRepoStub{account: &Account{ + ID: parentID, Platform: PlatformOpenAI, Type: tt.parentType, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}, + }} + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: tt.name, Model: tt.model, UpstreamModel: tt.upstream, + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000}, Duration: time.Second}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1, true), User: &User{ID: 2}, + Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark}, + }) + require.NoError(t, err) + require.Equal(t, tt.wantLong, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, tt.wantTotal, usageRepo.lastLog.TotalCost, 1e-12) + }) + } +} + +func TestCalculateCostUnified_ModelLongContextExemptionPreservesCustomIntervals(t *testing.T) { + service := NewBillingService(nil, nil) + for _, withResolver := range []bool{false, true} { + input := CostInput{Model: "gpt-6.1-sol", Tokens: UsageTokens{InputTokens: 300000}, RateMultiplier: 1, + LongContextBillingEnabled: boolPtr(true), DisableModelLongContextPricing: true} + if withResolver { + input.Ctx = context.Background() + input.Resolver = NewModelPricingResolver(nil, service) + } + cost, err := service.CalculateCostUnified(input) + require.NoError(t, err) + require.InDelta(t, 0.6, cost.TotalCost, 1e-12) + require.False(t, cost.LongContextBillingApplied) + if withResolver { + input.Resolved = &ResolvedPricing{ + BasePricing: &ModelPricing{InputPricePerToken: 2e-6}, + Intervals: []PricingInterval{{MinTokens: 272000, InputMultiplier: pricingMultiplier(3)}}, + longContextPricingEnabled: true, + } + cost, err = service.CalculateCostUnified(input) + require.NoError(t, err) + require.InDelta(t, 1.8, cost.TotalCost, 1e-12, "管理员显式区间价格应保留") + } + } +} + +func TestOpenAIGatewayServiceRecordUsage_GPT61SolLongContextBoundary(t *testing.T) { + for _, totalInput := range []int{271999, 272000, 272001} { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeAPIKey} { + t.Run(fmt.Sprintf("%s/%d", accountType, totalInput), func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: "boundary", Model: "gpt-6.1-sol", Duration: time.Second, + Usage: OpenAIUsage{InputTokens: totalInput, OutputTokens: 1000}}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1, true), User: &User{ID: 2}, + Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: accountType, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}}, + }) + require.NoError(t, err) + wantLong := accountType == AccountTypeAPIKey && totalInput > 272000 + wantInput, wantOutput := float64(totalInput)*2e-6, 0.01 + if wantLong { + wantInput *= 2 + wantOutput *= 1.5 + } + require.Equal(t, wantLong, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, wantInput, usageRepo.lastLog.InputCost, 1e-12) + require.InDelta(t, wantOutput, usageRepo.lastLog.OutputCost, 1e-12) + }) + } + } +} + +func TestOpenAIGatewayServiceRecordUsage_GPT61SolExemptionSurvivesRepricing(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1, true) + apiKey.GroupID = i64p(1) + apiKey.Group.Platform = PlatformOpenAI + apiKey.Group.RateMultiplier = 1.1 + apiKey.Group.FreeOpenAIFast = true + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI) + tier := "priority" + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "repricing", Model: "gpt-6-sol", UpstreamModel: "gpt-6.1-sol", BillingModel: "gpt-6-sol", + UpstreamResponseModel: "gpt-6.1-sol", ServiceTier: &tier, Duration: time.Second, + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000, CacheReadInputTokens: 80000}, + }, + APIKey: apiKey, User: &User{ID: 2}, + Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{openAILongContextBillingEnabledKey: true}}, + ChannelUsageFields: ChannelUsageFields{BillingModelSource: BillingModelSourceResponse}, + }) + require.NoError(t, err) + // 响应模型改用较低的缓存价;免费 Fast 再按标准档扣款,两次重算均须保留豁免。 + const standardCost = 220000*2e-6 + 80000*0.1e-6 + 2000*10e-6 + require.InDelta(t, standardCost*2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, standardCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, standardCost*1.1, userRepo.lastAmount, 1e-12) + require.False(t, usageRepo.lastLog.LongContextBillingApplied) + require.NotNil(t, usageRepo.lastLog.AccountStatsCost) + require.InDelta(t, standardCost*2, *usageRepo.lastLog.AccountStatsCost, 1e-12) +}