refactor(billing): 长上下文阶梯计价改为价格目录数据驱动

删除 GPT/Gemini 系列写死的阶梯规则,阶梯一律由目录数据驱动:

- 解析层折算 LiteLLM 的 *_above_XXXk_tokens 绝对价字段为阈值+倍率
  (阈值取自字段名,倍率 = above/基础价,cache 侧跟随输入倍率,
  xAI 按 provider 用达到即进高档语义);显式 long_context_* 字段
  按"字段存在"优先,显式 threshold=0 或 multiplier=1 可关闭阶梯
- 删除 OpenAI 272K 常量与 applyModelSpecificPricingPolicy 的长上下文
  强补分支、fallbackPrices/静态兜底条目的 LongContext 字段、Gemini 旧
  边际规则全链路(CalculateCostWithLongContext / RecordUsageWithLongContext /
  阶梯表 marginal 基准);保留 fastRatio 与 GPT-5.6 cache_write 1.25x
  防御分支、grok 静态兜底阶梯
- 渠道配置定价区间时完全以渠道区间为准,不再叠加目录阶梯
- 长上下文倍率 ≤0 在应用点按 1 计,防止显式字段残缺把分项乘 0 免费
- 目录重载时对比新旧数据,原有阶梯丢失打 WARN 哨兵告警

行为变化:Gemini 由边际 2x(仅 /v1beta、全模型)改为整单换档(数据
驱动、仅带 above_200k 的 pro 系),渠道平价之上叠加目录阶梯;Claude
sonnet-4/4.5 随目录数据获得 200K 整单阶梯(与官方定价一致);模型广场
官方参考价列与实付列同源展示目录阶梯。
This commit is contained in:
feeeei
2026-09-01 01:26:55 +08:00
parent 200602b41b
commit 530fb20f25
17 changed files with 599 additions and 633 deletions
+1 -1
View File
@@ -2279,7 +2279,7 @@ func setDefaults() {
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
viper.SetDefault("pricing.data_dir", "./data")
+1 -1
View File
@@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {
// ──────────────────────────────────────────────────────────
// Context helpers — used by handlers before building
// RecordUsageInput / RecordUsageLongContextInput.
// RecordUsageInput.
// ──────────────────────────────────────────────────────────
// GetInboundEndpoint returns the canonical inbound endpoint stored by
@@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
var longContextThreshold int
var longContextMultiplier float64
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
longContextThreshold = rule.Threshold
longContextMultiplier = rule.Multiplier
}
// 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
QuotaPlatform: quotaPlatform,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
LongContextThreshold: longContextThreshold,
LongContextMultiplier: longContextMultiplier,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
QuotaPlatform: quotaPlatform,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gemini_v1beta.models"),
@@ -9,15 +9,12 @@ import (
"time"
)
// ContextPricingBasis 阶梯的计价基准。
// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
// Gemini 边际口径("marginal")已随平台旧规则一并移除。
type ContextPricingBasis string
const (
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
// ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。
ContextPricingBasisMarginal ContextPricingBasis = "marginal"
)
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
@@ -73,8 +70,8 @@ const contextProbeDelta = 1000
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
//
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值),
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
// 规则变更无需同步到这里;相邻同价段会合并。
//
@@ -103,22 +100,13 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
return nil, nil
}
var legacy *LegacyLongContextRule
if in.Group != nil {
legacy = s.LegacyLongContextRule(in.Platform)
}
if !legacyLongContextApplies(resolved, in.Group, legacy) {
legacy = nil
}
req := TokenCostRequest{
Ctx: ctx,
Model: in.Model,
Group: in.Group,
RateMultiplier: 1,
Resolver: resolver,
Resolved: resolved,
LegacyLongContext: legacy,
Ctx: ctx,
Model: in.Model,
Group: in.Group,
RateMultiplier: 1,
Resolver: resolver,
Resolved: resolved,
}
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
r := req
@@ -126,7 +114,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
return s.CalculateTokenCostForRequest(r)
}
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy)
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
segments := buildContextSegments(plan.bounds)
tiers := make([]ContextPricingTier, 0, len(segments))
@@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
tiers = mergeEqualContextTiers(tiers)
applyContextTierLabels(tiers, plan)
basis := ContextPricingBasisWholeRequest
if legacy != nil {
basis = ContextPricingBasisMarginal
}
return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
}
// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
@@ -210,14 +194,8 @@ type contextBreakpointPlan struct {
}
// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan {
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan {
plan := contextBreakpointPlan{}
if legacy != nil {
plan.bounds = []int{legacy.Threshold}
plan.thresholdBound = legacy.Threshold
plan.threshold = legacy.Threshold
return plan
}
if !resolved.longContextPricingEnabled {
return plan
}
@@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) {
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), wantBasis: ContextPricingBasisWholeRequest,
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))
@@ -92,7 +105,7 @@ func scheduleScenarios() []scheduleScenario {
},
{
name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
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))
@@ -100,7 +113,7 @@ func scheduleScenarios() []scheduleScenario {
},
{
name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI,
group: nil, wantBasis: ContextPricingBasisWholeRequest,
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))
@@ -210,6 +223,7 @@ func scheduleScenarios() []scheduleScenario {
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)
@@ -219,46 +233,55 @@ func scheduleScenarios() []scheduleScenario {
},
},
{
name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal,
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)
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6))
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6))
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6))
},
},
{
name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
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: geminiCatalogStub(),
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, 1)
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: "", groupPlatform: PlatformGemini,
group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
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)
@@ -283,9 +306,11 @@ func scheduleScenarios() []scheduleScenario {
{
name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
group: enabledGroup(PlatformOpenAI),
catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6},
}),
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)
@@ -398,22 +423,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
}
// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
// 整单基准取所在档单价 × 全量;边际基准逐段累加。
// 整单基准取所在档单价 × 全量。
func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
if s.Basis == ContextPricingBasisMarginal {
total := 0.0
for _, tier := range s.Tiers {
if contextTokens <= tier.MinTokens {
break
}
upper := contextTokens
if tier.MaxTokens != nil && *tier.MaxTokens < upper {
upper = *tier.MaxTokens
}
total += float64(upper-tier.MinTokens) * tierPrice(tier, kind)
}
return total
}
return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
}
@@ -443,17 +454,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
pricingInput.GroupID = &gid
}
resolved := resolver.Resolve(ctx, pricingInput)
var legacy *LegacyLongContextRule
if sc.group != nil {
legacy = bs.LegacyLongContextRule(sc.platform)
}
if !legacyLongContextApplies(resolved, sc.group, legacy) {
legacy = nil
}
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, LegacyLongContext: legacy,
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
return bd.ActualCost
+35 -140
View File
@@ -116,12 +116,6 @@ type ModelPricing struct {
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
}
const (
openAIGPT54LongContextInputThreshold = 272000
openAIGPT54LongContextInputMultiplier = 2.0
openAIGPT54LongContextOutputMultiplier = 1.5
)
func normalizeBillingServiceTier(serviceTier string) string {
return strings.ToLower(strings.TrimSpace(serviceTier))
}
@@ -395,9 +389,6 @@ func (s *BillingService) initFallbackPricing() {
CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok
CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok
SupportsCacheBreakdown: false,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
// OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。
// Source: https://platform.openai.com/docs/pricing
@@ -405,24 +396,18 @@ func (s *BillingService) initFallbackPricing() {
InputPricePerToken: 5e-6,
OutputPricePerToken: 30e-6,
// 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。
CacheCreationPricePerToken: 5e-6,
CacheReadPricePerToken: 0.5e-6,
SupportsCacheBreakdown: false,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
CacheCreationPricePerToken: 5e-6,
CacheReadPricePerToken: 0.5e-6,
SupportsCacheBreakdown: false,
}, 2.5)
// GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。
s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{
InputPricePerToken: 30e-6,
OutputPricePerToken: 180e-6,
// 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。
CacheCreationPricePerToken: 30e-6,
CacheReadPricePerToken: 30e-6,
SupportsCacheBreakdown: false,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
CacheCreationPricePerToken: 30e-6,
CacheReadPricePerToken: 30e-6,
SupportsCacheBreakdown: false,
}
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
@@ -435,9 +420,6 @@ func (s *BillingService) initFallbackPricing() {
CacheCreationPricePerTokenPriority: 12.5e-6,
CacheReadPricePerToken: 0.5e-6,
CacheReadPricePerTokenPriority: 1e-6,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
InputPricePerToken: 2e-6,
@@ -448,9 +430,6 @@ func (s *BillingService) initFallbackPricing() {
CacheCreationPricePerTokenPriority: 5e-6,
CacheReadPricePerToken: 0.2e-6,
CacheReadPricePerTokenPriority: 0.4e-6,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
InputPricePerToken: 0.2e-6,
@@ -461,9 +440,6 @@ func (s *BillingService) initFallbackPricing() {
CacheCreationPricePerTokenPriority: 0.5e-6,
CacheReadPricePerToken: 0.02e-6,
CacheReadPricePerTokenPriority: 0.04e-6,
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
@@ -1125,10 +1101,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
CacheCreation1hPrice: price1h,
SupportsCacheBreakdown: enableBreakdown,
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
// xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。
LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"),
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
}), nil
}
}
@@ -1375,15 +1353,18 @@ func (s *BillingService) computeTokenBreakdown(
var baselineCost *CostBreakdown
if longContextPricingEligible {
baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false)
inputPrice *= pricing.LongContextInputMultiplier
outputPrice *= pricing.LongContextOutputMultiplier
// 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧),
// 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。
longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier)
inputPrice *= longCtxInputMultiplier
outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier)
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
cacheReadPrice *= pricing.LongContextInputMultiplier
cacheReadPrice *= longCtxInputMultiplier
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
cacheCreationMultiplier = pricing.LongContextInputMultiplier
cacheCreationMultiplier = longCtxInputMultiplier
}
bd := &CostBreakdown{}
@@ -1568,10 +1549,13 @@ func (s *BillingService) calculateCostInternalWithPolicy(
return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil
}
// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、
// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡
// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的
// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。
// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价
// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority
// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文
// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段)
// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部)
// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx
// 关闭强制,保留运营者配置。
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
return s.applyModelSpecificPricingPolicyEx(model, pricing, true)
}
@@ -1607,16 +1591,10 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
}
normalized := normalizeKnownOpenAICodexModel(model)
isGPT56 := isOpenAIGPT56Model(normalized)
usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized)
if !isGPT56 && !usesLegacyLongContextPricing {
return pricing
}
needsLongContextPolicy := (isGPT56 || usesLegacyLongContextPricing) &&
(pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0)
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
fastRatio := openAIModelFastPricingRatio(normalized)
if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 {
if !needsCacheCreationPolicy && fastRatio <= 0 {
return pricing
}
cloned := *pricing
@@ -1628,17 +1606,6 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25
}
}
if isGPT56 || usesLegacyLongContextPricing {
if cloned.LongContextInputThreshold <= 0 {
cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold
}
if cloned.LongContextInputMultiplier <= 0 {
cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier
}
if cloned.LongContextOutputMultiplier <= 0 {
cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier
}
}
if fastRatio > 0 {
enforceOpenAIFastPricingRatio(&cloned, fastRatio)
}
@@ -1678,6 +1645,14 @@ func enforceOpenAIFastPricingRatio(pricing *ModelPricing, ratio float64) {
}
}
// longContextMultiplierOrOne 把未配置(≤0)的长上下文倍率归一为 1。
func longContextMultiplierOrOne(m float64) float64 {
if m <= 0 {
return 1
}
return m
}
func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool {
if pricing == nil || pricing.LongContextInputThreshold <= 0 {
return false
@@ -1692,10 +1667,6 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens
return totalInputTokens > pricing.LongContextInputThreshold
}
func usesOpenAILegacyLongContextPricing(normalized string) bool {
return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
}
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) {
multiplier := s.cfg.Default.RateMultiplier
@@ -1705,82 +1676,6 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken
return s.CalculateCost(model, tokens, multiplier)
}
// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费
// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费
// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍)
//
// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0
// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k)
// 范围内正常计费,范围外 × 2 计费
func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) {
// 未启用长上下文计费,直接走正常计费
if threshold <= 0 || extraMultiplier <= 1 {
return s.CalculateCost(model, tokens, rateMultiplier)
}
// 计算总输入 token(缓存读取 + 新输入)
total := tokens.CacheReadTokens + tokens.InputTokens
if total <= threshold {
return s.CalculateCost(model, tokens, rateMultiplier)
}
// 拆分成范围内和范围外
var inRangeCacheTokens, inRangeInputTokens int
var outRangeCacheTokens, outRangeInputTokens int
if tokens.CacheReadTokens >= threshold {
// 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入
inRangeCacheTokens = threshold
inRangeInputTokens = 0
outRangeCacheTokens = tokens.CacheReadTokens - threshold
outRangeInputTokens = tokens.InputTokens
} else {
// 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入
inRangeCacheTokens = tokens.CacheReadTokens
inRangeInputTokens = threshold - tokens.CacheReadTokens
outRangeCacheTokens = 0
outRangeInputTokens = tokens.InputTokens - inRangeInputTokens
}
// 范围内部分:正常计费
inRangeTokens := UsageTokens{
InputTokens: inRangeInputTokens,
OutputTokens: tokens.OutputTokens, // 输出只算一次
CacheCreationTokens: tokens.CacheCreationTokens,
CacheReadTokens: inRangeCacheTokens,
CacheCreation5mTokens: tokens.CacheCreation5mTokens,
CacheCreation1hTokens: tokens.CacheCreation1hTokens,
ImageOutputTokens: tokens.ImageOutputTokens,
}
inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier)
if err != nil {
return nil, err
}
// 范围外部分:× extraMultiplier 计费
outRangeTokens := UsageTokens{
InputTokens: outRangeInputTokens,
CacheReadTokens: outRangeCacheTokens,
}
outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier)
if err != nil {
return inRangeCost, fmt.Errorf("out-range cost: %w", err)
}
// 合并成本
return &CostBreakdown{
InputCost: inRangeCost.InputCost + outRangeCost.InputCost,
ImageInputCost: inRangeCost.ImageInputCost + outRangeCost.ImageInputCost,
OutputCost: inRangeCost.OutputCost,
ImageOutputCost: inRangeCost.ImageOutputCost,
CacheCreationCost: inRangeCost.CacheCreationCost,
CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost,
TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost,
ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost,
LongContextBillingApplied: outRangeCost.ActualCost > 0,
}, nil
}
// ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配)
func (s *BillingService) ListSupportedModels() []string {
models := make([]string, 0)
+26 -130
View File
@@ -33,6 +33,11 @@ func newTestBillingService() *BillingService {
return NewBillingService(&config.Config{}, nil)
}
func newTestBillingServiceWithOpenAILadderCatalog(t *testing.T) *BillingService {
t.Helper()
return NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
}
func TestCalculateCost_BasicComputation(t *testing.T) {
svc := newTestBillingService()
@@ -200,9 +205,21 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) {
require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12)
require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
// 静态兜底价不携带长上下文阶梯:阶梯一律由目录数据(above_272k 折算)驱动。
require.Zero(t, pricing.LongContextInputThreshold)
require.Zero(t, pricing.LongContextInputMultiplier)
require.Zero(t, pricing.LongContextOutputMultiplier)
}
func TestGetModelPricing_CatalogAboveTierFieldsDriveLongContext(t *testing.T) {
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
pricing, err := svc.GetModelPricing("gpt-5.4")
require.NoError(t, err)
require.Equal(t, 272000, pricing.LongContextInputThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于")
}
func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
@@ -215,8 +232,8 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
cacheRead float64
longContext int
}{
{model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 272000},
{model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000},
{model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 0},
{model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 0},
{model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0},
{model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0},
}
@@ -247,7 +264,7 @@ func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) {
}
func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
tokens := UsageTokens{
InputTokens: 300000,
@@ -267,7 +284,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t
}
func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
cost, err := svc.calculateCostWithServiceTierPolicy(
"gpt-5.4-2026-03-05",
@@ -283,7 +300,7 @@ func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t
}
func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
tokens := UsageTokens{
InputTokens: 300000,
@@ -331,7 +348,7 @@ func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) {
// 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
// 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
tokens := UsageTokens{
@@ -359,7 +376,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *tes
// 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
tokens := UsageTokens{
@@ -381,7 +398,7 @@ func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *test
// 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此
// 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
tokens := UsageTokens{
@@ -402,7 +419,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t
// 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) {
svc := newTestBillingService()
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
tokens := UsageTokens{
@@ -921,114 +938,6 @@ func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
}
func TestCalculateCostWithLongContext_BelowThreshold(t *testing.T) {
svc := newTestBillingService()
tokens := UsageTokens{
InputTokens: 50000,
OutputTokens: 1000,
CacheReadTokens: 100000,
}
// 总输入 150k < 200k 阈值,应走正常计费
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
require.NoError(t, err)
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
require.NoError(t, err)
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
}
func TestCalculateCostWithLongContext_AboveThreshold_CacheExceedsThreshold(t *testing.T) {
svc := newTestBillingService()
// 缓存 210k + 输入 10k = 220k > 200k 阈值
// 缓存已超阈值:范围内 200k 缓存,范围外 10k 缓存 + 10k 输入
tokens := UsageTokens{
InputTokens: 10000,
OutputTokens: 1000,
CacheReadTokens: 210000,
}
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
require.NoError(t, err)
// 范围内:200k cache + 0 input + 1k output
inRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
InputTokens: 0,
OutputTokens: 1000,
CacheReadTokens: 200000,
}, 1.0)
// 范围外:10k cache + 10k input,倍率 2.0
outRange, _ := svc.CalculateCost("claude-sonnet-4", UsageTokens{
InputTokens: 10000,
CacheReadTokens: 10000,
}, 2.0)
require.InDelta(t, inRange.ActualCost+outRange.ActualCost, cost.ActualCost, 1e-10)
}
func TestCalculateCostWithLongContext_AboveThreshold_CacheBelowThreshold(t *testing.T) {
svc := newTestBillingService()
// 缓存 100k + 输入 150k = 250k > 200k 阈值
// 缓存未超阈值:范围内 100k 缓存 + 100k 输入,范围外 50k 输入
tokens := UsageTokens{
InputTokens: 150000,
OutputTokens: 1000,
CacheReadTokens: 100000,
}
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 2.0)
require.NoError(t, err)
require.True(t, cost.ActualCost > 0, "费用应大于 0")
// 正常费用不含长上下文
normalCost, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
require.True(t, cost.ActualCost > normalCost.ActualCost, "长上下文费用应高于正常费用")
}
func TestCalculateCostWithLongContext_MarkerRequiresActualCostIncrease(t *testing.T) {
svc := newTestBillingService()
tokens := UsageTokens{InputTokens: 300000}
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 0, 200000, 2.0)
require.NoError(t, err)
require.Zero(t, cost.ActualCost)
require.False(t, cost.LongContextBillingApplied)
}
func TestCalculateCostWithLongContext_DisabledThreshold(t *testing.T) {
svc := newTestBillingService()
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
// threshold <= 0 应禁用长上下文计费
cost1, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 0, 2.0)
require.NoError(t, err)
cost2, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
require.NoError(t, err)
require.InDelta(t, cost2.ActualCost, cost1.ActualCost, 1e-10)
}
func TestCalculateCostWithLongContext_ExtraMultiplierLessEqualOne(t *testing.T) {
svc := newTestBillingService()
tokens := UsageTokens{InputTokens: 300000}
// extraMultiplier <= 1 应禁用长上下文计费
cost, err := svc.CalculateCostWithLongContext("claude-sonnet-4", tokens, 1.0, 200000, 1.0)
require.NoError(t, err)
normalCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
require.NoError(t, err)
require.InDelta(t, normalCost.ActualCost, cost.ActualCost, 1e-10)
}
func TestCalculateImageCost(t *testing.T) {
svc := newTestBillingService()
@@ -1178,19 +1087,6 @@ func TestForceUpdatePricing_NilService(t *testing.T) {
require.Contains(t, err.Error(), "not initialized")
}
func TestCalculateCostWithLongContext_PropagatesError(t *testing.T) {
// 使用空的 fallback prices 让 GetModelPricing 失败
svc := &BillingService{
cfg: &config.Config{},
fallbackPrices: make(map[string]*ModelPricing),
}
tokens := UsageTokens{InputTokens: 300000, CacheReadTokens: 0}
_, err := svc.CalculateCostWithLongContext("unknown-model", tokens, 1.0, 200000, 2.0)
require.Error(t, err)
require.Contains(t, err.Error(), "pricing not found")
}
func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
svc := newTestBillingService()
@@ -5,33 +5,6 @@ import (
"time"
)
// LegacyLongContextRule 平台级"超出阈值部分按倍率计费"的旧规则。
//
// 语义为边际计费:仅 input/cache_read 中超过 Threshold 的部分乘 Multiplier,
// output 与 cache_write 不受影响(见 CalculateCostWithLongContext)。
// 只有 Gemini 原生 /v1beta 入口在该分组对该模型没有分组/渠道定价时才使用;
// 规则常量由 BillingService 统一持有,网关与模型广场都从这里读取。
type LegacyLongContextRule struct {
Threshold int
Multiplier float64
}
const (
geminiLegacyLongContextThreshold = 200000
geminiLegacyLongContextMultiplier = 2.0
)
// LegacyLongContextRule 返回平台的旧长上下文规则;无规则的平台返回 nil。
func (s *BillingService) LegacyLongContextRule(platform string) *LegacyLongContextRule {
if platform == PlatformGemini {
return &LegacyLongContextRule{
Threshold: geminiLegacyLongContextThreshold,
Multiplier: geminiLegacyLongContextMultiplier,
}
}
return nil
}
// TokenCostRequest 通用网关 token 计费请求。
type TokenCostRequest struct {
Ctx context.Context
@@ -44,27 +17,12 @@ type TokenCostRequest struct {
Resolver *ModelPricingResolver
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
Resolved *ResolvedPricing
// LegacyLongContext 入口携带的旧长上下文规则,nil 表示该入口不使用。
LegacyLongContext *LegacyLongContextRule
}
// legacyLongContextApplies 判定请求是否走旧长上下文规则:
// 分组/渠道显式定价优先;否则在规则存在且分组长上下文开关开启时生效。
func legacyLongContextApplies(resolved *ResolvedPricing, group *Group, rule *LegacyLongContextRule) bool {
if rule == nil || rule.Threshold <= 0 {
return false
}
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
return false
}
return group == nil || group.LongContextPricingEnabled
}
// CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用:
// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中);
// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费;
// 3. 否则有解析器与分组 → 统一计费(内置目录定价);
// 4. 否则按模型目录直接计费。
// 1. 分组/渠道显式定价,或有解析器与分组 → 统一计费
// (区间、分组卡、目录长上下文阶梯均在其中,阶梯由目录数据驱动);
// 2. 否则按模型目录直接计费。
//
// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。
func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) {
@@ -72,10 +30,6 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
}
if legacyLongContextApplies(resolved, req.Group, req.LegacyLongContext) {
return s.CalculateCostWithLongContext(req.Model, req.Tokens, req.RateMultiplier,
req.LegacyLongContext.Threshold, req.LegacyLongContext.Multiplier)
}
if req.Resolver != nil && req.Group != nil {
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
}
@@ -28,6 +28,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo
return bs, NewModelPricingResolver(cs, bs)
}
// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。
func geminiCatalogStub() *PricingService {
return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gemini-2.5-pro": {
@@ -39,22 +40,29 @@ func geminiCatalogStub() *PricingService {
})
}
func TestLegacyLongContextRule_OnlyGemini(t *testing.T) {
bs := NewBillingService(&config.Config{}, nil)
rule := bs.LegacyLongContextRule(PlatformGemini)
require.NotNil(t, rule)
require.Equal(t, 200000, rule.Threshold)
require.InDelta(t, 2.0, rule.Multiplier, 1e-12)
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} {
require.Nil(t, bs.LegacyLongContextRule(platform), platform)
}
// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价,
// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。
const geminiLadderCatalogJSON = `{
"gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
"input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
"cache_read_input_token_cost": 3.125e-07,
"input_cost_per_token_above_200k_tokens": 2.5e-06,
"output_cost_per_token_above_200k_tokens": 1.5e-05,
"cache_read_input_token_cost_above_200k_tokens": 6.25e-07}
}`
func geminiLadderCatalogStub(t *testing.T) *PricingService {
t.Helper()
return newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON)
}
func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testing.T) {
// 渠道平价之上叠加目录阶梯:与分组价卡/OpenAI 渠道价的既有语义一致,
// 超阈值整单按渠道价 × 目录倍率。
func TestCalculateTokenCostForRequest_ChannelFlatPriceStacksCatalogLadder(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
}}, geminiCatalogStub())
}}, geminiLadderCatalogStub(t))
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
gid := group.ID
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
@@ -63,23 +71,41 @@ func TestCalculateTokenCostForRequest_ChannelPricingWinsOverLegacyRule(t *testin
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
Resolver: resolver, Resolved: resolved, LegacyLongContext: bs.LegacyLongContextRule(PlatformGemini),
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
want, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
require.Equal(t, want, got)
// 渠道平价 10e-6 × 300K,旧规则未叠加
require.InDelta(t, 3.0, got.InputCost, 1e-9)
require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9)
require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9)
require.True(t, got.LongContextBillingApplied)
}
func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
// 渠道配置了定价区间时以渠道区间为准:目录阶梯(倍率)不再叠加。
func TestCalculateTokenCostForRequest_ChannelIntervalsOverrideCatalogLadder(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6)}},
}}, geminiLadderCatalogStub(t))
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
gid := group.ID
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
require.Equal(t, PricingSourceChannel, resolved.Source)
require.NotEmpty(t, resolved.Intervals)
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
require.InDelta(t, 300000*10e-6, got.InputCost, 1e-9)
require.InDelta(t, 1000*40e-6, got.OutputCost, 1e-9)
require.False(t, got.LongContextBillingApplied)
}
// 目录阶梯跟随分组长上下文开关;开启时为整单换档(输入 ×2、输出 ×1.5)。
func TestCalculateTokenCostForRequest_CatalogLadderFollowsGroupToggle(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiLadderCatalogStub(t))
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
rule := bs.LegacyLongContextRule(PlatformGemini)
for _, enabled := range []bool{true, false} {
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
@@ -89,32 +115,39 @@ func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T)
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
Resolver: resolver, Resolved: resolved, LegacyLongContext: rule,
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
if enabled {
want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier)
require.NoError(t, err)
require.Equal(t, want, got)
// 输入 200K × 1.25e-6 + 超出 100K × 1.25e-6 × 2 = 0.5;输出 1000 × 10e-6 = 0.01。
// 旧路径的加倍只体现在 ActualCost(分项 InputCost 不含倍率),探针也据此取值。
require.InDelta(t, 0.51, got.ActualCost, 1e-9)
// 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015
require.InDelta(t, 0.765, got.ActualCost, 1e-9)
require.True(t, got.LongContextBillingApplied)
} else {
want, err := bs.CalculateCostUnified(CostInput{
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
require.Equal(t, want, got)
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
require.False(t, got.LongContextBillingApplied)
}
}
}
// 目录条目没有阶梯字段时,开关开启也不产生阶梯。
func TestCalculateTokenCostForRequest_NoLadderFieldsMeansNoLadder(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, nil, geminiCatalogStub())
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
gid := group.ID
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
require.InDelta(t, 0.385, got.ActualCost, 1e-9)
require.False(t, got.LongContextBillingApplied)
}
func TestCalculateTokenCostForRequest_BuiltInPricingUsesUnifiedPath(t *testing.T) {
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, nil)
bs, resolver := newTokenCostTestEnv(t, PlatformOpenAI, nil, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true}
gid := group.ID
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
@@ -456,45 +456,6 @@ func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testi
require.Equal(t, 1, quotaSvc.quotaCalls)
}
func TestGatewayServiceRecordUsageWithLongContext_BillingUsesDetachedContext(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: context.DeadlineExceeded}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo)
reqCtx, cancel := context.WithCancel(context.Background())
cancel()
err := svc.RecordUsageWithLongContext(reqCtx, &RecordUsageLongContextInput{
Result: &ForwardResult{
RequestID: "gateway_long_context_detached_ctx",
Usage: ClaudeUsage{
InputTokens: 12,
OutputTokens: 8,
},
Model: "claude-sonnet-4",
Duration: time.Second,
},
APIKey: &APIKey{
ID: 502,
Quota: 100,
},
User: &User{ID: 602},
Account: &Account{ID: 702},
LongContextThreshold: 200000,
LongContextMultiplier: 2,
APIKeyService: quotaSvc,
})
require.NoError(t, err)
require.Equal(t, 1, usageRepo.calls)
require.Equal(t, 1, userRepo.deductCalls)
require.NoError(t, userRepo.lastCtxErr)
require.Equal(t, 1, quotaSvc.quotaCalls)
require.NoError(t, quotaSvc.lastQuotaCtxErr)
}
func TestGatewayServiceRecordUsage_UsesFallbackRequestIDForUsageLog(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -596,13 +596,6 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
}
}
// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。
type recordUsageOpts struct {
// 长上下文计费(仅 Gemini 路径需要)
LongContextThreshold int
LongContextMultiplier float64
}
// RecordUsage 记录使用量并扣费(或更新订阅用量)
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
return s.recordUsageCore(ctx, &recordUsageCoreInput{
@@ -622,54 +615,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
APIKeyService: input.APIKeyService,
QuotaPlatform: input.QuotaPlatform,
ChannelUsageFields: input.ChannelUsageFields,
}, &recordUsageOpts{})
}
// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费)
type RecordUsageLongContextInput struct {
Result *ForwardResult
APIKey *APIKey
User *User
Account *Account
Subscription *UserSubscription // 可选:订阅信息
PricingAt time.Time // token 售价固定时刻;零值保持既有的记录时刻语义
InboundEndpoint string // 入站端点(客户端请求路径)
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
LongContextThreshold int // 长上下文阈值(如 200000)
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0)
ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选)
QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform)
ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析)
}
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error {
return s.recordUsageCore(ctx, &recordUsageCoreInput{
Result: input.Result,
APIKey: input.APIKey,
User: input.User,
Account: input.Account,
Subscription: input.Subscription,
PricingAt: input.PricingAt,
InboundEndpoint: input.InboundEndpoint,
UpstreamEndpoint: input.UpstreamEndpoint,
UserAgent: input.UserAgent,
IPAddress: input.IPAddress,
SessionID: input.SessionID,
RequestPayloadHash: input.RequestPayloadHash,
ForceCacheBilling: input.ForceCacheBilling,
APIKeyService: input.APIKeyService,
QuotaPlatform: input.QuotaPlatform,
ChannelUsageFields: input.ChannelUsageFields,
}, &recordUsageOpts{
LongContextThreshold: input.LongContextThreshold,
LongContextMultiplier: input.LongContextMultiplier,
})
}
@@ -767,9 +712,8 @@ func logResponseModelBillingApplied(component string, account *Account, requestI
slog.Info("billing.response_model_applied", attrs...)
}
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
// recordUsageCore 是 RecordUsage 的核心实现。
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error {
result := input.Result
apiKey := input.APIKey
user := input.User
@@ -839,7 +783,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
}
// 计算费用
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt, opts)
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, pricingAt)
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
@@ -851,7 +795,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0,
); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) {
if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified {
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt, opts)
responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt)
baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil
if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
// billingModel 到此为止只是定价查表的入参,后续流程只消费 cost,
@@ -872,7 +816,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
// 创建使用日志
accountRateMultiplier := account.BillingRateMultiplier()
usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts)
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost)
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
if apiKey.GroupID != nil {
@@ -932,7 +876,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
return nil
}
// calculateRecordUsageCost 根据请求类型和选项计算费用。
// calculateRecordUsageCost 根据请求类型计算费用。
func (s *GatewayService) calculateRecordUsageCost(
ctx context.Context,
result *ForwardResult,
@@ -941,12 +885,11 @@ func (s *GatewayService) calculateRecordUsageCost(
multiplier float64,
imageMultiplier float64,
pricingAt time.Time,
opts *recordUsageOpts,
) *CostBreakdown {
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
if result.ImageCount > 0 {
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts)
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
}
return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
}
@@ -970,7 +913,7 @@ func (s *GatewayService) calculateRecordUsageCost(
}
// Token 计费;SearchCount 为叠加 surcharge(不替代 token)。
tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt, opts)
tokenCost := s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, pricingAt)
if result.SearchCount > 0 {
price := groupSearchPricePer1kFromAPIKey(apiKey)
if price != nil && *price == 0 {
@@ -1122,7 +1065,7 @@ func (s *GatewayService) calculateImageCost(
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
}
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价)
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价)
// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。
func (s *GatewayService) calculateTokenCost(
ctx context.Context,
@@ -1131,7 +1074,6 @@ func (s *GatewayService) calculateTokenCost(
billingModel string,
multiplier float64,
pricingAt time.Time,
opts *recordUsageOpts,
) *CostBreakdown {
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
@@ -1148,22 +1090,17 @@ func (s *GatewayService) calculateTokenCost(
gid := apiKey.Group.ID
resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group})
}
var legacy *LegacyLongContextRule
if opts.LongContextThreshold > 0 {
legacy = &LegacyLongContextRule{Threshold: opts.LongContextThreshold, Multiplier: opts.LongContextMultiplier}
}
cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: ctx,
Model: billingModel,
Group: apiKey.Group,
Tokens: tokens,
RateMultiplier: multiplier,
PricingAt: pricingAt,
ServiceTier: optionalStringValue(result.ServiceTier),
Resolver: s.resolver,
Resolved: resolved,
LegacyLongContext: legacy,
Ctx: ctx,
Model: billingModel,
Group: apiKey.Group,
Tokens: tokens,
RateMultiplier: multiplier,
PricingAt: pricingAt,
ServiceTier: optionalStringValue(result.ServiceTier),
Resolver: s.resolver,
Resolved: resolved,
})
if err != nil {
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
@@ -1172,14 +1109,6 @@ func (s *GatewayService) calculateTokenCost(
return cost
}
// LegacyLongContextRule 透传 BillingService 的平台旧长上下文规则,供入口 handler 取用。
func (s *GatewayService) LegacyLongContextRule(platform string) *LegacyLongContextRule {
if s == nil || s.billingService == nil {
return nil
}
return s.billingService.LegacyLongContextRule(platform)
}
// buildRecordUsageLog 构建使用日志并设置计费模式。
func (s *GatewayService) buildRecordUsageLog(
ctx context.Context,
@@ -1196,7 +1125,6 @@ func (s *GatewayService) buildRecordUsageLog(
billingType int8,
cacheTTLOverridden bool,
cost *CostBreakdown,
opts *recordUsageOpts,
) *UsageLog {
durationMs := int(result.Duration.Milliseconds())
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
@@ -362,7 +362,8 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
{ID: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
{ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
}
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI}, nil)
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformOpenAI, 20: PlatformOpenAI},
newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
@@ -395,26 +396,27 @@ func TestListGroups_TokenLadderFollowsGroupToggle(t *testing.T) {
}
}
func TestListGroups_GeminiLegacyRuleShownAsMarginal(t *testing.T) {
func TestListGroups_GeminiCatalogLadderShownWholeRequest(t *testing.T) {
channels := []Channel{{
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
}}
groups := []Group{{ID: 10, Name: "g", Platform: PlatformGemini, RateMultiplier: 1, LongContextPricingEnabled: true}}
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini}, geminiCatalogStub())
svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformGemini},
newStubPricingServiceFromJSON(t, geminiLadderCatalogJSON))
out, err := svc.ListGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
m := out[0].Models[0]
require.Equal(t, ContextPricingBasisMarginal, m.LongContextBasis)
require.Equal(t, ContextPricingBasisWholeRequest, m.LongContextBasis)
require.Len(t, m.Pricing.Intervals, 2)
require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
require.Equal(t, ">200K", m.Pricing.Intervals[1].TierLabel)
require.InDelta(t, 2.5e-6, *m.Pricing.Intervals[1].InputPrice, 1e-15)
require.InDelta(t, 10e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
// 官方参考不套用站内旧规则
require.InDelta(t, 15e-6, *m.Pricing.Intervals[1].OutputPrice, 1e-15)
// 官方参考价与实付同源:都来自目录数据的阶梯字段
require.NotNil(t, m.OfficialPricing)
require.Empty(t, m.OfficialPricing.Intervals)
require.Len(t, m.OfficialPricing.Intervals, 2)
}
func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
@@ -1182,11 +1182,21 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGrou
require.Equal(t, 1, userRepo.deductCalls)
}
// swapInOpenAILadderCatalog 给测试服务换上带 above_272k 阶梯字段的目录:
// 静态兜底价已不带阶梯,长上下文相关测试需要目录数据。
func swapInOpenAILadderCatalog(t *testing.T, svc *OpenAIGatewayService) {
t.Helper()
cfg := &config.Config{}
cfg.Default.RateMultiplier = 1.1
svc.billingService = NewBillingService(cfg, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
}
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
swapInOpenAILadderCatalog(t, svc)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
@@ -1227,6 +1237,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
t.Run("group on account off", func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
swapInOpenAILadderCatalog(t, svc)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true),
@@ -1242,6 +1253,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
t.Run("group off account on", func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
swapInOpenAILadderCatalog(t, svc)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{RequestID: "resp_and_group_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false),
@@ -1260,6 +1272,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
t.Run("group on account on", func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
swapInOpenAILadderCatalog(t, svc)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true),
@@ -1352,6 +1365,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett
&openAIRecordUsageSubRepoStub{},
nil,
)
swapInOpenAILadderCatalog(t, svc)
svc.accountRepo = accountRepo
parentID := int64(4016)
@@ -2784,7 +2798,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun
0.15,
1.0,
time.Time{},
nil,
)
require.NotNil(t, cost)
@@ -2824,7 +2837,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(
1.0,
1.0,
time.Time{},
nil,
)
require.NotNil(t, cost)
@@ -2857,7 +2869,6 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI
1.0,
1.0,
time.Time{},
nil,
)
require.NotNil(t, cost)
@@ -2947,7 +2958,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis
1.0,
1.0,
time.Time{},
nil,
)
require.NotNil(t, cost)
+126 -20
View File
@@ -1,6 +1,7 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -10,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -22,18 +24,19 @@ import (
)
var (
openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
// aboveTierPricePattern 匹配 LiteLLM 长上下文绝对价字段名
// (input_cost_per_token_above_272k_tokens / output_cost_per_token_above_200k_tokens 等)。
// 带 _flex/_priority 服务档后缀的变体与 cache 侧字段不参与阈值/倍率折算。
aboveTierPricePattern = regexp.MustCompile(`^(input|output)_cost_per_token_above_(\d+)k_tokens$`)
openAIGPT54FallbackPricing = &LiteLLMModelPricing{
InputCostPerToken: 2.5e-06, // $2.5 per MTok
OutputCostPerToken: 1.5e-05, // $15 per MTok
CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
LongContextInputTokenThreshold: 272000,
LongContextInputCostMultiplier: 2.0,
LongContextOutputCostMultiplier: 1.5,
LiteLLMProvider: "openai",
Mode: "chat",
SupportsPromptCaching: true,
InputCostPerToken: 2.5e-06, // $2.5 per MTok
OutputCostPerToken: 1.5e-05, // $15 per MTok
CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
LiteLLMProvider: "openai",
Mode: "chat",
SupportsPromptCaching: true,
}
openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{
InputCostPerToken: 5e-06,
@@ -44,9 +47,6 @@ var (
CacheCreationInputTokenCostPriority: 1.25e-05,
CacheReadInputTokenCost: 5e-07,
CacheReadInputTokenCostPriority: 1e-06,
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -61,9 +61,6 @@ var (
CacheCreationInputTokenCostPriority: 5e-06,
CacheReadInputTokenCost: 2e-07,
CacheReadInputTokenCostPriority: 4e-07,
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -78,9 +75,6 @@ var (
CacheCreationInputTokenCostPriority: 5e-07,
CacheReadInputTokenCost: 2e-08,
CacheReadInputTokenCostPriority: 4e-08,
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
SupportsServiceTier: true,
LiteLLMProvider: "openai",
Mode: "chat",
@@ -408,6 +402,7 @@ func (s *PricingService) downloadPricingData() error {
// 更新内存数据
s.mu.Lock()
warnDroppedLongContextLadders(s.pricingData, data)
s.pricingData = data
s.lastUpdated = time.Now()
s.localHash = syncHash
@@ -500,6 +495,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
}
hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil ||
entry.LongContextInputCostMultiplier != nil ||
entry.LongContextOutputCostMultiplier != nil
if !hasExplicitLongContext {
deriveLongContextFromAboveTierFields(rawEntry, pricing)
}
result[modelName] = pricing
}
@@ -514,6 +516,81 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
return result, nil
}
// deriveLongContextFromAboveTierFields 把 LiteLLM 目录的 *_above_XXXk_tokens 绝对价字段
// 折算成 long_context_* 阈值+倍率(sub2api 计费机制的内部表达):阈值取自字段名,
// 倍率 = above 价 ÷ 基础价。条目显式携带任一 long_context_* 字段(含显式 0)时由
// 调用方跳过折算,以显式配置为准——显式写 threshold=0 或 multiplier=1 均可关闭该
// 模型的阶梯。多个阈值并存时取最小阈值。
// cache_read/cache_creation 的 above 档在计费中统一跟随输入倍率,不单独折算;
// 现网 OpenAI/Claude 条目(含 5m/1h 分档)的 cache above 档均恰为基础价 × 输入倍率,
// Gemini pro 系存在无基础价的孤儿 cache_creation above 字段(数据缺陷,计费两侧均按 0)。
func deriveLongContextFromAboveTierFields(rawEntry json.RawMessage, pricing *LiteLLMModelPricing) {
if pricing == nil ||
pricing.LongContextInputTokenThreshold > 0 ||
pricing.LongContextInputCostMultiplier > 0 ||
pricing.LongContextOutputCostMultiplier > 0 {
return
}
if !bytes.Contains(rawEntry, []byte("_above_")) {
return
}
var fields map[string]any
if err := json.Unmarshal(rawEntry, &fields); err != nil {
return
}
type tierPrices struct{ input, output float64 }
tiers := make(map[int]*tierPrices)
for key, value := range fields {
m := aboveTierPricePattern.FindStringSubmatch(key)
if m == nil {
continue
}
price, ok := value.(float64)
if !ok || price <= 0 {
continue
}
thousands, err := strconv.Atoi(m[2])
if err != nil || thousands <= 0 {
continue
}
threshold := thousands * 1000
tp := tiers[threshold]
if tp == nil {
tp = &tierPrices{}
tiers[threshold] = tp
}
if m[1] == "input" {
tp.input = price
} else {
tp.output = price
}
}
if len(tiers) == 0 {
return
}
threshold := 0
for t := range tiers {
if threshold == 0 || t < threshold {
threshold = t
}
}
tp := tiers[threshold]
inputMultiplier, outputMultiplier := 1.0, 1.0
if tp.input > 0 && pricing.InputCostPerToken > 0 {
inputMultiplier = tp.input / pricing.InputCostPerToken
}
if tp.output > 0 && pricing.OutputCostPerToken > 0 {
outputMultiplier = tp.output / pricing.OutputCostPerToken
}
// above 价不高于基础价时视为无附加费,不生成阶梯。
if inputMultiplier <= 1 && outputMultiplier <= 1 {
return
}
pricing.LongContextInputTokenThreshold = threshold
pricing.LongContextInputCostMultiplier = inputMultiplier
pricing.LongContextOutputCostMultiplier = outputMultiplier
}
// loadPricingData 从本地文件加载价格数据
func (s *PricingService) loadPricingData(filePath string) error {
data, err := os.ReadFile(filePath)
@@ -533,6 +610,7 @@ func (s *PricingService) loadPricingData(filePath string) error {
hashStr := hex.EncodeToString(hash[:])
s.mu.Lock()
warnDroppedLongContextLadders(s.pricingData, pricingData)
s.pricingData = pricingData
s.localHash = hashStr
@@ -579,6 +657,34 @@ func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelP
return data
}
// warnDroppedLongContextLadders 对比新旧目录数据:原本带长上下文阶梯的条目在新数据里
// 丢失阈值时打 WARN。阶梯已完全数据驱动(无代码兜底),数据源一次误提交就会把阶梯
// 静默变成基础价少收(07-14~08-21 漏收事故的形态),这里是唯一的哨兵。
// 调用方需持有 s.mu 写锁。
func warnDroppedLongContextLadders(old, next map[string]*LiteLLMModelPricing) {
if len(old) == 0 {
return
}
var dropped []string
for name, prev := range old {
if prev == nil || prev.LongContextInputTokenThreshold <= 0 {
continue
}
if cur, ok := next[name]; ok && (cur == nil || cur.LongContextInputTokenThreshold <= 0) {
dropped = append(dropped, name)
}
}
if len(dropped) == 0 {
return
}
sort.Strings(dropped)
total := len(dropped)
if total > 20 {
dropped = append(dropped[:20], "...")
}
logger.LegacyPrintf("service.pricing", "[Pricing] Long-context ladder dropped for %d model(s) after reload: %s (verify catalog/override data if unintended)", total, strings.Join(dropped, ", "))
}
// useFallbackPricing 使用回退价格文件
func (s *PricingService) useFallbackPricing() error {
fallbackFile := s.cfg.Pricing.FallbackFile
+185 -11
View File
@@ -109,9 +109,8 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.
require.NoError(t, err)
require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12)
require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12)
require.Equal(t, 272000, pricing.LongContextInputThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
// 阶梯由目录数据驱动:条目无 above/long_context 字段时不再由策略强补。
require.Zero(t, pricing.LongContextInputThreshold)
tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100}
standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "")
@@ -129,6 +128,32 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.
}
}
// gpt56LadderCatalogJSON 三个 5.6 模型的目录条目:above_272k 绝对价 + priority 平价,
// cache_write 缺失由策略按 1.25 倍输入价补齐。
const gpt56LadderCatalogJSON = `{
"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1e-05,
"output_cost_per_token": 3e-05, "output_cost_per_token_priority": 6e-05,
"cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 1e-06,
"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},
"gpt-5.6-terra": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 2e-06, "input_cost_per_token_priority": 4e-06,
"output_cost_per_token": 1.2e-05, "output_cost_per_token_priority": 2.4e-05,
"cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_priority": 4e-07,
"input_cost_per_token_above_272k_tokens": 4e-06,
"output_cost_per_token_above_272k_tokens": 1.8e-05,
"cache_read_input_token_cost_above_272k_tokens": 4e-07},
"gpt-5.6-luna": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 2e-07, "input_cost_per_token_priority": 4e-07,
"output_cost_per_token": 1.2e-06, "output_cost_per_token_priority": 2.4e-06,
"cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_priority": 4e-08,
"input_cost_per_token_above_272k_tokens": 4e-07,
"output_cost_per_token_above_272k_tokens": 1.8e-06,
"cache_read_input_token_cost_above_272k_tokens": 4e-08}
}`
func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testing.T) {
models := []struct {
name string
@@ -157,7 +182,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi
for _, model := range models {
for _, tier := range tiers {
t.Run(model.name+"/"+tier.name, func(t *testing.T) {
svc := NewBillingService(&config.Config{}, nil)
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON))
serviceTier := ""
if tier.name != "standard" {
serviceTier = tier.name
@@ -174,7 +199,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi
}
func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) {
svc := NewBillingService(&config.Config{}, nil)
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON))
tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 100000, CacheReadTokens: 72000, OutputTokens: 10}
cost, err := svc.CalculateCost("gpt-5.6-sol", tokens, 1)
@@ -284,9 +309,8 @@ func assertGPT56FallbackPricing(t *testing.T, pricing *ModelPricing, input, cach
require.InDelta(t, cached, pricing.CacheReadPricePerToken, 1e-12)
require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12)
require.Equal(t, 272000, pricing.LongContextInputThreshold)
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
// 静态兜底只兜基础价;阶梯由目录数据(above_272k 折算或显式字段)驱动。
require.Zero(t, pricing.LongContextInputThreshold)
}
func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) {
@@ -436,9 +460,8 @@ func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T)
require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 1e-12)
require.Equal(t, 272000, got.LongContextInputTokenThreshold)
require.InDelta(t, 2.0, got.LongContextInputCostMultiplier, 1e-12)
require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12)
// 静态兜底只兜基础价,不携带长上下文阶梯(阶梯由目录数据驱动)。
require.Zero(t, got.LongContextInputTokenThreshold)
}
func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) {
@@ -719,3 +742,154 @@ func TestListModelNamesByProvider_EmptyCatalog(t *testing.T) {
require.NotNil(t, got)
require.Empty(t, got)
}
// --- above_XXXk 绝对价字段折算为阈值+倍率 ---
func TestParsePricingData_DerivesLongContextFromAboveTierFields(t *testing.T) {
svc := &PricingService{}
data, err := svc.parsePricingData([]byte(`{
"gpt-above": {"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,
"input_cost_per_token_above_272k_tokens_flex": 5e-06,
"output_cost_per_token_above_272k_tokens_flex": 2.25e-05},
"gemini-above": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
"input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
"input_cost_per_token_above_200k_tokens": 2.5e-06,
"output_cost_per_token_above_200k_tokens": 1.5e-05},
"explicit-wins": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
"long_context_input_cost_multiplier": 1,
"long_context_output_cost_multiplier": 1,
"input_cost_per_token_above_272k_tokens": 1e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05},
"no-surcharge": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
"input_cost_per_token_above_272k_tokens": 5e-06,
"output_cost_per_token_above_272k_tokens": 3e-05},
"cache-only-above": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
"cache_read_input_token_cost_above_272k_tokens": 1e-06},
"multi-threshold": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06,
"input_cost_per_token_above_128k_tokens": 2e-06,
"input_cost_per_token_above_272k_tokens": 4e-06}
}`))
require.NoError(t, err)
openai := data["gpt-above"]
require.Equal(t, 272000, openai.LongContextInputTokenThreshold, "阈值取自字段名(_flex 变体不参与)")
require.InDelta(t, 2.0, openai.LongContextInputCostMultiplier, 1e-12)
require.InDelta(t, 1.5, openai.LongContextOutputCostMultiplier, 1e-12)
gemini := data["gemini-above"]
require.Equal(t, 200000, gemini.LongContextInputTokenThreshold)
require.InDelta(t, 2.0, gemini.LongContextInputCostMultiplier, 1e-12)
require.InDelta(t, 1.5, gemini.LongContextOutputCostMultiplier, 1e-12)
explicit := data["explicit-wins"]
require.Zero(t, explicit.LongContextInputTokenThreshold, "显式 long_context_* 字段优先,不做折算")
require.InDelta(t, 1.0, explicit.LongContextInputCostMultiplier, 1e-12)
require.Zero(t, data["no-surcharge"].LongContextInputTokenThreshold, "above 价不高于基础价视为无附加费")
require.Zero(t, data["cache-only-above"].LongContextInputTokenThreshold, "仅 cache 侧 above 字段不构成阶梯")
require.Equal(t, 128000, data["multi-threshold"].LongContextInputTokenThreshold, "多阈值取最小")
}
func TestGetModelPricing_XAIThresholdInclusive(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
"grok-4.5": {"litellm_provider": "xai", "mode": "chat",
"input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"output_cost_per_token_above_200k_tokens": 1.2e-05}
}`))
pricing, err := svc.GetModelPricing("grok-4.5")
require.NoError(t, err)
require.Equal(t, 200000, pricing.LongContextInputThreshold)
require.True(t, pricing.LongContextThresholdInclusive, "xAI 阈值语义为达到即进高档")
}
// F3:显式 long_context 字段以"字段存在"为准——显式 0 也能压住 above 折算,关闭阶梯。
func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) {
svc := &PricingService{}
data, err := svc.parsePricingData([]byte(`{
"gpt-5.5": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
"long_context_input_token_threshold": 0,
"input_cost_per_token_above_272k_tokens": 1e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05}
}`))
require.NoError(t, err)
require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold)
require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier)
}
// F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。
func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) {
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000}
t.Run("only input multiplier", func(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
"partial-in": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05,
"cache_read_input_token_cost": 2e-07,
"long_context_input_token_threshold": 272000,
"long_context_input_cost_multiplier": 2.0}
}`))
cost, err := svc.CalculateCost("partial-in", tokens, 1.0)
require.NoError(t, err)
require.True(t, cost.LongContextBillingApplied)
require.InDelta(t, 300000*2e-6*2, cost.InputCost, 1e-10)
require.InDelta(t, 1000*1e-5, cost.OutputCost, 1e-10, "缺失的 output 倍率按 1 计,不得为 0")
require.InDelta(t, 10000*2e-7*2, cost.CacheReadCost, 1e-10)
})
t.Run("only output multiplier", func(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
"partial-out": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05,
"cache_read_input_token_cost": 2e-07,
"long_context_input_token_threshold": 272000,
"long_context_output_cost_multiplier": 1.5}
}`))
cost, err := svc.CalculateCost("partial-out", tokens, 1.0)
require.NoError(t, err)
require.True(t, cost.LongContextBillingApplied)
require.InDelta(t, 300000*2e-6, cost.InputCost, 1e-10, "缺失的 input 倍率按 1 计,不得为 0")
require.InDelta(t, 1000*1e-5*1.5, cost.OutputCost, 1e-10)
require.InDelta(t, 10000*2e-7, cost.CacheReadCost, 1e-10, "cache_read 跟随 input 倍率,同样按 1 计")
})
}
// 行为声明:目录带 above_200k 的 Claude sonnet 条目同样获得数据驱动的整单阶梯
// (与 Anthropic 官方 1M 长上下文定价一致),受分组长上下文开关约束。
func TestCalculateCost_ClaudeSonnetCatalogLadderIsDataDriven(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
"claude-sonnet-4-5": {"litellm_provider": "anthropic", "mode": "chat",
"input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05,
"cache_read_input_token_cost": 3e-07,
"input_cost_per_token_above_200k_tokens": 6e-06,
"output_cost_per_token_above_200k_tokens": 2.25e-05,
"cache_read_input_token_cost_above_200k_tokens": 6e-07}
}`))
pricing, err := svc.GetModelPricing("claude-sonnet-4-5")
require.NoError(t, err)
require.Equal(t, 200000, pricing.LongContextInputThreshold)
require.False(t, pricing.LongContextThresholdInclusive, "anthropic 为严格大于")
over := UsageTokens{InputTokens: 250000, OutputTokens: 1000}
cost, err := svc.CalculateCost("claude-sonnet-4-5", over, 1.0)
require.NoError(t, err)
require.True(t, cost.LongContextBillingApplied)
require.InDelta(t, 250000*3e-6*2, cost.InputCost, 1e-10)
require.InDelta(t, 1000*1.5e-5*1.5, cost.OutputCost, 1e-10)
under := UsageTokens{InputTokens: 200000, OutputTokens: 1000}
cost, err = svc.CalculateCost("claude-sonnet-4-5", under, 1.0)
require.NoError(t, err)
require.False(t, cost.LongContextBillingApplied, "恰好 200000 不进高档(严格大于)")
}
@@ -0,0 +1,33 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// openAILadderCatalogJSON 镜像真实同步目录的形态:长上下文用 above_272k 绝对价字段表达,
// 由解析层折算成阈值+倍率。静态 Go 兜底价不再携带阶梯,阶梯计费一律走目录数据。
const openAILadderCatalogJSON = `{
"gpt-5.4": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 2.5e-06, "output_cost_per_token": 1.5e-05,
"cache_read_input_token_cost": 2.5e-07, "cache_creation_input_token_cost": 2.5e-06,
"input_cost_per_token_above_272k_tokens": 5e-06,
"output_cost_per_token_above_272k_tokens": 2.25e-05,
"cache_read_input_token_cost_above_272k_tokens": 5e-07},
"gpt-5.5-pro": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 3e-05, "output_cost_per_token": 1.8e-04,
"input_cost_per_token_above_272k_tokens": 6e-05,
"output_cost_per_token_above_272k_tokens": 2.7e-04}
}`
// newStubPricingServiceFromJSON 用与生产一致的解析路径(含 above_XXXk 阶梯折算)
// 从原始目录 JSON 构造目录 stub。无 build tag:带 unit 标签与默认构建的测试文件都会用到。
func newStubPricingServiceFromJSON(t *testing.T, body string) *PricingService {
t.Helper()
s := &PricingService{}
data, err := s.parsePricingData([]byte(body))
require.NoError(t, err)
s.pricingData = data
return s
}
+2 -2
View File
@@ -1054,8 +1054,8 @@ plugins:
# 定价数据源(可选)
# =============================================================================
pricing:
# URL to fetch model pricing data (default: pinned model-price-repo commit)
# 获取模型定价数据的 URL(默认:固定 commit 的 model-price-repo)
# URL to fetch model pricing data (default: model-price-repo main branch)
# 获取模型定价数据的 URL(默认:model-price-repo main 分支)
remote_url: "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/refs/heads/main//model_prices_and_context_window.json"
# Hash verification URL (optional)
# 哈希校验 URL(可选)