mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #7619 from SavitarC/fix/account-stats-long-context-billing
fix(billing): 账号统计成本按 OpenAI 账号开关计入长上下文价
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user