Files
sub2api/backend/internal/service/billing_context_schedule_test.go
T
feeeei e2cfaa46e7 fix(pricing): 目录 cache 侧字段契约哨兵与 Gemini pro 缓存写入价修正
阶梯计价数据驱动后,目录条目的字段缺失或版本拼接会直接变成计费偏差,
解析层补两道数据契约哨兵,并修正出厂快照中 Gemini pro 系的缓存写入价:

- orphanCacheTierFields / warnOrphanCacheTierFields:条目带 cache 侧
  above 档(含 _priority/_flex 变体与 above_1hr 形态)却没有基础价时打
  WARN——cache 侧 above 档不参与计费取值,按"基础价 × 输入倍率"计,基础价
  缺失即该分项按 0 计;变体缺自身基础价时沿 变体→1h→标准 回落链任一存在
  即不算孤儿,与计费回落一致
- isLopsidedLongContextLadder / warnLopsidedLongContextLadders:above 档
  折算出只有一侧带附加费的阶梯时打 WARN(基础价与 above 档来自不同价格
  版本的形态),显式 long_context_* 字段不告警
- deriveLongContextFromAboveTierFields 注释改为陈述契约:cache above 档
  须恰为基础价 × 输入倍率
- 出厂快照 6 个 Gemini pro 条目补 cache_creation_input_token_cost(= 标准
  输入价,Google 对缓存写入不另收费)、above_200k 改为输入 above 价、带
  priority 输入价的 4 条补 priority 孪生,与 model-price-repo 数据修正同值

测试:
- 解析层:孤儿字段 WARN(含 priority/1h 孤儿正例、1h 回落 5m 反例)、单侧
  阶梯 WARN、出厂快照契约(全条目无孤儿字段、无单侧阶梯、Gemini pro 缓存
  写入价 = 输入价)
- 用量契约:extractGeminiUsage 与 Antigravity 非流式/流式转换均不产生
  cache_creation token(Gemini usageMetadata 无该类别)
- 计费:gemini-2.5-pro 阶梯对 cache_read / cache_creation 分项生效,阈值
  计入缓存写入 token;阶梯表场景与夹具同步为现行目录值
2026-09-01 01:26:55 +08:00

626 lines
27 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)
}
// --- 对账:阶梯表推算的费用必须等于真实计费函数 ---
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)
})
}