From fd7af1d462a57a363af848354968b9f2d3b97670 Mon Sep 17 00:00:00 2001 From: SavitarC <71271200+SavitarC@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:29:55 +0800 Subject: [PATCH] fix(billing): honor long-context gate in account stats pricing --- .../internal/service/account_stats_pricing.go | 26 ++- .../service/account_stats_pricing_test.go | 150 +++++++++++++++--- .../internal/service/gateway_usage_billing.go | 1 + .../internal/service/openai_gateway_usage.go | 1 + 4 files changed, 148 insertions(+), 30 deletions(-) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 4a738ccf77..dcd08e1193 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -19,6 +19,8 @@ import ( // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 // pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 +// longContextPricingEnabled 表示上游是否对本次请求收取长上下文费率,用于优先级 3; +// 由 accountStatsLongContextPricingEnabled 按账号开关得出,不受分组售价开关影响。 // reasoningEffort 是最终转发等级;按账号统计定价中配置的等级倍率计费。 func resolveAccountStatsCost( ctx context.Context, @@ -32,6 +34,7 @@ func resolveAccountStatsCost( totalCost float64, serviceTier string, pricingAt time.Time, + longContextPricingEnabled bool, reasoningEfforts ...string, ) *float64 { reasoningEffort := "" @@ -64,7 +67,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort) } return nil @@ -74,11 +77,16 @@ func resolveAccountStatsCost( // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, // 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的 // 语义:只取模型定价文件,不引入自定义售价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 { +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, longContextPricingEnabled bool, reasoningEfforts ...string) *float64 { reasoningEffort := "" if len(reasoningEfforts) > 0 { reasoningEffort = reasoningEfforts[0] } + resolver := NewModelPricingResolver(nil, billingService) + resolved := resolver.Resolve(context.Background(), PricingInput{Model: model}) + // 无分组的解析结果默认开启长上下文,CostInput.LongContextBillingEnabled=false 无法否决, + // 因此直接覆写解析结果。 + resolved.longContextPricingEnabled = longContextPricingEnabled breakdown, err := billingService.CalculateCostUnified(CostInput{ Ctx: context.Background(), Model: model, @@ -87,7 +95,8 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us ServiceTier: normalizeBillingServiceTier(serviceTier), ReasoningEffort: reasoningEffort, PricingAt: pricingAt, - Resolver: NewModelPricingResolver(nil, billingService), + Resolver: resolver, + Resolved: resolved, }) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil @@ -263,6 +272,7 @@ func applyAccountStatsCost( tokens UsageTokens, totalCost float64, pricingAt time.Time, + longContextPricingEnabled bool, ) { model := upstreamModel if model == "" { @@ -281,6 +291,14 @@ func applyAccountStatsCost( reasoningEffort = *usageLog.ReasoningEffort } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort, ) } + +// accountStatsLongContextPricingEnabled 判断账号统计成本是否计入长上下文阶梯。 +// 账号统计成本反映上游实际成本:OpenAI 账号由开关声明上游是否收取长上下文费率; +// 其它平台没有该开关(accountGate 为 nil),按官方阶梯计。分组开关只决定客户售价, +// 不参与成本判断。 +func accountStatsLongContextPricingEnabled(accountGate *bool) bool { + return accountGate == nil || *accountGate +} diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 622fc73f3c..26eb645935 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) { }, }) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 require.InDelta(t, 0.2, *result, 1e-12) @@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing. "claude-fable-5-1": {InputPricePerToken: 0.001}, }) tokens := UsageTokens{InputTokens: 100} - standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh") - max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") + standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "xhigh") + max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "max") require.NotNil(t, standard) require.NotNil(t, max) require.Equal(t, *standard, *max) @@ -503,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { }) tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}, true) require.NotNil(t, result) // Input and cache-read use the 2x input tier; output uses the 1.5x tier. @@ -542,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}, true) require.NotNil(t, result) require.InDelta(t, tt.want, *result, 1e-12) }) @@ -572,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) CacheReadTokens: 5, } - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}, true) require.NotNil(t, result) // priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。 @@ -583,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) { // "nonexistent-model" does not match any fallback pattern bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{}) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}, true) require.Nil(t, result) } @@ -593,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) { "claude-sonnet-4": nil, }) tokens := UsageTokens{InputTokens: 100} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.Nil(t, result) } @@ -605,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) { }, }) tokens := UsageTokens{} // all zero tokens → cost = 0 → nil - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.Nil(t, result) } @@ -622,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) { OutputTokens: 50, ImageOutputTokens: 10, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.NotNil(t, result) // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。 // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28 @@ -644,7 +644,7 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { CacheCreationTokens: 200, CacheReadTokens: 300, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true) require.NotNil(t, result) // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005 // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95 @@ -692,7 +692,7 @@ func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) { {"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1}, } { t.Run(slot.name, func(t *testing.T) { - cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at) + cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at, true) require.NotNil(t, cost) require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12) }) @@ -738,7 +738,7 @@ func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) { groupID = 99 } cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(), - 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak) + 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak, true) if tt.noChannel { require.Nil(t, cost) return @@ -759,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) { nil, // channelService is nil newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true, ) require.Nil(t, result) } @@ -775,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "", // empty upstream model - UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true, ) require.Nil(t, result) } @@ -792,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 99, "claude-sonnet-4", // groupID 99 has no channel - UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true, ) require.Nil(t, result) } @@ -823,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) { context.Background(), cs, nil, // billingService not needed when custom rule hits 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率 + tokens, 1, 999.0, "priority", time.Time{}, true, // 自定义账号价格不叠加服务层级倍率 ) require.NotNil(t, result) // 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0 @@ -845,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率 + tokens, 1, 0.75, "priority", time.Time{}, true, // 已完成用户计费,不再重复应用服务层级倍率 ) require.NotNil(t, result) require.InDelta(t, 0.75, *result, 1e-12) @@ -863,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0 + UsageTokens{}, 1, 0.0, "", time.Time{}, true, // totalCost = 0 ) require.Nil(t, result) } @@ -890,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "", time.Time{}, // totalCost ignored + tokens, 1, 999.0, "", time.Time{}, true, // totalCost ignored ) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 @@ -911,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-opus-5", UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, - 1, 0, "fast", time.Time{}, + 1, 0, "fast", time.Time{}, true, ) require.NotNil(t, result) require.InDelta(t, 60, *result, 1e-12) @@ -930,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing context.Background(), cs, bs, 1, 10, "gemini-3.6-flash-low", - UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, true, ) require.NotNil(t, result) require.InDelta(t, 9.15, *result, 1e-12) @@ -954,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { context.Background(), cs, bs, 1, 10, "totally-unknown-model", - tokens, 1, 0.0, "", time.Time{}, + tokens, 1, 0.0, "", time.Time{}, true, ) require.Nil(t, result) } @@ -971,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) { context.Background(), cs, nil, // billingService is nil 1, 10, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, + UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, true, ) require.Nil(t, result) } @@ -1004,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins) + tokens, 1, 99.0, "", time.Time{}, true, // totalCost = 99.0 (would be used if ApplyPricing wins) ) require.NotNil(t, result) // Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost) @@ -1032,13 +1032,111 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) { applyAccountStatsCost( context.Background(), usageLog, cs, bs, 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", - UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, + UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, true, ) require.NotNil(t, usageLog.AccountStatsCost) require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12) } +func TestApplyAccountStatsCost_LongContextFollowsAccountGate(t *testing.T) { + // 渠道售价不参与优先级 3:结果只取模型定价文件。 + channel := &Channel{ + ID: 1, + Status: StatusActive, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"gpt-5.6-sol"}, InputPrice: testPtrFloat64(0.01), + }}, + } + cs := newTestChannelServiceForStats(t, channel, 10, PlatformOpenAI) + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "gpt-5.6-sol": { + InputPricePerToken: 0.001, + InputPricePerTokenPriority: 0.002, + OutputPricePerToken: 0.002, + OutputPricePerTokenPriority: 0.004, + CacheReadPricePerToken: 0.0001, + CacheReadPricePerTokenPriority: 0.0002, + LongContextInputThreshold: 100, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + }, + }) + tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} + accountOff, accountOn := false, true + for _, tt := range []struct { + name string + gate *bool + tier string + wantCost float64 + }{ + {name: "account_off", gate: &accountOff, wantCost: 0.1215}, + {name: "account_off_priority", gate: &accountOff, tier: "priority", wantCost: 0.243}, + {name: "account_on_priority", gate: &accountOn, tier: "priority", wantCost: 0.466}, + // 非 OpenAI 平台没有账号开关,按官方阶梯计。 + {name: "no_gate", wantCost: 0.233}, + {name: "no_gate_priority", tier: "priority", wantCost: 0.466}, + } { + t.Run(tt.name, func(t *testing.T) { + usageLog := &UsageLog{ServiceTier: &tt.tier} + applyAccountStatsCost(context.Background(), usageLog, cs, bs, + 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", tokens, 999, time.Time{}, + accountStatsLongContextPricingEnabled(tt.gate)) + require.NotNil(t, usageLog.AccountStatsCost) + require.InDelta(t, tt.wantCost, *usageLog.AccountStatsCost, 1e-12) + }) + } +} + +// 分组开关只决定客户售价;账号统计成本按账号开关判断上游是否收取长上下文费率。 +func TestOpenAIGatewayServiceRecordUsage_AccountStatsLongContextFollowsAccountGate(t *testing.T) { + baseCost := 300000*2.5e-6 + 2000*15e-6 + longContextCost := 300000*2.5e-6*2 + 2000*15e-6*1.5 + for _, tt := range []struct { + name string + groupLongContext bool + accountExtra map[string]any + wantTotalCost float64 + wantAccountCost float64 + }{ + {name: "group_on_account_off", groupLongContext: true, wantTotalCost: longContextCost, wantAccountCost: baseCost}, + {name: "group_off_account_off", wantTotalCost: baseCost, wantAccountCost: baseCost}, + { + name: "group_off_account_on", + accountExtra: map[string]any{"openai_long_context_billing_enabled": true}, + wantTotalCost: longContextCost, + wantAccountCost: longContextCost, + }, + } { + t.Run(tt.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + swapInOpenAILadderCatalog(t, svc) + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI) + apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1015, tt.groupLongContext) + apiKey.GroupID = i64p(1) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_account_stats_long_context_" + tt.name, + Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000}, + Model: "gpt-5.4-2026-03-05", + Duration: time.Second, + }, + APIKey: apiKey, + User: &User{ID: 2015}, + Account: &Account{ID: 3015, Platform: PlatformOpenAI, Extra: tt.accountExtra}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, tt.wantTotalCost, usageRepo.lastLog.TotalCost, 1e-10) + require.NotNil(t, usageRepo.lastLog.AccountStatsCost) + require.InDelta(t, tt.wantAccountCost, *usageRepo.lastLog.AccountStatsCost, 1e-10) + }) + } +} + // --------------------------------------------------------------------------- // helpers for resolveAccountStatsCost tests // --------------------------------------------------------------------------- diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 53bd3d8ccd..64b4e05fbe 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -870,6 +870,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage ImageOutputTokens: result.Usage.ImageOutputTokens, }, cost.TotalCost, pricingAt, + accountStatsLongContextPricingEnabled(nil), ) } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index cd2c7aadea..c7e6b3112b 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -482,6 +482,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), ) }