Files
sub2api/backend/internal/service/billing_context_schedule_test.go
T

644 lines
28 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"
"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)
})
}