Merge pull request #7619 from SavitarC/fix/account-stats-long-context-billing

fix(billing): 账号统计成本按 OpenAI 账号开关计入长上下文价
This commit is contained in:
Wesley Liddick
2026-09-28 09:55:19 +08:00
committed by GitHub
4 changed files with 148 additions and 30 deletions
@@ -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
}
@@ -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
// ---------------------------------------------------------------------------
@@ -870,6 +870,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
ImageOutputTokens: result.Usage.ImageOutputTokens,
},
cost.TotalCost, pricingAt,
accountStatsLongContextPricingEnabled(nil),
)
}
@@ -484,6 +484,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),
)
}