mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Merge pull request #6700 from qiu121/codex/fix/deepseek-account-cost-peak
fix(billing): 修复 DeepSeek 高峰账号成本少计
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user