From b5827cfd54d58c248a9480b800444d0b40f0c6ea Mon Sep 17 00:00:00 2001 From: alfadb Date: Sat, 29 Aug 2026 17:30:23 +0800 Subject: [PATCH] fix(pricing): align DeepSeek billing with official peak/off-peak rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 DeepSeek 官方峰谷价格与模型定价。 --- backend/internal/service/billing_service.go | 136 ++++++- .../internal/service/billing_service_test.go | 43 ++- .../internal/service/deepseek_pricing_test.go | 345 ++++++++++++++++++ .../model_prices_and_context_window.json | 60 +-- frontend/src/composables/useModelWhitelist.ts | 3 +- 5 files changed, 535 insertions(+), 52 deletions(-) create mode 100644 backend/internal/service/deepseek_pricing_test.go diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index a2727af7a4..7002fc09cc 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -230,6 +231,46 @@ func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) floa // sources can price the requested model. var ErrModelPricingUnavailable = errors.New("pricing not found") +// ---- DeepSeek 官方低谷价($/token,2026-08-23 起生效)---- +// Source: https://api-docs.deepseek.com/quick_start/pricing +// 高峰价 = 2× 低谷价;高峰时段 01:00–04:00 与 06:00–10:00 UTC(仅工作日), +// 北京时间周六/周日全天低谷。时段判定见 deepseekPeakMultiplierAt。 +const ( + deepseekFlashOffPeakInputPrice = 2.2e-7 // $0.22 per MTok (cache miss) + deepseekFlashOffPeakOutputPrice = 6.6e-7 // $0.66 per MTok + deepseekFlashOffPeakCacheRead = 7e-9 // $0.007 per MTok (cache hit) + deepseekProOffPeakInputPrice = 6.6e-7 // $0.66 per MTok (cache miss) + deepseekProOffPeakOutputPrice = 1.98e-6 // $1.98 per MTok + deepseekProOffPeakCacheRead = 2.2e-8 // $0.022 per MTok (cache hit) +) + +// isDeepSeekModel 判断模型名是否为 DeepSeek 模型(大小写不敏感)。 +// 任意 deepseek- 前缀均视为 DeepSeek 模型:官方模型(v4-flash / v4-pro / +// v4-flash-vision-exp)按各自价卡计价,其余 deepseek-*(含已停服的 +// deepseek-chat / deepseek-reasoner 与未知型号)统一按 flash 价兜底, +// 避免计费中断;新名字由 fallback warn 日志(每模型每进程一条)暴露, +// 运营者据此更新价卡。 +func isDeepSeekModel(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "deepseek-") +} + +// deepseekPeakMultiplierAt 返回指定时刻的 DeepSeek 官方峰谷定价因子。 +// 官方口径(2026-08-23 起生效):高峰价 = 2× 低谷价;高峰时段为 +// 01:00–04:00 与 06:00–10:00 UTC(半开区间),仅工作日; +// 周末(北京时间周六/周日)全天低谷。北京时间用固定 +8 偏移(无夏令时)。 +func deepseekPeakMultiplierAt(now time.Time) float64 { + beijing := now.In(time.FixedZone("Asia/Shanghai", 8*3600)) + switch beijing.Weekday() { + case time.Saturday, time.Sunday: + return 1.0 + } + switch h := now.UTC().Hour(); { + case h >= 1 && h < 4, h >= 6 && h < 10: + return 2.0 + } + return 1.0 +} + // BillingService 计费服务 type BillingService struct { cfg *config.Config @@ -466,19 +507,30 @@ func (s *BillingService) initFallbackPricing() { // 覆盖逻辑见同文件 getFallbackPricing() // ============================================================ - // ---- DeepSeek V4 系列 ---- + // ---- DeepSeek 系列 ---- // Source: https://api-docs.deepseek.com/quick_start/pricing - // (deepseek-chat / deepseek-reasoner 为 deepseek-v4-flash 的兼容别名,2026/07/24 弃用) + // 官方口径(2026-08-23 起生效):现行模型为 deepseek-v4-flash / + // deepseek-v4-pro / deepseek-v4-flash-vision-exp;deepseek-chat / + // deepseek-reasoner 已停止服务,其余 deepseek-*(含未知型号)统一按 + // flash 价兜底(见 getFallbackPricing),避免计费中断。 + // 以下均为官方低谷价;高峰价 = 2× 低谷价(高峰时段 01:00–04:00 + // 与 06:00–10:00 UTC,仅工作日;北京时间周六/周日全天低谷),见 deepseekPeakMultiplierAt。 s.fallbackPrices["deepseek-v4-pro"] = &ModelPricing{ - InputPricePerToken: 4.35e-7, // $0.435 per MTok (cache miss) - OutputPricePerToken: 8.7e-7, // $0.87 per MTok - CacheReadPricePerToken: 3.625e-9, // $0.003625 per MTok (cache hit) + InputPricePerToken: deepseekProOffPeakInputPrice, // $0.66 per MTok (cache miss, off-peak) + OutputPricePerToken: deepseekProOffPeakOutputPrice, // $1.98 per MTok + CacheReadPricePerToken: deepseekProOffPeakCacheRead, // $0.022 per MTok (cache hit) SupportsCacheBreakdown: false, } s.fallbackPrices["deepseek-v4-flash"] = &ModelPricing{ - InputPricePerToken: 1.4e-7, // $0.14 per MTok (cache miss) - OutputPricePerToken: 2.8e-7, // $0.28 per MTok - CacheReadPricePerToken: 2.8e-9, // $0.0028 per MTok (cache hit) + InputPricePerToken: deepseekFlashOffPeakInputPrice, // $0.22 per MTok (cache miss, off-peak) + OutputPricePerToken: deepseekFlashOffPeakOutputPrice, // $0.66 per MTok + CacheReadPricePerToken: deepseekFlashOffPeakCacheRead, // $0.007 per MTok (cache hit) + SupportsCacheBreakdown: false, + } + s.fallbackPrices["deepseek-v4-flash-vision-exp"] = &ModelPricing{ + InputPricePerToken: deepseekFlashOffPeakInputPrice, + OutputPricePerToken: deepseekFlashOffPeakOutputPrice, + CacheReadPricePerToken: deepseekFlashOffPeakCacheRead, SupportsCacheBreakdown: false, } @@ -796,15 +848,21 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { return s.fallbackPrices["gemini-3.6-flash"] } - // DeepSeek V4 系列:仅匹配已知 V4 Pro/Flash 与官方兼容别名 - // (deepseek-chat / deepseek-reasoner → V4 Flash),未知 deepseek-* 型号不回退,避免误计价。 + // DeepSeek 系列:官方模型 V4 Pro/Flash(含 vision-exp)按各自价卡; + // 其余 deepseek-*(含已停服的 deepseek-chat / deepseek-reasoner 与未知型号) + // 统一按 flash 价兜底,避免计费中断。新名字由 fallback warn 日志 + // (每模型每进程一条)暴露,运营者据此更新价卡。 + // "deepseek-v4-flash-vision-exp" 含 "deepseek-v4-flash" 子串,显式分支置于 flash 之前,语义清晰。 + if strings.Contains(modelLower, "deepseek-v4-flash-vision-exp") { + return s.fallbackPrices["deepseek-v4-flash-vision-exp"] + } if strings.Contains(modelLower, "deepseek-v4-flash") { return s.fallbackPrices["deepseek-v4-flash"] } if strings.Contains(modelLower, "deepseek-v4-pro") { return s.fallbackPrices["deepseek-v4-pro"] } - if strings.Contains(modelLower, "deepseek-chat") || strings.Contains(modelLower, "deepseek-reasoner") { + if strings.HasPrefix(modelLower, "deepseek-") { return s.fallbackPrices["deepseek-v4-flash"] } @@ -1246,7 +1304,28 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos return nil, fmt.Errorf("no pricing available for model: %s: %w", input.Model, ErrModelPricingUnavailable) } - pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing) + // 默认价卡(Source=LiteLLM)应用 DeepSeek 官方价强制覆盖(幂等,GetModelPricing + // 内部已强制过);分组/渠道自定义定价保留运营者配置,不强制覆盖官方价。 + pricing = s.applyModelSpecificPricingPolicyEx(input.Model, pricing, resolved.Source == PricingSourceLiteLLM) + + // DeepSeek 模型默认价卡按官方峰谷口径调整:高峰时段(01:00–04:00 与 + // 06:00–10:00 UTC,仅工作日;北京时间周末全天低谷)按 2× 低谷价计费。 + // 仅作用于默认价卡(Source=LiteLLM,无分组/渠道自定义定价)——分组/渠道 + // 自定义定价保持运营者语义,不叠加。PricingAt 为零值时回退当前时刻。 + // 先克隆再乘,避免污染共享 fallbackPrices 指针。 + if resolved.Source == PricingSourceLiteLLM && isDeepSeekModel(input.Model) { + pricingAt := input.PricingAt + if pricingAt.IsZero() { + pricingAt = timezone.Now() + } + if mult := deepseekPeakMultiplierAt(pricingAt); mult > 1 { + cloned := *pricing + cloned.InputPricePerToken *= mult + cloned.OutputPricePerToken *= mult + cloned.CacheReadPricePerToken *= mult + pricing = &cloned + } + } // 官方长上下文阶梯仅在无区间定价时应用(区间定价已包含上下文分层)。 applyLongCtx := len(resolved.Intervals) == 0 && contextTierPricingEnabled @@ -1489,10 +1568,43 @@ func (s *BillingService) calculateCostInternalWithPolicy( return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil } +// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、 +// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡 +// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的 +// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。 func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing { + return s.applyModelSpecificPricingPolicyEx(model, pricing, true) +} + +// applyModelSpecificPricingPolicyEx 与 applyModelSpecificPricingPolicy 相同, +// 但由调用方控制是否强制 DeepSeek 官方价(forceDeepSeekRates)。 +// calculateTokenCost 对分组/渠道自定义定价(Source 非 LiteLLM)传 false: +// 强制覆盖会把运营者配置的售价盖回官方价,违反自定义定价语义。 +func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing *ModelPricing, forceDeepSeekRates bool) *ModelPricing { if pricing == nil { return nil } + // DeepSeek 模型:无论 JSON/远端价格表给什么价,一律强制官方低谷价 + // (2026-08-23 起生效)。这是覆盖远端旧价的关键——远端仓库不可改,生产会先 + // 拉到旧价,必须在此兜底修正;克隆后再覆盖,避免污染共享 fallbackPrices 指针。 + // 档位判定:含 "deepseek-v4-pro" 的版本化名称(如 deepseek-v4-pro-0813)归 pro 档, + // 其余 deepseek-*(含已停服的 chat/reasoner 与未知型号)统一归 flash 档。 + // 高峰时段倍率不在本函数处理,由 calculateTokenCost 按 deepseekPeakMultiplierAt + // 对默认价卡另行叠加(分组/渠道自定义定价不叠加)。 + if forceDeepSeekRates && isDeepSeekModel(model) { + cloned := *pricing + if strings.Contains(strings.ToLower(strings.TrimSpace(model)), "deepseek-v4-pro") { + cloned.InputPricePerToken = deepseekProOffPeakInputPrice + cloned.OutputPricePerToken = deepseekProOffPeakOutputPrice + cloned.CacheReadPricePerToken = deepseekProOffPeakCacheRead + } else { + // deepseek-v4-flash / deepseek-v4-flash-vision-exp 与其余 deepseek-* 共用 flash 价。 + cloned.InputPricePerToken = deepseekFlashOffPeakInputPrice + cloned.OutputPricePerToken = deepseekFlashOffPeakOutputPrice + cloned.CacheReadPricePerToken = deepseekFlashOffPeakCacheRead + } + return &cloned + } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized) diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 0f8bbacf9a..99b8ebd8d9 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -490,30 +490,45 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { { name: "deepseek v4 pro", model: "deepseek-v4-pro", - expectedInput: 4.35e-7, - expectedOutput: floatPtr(8.7e-7), - expectedCacheRead: floatPtr(3.625e-9), + expectedInput: 6.6e-7, + expectedOutput: floatPtr(1.98e-6), + expectedCacheRead: floatPtr(2.2e-8), }, { name: "deepseek v4 flash", model: "deepseek-v4-flash", - expectedInput: 1.4e-7, - expectedOutput: floatPtr(2.8e-7), - expectedCacheRead: floatPtr(2.8e-9), + expectedInput: 2.2e-7, + expectedOutput: floatPtr(6.6e-7), + expectedCacheRead: floatPtr(7e-9), }, { - name: "deepseek chat alias → flash", + name: "deepseek v4 flash vision exp", + model: "deepseek-v4-flash-vision-exp", + expectedInput: 2.2e-7, + expectedOutput: floatPtr(6.6e-7), + expectedCacheRead: floatPtr(7e-9), + }, + { + // deepseek-chat / deepseek-reasoner 已停止服务,统一按 flash 价兜底。 + name: "deepseek chat discontinued maps to flash", model: "deepseek-chat", - expectedInput: 1.4e-7, - expectedOutput: floatPtr(2.8e-7), - expectedCacheRead: floatPtr(2.8e-9), + expectedInput: 2.2e-7, + expectedOutput: floatPtr(6.6e-7), + expectedCacheRead: floatPtr(7e-9), }, { - name: "deepseek reasoner alias → flash", + name: "deepseek reasoner discontinued maps to flash", model: "deepseek-reasoner", - expectedInput: 1.4e-7, - expectedOutput: floatPtr(2.8e-7), - expectedCacheRead: floatPtr(2.8e-9), + expectedInput: 2.2e-7, + expectedOutput: floatPtr(6.6e-7), + expectedCacheRead: floatPtr(7e-9), + }, + { + name: "unknown deepseek maps to flash", + model: "deepseek-foo", + expectedInput: 2.2e-7, + expectedOutput: floatPtr(6.6e-7), + expectedCacheRead: floatPtr(7e-9), }, // ---- 智谱 GLM(z.ai USD 口径)---- diff --git a/backend/internal/service/deepseek_pricing_test.go b/backend/internal/service/deepseek_pricing_test.go new file mode 100644 index 0000000000..3b813929a4 --- /dev/null +++ b/backend/internal/service/deepseek_pricing_test.go @@ -0,0 +1,345 @@ +//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) + }) + } +} diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index 490e768f27..94790cdba1 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -745,15 +745,15 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, - "deepseek-chat": { - "cache_read_input_token_cost": 2.8e-08, - "input_cost_per_token": 2.8e-07, + "deepseek-v4-flash": { + "cache_read_input_token_cost": 7e-09, + "input_cost_per_token": 2.2e-07, "litellm_provider": "deepseek", - "max_input_tokens": 131072, - "max_output_tokens": 8192, - "max_tokens": 8192, + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 4.2e-07, + "output_cost_per_token": 6.6e-07, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" @@ -762,44 +762,54 @@ "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true }, - "deepseek-reasoner": { - "cache_read_input_token_cost": 2.8e-08, - "input_cost_per_token": 2.8e-07, + "deepseek-v4-flash-vision-exp": { + "cache_read_input_token_cost": 7e-09, + "input_cost_per_token": 2.2e-07, "litellm_provider": "deepseek", - "max_input_tokens": 131072, - "max_output_tokens": 65536, - "max_tokens": 65536, + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 4.2e-07, + "output_cost_per_token": 6.6e-07, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" ], - "supports_function_calling": false, + "supports_function_calling": true, "supports_native_streaming": true, - "supports_parallel_function_calling": false, + "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, - "supports_tool_choice": false + "supports_tool_choice": true, + "supports_vision": true }, - "deepseek-v3-2-251201": { - "input_cost_per_token": 0.0, - "litellm_provider": "volcengine", - "max_input_tokens": 98304, - "max_output_tokens": 32768, - "max_tokens": 32768, + "deepseek-v4-pro": { + "cache_read_input_token_cost": 2.2e-08, + "input_cost_per_token": 6.6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 1048576, + "max_output_tokens": 393216, + "max_tokens": 393216, "mode": "chat", - "output_cost_per_token": 0.0, - "supports_assistant_prefill": true, + "output_cost_per_token": 1.98e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, "supports_tool_choice": true }, "gemini-2.0-flash": { diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 4b16d44b02..548044394b 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -109,7 +109,8 @@ const qwenModels = [ // DeepSeek const deepseekModels = [ - 'deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', + 'deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', + 'deepseek-coder', 'deepseek-v3', 'deepseek-v3-0324', 'deepseek-r1', 'deepseek-r1-0528', 'deepseek-r1-distill-qwen-32b', 'deepseek-r1-distill-qwen-14b', 'deepseek-r1-distill-qwen-7b',