Merge pull request #5502 from pyt111/codex/fix-account-stats-service-tier

fix: 修复 service tier 账号成本统计
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-08-12 09:58:16 +08:00
commit 1225437099
2 files changed
+129 -24

No files matched your search

@@ -16,6 +16,7 @@ import (
//
// upstreamModel 是最终发往上游的模型 ID。
// totalCost 是本次请求的客户计费(倍率前),用于优先级 2。
// serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。
func resolveAccountStatsCost(
ctx context.Context,
channelService *ChannelService,
@@ -26,6 +27,7 @@ func resolveAccountStatsCost(
tokens UsageTokens,
requestCount int,
totalCost float64,
serviceTier string,
) *float64 {
if channelService == nil || upstreamModel == "" {
return nil
@@ -53,20 +55,22 @@ func resolveAccountStatsCost(
// 优先级 3:模型定价文件(LiteLLM)默认价格
if billingService != nil {
return tryModelFilePricing(billingService, upstreamModel, tokens)
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier)
}
return nil
}
// tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的标准价格计算费用。
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens) *float64 {
// tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 {
pricing, err := billingService.GetModelPricing(model)
if err != nil || pricing == nil {
return nil
}
if billingService.shouldApplySessionLongContextPricing(tokens, pricing) {
breakdown, err := billingService.CalculateCost(model, tokens, 1)
normalizedTier := normalizeBillingServiceTier(serviceTier)
if normalizedTier == "priority" || normalizedTier == "flex" ||
billingService.shouldApplySessionLongContextPricing(tokens, pricing) {
breakdown, err := billingService.CalculateCostWithServiceTier(model, tokens, 1, normalizedTier)
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
return nil
}
@@ -241,7 +245,11 @@ func applyAccountStatsCost(
if usageLog != nil && usageLog.ImageCount > 0 {
requestCount = usageLog.ImageCount
}
serviceTier := ""
if usageLog != nil && usageLog.ServiceTier != nil {
serviceTier = *usageLog.ServiceTier
}
usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost,
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier,
)
}
@@ -454,7 +454,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, "")
require.NotNil(t, result)
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
require.InDelta(t, 0.2, *result, 1e-12)
@@ -473,18 +473,87 @@ 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, "")
require.NotNil(t, result)
// Input and cache-read use the 2x input tier; output uses the 1.5x tier.
require.InDelta(t, 0.233, *result, 1e-12)
}
func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) {
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
"gpt-5.6-sol": {
InputPricePerToken: 0.001,
InputPricePerTokenPriority: 0.002,
OutputPricePerToken: 0.002,
OutputPricePerTokenPriority: 0.004,
CacheCreationPricePerToken: 0.003,
CacheCreationPricePerTokenPriority: 0.006,
CacheReadPricePerToken: 0.0005,
CacheReadPricePerTokenPriority: 0.001,
},
})
tokens := UsageTokens{
InputTokens: 100,
OutputTokens: 50,
CacheCreationTokens: 20,
CacheReadTokens: 10,
}
tests := []struct {
name string
serviceTier string
want float64
}{
{name: "standard", serviceTier: "", want: 0.265},
{name: "priority", serviceTier: "priority", want: 0.53},
{name: "flex", serviceTier: "flex", want: 0.1325},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier)
require.NotNil(t, result)
require.InDelta(t, tt.want, *result, 1e-12)
})
}
}
func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) {
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
"gpt-5.6-sol": {
InputPricePerToken: 0.001,
InputPricePerTokenPriority: 0.002,
OutputPricePerToken: 0.002,
OutputPricePerTokenPriority: 0.004,
CacheCreationPricePerToken: 0.003,
CacheCreationPricePerTokenPriority: 0.006,
CacheReadPricePerToken: 0.0005,
CacheReadPricePerTokenPriority: 0.001,
LongContextInputThreshold: 100,
LongContextInputMultiplier: 2,
LongContextOutputMultiplier: 1.5,
},
})
tokens := UsageTokens{
InputTokens: 101,
OutputTokens: 10,
CacheCreationTokens: 5,
CacheReadTokens: 5,
}
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority")
require.NotNil(t, result)
// priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。
require.InDelta(t, 0.534, *result, 1e-12)
}
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, "")
require.Nil(t, result)
}
@@ -494,7 +563,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, "")
require.Nil(t, result)
}
@@ -506,7 +575,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, "")
require.Nil(t, result)
}
@@ -523,7 +592,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, "")
require.NotNil(t, result)
// 100*0.001 + 50*0.002 + 10*0.01 = 0.1 + 0.1 + 0.1 = 0.3
require.InDelta(t, 0.3, *result, 1e-12)
@@ -544,7 +613,7 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
CacheCreationTokens: 200,
CacheReadTokens: 300,
}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens)
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "")
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
@@ -561,7 +630,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, "",
)
require.Nil(t, result)
}
@@ -577,7 +646,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, "",
)
require.Nil(t, result)
}
@@ -594,7 +663,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, "",
)
require.Nil(t, result)
}
@@ -625,7 +694,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, // totalCost ignored because custom rule hits
tokens, 1, 999.0, "priority", // 自定义账号价格不叠加服务层级倍率
)
require.NotNil(t, result)
// 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
@@ -647,7 +716,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
tokens, 1, 0.75, // totalCost = 0.75
tokens, 1, 0.75, "priority", // 已完成用户计费,不再重复应用服务层级倍率
)
require.NotNil(t, result)
require.InDelta(t, 0.75, *result, 1e-12)
@@ -665,7 +734,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, "", // totalCost = 0
)
require.Nil(t, result)
}
@@ -692,7 +761,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, "", // totalCost ignored
)
require.NotNil(t, result)
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
@@ -712,7 +781,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, "",
)
require.NotNil(t, result)
require.InDelta(t, 9.15, *result, 1e-12)
@@ -736,7 +805,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, "",
)
require.Nil(t, result)
}
@@ -753,7 +822,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, "",
)
require.Nil(t, result)
}
@@ -786,13 +855,41 @@ 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, "", // 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)
require.InDelta(t, 5.0, *result, 1e-12)
}
func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) {
channel := &Channel{
ID: 1,
Status: StatusActive,
ApplyPricingToAccountStats: false,
}
cs := newTestChannelServiceForStats(t, channel, 10, "openai")
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
"gpt-5.6-sol": {
InputPricePerToken: 0.001,
InputPricePerTokenPriority: 0.002,
OutputPricePerToken: 0.002,
OutputPricePerTokenPriority: 0.004,
},
})
serviceTier := "priority"
usageLog := &UsageLog{ServiceTier: &serviceTier}
applyAccountStatsCost(
context.Background(), usageLog, cs, bs,
1, 10, "gpt-5.6-sol", "gpt-5.6-sol",
UsageTokens{InputTokens: 100, OutputTokens: 50}, 999,
)
require.NotNil(t, usageLog.AccountStatsCost)
require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12)
}
// ---------------------------------------------------------------------------
// helpers for resolveAccountStatsCost tests
// ---------------------------------------------------------------------------