From 9261dd77343d296d109e788224989b743e746f98 Mon Sep 17 00:00:00 2001 From: pyt111 Date: Tue, 11 Aug 2026 09:55:19 +0800 Subject: [PATCH] [verified] fix: apply service-tier pricing to account cost --- .../internal/service/account_stats_pricing.go | 20 ++- .../service/account_stats_pricing_test.go | 133 +++++++++++++++--- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 1098fff9e..df9e8e05a 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -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, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index a934a5621..48336a583 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -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 // ---------------------------------------------------------------------------