Merge pull request #6700 from qiu121/codex/fix/deepseek-account-cost-peak

fix(billing): 修复 DeepSeek 高峰账号成本少计
This commit is contained in:
Wesley Liddick
2026-09-07 18:49:19 +08:00
committed by GitHub
6 changed files with 275 additions and 35 deletions
@@ -3,6 +3,7 @@ package service
import (
"context"
"strings"
"time"
)
// resolveAccountStatsCost 计算账号统计定价费用。
@@ -17,6 +18,7 @@ import (
// upstreamModel 是最终发往上游的模型 ID。
// totalCost 是本次请求的客户计费(倍率前),用于优先级 2。
// serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。
// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。
// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。
func resolveAccountStatsCost(
ctx context.Context,
@@ -29,6 +31,7 @@ func resolveAccountStatsCost(
requestCount int,
totalCost float64,
serviceTier string,
pricingAt time.Time,
reasoningEfforts ...string,
) *float64 {
reasoningEffort := ""
@@ -61,7 +64,7 @@ func resolveAccountStatsCost(
// 优先级 3:模型定价文件(LiteLLM)默认价格
if billingService != nil {
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort)
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort)
}
return nil
@@ -69,20 +72,26 @@ func resolveAccountStatsCost(
// tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的
// 语义:只取模型定价文件,不引入渠道自定义定价。
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 {
// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的
// 语义:只取模型定价文件,不引入自定义售价。
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 {
reasoningEffort := ""
if len(reasoningEfforts) > 0 {
reasoningEffort = reasoningEfforts[0]
}
breakdown, err := billingService.CalculateCostWithServiceTier(
model, tokens, 1, normalizeBillingServiceTier(serviceTier),
)
breakdown, err := billingService.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: model,
Tokens: tokens,
RateMultiplier: 1,
ServiceTier: normalizeBillingServiceTier(serviceTier),
ReasoningEffort: reasoningEffort,
PricingAt: pricingAt,
Resolver: NewModelPricingResolver(nil, billingService),
})
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
return nil
}
applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil))
return &breakdown.TotalCost
}
@@ -253,6 +262,7 @@ func applyAccountStatsCost(
upstreamModel, requestedModel string,
tokens UsageTokens,
totalCost float64,
pricingAt time.Time,
) {
model := upstreamModel
if model == "" {
@@ -271,6 +281,6 @@ func applyAccountStatsCost(
reasoningEffort = *usageLog.ReasoningEffort
}
usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort,
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort,
)
}
@@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) {
},
})
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "")
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
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_Fable51MaxEffortUsesTripleQuota(t *testing.T) {
"claude-fable-5-1": {InputPricePerToken: 0.001},
})
tokens := UsageTokens{InputTokens: 100}
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh")
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max")
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh")
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max")
require.NotNil(t, standard)
require.NotNil(t, max)
require.InDelta(t, *standard*3, *max, 1e-12)
@@ -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, "")
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{})
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)
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{})
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")
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{})
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, "")
result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{})
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, "")
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
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, "")
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
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, "")
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
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,13 +644,111 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
CacheCreationTokens: 200,
CacheReadTokens: 300,
}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "")
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
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
require.InDelta(t, 0.95, *result, 1e-12)
}
func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) {
weekday := func(hour, minute int) time.Time {
return time.Date(2026, time.August, 24, hour, minute, 0, 0, time.UTC)
}
for _, model := range []struct {
name string
input, output, cacheReadPrice float64
}{
{"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9},
{"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8},
} {
for _, usage := range []struct {
name string
tokens UsageTokens
}{
{"input", UsageTokens{InputTokens: 1000}},
{"output", UsageTokens{OutputTokens: 500}},
{"cache_read", UsageTokens{CacheReadTokens: 1000}},
{"mixed", UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}},
} {
t.Run(model.name+"/"+usage.name, func(t *testing.T) {
bs := newTestBillingService()
tokens := usage.tokens
baseCost := float64(tokens.InputTokens)*model.input +
float64(tokens.OutputTokens)*model.output + float64(tokens.CacheReadTokens)*model.cacheReadPrice
for _, slot := range []struct {
name string
at time.Time
multiplier float64
}{
{"before_morning_peak", weekday(0, 59), 1},
{"morning_peak_start", weekday(1, 0), 2},
{"morning_peak_last_minute", weekday(3, 59), 2},
{"morning_peak_end", weekday(4, 0), 1},
{"afternoon_peak_start", weekday(6, 0), 2},
{"afternoon_peak_last_minute", weekday(9, 59), 2},
{"afternoon_peak_end", weekday(10, 0), 1},
{"saturday", time.Date(2026, time.August, 22, 2, 0, 0, 0, time.UTC), 1},
{"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)
require.NotNil(t, cost)
require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12)
})
}
})
}
}
}
func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) {
peak := time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC)
for _, tt := range []struct {
name string
customRule bool
applyPricing bool
noChannel bool
want float64
}{
{name: "catalog", want: 1000 * 2.2e-7 * 2},
{name: "custom_rule", customRule: true, want: 1},
{name: "custom_rule_before_customer_price", customRule: true, applyPricing: true, want: 1},
{name: "customer_price", applyPricing: true, want: 0.75},
{name: "no_channel", noChannel: true},
} {
t.Run(tt.name, func(t *testing.T) {
channel := &Channel{
ID: 1, Status: StatusActive, ApplyPricingToAccountStats: tt.applyPricing,
ModelPricing: []ChannelModelPricing{{
Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.02),
}},
}
if tt.customRule {
channel.AccountStatsPricingRules = []AccountStatsPricingRule{{
AccountIDs: []int64{1},
Pricing: []ChannelModelPricing{{
Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.001),
}},
}}
}
cs := newTestChannelServiceForStats(t, channel, 10, PlatformDeepseek)
groupID := int64(10)
if tt.noChannel {
groupID = 99
}
cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(),
1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak)
if tt.noChannel {
require.Nil(t, cost)
return
}
require.NotNil(t, cost)
require.InDelta(t, tt.want, *cost, 1e-12)
})
}
}
// ---------------------------------------------------------------------------
// resolveAccountStatsCost — integration tests covering the 4-level priority chain
// ---------------------------------------------------------------------------
@@ -661,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, "",
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
)
require.Nil(t, result)
}
@@ -677,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, "",
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
)
require.Nil(t, result)
}
@@ -694,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, "",
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
)
require.Nil(t, result)
}
@@ -725,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", // 自定义账号价格不叠加服务层级倍率
tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率
)
require.NotNil(t, result)
// 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
@@ -747,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
tokens, 1, 0.75, "priority", // 已完成用户计费,不再重复应用服务层级倍率
tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率
)
require.NotNil(t, result)
require.InDelta(t, 0.75, *result, 1e-12)
@@ -765,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
UsageTokens{}, 1, 0.0, "", // totalCost = 0
UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0
)
require.Nil(t, result)
}
@@ -792,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) {
context.Background(),
cs, bs,
1, 10, "claude-sonnet-4",
tokens, 1, 999.0, "", // totalCost ignored
tokens, 1, 999.0, "", time.Time{}, // totalCost ignored
)
require.NotNil(t, result)
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
@@ -813,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",
1, 0, "fast", time.Time{},
)
require.NotNil(t, result)
require.InDelta(t, 60, *result, 1e-12)
@@ -832,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, "",
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{},
)
require.NotNil(t, result)
require.InDelta(t, 9.15, *result, 1e-12)
@@ -856,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) {
context.Background(),
cs, bs,
1, 10, "totally-unknown-model",
tokens, 1, 0.0, "",
tokens, 1, 0.0, "", time.Time{},
)
require.Nil(t, result)
}
@@ -873,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, "",
UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{},
)
require.Nil(t, result)
}
@@ -906,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
tokens, 1, 99.0, "", // totalCost = 99.0 (would be used if ApplyPricing wins)
tokens, 1, 99.0, "", time.Time{}, // 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)
@@ -934,7 +1032,7 @@ 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,
UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{},
)
require.NotNil(t, usageLog.AccountStatsCost)
@@ -460,6 +460,69 @@ func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T
}
}
func TestGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) {
for _, model := range []struct {
name string
offPeakCost float64
}{
{"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9},
{"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8},
} {
for _, slot := range []struct {
name string
pricingAt time.Time
multiplier float64
}{
{"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2},
{"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1},
} {
t.Run(model.name+"/"+slot.name, func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{})
groupID := int64(905)
svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, groupID, PlatformDeepseek)
svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService)
alias := "customer-chat"
inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7
group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8,
ModelPricing: []ChannelModelPricing{{
Models: []string{alias}, BillingMode: BillingModeToken,
InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice,
}},
}
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
Result: &ForwardResult{
RequestID: "gateway_deepseek_account_stats_" + model.name + "_" + slot.name,
Model: alias, UpstreamModel: model.name,
Usage: ClaudeUsage{InputTokens: 1000, OutputTokens: 500, CacheReadInputTokens: 1000},
},
APIKey: &APIKey{ID: 805, GroupID: &groupID, Group: group},
User: &User{ID: 605}, Account: &Account{ID: 705, Platform: PlatformDeepseek},
PricingAt: slot.pricingAt,
ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
log := usageRepo.lastLog
require.Equal(t, alias, log.RequestedModel)
require.NotNil(t, log.UpstreamModel)
require.Equal(t, model.name, *log.UpstreamModel)
require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute)
require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation")
customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice
require.InDelta(t, customerTotal, log.TotalCost, 1e-12)
require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12)
require.Equal(t, 1, userRepo.deductCalls)
require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12)
require.NotNil(t, log.AccountStatsCost)
require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12,
"account cost must use the upstream model and historical PricingAt")
})
}
}
}
func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -831,7 +831,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
},
cost.TotalCost,
cost.TotalCost, pricingAt,
)
}
@@ -569,6 +569,75 @@ func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesExplicitPricingAt(t *tes
require.InDelta(t, baseCost*0.8, usageRepo.lastLog.ActualCost, 1e-12)
require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12)
}
func TestOpenAIGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) {
for _, model := range []struct {
name string
offPeakCost float64
}{
{"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9},
{"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8},
} {
for _, slot := range []struct {
name string
pricingAt time.Time
multiplier float64
}{
{"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2},
{"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1},
} {
t.Run(model.name+"/"+slot.name, func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil)
groupID := int64(18)
cache := newEmptyChannelCache()
cache.channelByGroupID[groupID] = &Channel{ID: 1, Status: StatusActive}
cache.groupPlatform[groupID] = PlatformDeepseek
cache.loadedAt = time.Now()
svc.channelService = &ChannelService{}
svc.channelService.cache.Store(cache)
svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService)
alias := "customer-chat"
inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7
group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8,
ModelPricing: []ChannelModelPricing{{
Models: []string{alias}, BillingMode: BillingModeToken,
InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice,
}},
}
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "openai_deepseek_account_stats_" + model.name + "_" + slot.name,
Model: alias, BillingModel: alias, UpstreamModel: model.name,
Usage: OpenAIUsage{InputTokens: 2000, OutputTokens: 500, CacheReadInputTokens: 1000},
},
APIKey: &APIKey{ID: 1008, GroupID: &groupID, Group: group},
User: &User{ID: 2008}, Account: &Account{ID: 3008, Platform: PlatformDeepseek},
PricingAt: slot.pricingAt,
ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
log := usageRepo.lastLog
require.Equal(t, alias, log.RequestedModel)
require.NotNil(t, log.UpstreamModel)
require.Equal(t, model.name, *log.UpstreamModel)
require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute)
require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation")
customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice
require.InDelta(t, customerTotal, log.TotalCost, 1e-12)
require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12)
require.Equal(t, 1, userRepo.deductCalls)
require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12)
require.NotNil(t, log.AccountStatsCost)
require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12,
"account cost must use the upstream model and historical PricingAt")
})
}
}
}
func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -472,7 +472,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if apiKey.GroupID != nil {
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
tokens, cost.TotalCost,
tokens, cost.TotalCost, pricingAt,
)
}