mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
- 内置兜底价与本地价格表快照的 gemini-3.6-flash 改为 $0.75 输入 / $3.75 输出 / $0.075 缓存读取(Priority 档 $1.35 / $6.75 / $0.135),有效期至 2026-12-31, 2027-01-01 起恢复 $1.50 / $7.50 / $0.15,与 3.7 / 3.8 Flash 及远程价格目录一致
1156 lines
36 KiB
Go
1156 lines
36 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// matchAccountStatsRule
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestMatchAccountStatsRule_BothEmpty_NoMatch(t *testing.T) {
|
|
rule := &AccountStatsPricingRule{}
|
|
require.False(t, matchAccountStatsRule(rule, 1, 10))
|
|
}
|
|
|
|
func TestMatchAccountStatsRule_AccountIDMatch(t *testing.T) {
|
|
rule := &AccountStatsPricingRule{AccountIDs: []int64{1, 2, 3}}
|
|
require.True(t, matchAccountStatsRule(rule, 2, 999))
|
|
}
|
|
|
|
func TestMatchAccountStatsRule_GroupIDMatch(t *testing.T) {
|
|
rule := &AccountStatsPricingRule{GroupIDs: []int64{10, 20}}
|
|
require.True(t, matchAccountStatsRule(rule, 999, 20))
|
|
}
|
|
|
|
func TestMatchAccountStatsRule_BothConfigured_AccountMatch(t *testing.T) {
|
|
rule := &AccountStatsPricingRule{
|
|
AccountIDs: []int64{1, 2},
|
|
GroupIDs: []int64{10, 20},
|
|
}
|
|
require.True(t, matchAccountStatsRule(rule, 2, 999))
|
|
}
|
|
|
|
func TestMatchAccountStatsRule_BothConfigured_GroupMatch(t *testing.T) {
|
|
rule := &AccountStatsPricingRule{
|
|
AccountIDs: []int64{1, 2},
|
|
GroupIDs: []int64{10, 20},
|
|
}
|
|
require.True(t, matchAccountStatsRule(rule, 999, 10))
|
|
}
|
|
|
|
func TestMatchAccountStatsRule_BothConfigured_NeitherMatch(t *testing.T) {
|
|
rule := &AccountStatsPricingRule{
|
|
AccountIDs: []int64{1, 2},
|
|
GroupIDs: []int64{10, 20},
|
|
}
|
|
require.False(t, matchAccountStatsRule(rule, 999, 999))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// findPricingForModel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFindPricingForModel(t *testing.T) {
|
|
exactPricing := ChannelModelPricing{
|
|
ID: 1,
|
|
Models: []string{"claude-opus-4"},
|
|
}
|
|
wildcardPricing := ChannelModelPricing{
|
|
ID: 2,
|
|
Models: []string{"claude-*"},
|
|
}
|
|
platformPricing := ChannelModelPricing{
|
|
ID: 3,
|
|
Platform: "openai",
|
|
Models: []string{"gpt-4o"},
|
|
}
|
|
emptyPlatformPricing := ChannelModelPricing{
|
|
ID: 4,
|
|
Models: []string{"gemini-2.5-pro"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
list []ChannelModelPricing
|
|
platform string
|
|
model string
|
|
wantID int64
|
|
wantNil bool
|
|
}{
|
|
{
|
|
name: "exact match",
|
|
list: []ChannelModelPricing{exactPricing},
|
|
platform: "anthropic",
|
|
model: "claude-opus-4",
|
|
wantID: 1,
|
|
},
|
|
{
|
|
name: "exact match case insensitive",
|
|
list: []ChannelModelPricing{{ID: 5, Models: []string{"Claude-Opus-4"}}},
|
|
platform: "",
|
|
model: "claude-opus-4",
|
|
wantID: 5,
|
|
},
|
|
{
|
|
name: "wildcard match",
|
|
list: []ChannelModelPricing{wildcardPricing},
|
|
platform: "anthropic",
|
|
model: "claude-opus-4",
|
|
wantID: 2,
|
|
},
|
|
{
|
|
name: "exact match takes priority over wildcard",
|
|
list: []ChannelModelPricing{wildcardPricing, exactPricing},
|
|
platform: "anthropic",
|
|
model: "claude-opus-4",
|
|
wantID: 1,
|
|
},
|
|
{
|
|
name: "platform mismatch skipped",
|
|
list: []ChannelModelPricing{platformPricing},
|
|
platform: "anthropic",
|
|
model: "gpt-4o",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty platform in pricing matches any",
|
|
list: []ChannelModelPricing{emptyPlatformPricing},
|
|
platform: "gemini",
|
|
model: "gemini-2.5-pro",
|
|
wantID: 4,
|
|
},
|
|
{
|
|
name: "empty platform in query matches any pricing platform",
|
|
list: []ChannelModelPricing{platformPricing},
|
|
platform: "",
|
|
model: "gpt-4o",
|
|
wantID: 3,
|
|
},
|
|
{
|
|
name: "no match at all",
|
|
list: []ChannelModelPricing{exactPricing, wildcardPricing},
|
|
platform: "anthropic",
|
|
model: "gpt-4o",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "empty list returns nil",
|
|
list: nil,
|
|
model: "claude-opus-4",
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "wildcard matches by config order (first match wins)",
|
|
list: []ChannelModelPricing{
|
|
{ID: 10, Models: []string{"claude-*"}},
|
|
{ID: 11, Models: []string{"claude-opus-*"}},
|
|
},
|
|
platform: "",
|
|
model: "claude-opus-4",
|
|
wantID: 10, // config order: "claude-*" is first and matches, so it wins
|
|
},
|
|
{
|
|
name: "shorter wildcard used when longer does not match",
|
|
list: []ChannelModelPricing{
|
|
{ID: 10, Models: []string{"claude-*"}},
|
|
{ID: 11, Models: []string{"claude-opus-*"}},
|
|
},
|
|
platform: "",
|
|
model: "claude-sonnet-4",
|
|
wantID: 10, // only "claude-*" matches
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := findPricingForModel(tt.list, tt.platform, tt.model)
|
|
if tt.wantNil {
|
|
require.Nil(t, result)
|
|
return
|
|
}
|
|
require.NotNil(t, result)
|
|
require.Equal(t, tt.wantID, result.ID)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// calculateStatsCost
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCalculateStatsCost_NilPricing(t *testing.T) {
|
|
result := calculateStatsCost(nil, UsageTokens{}, 1)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestCalculateStatsCost_TokenBilling(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeToken,
|
|
InputPrice: testPtrFloat64(0.001),
|
|
OutputPrice: testPtrFloat64(0.002),
|
|
}
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
}
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
require.NotNil(t, result)
|
|
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
|
|
require.InDelta(t, 0.2, *result, 1e-12)
|
|
}
|
|
|
|
func TestCalculateStatsCost_TokenBilling_WithCache(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeToken,
|
|
InputPrice: testPtrFloat64(0.001),
|
|
OutputPrice: testPtrFloat64(0.002),
|
|
CacheWritePrice: testPtrFloat64(0.003),
|
|
CacheReadPrice: testPtrFloat64(0.0005),
|
|
}
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
CacheCreationTokens: 200,
|
|
CacheReadTokens: 300,
|
|
}
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
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 TestCalculateStatsCost_TokenBilling_WithCacheTTLPrices(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeToken,
|
|
CacheWritePrice: testPtrFloat64(0.003),
|
|
CacheWrite1hPrice: testPtrFloat64(0.005),
|
|
}
|
|
tokens := UsageTokens{
|
|
CacheCreationTokens: 200,
|
|
CacheCreation5mTokens: 80,
|
|
CacheCreation1hTokens: 120,
|
|
}
|
|
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
require.NotNil(t, result)
|
|
// 80*0.003 + 120*0.005 = 0.84
|
|
require.InDelta(t, 0.84, *result, 1e-12)
|
|
}
|
|
|
|
func TestCalculateStatsCost_TokenBilling_WithImageOutput(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeToken,
|
|
InputPrice: testPtrFloat64(0.001),
|
|
OutputPrice: testPtrFloat64(0.002),
|
|
ImageOutputPrice: testPtrFloat64(0.01),
|
|
}
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
ImageOutputTokens: 10,
|
|
}
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
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)
|
|
}
|
|
|
|
func TestCalculateStatsCost_TokenBilling_PartialPricesNil(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeToken,
|
|
InputPrice: testPtrFloat64(0.001),
|
|
// OutputPrice, CacheWritePrice, etc. are all nil → treated as 0
|
|
}
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
CacheCreationTokens: 200,
|
|
}
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
require.NotNil(t, result)
|
|
// Only input contributes: 100*0.001 = 0.1
|
|
require.InDelta(t, 0.1, *result, 1e-12)
|
|
}
|
|
|
|
func TestCalculateStatsCost_TokenBilling_AllTokensZero(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeToken,
|
|
InputPrice: testPtrFloat64(0.001),
|
|
OutputPrice: testPtrFloat64(0.002),
|
|
}
|
|
tokens := UsageTokens{} // all zeros
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
// totalCost == 0 → returns nil (does not override, falls back to default formula)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestCalculateStatsCost_PerRequestBilling(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModePerRequest,
|
|
PerRequestPrice: testPtrFloat64(0.05),
|
|
}
|
|
tokens := UsageTokens{InputTokens: 999, OutputTokens: 999}
|
|
result := calculateStatsCost(pricing, tokens, 3)
|
|
require.NotNil(t, result)
|
|
// 0.05 * 3 = 0.15
|
|
require.InDelta(t, 0.15, *result, 1e-12)
|
|
}
|
|
|
|
func TestCalculateStatsCost_PerRequestBilling_PriceNil(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModePerRequest,
|
|
// PerRequestPrice is nil
|
|
}
|
|
result := calculateStatsCost(pricing, UsageTokens{}, 1)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestCalculateStatsCost_PerRequestBilling_PriceZero(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModePerRequest,
|
|
PerRequestPrice: testPtrFloat64(0),
|
|
}
|
|
result := calculateStatsCost(pricing, UsageTokens{}, 1)
|
|
// price == 0 → condition *pricing.PerRequestPrice > 0 is false → returns nil
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestCalculateStatsCost_ImageBilling(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeImage,
|
|
PerRequestPrice: testPtrFloat64(0.10),
|
|
}
|
|
result := calculateStatsCost(pricing, UsageTokens{}, 2)
|
|
require.NotNil(t, result)
|
|
// 0.10 * 2 = 0.20
|
|
require.InDelta(t, 0.20, *result, 1e-12)
|
|
}
|
|
|
|
func TestCalculateStatsCost_ImageBilling_PriceNil(t *testing.T) {
|
|
pricing := &ChannelModelPricing{
|
|
BillingMode: BillingModeImage,
|
|
// PerRequestPrice is nil
|
|
}
|
|
result := calculateStatsCost(pricing, UsageTokens{}, 1)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestCalculateStatsCost_DefaultBillingMode_FallsToToken(t *testing.T) {
|
|
// BillingMode is empty string (default) → falls into token billing
|
|
pricing := &ChannelModelPricing{
|
|
InputPrice: testPtrFloat64(0.001),
|
|
OutputPrice: testPtrFloat64(0.002),
|
|
}
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
}
|
|
result := calculateStatsCost(pricing, tokens, 1)
|
|
require.NotNil(t, result)
|
|
require.InDelta(t, 0.2, *result, 1e-12)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tryCustomRules — 多规则顺序测试
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestTryCustomRules_FirstMatchWins(t *testing.T) {
|
|
channel := &Channel{
|
|
AccountStatsPricingRules: []AccountStatsPricingRule{
|
|
{
|
|
GroupIDs: []int64{1},
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 100, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.01), OutputPrice: testPtrFloat64(0.02)},
|
|
},
|
|
},
|
|
{
|
|
GroupIDs: []int64{1},
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 200, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.99), OutputPrice: testPtrFloat64(0.99)},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
|
result := tryCustomRules(channel, 999, 1, "", "claude-opus-4", tokens, 1)
|
|
require.NotNil(t, result)
|
|
// 应使用第一条规则的价格:100*0.01 + 50*0.02 = 2.0
|
|
require.InDelta(t, 2.0, *result, 1e-12)
|
|
}
|
|
|
|
func TestTryCustomRules_SkipsNonMatchingRules(t *testing.T) {
|
|
channel := &Channel{
|
|
AccountStatsPricingRules: []AccountStatsPricingRule{
|
|
{
|
|
AccountIDs: []int64{888}, // 不匹配
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 100, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.99)},
|
|
},
|
|
},
|
|
{
|
|
GroupIDs: []int64{1}, // 匹配
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 200, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.05)},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
tokens := UsageTokens{InputTokens: 100}
|
|
result := tryCustomRules(channel, 999, 1, "", "claude-opus-4", tokens, 1)
|
|
require.NotNil(t, result)
|
|
// 跳过规则1(账号不匹配),使用规则2:100*0.05 = 5.0
|
|
require.InDelta(t, 5.0, *result, 1e-12)
|
|
}
|
|
|
|
func TestTryCustomRules_NoMatch_ReturnsNil(t *testing.T) {
|
|
channel := &Channel{
|
|
AccountStatsPricingRules: []AccountStatsPricingRule{
|
|
{
|
|
AccountIDs: []int64{888},
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 100, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.01)},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
tokens := UsageTokens{InputTokens: 100}
|
|
result := tryCustomRules(channel, 999, 2, "", "claude-opus-4", tokens, 1)
|
|
require.Nil(t, result) // 账号和分组都不匹配
|
|
}
|
|
|
|
func TestTryCustomRules_RuleMatchesButModelNot_ContinuesToNext(t *testing.T) {
|
|
channel := &Channel{
|
|
AccountStatsPricingRules: []AccountStatsPricingRule{
|
|
{
|
|
GroupIDs: []int64{1},
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 100, Models: []string{"gpt-4o"}, InputPrice: testPtrFloat64(0.01)}, // 模型不匹配
|
|
},
|
|
},
|
|
{
|
|
GroupIDs: []int64{1},
|
|
Pricing: []ChannelModelPricing{
|
|
{ID: 200, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.05)}, // 模型匹配
|
|
},
|
|
},
|
|
},
|
|
}
|
|
tokens := UsageTokens{InputTokens: 100}
|
|
result := tryCustomRules(channel, 999, 1, "", "claude-opus-4", tokens, 1)
|
|
require.NotNil(t, result)
|
|
require.InDelta(t, 5.0, *result, 1e-12) // 使用规则2
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tryModelFilePricing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// newTestBillingServiceWithPrices creates a BillingService with pre-populated
|
|
// fallback prices for testing. No config or pricing service is needed.
|
|
// The key must match what getFallbackPricing resolves to for a given model name.
|
|
// E.g., model "claude-sonnet-4" resolves to key "claude-sonnet-4".
|
|
func newTestBillingServiceWithPrices(prices map[string]*ModelPricing) *BillingService {
|
|
return &BillingService{
|
|
fallbackPrices: prices,
|
|
}
|
|
}
|
|
|
|
func TestTryModelFilePricing_Success(t *testing.T) {
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-sonnet-4": {
|
|
InputPricePerToken: 0.001,
|
|
OutputPricePerToken: 0.002,
|
|
},
|
|
})
|
|
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
|
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
|
require.NotNil(t, result)
|
|
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
|
|
require.InDelta(t, 0.2, *result, 1e-12)
|
|
}
|
|
|
|
func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing.T) {
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-fable-5-1": {InputPricePerToken: 0.001},
|
|
})
|
|
tokens := UsageTokens{InputTokens: 100}
|
|
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "xhigh")
|
|
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "max")
|
|
require.NotNil(t, standard)
|
|
require.NotNil(t, max)
|
|
require.Equal(t, *standard, *max)
|
|
}
|
|
|
|
func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) {
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"gpt-5.6-sol": {
|
|
InputPricePerToken: 0.001,
|
|
OutputPricePerToken: 0.002,
|
|
CacheReadPricePerToken: 0.0001,
|
|
LongContextInputThreshold: 100,
|
|
LongContextInputMultiplier: 2,
|
|
LongContextOutputMultiplier: 1.5,
|
|
},
|
|
})
|
|
tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
|
|
|
|
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}, true)
|
|
|
|
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, time.Time{}, true)
|
|
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", time.Time{}, true)
|
|
|
|
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, "", time.Time{}, true)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestTryModelFilePricing_NilFallback(t *testing.T) {
|
|
// getFallbackPricing returns nil when key maps to nil
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-sonnet-4": nil,
|
|
})
|
|
tokens := UsageTokens{InputTokens: 100}
|
|
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestTryModelFilePricing_ZeroCost(t *testing.T) {
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-sonnet-4": {
|
|
InputPricePerToken: 0.001,
|
|
OutputPricePerToken: 0.002,
|
|
},
|
|
})
|
|
tokens := UsageTokens{} // all zero tokens → cost = 0 → nil
|
|
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestTryModelFilePricing_WithImageOutput(t *testing.T) {
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-sonnet-4": {
|
|
InputPricePerToken: 0.001,
|
|
OutputPricePerToken: 0.002,
|
|
ImageOutputPricePerToken: 0.01,
|
|
},
|
|
})
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
ImageOutputTokens: 10,
|
|
}
|
|
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
|
require.NotNil(t, result)
|
|
// ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。
|
|
// 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28
|
|
require.InDelta(t, 0.28, *result, 1e-12)
|
|
}
|
|
|
|
func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-sonnet-4": {
|
|
InputPricePerToken: 0.001,
|
|
OutputPricePerToken: 0.002,
|
|
CacheCreationPricePerToken: 0.003,
|
|
CacheReadPricePerToken: 0.0005,
|
|
},
|
|
})
|
|
tokens := UsageTokens{
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
CacheCreationTokens: 200,
|
|
CacheReadTokens: 300,
|
|
}
|
|
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
|
|
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", 1.5e-7, 6e-7, 3e-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, true)
|
|
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 * 1.5e-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, true)
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveAccountStatsCost_NilChannelService(t *testing.T) {
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
nil, // channelService is nil
|
|
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
|
1, 1, "claude-sonnet-4",
|
|
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
|
|
)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) {
|
|
cs := newTestChannelServiceForStats(t, &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
}, 1, "")
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs,
|
|
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
|
1, 1, "", // empty upstream model
|
|
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
|
|
)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) {
|
|
// Group 99 is NOT in the cache, so GetChannelForGroup returns nil
|
|
cs := newTestChannelServiceForStats(t, &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
}, 1, "")
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs,
|
|
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
|
|
1, 99, "claude-sonnet-4", // groupID 99 has no channel
|
|
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
|
|
)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
AccountStatsPricingRules: []AccountStatsPricingRule{
|
|
{
|
|
GroupIDs: []int64{10},
|
|
Pricing: []ChannelModelPricing{
|
|
{
|
|
ID: 100,
|
|
Models: []string{"claude-sonnet-4"},
|
|
InputPrice: testPtrFloat64(0.01),
|
|
OutputPrice: testPtrFloat64(0.02),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, nil, // billingService not needed when custom rule hits
|
|
1, 10, "claude-sonnet-4",
|
|
tokens, 1, 999.0, "priority", time.Time{}, true, // 自定义账号价格不叠加服务层级倍率
|
|
)
|
|
require.NotNil(t, result)
|
|
// 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
|
|
require.InDelta(t, 2.0, *result, 1e-12)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: true,
|
|
// No custom rules
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, nil,
|
|
1, 10, "claude-sonnet-4",
|
|
tokens, 1, 0.75, "priority", time.Time{}, true, // 已完成用户计费,不再重复应用服务层级倍率
|
|
)
|
|
require.NotNil(t, result)
|
|
require.InDelta(t, 0.75, *result, 1e-12)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_ReturnsNil(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: true,
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, nil,
|
|
1, 10, "claude-sonnet-4",
|
|
UsageTokens{}, 1, 0.0, "", time.Time{}, true, // totalCost = 0
|
|
)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: false, // not enabled
|
|
// No custom rules
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-sonnet-4": {
|
|
InputPricePerToken: 0.001,
|
|
OutputPricePerToken: 0.002,
|
|
},
|
|
})
|
|
|
|
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, bs,
|
|
1, 10, "claude-sonnet-4",
|
|
tokens, 1, 999.0, "", time.Time{}, true, // totalCost ignored
|
|
)
|
|
require.NotNil(t, result)
|
|
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
|
|
require.InDelta(t, 0.2, *result, 1e-12)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) {
|
|
channel := &Channel{ID: 1, Status: StatusActive}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"claude-opus-5": {
|
|
InputPricePerToken: 5e-6,
|
|
OutputPricePerToken: 25e-6,
|
|
},
|
|
})
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(), cs, bs,
|
|
1, 10, "claude-opus-5",
|
|
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000},
|
|
1, 0, "fast", time.Time{}, true,
|
|
)
|
|
require.NotNil(t, result)
|
|
require.InDelta(t, 60, *result, 1e-12)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: false,
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "antigravity")
|
|
bs := NewBillingService(&config.Config{}, nil)
|
|
|
|
result := resolveAccountStatsCost(
|
|
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, "", time.Time{}, true,
|
|
)
|
|
require.NotNil(t, result)
|
|
require.InDelta(t, 4.575, *result, 1e-12)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: false,
|
|
// No custom rules
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
// BillingService with no pricing for the model
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{})
|
|
|
|
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, bs,
|
|
1, 10, "totally-unknown-model",
|
|
tokens, 1, 0.0, "", time.Time{}, true,
|
|
)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) {
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: false,
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, nil, // billingService is nil
|
|
1, 10, "claude-sonnet-4",
|
|
UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, true,
|
|
)
|
|
require.Nil(t, result)
|
|
}
|
|
|
|
func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T) {
|
|
// Both custom rule and ApplyPricingToAccountStats are configured;
|
|
// custom rule should take precedence.
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ApplyPricingToAccountStats: true,
|
|
AccountStatsPricingRules: []AccountStatsPricingRule{
|
|
{
|
|
GroupIDs: []int64{10},
|
|
Pricing: []ChannelModelPricing{
|
|
{
|
|
ID: 100,
|
|
Models: []string{"claude-sonnet-4"},
|
|
InputPrice: testPtrFloat64(0.05),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
|
|
|
|
tokens := UsageTokens{InputTokens: 100}
|
|
|
|
result := resolveAccountStatsCost(
|
|
context.Background(),
|
|
cs, nil,
|
|
1, 10, "claude-sonnet-4",
|
|
tokens, 1, 99.0, "", time.Time{}, true, // 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, time.Time{}, true,
|
|
)
|
|
|
|
require.NotNil(t, usageLog.AccountStatsCost)
|
|
require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12)
|
|
}
|
|
|
|
func TestApplyAccountStatsCost_LongContextFollowsAccountGate(t *testing.T) {
|
|
// 渠道售价不参与优先级 3:结果只取模型定价文件。
|
|
channel := &Channel{
|
|
ID: 1,
|
|
Status: StatusActive,
|
|
ModelPricing: []ChannelModelPricing{{
|
|
Models: []string{"gpt-5.6-sol"}, InputPrice: testPtrFloat64(0.01),
|
|
}},
|
|
}
|
|
cs := newTestChannelServiceForStats(t, channel, 10, PlatformOpenAI)
|
|
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
|
|
"gpt-5.6-sol": {
|
|
InputPricePerToken: 0.001,
|
|
InputPricePerTokenPriority: 0.002,
|
|
OutputPricePerToken: 0.002,
|
|
OutputPricePerTokenPriority: 0.004,
|
|
CacheReadPricePerToken: 0.0001,
|
|
CacheReadPricePerTokenPriority: 0.0002,
|
|
LongContextInputThreshold: 100,
|
|
LongContextInputMultiplier: 2,
|
|
LongContextOutputMultiplier: 1.5,
|
|
},
|
|
})
|
|
tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
|
|
accountOff, accountOn := false, true
|
|
for _, tt := range []struct {
|
|
name string
|
|
gate *bool
|
|
tier string
|
|
wantCost float64
|
|
}{
|
|
{name: "account_off", gate: &accountOff, wantCost: 0.1215},
|
|
{name: "account_off_priority", gate: &accountOff, tier: "priority", wantCost: 0.243},
|
|
{name: "account_on_priority", gate: &accountOn, tier: "priority", wantCost: 0.466},
|
|
// 非 OpenAI 平台没有账号开关,按官方阶梯计。
|
|
{name: "no_gate", wantCost: 0.233},
|
|
{name: "no_gate_priority", tier: "priority", wantCost: 0.466},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
usageLog := &UsageLog{ServiceTier: &tt.tier}
|
|
applyAccountStatsCost(context.Background(), usageLog, cs, bs,
|
|
1, 10, "gpt-5.6-sol", "gpt-5.6-sol", tokens, 999, time.Time{},
|
|
accountStatsLongContextPricingEnabled(tt.gate))
|
|
require.NotNil(t, usageLog.AccountStatsCost)
|
|
require.InDelta(t, tt.wantCost, *usageLog.AccountStatsCost, 1e-12)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 分组开关只决定客户售价;账号统计成本按账号开关判断上游是否收取长上下文费率。
|
|
func TestOpenAIGatewayServiceRecordUsage_AccountStatsLongContextFollowsAccountGate(t *testing.T) {
|
|
baseCost := 300000*2.5e-6 + 2000*15e-6
|
|
longContextCost := 300000*2.5e-6*2 + 2000*15e-6*1.5
|
|
for _, tt := range []struct {
|
|
name string
|
|
groupLongContext bool
|
|
accountExtra map[string]any
|
|
wantTotalCost float64
|
|
wantAccountCost float64
|
|
}{
|
|
{name: "group_on_account_off", groupLongContext: true, wantTotalCost: longContextCost, wantAccountCost: baseCost},
|
|
{name: "group_off_account_off", wantTotalCost: baseCost, wantAccountCost: baseCost},
|
|
{
|
|
name: "group_off_account_on",
|
|
accountExtra: map[string]any{"openai_long_context_billing_enabled": true},
|
|
wantTotalCost: longContextCost,
|
|
wantAccountCost: longContextCost,
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
|
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
|
swapInOpenAILadderCatalog(t, svc)
|
|
svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI)
|
|
apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1015, tt.groupLongContext)
|
|
apiKey.GroupID = i64p(1)
|
|
|
|
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
|
Result: &OpenAIForwardResult{
|
|
RequestID: "resp_account_stats_long_context_" + tt.name,
|
|
Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000},
|
|
Model: "gpt-5.4-2026-03-05",
|
|
Duration: time.Second,
|
|
},
|
|
APIKey: apiKey,
|
|
User: &User{ID: 2015},
|
|
Account: &Account{ID: 3015, Platform: PlatformOpenAI, Extra: tt.accountExtra},
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, usageRepo.lastLog)
|
|
require.InDelta(t, tt.wantTotalCost, usageRepo.lastLog.TotalCost, 1e-10)
|
|
require.NotNil(t, usageRepo.lastLog.AccountStatsCost)
|
|
require.InDelta(t, tt.wantAccountCost, *usageRepo.lastLog.AccountStatsCost, 1e-10)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers for resolveAccountStatsCost tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// newTestChannelServiceForStats creates a ChannelService with a single channel
|
|
// mapped to the given groupID, suitable for resolveAccountStatsCost tests.
|
|
func newTestChannelServiceForStats(t *testing.T, channel *Channel, groupID int64, platform string) *ChannelService {
|
|
t.Helper()
|
|
cache := newEmptyChannelCache()
|
|
cache.channelByGroupID[groupID] = channel
|
|
cache.groupPlatform[groupID] = platform
|
|
cs := &ChannelService{}
|
|
cache.loadedAt = time.Now()
|
|
cs.cache.Store(cache)
|
|
return cs
|
|
}
|