diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index c25a66f674..f27d5bb367 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -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, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index a346f4405f..7d4d8c7d03 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, "") + 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) diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index ecb72f985f..a0d1c0e180 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -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{} diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0cc8467523..1e1a0f8477 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -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, ) } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b3f4153784..e5dca2d7b7 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -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{} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 79a7999799..4570ed1631 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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, ) }