Files
sub2api/backend/internal/service/deepseek_pricing_test.go
T
alfadb b5827cfd54 fix(pricing): align DeepSeek billing with official peak/off-peak rates
同步 DeepSeek 官方峰谷价格与模型定价。
2026-08-29 17:30:23 +08:00

346 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build unit
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// deepseekPeakMultiplierAt:官方峰谷口径(2026-08-23 起生效)
// 高峰时段 01:00–04:00 与 06:00–10:00 UTC(半开区间,仅工作日);
// 北京时间周六/周日全天低谷;高峰价 = 2× 低谷价。
// 2026-08-24 为周一(工作日),2026-08-22 周六、2026-08-23 周日。
// ---------------------------------------------------------------------------
func TestDeepseekPeakMultiplierAt(t *testing.T) {
mon := func(hour, min int) time.Time { return time.Date(2026, 8, 24, hour, min, 0, 0, time.UTC) }
sat := func(hour, min int) time.Time { return time.Date(2026, 8, 22, hour, min, 0, 0, time.UTC) }
sun := func(hour, min int) time.Time { return time.Date(2026, 8, 23, hour, min, 0, 0, time.UTC) }
tests := []struct {
name string
now time.Time
want float64
}{
// 工作日高峰窗口边界(半开区间)
{"weekday 01:00 peak start", mon(1, 0), 2.0},
{"weekday 03:59 peak upper bound", mon(3, 59), 2.0},
{"weekday 04:00 peak end", mon(4, 0), 1.0},
{"weekday 06:00 peak start", mon(6, 0), 2.0},
{"weekday 09:59 peak upper bound", mon(9, 59), 2.0},
{"weekday 10:00 peak end", mon(10, 0), 1.0},
// 工作日低谷时段
{"weekday 00:00 off-peak", mon(0, 0), 1.0},
{"weekday 05:00 off-peak", mon(5, 0), 1.0},
{"weekday 12:00 off-peak", mon(12, 0), 1.0},
{"weekday 23:59 off-peak", mon(23, 59), 1.0},
// 北京时间周末全天低谷(即使 UTC 处于高峰时段)
{"saturday utc 02:00 beijing sat 10:00", sat(2, 0), 1.0},
{"sunday utc 07:00 beijing sun 15:00", sun(7, 0), 1.0},
// 北京时间与 UTC 跨日边界:UTC 周六 16:30 = 北京周日 00:30 → 周末低谷
{"utc saturday 16:30 = beijing sunday 00:30", sat(16, 30), 1.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, deepseekPeakMultiplierAt(tt.now))
})
}
}
func TestIsDeepSeekModel(t *testing.T) {
deepseek := []string{
"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp",
"deepseek-chat", "deepseek-reasoner", "deepseek-v3-2-251201",
"deepseek-coder", "deepseek-foo", "deepseek-v4-pro-0813",
"DEEPSEEK-V4-PRO", " deepseek-v4-flash ",
}
for _, m := range deepseek {
require.True(t, isDeepSeekModel(m), "model %q should be deepseek", m)
}
nonDeepseek := []string{
"gpt-5.4", "claude-sonnet-4", "deepseekcoder", // 无连字符不算 deepseek- 前缀
"", " deepseek", // 无连字符后缀
}
for _, m := range nonDeepseek {
require.False(t, isDeepSeekModel(m), "model %q should not be deepseek", m)
}
}
// ---------------------------------------------------------------------------
// 默认价卡(Source=LiteLLM)按官方峰谷倍率计费;分组/渠道自定义定价不叠加
// ---------------------------------------------------------------------------
func TestCalculateCostUnified_DeepseekDefaultCardPeakMultiplier(t *testing.T) {
bs := newTestBillingService()
resolver := NewModelPricingResolver(nil, bs)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}
// 低谷成本:1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9 = 5.57e-4
offPeakTotal := 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9
offPeak, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-flash", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
PricingAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC), // 周一低谷
})
require.NoError(t, err)
require.InDelta(t, offPeakTotal, offPeak.TotalCost, 1e-10)
peak, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-flash", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
PricingAt: time.Date(2026, 8, 24, 2, 0, 0, 0, time.UTC), // 周一高峰
})
require.NoError(t, err)
require.InDelta(t, offPeakTotal*2, peak.TotalCost, 1e-10)
}
func TestCalculateCostUnified_DeepseekProDefaultCardPeakMultiplier(t *testing.T) {
bs := newTestBillingService()
resolver := NewModelPricingResolver(nil, bs)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}
offPeakTotal := 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8
offPeak, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-pro", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
PricingAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
require.InDelta(t, offPeakTotal, offPeak.TotalCost, 1e-10)
peak, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-pro", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
PricingAt: time.Date(2026, 8, 24, 6, 30, 0, 0, time.UTC), // 周一高峰
})
require.NoError(t, err)
require.InDelta(t, offPeakTotal*2, peak.TotalCost, 1e-10)
}
func TestCalculateCostUnified_DeepseekVersionedNamePeakMultiplier(t *testing.T) {
bs := newTestBillingService()
resolver := NewModelPricingResolver(nil, bs)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}
offPeakTotal := 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9
offPeak, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-flash-0731", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
PricingAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC), // 周一低谷
})
require.NoError(t, err)
require.InDelta(t, offPeakTotal, offPeak.TotalCost, 1e-10)
peak, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-flash-0731", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
PricingAt: time.Date(2026, 8, 24, 2, 0, 0, 0, time.UTC), // 周一高峰
})
require.NoError(t, err)
require.InDelta(t, offPeakTotal*2, peak.TotalCost, 1e-10)
}
func TestCalculateCostUnified_DeepseekGroupPricingNotScaledByPeak(t *testing.T) {
bs := newTestBillingService()
resolver := NewModelPricingResolver(nil, bs)
inputPrice := 1e-6
outputPrice := 2e-6
group := &Group{
ID: 1, Name: "ds-group", Platform: PlatformDeepseek, Status: StatusActive,
ModelPricing: []ChannelModelPricing{{
Models: []string{"deepseek-v4-flash"}, BillingMode: BillingModeToken,
InputPrice: &inputPrice, OutputPrice: &outputPrice,
}},
}
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "deepseek-v4-flash", Group: group})
require.Equal(t, PricingSourceGroup, resolved.Source)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}
// 分组自定义价:1000*1e-6 + 500*2e-6 + 1000*7e-9(缓存读沿用官方 flash 价)
groupTotal := 1000*1e-6 + 500*2e-6 + 1000*7e-9
for _, pricingAt := range []time.Time{
time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC), // 低谷
time.Date(2026, 8, 24, 2, 0, 0, 0, time.UTC), // 高峰
} {
cost, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "deepseek-v4-flash", Group: group,
Tokens: tokens, RateMultiplier: 1.0, Resolver: resolver, PricingAt: pricingAt,
})
require.NoError(t, err)
require.InDelta(t, groupTotal, cost.TotalCost, 1e-10,
"分组自定义定价不应叠加官方峰谷倍率(pricingAt=%v)", pricingAt)
}
}
func TestCalculateCostUnified_NonDeepseekDefaultCardNotScaledByPeak(t *testing.T) {
bs := newTestBillingService()
resolver := NewModelPricingResolver(nil, bs)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
total := 1000*3e-6 + 500*15e-6 // claude-sonnet-4 fallback
for _, pricingAt := range []time.Time{
time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC),
time.Date(2026, 8, 24, 2, 0, 0, 0, time.UTC),
} {
cost, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "claude-sonnet-4", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver, PricingAt: pricingAt,
})
require.NoError(t, err)
require.InDelta(t, total, cost.TotalCost, 1e-10,
"非 DeepSeek 模型不应受官方峰谷倍率影响(pricingAt=%v)", pricingAt)
}
}
func TestCalculateCostUnified_DeepseekPricingAtZeroFallsBackToNow(t *testing.T) {
bs := newTestBillingService()
resolver := NewModelPricingResolver(nil, bs)
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
base := CostInput{
Ctx: context.Background(), Model: "deepseek-v4-flash", Tokens: tokens,
RateMultiplier: 1.0, Resolver: resolver,
}
// PricingAt 零值 → 回退 timezone.Now(),与显式传入当前时刻结果一致。
costZero, err := bs.CalculateCostUnified(base)
require.NoError(t, err)
costNow, err := bs.CalculateCostUnified(CostInput{
Ctx: base.Ctx, Model: base.Model, Tokens: base.Tokens,
RateMultiplier: base.RateMultiplier, Resolver: base.Resolver,
PricingAt: timezone.Now(),
})
require.NoError(t, err)
require.Equal(t, costZero.TotalCost, costNow.TotalCost)
}
// ---------------------------------------------------------------------------
// 官方价强制覆盖(远端旧价兜底)与未知 deepseek-* flash 兜底
// ---------------------------------------------------------------------------
func TestGetModelPricing_DeepseekForcesOfficialRatesOverJSON(t *testing.T) {
// JSON 给任意价(模拟远端旧价/占位价),deepseek-* 必须被强制覆盖为官方低谷价。
pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
"deepseek-v4-flash": {InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6, CacheReadInputTokenCost: 1e-8},
"deepseek-v4-pro": {InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6, CacheReadInputTokenCost: 1e-8},
"deepseek-v4-flash-vision-exp": {InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6, CacheReadInputTokenCost: 1e-8},
"deepseek-chat": {InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6, CacheReadInputTokenCost: 1e-8},
"deepseek-reasoner": {InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6, CacheReadInputTokenCost: 1e-8},
}}
bs := NewBillingService(&config.Config{}, pricingSvc)
tests := []struct {
model string
input, output, cacheRead float64
}{
{"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9},
{"deepseek-v4-flash-vision-exp", 2.2e-7, 6.6e-7, 7e-9},
{"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8},
// 已停服的 chat/reasoner:即使 JSON 有旧条目也按 flash 价兜底。
{"deepseek-chat", 2.2e-7, 6.6e-7, 7e-9},
{"deepseek-reasoner", 2.2e-7, 6.6e-7, 7e-9},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
pricing, err := bs.GetModelPricing(tt.model)
require.NoError(t, err)
require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-15)
require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-15)
require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-15)
require.True(t, bs.HasIdentifiedTokenPricing(tt.model))
})
}
// 版本化名称(不在 JSON / fallbackPrices 精确表中):按子串归档计价。
versioned := []struct {
model string
input, output, cacheRead float64
}{
{"deepseek-v4-pro-0813", 6.6e-7, 1.98e-6, 2.2e-8},
{"deepseek-v4-flash-0731", 2.2e-7, 6.6e-7, 7e-9},
}
for _, tt := range versioned {
t.Run(tt.model, func(t *testing.T) {
pricing, err := bs.GetModelPricing(tt.model)
require.NoError(t, err)
require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-15)
require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-15)
require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-15)
})
}
}
func TestGetModelPricing_UnknownDeepseekMapsToFlash(t *testing.T) {
// JSON 含 $0 占位条目(如旧 deepseek-v3-2-251201):未知 deepseek-* 不再
// fail-closed,统一按 flash 价兜底(2.2e-7/6.6e-7/7e-9),不得按 $0 计费。
pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
"deepseek-v3-2-251201": {InputCostPerToken: 0, OutputCostPerToken: 0},
}}
bs := NewBillingService(&config.Config{}, pricingSvc)
for _, m := range []string{"deepseek-v3-2-251201", "deepseek-chat", "deepseek-reasoner", "deepseek-foo"} {
t.Run(m, func(t *testing.T) {
pricing, err := bs.GetModelPricing(m)
require.NoError(t, err)
require.InDelta(t, 2.2e-7, pricing.InputPricePerToken, 1e-15)
require.InDelta(t, 6.6e-7, pricing.OutputPricePerToken, 1e-15)
require.InDelta(t, 7e-9, pricing.CacheReadPricePerToken, 1e-15)
})
}
}
// ---------------------------------------------------------------------------
// 本地兜底 JSON:无 $0 占位条目,官方模型价格为官方低谷价
// ---------------------------------------------------------------------------
func TestDeepseekPricingFileMatchesOfficialRates(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
require.NoError(t, err)
pricingSvc := &PricingService{}
pricingData, err := pricingSvc.parsePricingData(data)
require.NoError(t, err)
_, ok := pricingData["deepseek-v3-2-251201"]
require.False(t, ok, "deepseek-v3-2-251201($0 占位条目)必须从价格表中移除")
for _, discontinued := range []string{"deepseek-chat", "deepseek-reasoner"} {
_, ok := pricingData[discontinued]
require.False(t, ok, "%s 已停止服务,必须从价格表中移除", discontinued)
}
tests := []struct {
model string
input, output, cacheRead float64
}{
{"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9},
{"deepseek-v4-flash-vision-exp", 2.2e-7, 6.6e-7, 7e-9},
{"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
entry, ok := pricingData[tt.model]
require.True(t, ok, "model %s must exist in pricing file", tt.model)
require.InDelta(t, tt.input, entry.InputCostPerToken, 1e-15)
require.InDelta(t, tt.output, entry.OutputCostPerToken, 1e-15)
require.InDelta(t, tt.cacheRead, entry.CacheReadInputTokenCost, 1e-15)
})
}
}