mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
644 lines
28 KiB
Go
644 lines
28 KiB
Go
//go:build unit
|
||
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"math"
|
||
"math/rand"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// scheduleScenario 阶梯表测试场景:同一份场景既做断言也做与计费函数的对账。
|
||
type scheduleScenario struct {
|
||
name string
|
||
model string
|
||
platform string
|
||
groupPlatform string
|
||
group *Group
|
||
channel []ChannelModelPricing
|
||
catalog *PricingService
|
||
wantErr bool
|
||
wantNil bool
|
||
wantBasis ContextPricingBasis
|
||
check func(t *testing.T, s *ContextPricingSchedule)
|
||
}
|
||
|
||
func enabledGroup(platform string) *Group {
|
||
return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: true}
|
||
}
|
||
|
||
func disabledGroup(platform string) *Group {
|
||
return &Group{ID: 100, Platform: platform, LongContextPricingEnabled: false}
|
||
}
|
||
|
||
func sonnetChannel(iv ...PricingInterval) []ChannelModelPricing {
|
||
return []ChannelModelPricing{{
|
||
Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
|
||
InputPrice: testPtrFloat64(2e-6), Intervals: iv,
|
||
}}
|
||
}
|
||
|
||
func requireTier(t *testing.T, tier ContextPricingTier, min int, max *int, label string, input, output, cacheWrite, cacheRead *float64) {
|
||
t.Helper()
|
||
require.Equal(t, min, tier.MinTokens, "min")
|
||
if max == nil {
|
||
require.Nil(t, tier.MaxTokens, "max should be unbounded")
|
||
} else {
|
||
require.NotNil(t, tier.MaxTokens, "max")
|
||
require.Equal(t, *max, *tier.MaxTokens, "max")
|
||
}
|
||
require.Equal(t, label, tier.Label, "label")
|
||
requirePrice(t, input, tier.Input, "input")
|
||
requirePrice(t, output, tier.Output, "output")
|
||
requirePrice(t, cacheWrite, tier.CacheWrite, "cache_write")
|
||
requirePrice(t, cacheRead, tier.CacheRead, "cache_read")
|
||
}
|
||
|
||
func requirePrice(t *testing.T, want, got *float64, field string) {
|
||
t.Helper()
|
||
if want == nil {
|
||
require.Nil(t, got, field)
|
||
return
|
||
}
|
||
require.NotNil(t, got, field)
|
||
require.InDelta(t, *want, *got, 1e-15, field)
|
||
}
|
||
|
||
// mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。
|
||
func mustCatalogFromJSON(body string) *PricingService {
|
||
s := &PricingService{}
|
||
data, err := s.parsePricingData([]byte(body))
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
s.pricingData = data
|
||
return s
|
||
}
|
||
|
||
func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) }
|
||
|
||
func scheduleScenarios() []scheduleScenario {
|
||
p := testPtrFloat64
|
||
return []scheduleScenario{
|
||
{
|
||
name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||
group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||
requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "Grok 达到阈值即进高档", model: "grok-4.5", platform: PlatformGrok, groupPlatform: PlatformGrok,
|
||
group: enabledGroup(PlatformGrok), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requireTier(t, s.Tiers[0], 0, intPtr(199999), "<200K", p(2e-6), p(6e-6), nil, p(0.3e-6))
|
||
requireTier(t, s.Tiers[1], 199999, nil, "≥200K", p(4e-6), p(12e-6), nil, p(0.6e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||
group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI,
|
||
group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "渠道倍率区间按渠道平价覆盖后的 base 折算(区间自定义标签不用于档位)", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(
|
||
PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
|
||
PricingInterval{MinTokens: 200000, TierLabel: "long", InputMultiplier: p(2), OutputMultiplier: p(1.5), CacheWriteMultiplier: p(2), CacheReadMultiplier: p(2)},
|
||
),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(22.5e-6), p(7.5e-6), p(0.6e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "区间显式价优先于倍率", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(
|
||
PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
|
||
PricingInterval{MinTokens: 200000, InputPrice: p(9e-6), InputMultiplier: p(2)},
|
||
),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requirePrice(t, p(9e-6), s.Tiers[1].Input, "input")
|
||
},
|
||
},
|
||
{
|
||
name: "区间空洞按 base 补档且同价段合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(
|
||
PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(0.5)},
|
||
PricingInterval{MinTokens: 200000, InputMultiplier: p(2)},
|
||
),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 3)
|
||
requireTier(t, s.Tiers[0], 0, intPtr(100000), "≤100K", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
requireTier(t, s.Tiers[1], 100000, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
requireTier(t, s.Tiers[2], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "首档同价空洞被合并", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(
|
||
PricingInterval{MinTokens: 0, MaxTokens: intPtr(100000), InputMultiplier: p(1)},
|
||
PricingInterval{MinTokens: 200000, InputMultiplier: p(2)},
|
||
),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "末档有上限时尾段回落 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: enabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(
|
||
PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(1)},
|
||
PricingInterval{MinTokens: 200000, MaxTokens: intPtr(1000000), InputMultiplier: p(2)},
|
||
),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 3)
|
||
requireTier(t, s.Tiers[1], 200000, intPtr(1000000), "≤1M", p(4e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
requireTier(t, s.Tiers[2], 1000000, nil, ">1M", p(2e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "分组关闭时渠道区间折叠到 1-token 档", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(
|
||
PricingInterval{MinTokens: 0, MaxTokens: intPtr(200000), InputMultiplier: p(0.5)},
|
||
PricingInterval{MinTokens: 200000, InputMultiplier: p(2)},
|
||
),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "分组关闭且首档不从 0 起时平价为 base", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: disabledGroup(PlatformAnthropic), wantBasis: ContextPricingBasisWholeRequest,
|
||
channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(2)}),
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
requirePrice(t, p(2e-6), s.Tiers[0].Input, "input")
|
||
},
|
||
},
|
||
{
|
||
name: "分组 token 价卡整张替换渠道定价并剥区间", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||
Models: []string{"claude-sonnet-*"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||
Intervals: []PricingInterval{{MinTokens: 200000, InputMultiplier: p(5)}},
|
||
}}},
|
||
channel: sonnetChannel(PricingInterval{MinTokens: 200000, InputMultiplier: p(3)}),
|
||
wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
requireTier(t, s.Tiers[0], 0, nil, "", p(1e-6), p(15e-6), p(3.75e-6), p(0.3e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "分组价卡之上叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||
group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||
Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||
}}},
|
||
catalog: openAILadderCatalog(),
|
||
wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requirePrice(t, p(1e-6), s.Tiers[0].Input, "input")
|
||
requirePrice(t, p(2e-6), s.Tiers[1].Input, "input")
|
||
requirePrice(t, p(22.5e-6), s.Tiers[1].Output, "output")
|
||
},
|
||
},
|
||
{
|
||
name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
// 缓存写入按标准输入价,与 input 一同整单换档
|
||
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), p(1.25e-6), p(0.125e-6))
|
||
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||
group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
},
|
||
},
|
||
{
|
||
name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON),
|
||
channel: []ChannelModelPricing{{
|
||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
|
||
}},
|
||
wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requirePrice(t, p(3e-6), s.Tiers[0].Input, "input")
|
||
requirePrice(t, p(6e-6), s.Tiers[1].Input, "input")
|
||
},
|
||
},
|
||
{
|
||
name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
},
|
||
},
|
||
{
|
||
name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
|
||
group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
},
|
||
},
|
||
{
|
||
name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite,
|
||
group: enabledGroup(PlatformComposite),
|
||
channel: []ChannelModelPricing{{
|
||
Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||
}},
|
||
catalog: openAILadderCatalog(),
|
||
wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requirePrice(t, p(1e-6), s.Tiers[0].Input, "input")
|
||
requirePrice(t, p(2e-6), s.Tiers[1].Input, "input")
|
||
},
|
||
},
|
||
{
|
||
name: "渠道显式 cache_write=0 保留为 0", model: "claude-sonnet-4", platform: PlatformAnthropic, groupPlatform: PlatformAnthropic,
|
||
group: enabledGroup(PlatformAnthropic),
|
||
channel: []ChannelModelPricing{{
|
||
Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
|
||
InputPrice: p(2e-6), CacheWritePrice: p(0),
|
||
}},
|
||
wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 1)
|
||
require.NotNil(t, s.Tiers[0].CacheWrite)
|
||
require.Zero(t, *s.Tiers[0].CacheWrite)
|
||
},
|
||
},
|
||
{
|
||
name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||
group: enabledGroup(PlatformOpenAI),
|
||
catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
|
||
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07,
|
||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||
"cache_read_input_token_cost_above_272k_tokens": 1e-06}}`),
|
||
wantBasis: ContextPricingBasisWholeRequest,
|
||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||
require.Len(t, s.Tiers, 2)
|
||
requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(5e-6), p(30e-6), p(6.25e-6), p(0.5e-6))
|
||
requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(10e-6), p(45e-6), p(12.5e-6), p(1e-6))
|
||
},
|
||
},
|
||
{
|
||
name: "图片模式返回 nil", model: "gpt-image-2", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||
group: enabledGroup(PlatformOpenAI),
|
||
channel: []ChannelModelPricing{{
|
||
Platform: PlatformOpenAI, Models: []string{"gpt-image-2"}, BillingMode: BillingModeImage, PerRequestPrice: p(0.04),
|
||
}},
|
||
wantNil: true,
|
||
},
|
||
{
|
||
name: "无任何定价来源报错", model: "unknown-model-xyz", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||
group: enabledGroup(PlatformOpenAI), wantErr: true,
|
||
},
|
||
}
|
||
}
|
||
|
||
func newScheduleTestEnv(t *testing.T, sc scheduleScenario) (*BillingService, *ModelPricingResolver) {
|
||
t.Helper()
|
||
return newTokenCostTestEnv(t, sc.groupPlatform, sc.channel, sc.catalog)
|
||
}
|
||
|
||
func TestResolveContextPricingSchedule_Scenarios(t *testing.T) {
|
||
for _, sc := range scheduleScenarios() {
|
||
t.Run(sc.name, func(t *testing.T) {
|
||
bs, resolver := newScheduleTestEnv(t, sc)
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||
Model: sc.model, Group: sc.group, Platform: sc.platform,
|
||
})
|
||
if sc.wantErr {
|
||
require.Error(t, err)
|
||
require.Nil(t, sched)
|
||
return
|
||
}
|
||
require.NoError(t, err)
|
||
if sc.wantNil {
|
||
require.Nil(t, sched)
|
||
return
|
||
}
|
||
require.NotNil(t, sched)
|
||
require.Equal(t, sc.wantBasis, sched.Basis)
|
||
require.NotEmpty(t, sched.Tiers)
|
||
require.Zero(t, sched.Tiers[0].MinTokens, "首档从 0 起")
|
||
for i := 1; i < len(sched.Tiers); i++ {
|
||
require.NotNil(t, sched.Tiers[i-1].MaxTokens)
|
||
require.Equal(t, *sched.Tiers[i-1].MaxTokens, sched.Tiers[i].MinTokens, "档位连续")
|
||
require.Less(t, sched.Tiers[i-1].MinTokens, sched.Tiers[i].MinTokens, "档位按上下文升序")
|
||
}
|
||
if len(sched.Tiers) > 1 {
|
||
for i, tier := range sched.Tiers {
|
||
require.NotEmpty(t, tier.Label, "多档时每档都有标签 #%d", i)
|
||
}
|
||
require.Nil(t, sched.Tiers[len(sched.Tiers)-1].MaxTokens, "末档无上限")
|
||
}
|
||
if sc.check != nil {
|
||
sc.check(t, sched)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestResolveContextPricingSchedule_NilResolver(t *testing.T) {
|
||
bs := NewBillingService(&config.Config{}, nil)
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), nil, ContextPricingScheduleInput{Model: "gpt-5.4"})
|
||
require.Error(t, err)
|
||
require.Nil(t, sched)
|
||
}
|
||
|
||
func TestPlazaSchedulePreservesCacheWrite1h(t *testing.T) {
|
||
cp := sonnetChannel(PricingInterval{MinTokens: 272000, CacheWriteMultiplier: testPtrFloat64(2)})
|
||
cp[0].CacheWritePrice = testPtrFloat64(12.5e-6)
|
||
cp[0].CacheWrite1hPrice = testPtrFloat64(20e-6)
|
||
group := enabledGroup(PlatformAnthropic)
|
||
bs, resolver := newScheduleTestEnv(t, scheduleScenario{channel: cp, groupPlatform: PlatformAnthropic})
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic})
|
||
require.NoError(t, err)
|
||
display := plazaPricingFromSchedule(&cp[0], sched)
|
||
requirePrice(t, testPtrFloat64(20e-6), display.CacheWrite1hPrice, "base 1h")
|
||
require.Len(t, display.Intervals, 2)
|
||
requirePrice(t, testPtrFloat64(25e-6), display.Intervals[1].CacheWritePrice, "tier 5m")
|
||
requirePrice(t, testPtrFloat64(40e-6), display.Intervals[1].CacheWrite1hPrice, "tier 1h")
|
||
a := ContextPricingTier{CacheWrite1h: testPtrFloat64(1)}
|
||
b := ContextPricingTier{CacheWrite1h: testPtrFloat64(2)}
|
||
require.Len(t, mergeEqualContextTiers([]ContextPricingTier{a, b}), 2)
|
||
}
|
||
|
||
// --- 对账:阶梯表推算的费用必须等于真实计费函数 ---
|
||
|
||
type tokenKind int
|
||
|
||
const (
|
||
kindInput tokenKind = iota
|
||
kindOutput
|
||
kindCacheRead
|
||
kindCacheWrite
|
||
)
|
||
|
||
func tierPrice(tier ContextPricingTier, kind tokenKind) float64 {
|
||
var p *float64
|
||
switch kind {
|
||
case kindInput:
|
||
p = tier.Input
|
||
case kindOutput:
|
||
p = tier.Output
|
||
case kindCacheRead:
|
||
p = tier.CacheRead
|
||
case kindCacheWrite:
|
||
p = tier.CacheWrite
|
||
}
|
||
if p == nil {
|
||
return 0
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
|
||
for _, tier := range tiers {
|
||
if contextTokens > tier.MinTokens && (tier.MaxTokens == nil || contextTokens <= *tier.MaxTokens) {
|
||
return tier
|
||
}
|
||
}
|
||
return tiers[len(tiers)-1]
|
||
}
|
||
|
||
// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
|
||
// 整单基准取所在档单价 × 全量。
|
||
func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
|
||
return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
|
||
}
|
||
|
||
func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
|
||
const outputProbe = 1000
|
||
rng := rand.New(rand.NewSource(20260823))
|
||
for _, sc := range scheduleScenarios() {
|
||
if sc.wantErr || sc.wantNil {
|
||
continue
|
||
}
|
||
t.Run(sc.name, func(t *testing.T) {
|
||
bs, resolver := newScheduleTestEnv(t, sc)
|
||
ctx := context.Background()
|
||
if sc.platform != "" {
|
||
ctx = WithResolvedTargetPlatform(ctx, sc.platform)
|
||
}
|
||
sched, err := bs.ResolveContextPricingSchedule(ctx, resolver, ContextPricingScheduleInput{
|
||
Model: sc.model, Group: sc.group, Platform: sc.platform,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, sched)
|
||
|
||
// 消费方视角重建同一份计费请求(与网关一致)。
|
||
pricingInput := PricingInput{Model: sc.model, Group: sc.group}
|
||
if sc.group != nil {
|
||
gid := sc.group.ID
|
||
pricingInput.GroupID = &gid
|
||
}
|
||
resolved := resolver.Resolve(ctx, pricingInput)
|
||
cost := func(tokens UsageTokens) float64 {
|
||
bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||
Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1,
|
||
Resolver: resolver, Resolved: resolved,
|
||
})
|
||
require.NoError(t, err)
|
||
return bd.ActualCost
|
||
}
|
||
|
||
probes := []int{1, 2, 999, 1000, 1001, 10_000_000}
|
||
for _, tier := range sched.Tiers {
|
||
for _, b := range []*int{tier.MaxTokens} {
|
||
if b == nil {
|
||
continue
|
||
}
|
||
probes = append(probes, *b-1, *b, *b+1)
|
||
}
|
||
}
|
||
for i := 0; i < 8; i++ {
|
||
probes = append(probes, 1+rng.Intn(3_000_000))
|
||
}
|
||
|
||
for _, c := range probes {
|
||
if c <= 0 {
|
||
continue
|
||
}
|
||
assertCostClose(t, expectedCostFromSchedule(sched, kindInput, c), cost(UsageTokens{InputTokens: c}), "input@%d", c)
|
||
assertCostClose(t, expectedCostFromSchedule(sched, kindCacheRead, c), cost(UsageTokens{CacheReadTokens: c}), "cache_read@%d", c)
|
||
assertCostClose(t, expectedCostFromSchedule(sched, kindCacheWrite, c), cost(UsageTokens{CacheCreationTokens: c}), "cache_write@%d", c)
|
||
wantOutput := float64(outputProbe) * tierPrice(tierAt(sched.Tiers, c), kindOutput)
|
||
gotOutput := cost(UsageTokens{InputTokens: c, OutputTokens: outputProbe}) - cost(UsageTokens{InputTokens: c})
|
||
assertCostClose(t, wantOutput, gotOutput, "output@%d", c)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func assertCostClose(t *testing.T, want, got float64, format string, args ...any) {
|
||
t.Helper()
|
||
tolerance := 1e-12 + 1e-9*math.Abs(want)
|
||
require.InDeltaf(t, want, got, tolerance, format, args...)
|
||
}
|
||
|
||
func sonnetChannelWithTimePricing(tp *ChannelTimePricing) []ChannelModelPricing {
|
||
return []ChannelModelPricing{{
|
||
Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
|
||
InputPrice: testPtrFloat64(2e-6), TimePricing: tp,
|
||
}}
|
||
}
|
||
|
||
func TestResolveContextPricingSchedule_TimePricing(t *testing.T) {
|
||
valid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
|
||
{StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2},
|
||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||
{StartTime: "12:00", EndTime: "13:00", Multiplier: 1},
|
||
}}
|
||
|
||
t.Run("渠道分时按开始时间升序列出且跳过倍率 1 的时段", func(t *testing.T) {
|
||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil)
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, sched.TimePricing)
|
||
require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone)
|
||
require.False(t, sched.TimePricing.WeekdaysOnly)
|
||
require.Equal(t, []TimePricingPeriod{
|
||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||
{StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2},
|
||
}, sched.TimePricing.Periods)
|
||
// 阶梯表单价是标准时段价,不含分时倍率
|
||
requirePrice(t, testPtrFloat64(2e-6), sched.Tiers[0].Input, "input")
|
||
|
||
// 对账:时段内的真实计费 = 标准单价 × token × 倍率
|
||
group := enabledGroup(PlatformAnthropic)
|
||
gid := group.ID
|
||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group})
|
||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||
require.NoError(t, err)
|
||
for _, tc := range []struct {
|
||
at time.Time
|
||
want float64
|
||
}{
|
||
{time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 0.5},
|
||
{time.Date(2026, 8, 23, 12, 30, 0, 0, loc), 1},
|
||
{time.Date(2026, 8, 23, 21, 59, 59, 0, loc), 1.2},
|
||
{time.Date(2026, 8, 23, 22, 0, 0, 0, loc), 1},
|
||
} {
|
||
cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||
Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000},
|
||
RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved,
|
||
})
|
||
require.NoError(t, err)
|
||
assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at)
|
||
}
|
||
})
|
||
|
||
t.Run("仅工作日配置透传标注且时段仍列出", func(t *testing.T) {
|
||
weekdays := &ChannelTimePricing{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{
|
||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||
}}
|
||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(weekdays), nil)
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||
})
|
||
require.NoError(t, err)
|
||
require.NotNil(t, sched.TimePricing, "探针锚点必须落在工作日,否则仅工作日配置的时段会被整组剔除")
|
||
require.True(t, sched.TimePricing.WeekdaysOnly)
|
||
require.Equal(t, []TimePricingPeriod{
|
||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||
}, sched.TimePricing.Periods)
|
||
|
||
// 对账:工作日时段内乘倍率,周末同一时段按标准价
|
||
group := enabledGroup(PlatformAnthropic)
|
||
gid := group.ID
|
||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group})
|
||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||
require.NoError(t, err)
|
||
for _, tc := range []struct {
|
||
at time.Time
|
||
want float64
|
||
}{
|
||
{time.Date(2026, 8, 24, 3, 0, 0, 0, loc), 0.5}, // 周一
|
||
{time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 1}, // 周日
|
||
} {
|
||
cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||
Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000},
|
||
RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved,
|
||
})
|
||
require.NoError(t, err)
|
||
assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at)
|
||
}
|
||
})
|
||
|
||
t.Run("配置非法时计费按 1 计,阶梯表不列分时", func(t *testing.T) {
|
||
invalid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{
|
||
{StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5},
|
||
{StartTime: "08:00", EndTime: "09:00", Multiplier: 0.8}, // 与上一段重叠
|
||
}}
|
||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(invalid), nil)
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Nil(t, sched.TimePricing)
|
||
})
|
||
|
||
t.Run("分组价卡覆盖后渠道分时不再生效", func(t *testing.T) {
|
||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil)
|
||
group := &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||
Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6),
|
||
}}}
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||
Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Nil(t, sched.TimePricing)
|
||
})
|
||
|
||
t.Run("无分时配置为 nil", func(t *testing.T) {
|
||
bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(nil), nil)
|
||
sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{
|
||
Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Nil(t, sched.TimePricing)
|
||
})
|
||
}
|