mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
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:
@@ -2279,7 +2279,7 @@ func setDefaults() {
|
|||||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||||
viper.SetDefault("rate_limit.oauth_401_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.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.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||||
viper.SetDefault("pricing.data_dir", "./data")
|
viper.SetDefault("pricing.data_dir", "./data")
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {
|
|||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// Context helpers — used by handlers before building
|
// Context helpers — used by handlers before building
|
||||||
// RecordUsageInput / RecordUsageLongContextInput.
|
// RecordUsageInput.
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
||||||
|
|||||||
@@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
|||||||
forceCacheBilling := fs.ForceCacheBilling
|
forceCacheBilling := fs.ForceCacheBilling
|
||||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||||
sessionID := service.ExtractClientSessionID(c)
|
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) {
|
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||||
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
|
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||||
Result: result,
|
Result: result,
|
||||||
QuotaPlatform: quotaPlatform,
|
QuotaPlatform: quotaPlatform,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
User: apiKey.User,
|
User: apiKey.User,
|
||||||
Account: account,
|
Account: account,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
PricingAt: pricingAt,
|
PricingAt: pricingAt,
|
||||||
InboundEndpoint: inboundEndpoint,
|
InboundEndpoint: inboundEndpoint,
|
||||||
UpstreamEndpoint: upstreamEndpoint,
|
UpstreamEndpoint: upstreamEndpoint,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
IPAddress: clientIP,
|
IPAddress: clientIP,
|
||||||
RequestPayloadHash: requestPayloadHash,
|
RequestPayloadHash: requestPayloadHash,
|
||||||
LongContextThreshold: longContextThreshold,
|
ForceCacheBilling: forceCacheBilling,
|
||||||
LongContextMultiplier: longContextMultiplier,
|
APIKeyService: h.apiKeyService,
|
||||||
ForceCacheBilling: forceCacheBilling,
|
SessionID: sessionID,
|
||||||
APIKeyService: h.apiKeyService,
|
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||||
SessionID: sessionID,
|
|
||||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.L().With(
|
logger.L().With(
|
||||||
zap.String("component", "handler.gemini_v1beta.models"),
|
zap.String("component", "handler.gemini_v1beta.models"),
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextPricingBasis 阶梯的计价基准。
|
// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
|
||||||
|
// Gemini 边际口径("marginal")已随平台旧规则一并移除。
|
||||||
type ContextPricingBasis string
|
type ContextPricingBasis string
|
||||||
|
|
||||||
const (
|
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
||||||
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
|
const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
||||||
ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
|
|
||||||
// ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。
|
|
||||||
ContextPricingBasisMarginal ContextPricingBasis = "marginal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
|
// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
|
||||||
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
|
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
|
||||||
@@ -73,8 +70,8 @@ const contextProbeDelta = 1000
|
|||||||
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
|
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
|
||||||
//
|
//
|
||||||
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
|
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
|
||||||
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。
|
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
|
||||||
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值),
|
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
|
||||||
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
|
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
|
||||||
// 规则变更无需同步到这里;相邻同价段会合并。
|
// 规则变更无需同步到这里;相邻同价段会合并。
|
||||||
//
|
//
|
||||||
@@ -103,22 +100,13 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
|||||||
return nil, nil
|
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{
|
req := TokenCostRequest{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Model: in.Model,
|
Model: in.Model,
|
||||||
Group: in.Group,
|
Group: in.Group,
|
||||||
RateMultiplier: 1,
|
RateMultiplier: 1,
|
||||||
Resolver: resolver,
|
Resolver: resolver,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
LegacyLongContext: legacy,
|
|
||||||
}
|
}
|
||||||
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
|
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
|
||||||
r := req
|
r := req
|
||||||
@@ -126,7 +114,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
|||||||
return s.CalculateTokenCostForRequest(r)
|
return s.CalculateTokenCostForRequest(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy)
|
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
|
||||||
segments := buildContextSegments(plan.bounds)
|
segments := buildContextSegments(plan.bounds)
|
||||||
|
|
||||||
tiers := make([]ContextPricingTier, 0, len(segments))
|
tiers := make([]ContextPricingTier, 0, len(segments))
|
||||||
@@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
|
|||||||
tiers = mergeEqualContextTiers(tiers)
|
tiers = mergeEqualContextTiers(tiers)
|
||||||
applyContextTierLabels(tiers, plan)
|
applyContextTierLabels(tiers, plan)
|
||||||
|
|
||||||
basis := ContextPricingBasisWholeRequest
|
return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
||||||
if legacy != nil {
|
|
||||||
basis = ContextPricingBasisMarginal
|
|
||||||
}
|
|
||||||
return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
|
// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
|
||||||
@@ -210,14 +194,8 @@ type contextBreakpointPlan struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
|
// 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{}
|
plan := contextBreakpointPlan{}
|
||||||
if legacy != nil {
|
|
||||||
plan.bounds = []int{legacy.Threshold}
|
|
||||||
plan.thresholdBound = legacy.Threshold
|
|
||||||
plan.threshold = legacy.Threshold
|
|
||||||
return plan
|
|
||||||
}
|
|
||||||
if !resolved.longContextPricingEnabled {
|
if !resolved.longContextPricingEnabled {
|
||||||
return plan
|
return plan
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) {
|
|||||||
require.InDelta(t, *want, *got, 1e-15, 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 {
|
func scheduleScenarios() []scheduleScenario {
|
||||||
p := testPtrFloat64
|
p := testPtrFloat64
|
||||||
return []scheduleScenario{
|
return []scheduleScenario{
|
||||||
{
|
{
|
||||||
name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
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) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 2)
|
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[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,
|
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) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 1)
|
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))
|
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,
|
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) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 2)
|
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))
|
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{{
|
group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
|
||||||
Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||||||
}}},
|
}}},
|
||||||
|
catalog: openAILadderCatalog(),
|
||||||
wantBasis: ContextPricingBasisWholeRequest,
|
wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 2)
|
require.Len(t, s.Tiers, 2)
|
||||||
@@ -219,46 +233,55 @@ func scheduleScenarios() []scheduleScenario {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal,
|
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 2)
|
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[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,
|
name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||||
group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 1)
|
require.Len(t, s.Tiers, 1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||||
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(),
|
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON),
|
||||||
channel: []ChannelModelPricing{{
|
channel: []ChannelModelPricing{{
|
||||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
|
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
|
||||||
}},
|
}},
|
||||||
wantBasis: ContextPricingBasisWholeRequest,
|
wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
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(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,
|
name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
|
||||||
group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 1)
|
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,
|
name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite,
|
||||||
group: enabledGroup(PlatformComposite),
|
group: enabledGroup(PlatformComposite),
|
||||||
channel: []ChannelModelPricing{{
|
channel: []ChannelModelPricing{{
|
||||||
Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
|
||||||
}},
|
}},
|
||||||
|
catalog: openAILadderCatalog(),
|
||||||
wantBasis: ContextPricingBasisWholeRequest,
|
wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 2)
|
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,
|
name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
|
||||||
group: enabledGroup(PlatformOpenAI),
|
group: enabledGroup(PlatformOpenAI),
|
||||||
catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
|
||||||
"gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6},
|
"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,
|
wantBasis: ContextPricingBasisWholeRequest,
|
||||||
check: func(t *testing.T, s *ContextPricingSchedule) {
|
check: func(t *testing.T, s *ContextPricingSchedule) {
|
||||||
require.Len(t, s.Tiers, 2)
|
require.Len(t, s.Tiers, 2)
|
||||||
@@ -398,22 +423,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
|
// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
|
||||||
// 整单基准取所在档单价 × 全量;边际基准逐段累加。
|
// 整单基准取所在档单价 × 全量。
|
||||||
func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
|
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)
|
return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,17 +454,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
|
|||||||
pricingInput.GroupID = &gid
|
pricingInput.GroupID = &gid
|
||||||
}
|
}
|
||||||
resolved := resolver.Resolve(ctx, pricingInput)
|
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 {
|
cost := func(tokens UsageTokens) float64 {
|
||||||
bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||||
Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1,
|
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)
|
require.NoError(t, err)
|
||||||
return bd.ActualCost
|
return bd.ActualCost
|
||||||
|
|||||||
@@ -116,12 +116,6 @@ type ModelPricing struct {
|
|||||||
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
|
ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
openAIGPT54LongContextInputThreshold = 272000
|
|
||||||
openAIGPT54LongContextInputMultiplier = 2.0
|
|
||||||
openAIGPT54LongContextOutputMultiplier = 1.5
|
|
||||||
)
|
|
||||||
|
|
||||||
func normalizeBillingServiceTier(serviceTier string) string {
|
func normalizeBillingServiceTier(serviceTier string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(serviceTier))
|
return strings.ToLower(strings.TrimSpace(serviceTier))
|
||||||
}
|
}
|
||||||
@@ -395,9 +389,6 @@ func (s *BillingService) initFallbackPricing() {
|
|||||||
CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok
|
CacheReadPricePerToken: 0.25e-6, // $0.25 per MTok
|
||||||
CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok
|
CacheReadPricePerTokenPriority: 0.5e-6, // $0.5 per MTok
|
||||||
SupportsCacheBreakdown: false,
|
SupportsCacheBreakdown: false,
|
||||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
}
|
}
|
||||||
// OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。
|
// OpenAI GPT-5.5 官方价格;Fast 为标准价 2.5 倍。
|
||||||
// Source: https://platform.openai.com/docs/pricing
|
// Source: https://platform.openai.com/docs/pricing
|
||||||
@@ -405,24 +396,18 @@ func (s *BillingService) initFallbackPricing() {
|
|||||||
InputPricePerToken: 5e-6,
|
InputPricePerToken: 5e-6,
|
||||||
OutputPricePerToken: 30e-6,
|
OutputPricePerToken: 30e-6,
|
||||||
// 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。
|
// 官方未列独立 cache-write 价;内部出现 cache creation token 时按输入价兜底。
|
||||||
CacheCreationPricePerToken: 5e-6,
|
CacheCreationPricePerToken: 5e-6,
|
||||||
CacheReadPricePerToken: 0.5e-6,
|
CacheReadPricePerToken: 0.5e-6,
|
||||||
SupportsCacheBreakdown: false,
|
SupportsCacheBreakdown: false,
|
||||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
}, 2.5)
|
}, 2.5)
|
||||||
// GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。
|
// GPT-5.5 Pro 当前不提供 Fast;保留标准、Flex 和长上下文 fallback 价格。
|
||||||
s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{
|
s.fallbackPrices["gpt-5.5-pro"] = &ModelPricing{
|
||||||
InputPricePerToken: 30e-6,
|
InputPricePerToken: 30e-6,
|
||||||
OutputPricePerToken: 180e-6,
|
OutputPricePerToken: 180e-6,
|
||||||
// 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。
|
// 官方未列独立 cached-input/cache-write 价;内部出现对应 token 时按输入价兜底。
|
||||||
CacheCreationPricePerToken: 30e-6,
|
CacheCreationPricePerToken: 30e-6,
|
||||||
CacheReadPricePerToken: 30e-6,
|
CacheReadPricePerToken: 30e-6,
|
||||||
SupportsCacheBreakdown: false,
|
SupportsCacheBreakdown: false,
|
||||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
|
// OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。
|
||||||
@@ -435,9 +420,6 @@ func (s *BillingService) initFallbackPricing() {
|
|||||||
CacheCreationPricePerTokenPriority: 12.5e-6,
|
CacheCreationPricePerTokenPriority: 12.5e-6,
|
||||||
CacheReadPricePerToken: 0.5e-6,
|
CacheReadPricePerToken: 0.5e-6,
|
||||||
CacheReadPricePerTokenPriority: 1e-6,
|
CacheReadPricePerTokenPriority: 1e-6,
|
||||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
}
|
}
|
||||||
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
|
s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
|
||||||
InputPricePerToken: 2e-6,
|
InputPricePerToken: 2e-6,
|
||||||
@@ -448,9 +430,6 @@ func (s *BillingService) initFallbackPricing() {
|
|||||||
CacheCreationPricePerTokenPriority: 5e-6,
|
CacheCreationPricePerTokenPriority: 5e-6,
|
||||||
CacheReadPricePerToken: 0.2e-6,
|
CacheReadPricePerToken: 0.2e-6,
|
||||||
CacheReadPricePerTokenPriority: 0.4e-6,
|
CacheReadPricePerTokenPriority: 0.4e-6,
|
||||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
}
|
}
|
||||||
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
|
s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
|
||||||
InputPricePerToken: 0.2e-6,
|
InputPricePerToken: 0.2e-6,
|
||||||
@@ -461,9 +440,6 @@ func (s *BillingService) initFallbackPricing() {
|
|||||||
CacheCreationPricePerTokenPriority: 0.5e-6,
|
CacheCreationPricePerTokenPriority: 0.5e-6,
|
||||||
CacheReadPricePerToken: 0.02e-6,
|
CacheReadPricePerToken: 0.02e-6,
|
||||||
CacheReadPricePerTokenPriority: 0.04e-6,
|
CacheReadPricePerTokenPriority: 0.04e-6,
|
||||||
LongContextInputThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
|
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
|
||||||
@@ -1125,10 +1101,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
|
|||||||
CacheCreation1hPrice: price1h,
|
CacheCreation1hPrice: price1h,
|
||||||
SupportsCacheBreakdown: enableBreakdown,
|
SupportsCacheBreakdown: enableBreakdown,
|
||||||
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold,
|
||||||
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
// xAI 的长上下文阈值语义为"达到即进高档"(LiteLLM 同口径),其余提供商为严格大于。
|
||||||
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
LongContextThresholdInclusive: strings.EqualFold(litellmPricing.LiteLLMProvider, "xai"),
|
||||||
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
|
||||||
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
|
||||||
|
ImageInputPricePerToken: litellmPricing.InputCostPerImageToken,
|
||||||
|
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1375,15 +1353,18 @@ func (s *BillingService) computeTokenBreakdown(
|
|||||||
var baselineCost *CostBreakdown
|
var baselineCost *CostBreakdown
|
||||||
if longContextPricingEligible {
|
if longContextPricingEligible {
|
||||||
baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false)
|
baselineCost = s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, false)
|
||||||
inputPrice *= pricing.LongContextInputMultiplier
|
// 倍率 ≤0 表示该项未配置(目录/覆写条目可能只写了 input 或 output 一侧),
|
||||||
outputPrice *= pricing.LongContextOutputMultiplier
|
// 按 1 计而不是乘 0:乘 0 会把超阈值请求的对应分项算成免费。
|
||||||
|
longCtxInputMultiplier := longContextMultiplierOrOne(pricing.LongContextInputMultiplier)
|
||||||
|
inputPrice *= longCtxInputMultiplier
|
||||||
|
outputPrice *= longContextMultiplierOrOne(pricing.LongContextOutputMultiplier)
|
||||||
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
// 缓存读取本质上是输入侧的复用,应与 input 一同应用长上下文倍率;
|
||||||
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
// 否则 cache hit 越多,少计的费用越多(见 #2293)。
|
||||||
cacheReadPrice *= pricing.LongContextInputMultiplier
|
cacheReadPrice *= longCtxInputMultiplier
|
||||||
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
// 缓存创建(cache_write)也是输入侧操作,三档价格(标准 / 5m / 1h)
|
||||||
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
// 都通过 computeCacheCreationCost 直接读取 pricing.*,不会经过这里
|
||||||
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
// 的倍率修改,因此显式向下传一个倍率,避免长上下文场景下被漏乘。
|
||||||
cacheCreationMultiplier = pricing.LongContextInputMultiplier
|
cacheCreationMultiplier = longCtxInputMultiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
bd := &CostBreakdown{}
|
bd := &CostBreakdown{}
|
||||||
@@ -1568,10 +1549,13 @@ func (s *BillingService) calculateCostInternalWithPolicy(
|
|||||||
return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil
|
return s.computeTokenBreakdown(pricing, tokens, rateMultiplier, serviceTier, longContextBillingEnabled), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyModelSpecificPricingPolicy 应用模型特定定价策略(GPT-5.6 长上下文/缓存写入、
|
// applyModelSpecificPricingPolicy 对目录数据做模型特定修正:DeepSeek 官方价
|
||||||
// DeepSeek 官方价强制覆盖等)。默认强制 DeepSeek 官方价——该路径仅被默认价卡
|
// 强制覆盖;GPT-5.6 缺 cache_write 价时按官方规则补 1.25 倍输入价;Fast/priority
|
||||||
// (GetModelPricing 内部)调用;分组/渠道自定义定价路径用带参数的
|
// 档按业务倍率改写(本地/远程目录的 priority 价可能沿用官方旧口径)。长上下文
|
||||||
// applyModelSpecificPricingPolicyEx 关闭强制,保留运营者配置。
|
// 阶梯不在此处补齐:一律由目录数据(above_XXXk 折算或显式 long_context_* 字段)
|
||||||
|
// 驱动。默认强制 DeepSeek 官方价——该路径仅被默认价卡(GetModelPricing 内部)
|
||||||
|
// 调用;分组/渠道自定义定价路径用带参数的 applyModelSpecificPricingPolicyEx
|
||||||
|
// 关闭强制,保留运营者配置。
|
||||||
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing *ModelPricing) *ModelPricing {
|
||||||
return s.applyModelSpecificPricingPolicyEx(model, pricing, true)
|
return s.applyModelSpecificPricingPolicyEx(model, pricing, true)
|
||||||
}
|
}
|
||||||
@@ -1607,16 +1591,10 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
|||||||
}
|
}
|
||||||
normalized := normalizeKnownOpenAICodexModel(model)
|
normalized := normalizeKnownOpenAICodexModel(model)
|
||||||
isGPT56 := isOpenAIGPT56Model(normalized)
|
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 ||
|
needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 ||
|
||||||
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
|
(pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0))
|
||||||
fastRatio := openAIModelFastPricingRatio(normalized)
|
fastRatio := openAIModelFastPricingRatio(normalized)
|
||||||
if !needsLongContextPolicy && !needsCacheCreationPolicy && fastRatio <= 0 {
|
if !needsCacheCreationPolicy && fastRatio <= 0 {
|
||||||
return pricing
|
return pricing
|
||||||
}
|
}
|
||||||
cloned := *pricing
|
cloned := *pricing
|
||||||
@@ -1628,17 +1606,6 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
|||||||
cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25
|
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 {
|
if fastRatio > 0 {
|
||||||
enforceOpenAIFastPricingRatio(&cloned, fastRatio)
|
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 {
|
func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens, pricing *ModelPricing) bool {
|
||||||
if pricing == nil || pricing.LongContextInputThreshold <= 0 {
|
if pricing == nil || pricing.LongContextInputThreshold <= 0 {
|
||||||
return false
|
return false
|
||||||
@@ -1692,10 +1667,6 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens
|
|||||||
return totalInputTokens > pricing.LongContextInputThreshold
|
return totalInputTokens > pricing.LongContextInputThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
func usesOpenAILegacyLongContextPricing(normalized string) bool {
|
|
||||||
return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
|
|
||||||
}
|
|
||||||
|
|
||||||
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
|
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
|
||||||
func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) {
|
func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageTokens) (*CostBreakdown, error) {
|
||||||
multiplier := s.cfg.Default.RateMultiplier
|
multiplier := s.cfg.Default.RateMultiplier
|
||||||
@@ -1705,82 +1676,6 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken
|
|||||||
return s.CalculateCost(model, tokens, multiplier)
|
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,因为有模糊匹配)
|
// ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配)
|
||||||
func (s *BillingService) ListSupportedModels() []string {
|
func (s *BillingService) ListSupportedModels() []string {
|
||||||
models := make([]string, 0)
|
models := make([]string, 0)
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ func newTestBillingService() *BillingService {
|
|||||||
return NewBillingService(&config.Config{}, nil)
|
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) {
|
func TestCalculateCost_BasicComputation(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
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, 2.5e-6, pricing.InputPricePerToken, 1e-12)
|
||||||
require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
|
require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
|
||||||
require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 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.Equal(t, 272000, pricing.LongContextInputThreshold)
|
||||||
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
|
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
|
||||||
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
|
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
|
||||||
|
require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
||||||
@@ -215,8 +232,8 @@ func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
|
|||||||
cacheRead float64
|
cacheRead float64
|
||||||
longContext int
|
longContext int
|
||||||
}{
|
}{
|
||||||
{model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-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: 272000},
|
{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.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},
|
{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) {
|
func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
InputTokens: 300000,
|
InputTokens: 300000,
|
||||||
@@ -267,7 +284,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) {
|
func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
cost, err := svc.calculateCostWithServiceTierPolicy(
|
cost, err := svc.calculateCostWithServiceTierPolicy(
|
||||||
"gpt-5.4-2026-03-05",
|
"gpt-5.4-2026-03-05",
|
||||||
@@ -283,7 +300,7 @@ func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
|
func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
InputTokens: 300000,
|
InputTokens: 300000,
|
||||||
@@ -331,7 +348,7 @@ func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) {
|
|||||||
// 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
|
// 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
|
||||||
// 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
|
// 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
|
||||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) {
|
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
@@ -359,7 +376,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *tes
|
|||||||
|
|
||||||
// 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。
|
// 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。
|
||||||
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) {
|
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
@@ -381,7 +398,7 @@ func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *test
|
|||||||
// 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此
|
// 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此
|
||||||
// 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。
|
// 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。
|
||||||
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) {
|
func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
// InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
@@ -402,7 +419,7 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t
|
|||||||
|
|
||||||
// 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。
|
// 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。
|
||||||
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) {
|
func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingServiceWithOpenAILadderCatalog(t)
|
||||||
|
|
||||||
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
// InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
@@ -921,114 +938,6 @@ func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
|
|||||||
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
|
require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
|
||||||
require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
|
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) {
|
func TestCalculateImageCost(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingService()
|
||||||
|
|
||||||
@@ -1178,19 +1087,6 @@ func TestForceUpdatePricing_NilService(t *testing.T) {
|
|||||||
require.Contains(t, err.Error(), "not initialized")
|
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) {
|
func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
|
||||||
svc := newTestBillingService()
|
svc := newTestBillingService()
|
||||||
|
|
||||||
|
|||||||
@@ -5,33 +5,6 @@ import (
|
|||||||
"time"
|
"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 计费请求。
|
// TokenCostRequest 通用网关 token 计费请求。
|
||||||
type TokenCostRequest struct {
|
type TokenCostRequest struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
@@ -44,27 +17,12 @@ type TokenCostRequest struct {
|
|||||||
Resolver *ModelPricingResolver
|
Resolver *ModelPricingResolver
|
||||||
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
|
// Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。
|
||||||
Resolved *ResolvedPricing
|
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 费用:
|
// CalculateTokenCostForRequest 按通用网关的路径选择计算 token 费用:
|
||||||
// 1. 分组/渠道显式定价 → 统一计费(区间、分组卡、目录阶梯均在其中);
|
// 1. 分组/渠道显式定价,或有解析器与分组 → 统一计费
|
||||||
// 2. 否则入口带旧长上下文规则且分组开关开启 → 旧边际计费;
|
// (区间、分组卡、目录长上下文阶梯均在其中,阶梯由目录数据驱动);
|
||||||
// 3. 否则有解析器与分组 → 统一计费(内置目录定价);
|
// 2. 否则按模型目录直接计费。
|
||||||
// 4. 否则按模型目录直接计费。
|
|
||||||
//
|
//
|
||||||
// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。
|
// 模型广场的阶梯表查询与网关使用同一入口,保证展示与扣费同源。
|
||||||
func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*CostBreakdown, error) {
|
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) {
|
if resolved != nil && (resolved.Source == PricingSourceGroup || resolved.Source == PricingSourceChannel) {
|
||||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
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 {
|
if req.Resolver != nil && req.Group != nil {
|
||||||
return s.CalculateCostUnified(s.tokenCostInput(req, resolved))
|
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)
|
return bs, NewModelPricingResolver(cs, bs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// geminiCatalogStub 无阶梯字段的 gemini 目录条目(用于验证"无数据即无阶梯")。
|
||||||
func geminiCatalogStub() *PricingService {
|
func geminiCatalogStub() *PricingService {
|
||||||
return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
return newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
|
||||||
"gemini-2.5-pro": {
|
"gemini-2.5-pro": {
|
||||||
@@ -39,22 +40,29 @@ func geminiCatalogStub() *PricingService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLegacyLongContextRule_OnlyGemini(t *testing.T) {
|
// geminiLadderCatalogJSON 镜像真实目录的 gemini pro 条目:above_200k 绝对价,
|
||||||
bs := NewBillingService(&config.Config{}, nil)
|
// 由解析层折算成 200K 阈值 + 输入 ×2 / 输出 ×1.5。
|
||||||
rule := bs.LegacyLongContextRule(PlatformGemini)
|
const geminiLadderCatalogJSON = `{
|
||||||
require.NotNil(t, rule)
|
"gemini-2.5-pro": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
|
||||||
require.Equal(t, 200000, rule.Threshold)
|
"input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
|
||||||
require.InDelta(t, 2.0, rule.Multiplier, 1e-12)
|
"cache_read_input_token_cost": 3.125e-07,
|
||||||
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformAntigravity, PlatformComposite, ""} {
|
"input_cost_per_token_above_200k_tokens": 2.5e-06,
|
||||||
require.Nil(t, bs.LegacyLongContextRule(platform), platform)
|
"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{{
|
bs, resolver := newTokenCostTestEnv(t, PlatformGemini, []ChannelModelPricing{{
|
||||||
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
|
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken,
|
||||||
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
|
InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(40e-6),
|
||||||
}}, geminiCatalogStub())
|
}}, geminiLadderCatalogStub(t))
|
||||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: true}
|
||||||
gid := group.ID
|
gid := group.ID
|
||||||
resolved := resolver.Resolve(context.Background(), PricingInput{Model: "gemini-2.5-pro", GroupID: &gid, Group: group})
|
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}
|
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
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)
|
require.NoError(t, err)
|
||||||
want, err := bs.CalculateCostUnified(CostInput{
|
require.InDelta(t, 300000*10e-6*2, got.InputCost, 1e-9)
|
||||||
Ctx: context.Background(), Model: "gemini-2.5-pro", GroupID: &gid, Group: group, Tokens: tokens,
|
require.InDelta(t, 1000*40e-6*1.5, got.OutputCost, 1e-9)
|
||||||
RequestCount: 1, RateMultiplier: 1, Resolver: resolver, Resolved: resolved,
|
require.True(t, got.LongContextBillingApplied)
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, want, got)
|
|
||||||
// 渠道平价 10e-6 × 300K,旧规则未叠加
|
|
||||||
require.InDelta(t, 3.0, got.InputCost, 1e-9)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||||
rule := bs.LegacyLongContextRule(PlatformGemini)
|
|
||||||
|
|
||||||
for _, enabled := range []bool{true, false} {
|
for _, enabled := range []bool{true, false} {
|
||||||
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
|
group := &Group{ID: 100, Platform: PlatformGemini, LongContextPricingEnabled: enabled}
|
||||||
@@ -89,32 +115,39 @@ func TestCalculateTokenCostForRequest_LegacyRuleFollowsGroupToggle(t *testing.T)
|
|||||||
|
|
||||||
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
|
||||||
Ctx: context.Background(), Model: "gemini-2.5-pro", Group: group, Tokens: tokens, RateMultiplier: 1,
|
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)
|
require.NoError(t, err)
|
||||||
if enabled {
|
if enabled {
|
||||||
want, err := bs.CalculateCostWithLongContext("gemini-2.5-pro", tokens, 1, rule.Threshold, rule.Multiplier)
|
// 300K × 1.25e-6 × 2 = 0.75;1000 × 10e-6 × 1.5 = 0.015
|
||||||
require.NoError(t, err)
|
require.InDelta(t, 0.765, got.ActualCost, 1e-9)
|
||||||
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)
|
|
||||||
require.True(t, got.LongContextBillingApplied)
|
require.True(t, got.LongContextBillingApplied)
|
||||||
} else {
|
} 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.InDelta(t, 0.385, got.ActualCost, 1e-9)
|
||||||
require.False(t, got.LongContextBillingApplied)
|
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) {
|
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}
|
group := &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true}
|
||||||
gid := group.ID
|
gid := group.ID
|
||||||
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000}
|
||||||
|
|||||||
@@ -456,45 +456,6 @@ func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testi
|
|||||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
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) {
|
func TestGatewayServiceRecordUsage_UsesFallbackRequestIDForUsageLog(t *testing.T) {
|
||||||
usageRepo := &openAIRecordUsageLogRepoStub{}
|
usageRepo := &openAIRecordUsageLogRepoStub{}
|
||||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||||
|
|||||||
@@ -596,13 +596,6 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。
|
|
||||||
type recordUsageOpts struct {
|
|
||||||
// 长上下文计费(仅 Gemini 路径需要)
|
|
||||||
LongContextThreshold int
|
|
||||||
LongContextMultiplier float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
||||||
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
|
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
|
||||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||||
@@ -622,54 +615,6 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
APIKeyService: input.APIKeyService,
|
APIKeyService: input.APIKeyService,
|
||||||
QuotaPlatform: input.QuotaPlatform,
|
QuotaPlatform: input.QuotaPlatform,
|
||||||
ChannelUsageFields: input.ChannelUsageFields,
|
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...)
|
slog.Info("billing.response_model_applied", attrs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
|
// recordUsageCore 是 RecordUsage 的核心实现。
|
||||||
// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。
|
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput) error {
|
||||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
|
|
||||||
result := input.Result
|
result := input.Result
|
||||||
apiKey := input.APIKey
|
apiKey := input.APIKey
|
||||||
user := input.User
|
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:按上游成功响应自报的模型计费(渠道显式开启才生效)。
|
// response_model:按上游成功响应自报的模型计费(渠道显式开启才生效)。
|
||||||
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
|
// 采纳条件见 responseModelBillingDeclaration + hasIdentifiedResponseModelPricing
|
||||||
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
|
// + responseModelBillingAdoptable。任一条件不满足都静默回落基线,即开启本模式前的
|
||||||
@@ -851,7 +795,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
|||||||
result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0,
|
result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0,
|
||||||
); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) {
|
); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) {
|
||||||
if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified {
|
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
|
baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil
|
||||||
if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
|
if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) {
|
||||||
// billingModel 到此为止只是定价查表的入参,后续流程只消费 cost,
|
// billingModel 到此为止只是定价查表的入参,后续流程只消费 cost,
|
||||||
@@ -872,7 +816,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
|||||||
// 创建使用日志
|
// 创建使用日志
|
||||||
accountRateMultiplier := account.BillingRateMultiplier()
|
accountRateMultiplier := account.BillingRateMultiplier()
|
||||||
usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
|
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 {
|
if apiKey.GroupID != nil {
|
||||||
@@ -932,7 +876,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateRecordUsageCost 根据请求类型和选项计算费用。
|
// calculateRecordUsageCost 根据请求类型计算费用。
|
||||||
func (s *GatewayService) calculateRecordUsageCost(
|
func (s *GatewayService) calculateRecordUsageCost(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
result *ForwardResult,
|
result *ForwardResult,
|
||||||
@@ -941,12 +885,11 @@ func (s *GatewayService) calculateRecordUsageCost(
|
|||||||
multiplier float64,
|
multiplier float64,
|
||||||
imageMultiplier float64,
|
imageMultiplier float64,
|
||||||
pricingAt time.Time,
|
pricingAt time.Time,
|
||||||
opts *recordUsageOpts,
|
|
||||||
) *CostBreakdown {
|
) *CostBreakdown {
|
||||||
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
|
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
|
||||||
if result.ImageCount > 0 {
|
if result.ImageCount > 0 {
|
||||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
|
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)
|
return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
|
||||||
}
|
}
|
||||||
@@ -970,7 +913,7 @@ func (s *GatewayService) calculateRecordUsageCost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Token 计费;SearchCount 为叠加 surcharge(不替代 token)。
|
// 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 {
|
if result.SearchCount > 0 {
|
||||||
price := groupSearchPricePer1kFromAPIKey(apiKey)
|
price := groupSearchPricePer1kFromAPIKey(apiKey)
|
||||||
if price != nil && *price == 0 {
|
if price != nil && *price == 0 {
|
||||||
@@ -1122,7 +1065,7 @@ func (s *GatewayService) calculateImageCost(
|
|||||||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 旧长上下文规则 → 内置定价)
|
// calculateTokenCost 计算 Token 计费:路径选择(分组/渠道定价 → 内置定价)
|
||||||
// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。
|
// 统一交给 BillingService.CalculateTokenCostForRequest,与模型广场的阶梯表查询同源。
|
||||||
func (s *GatewayService) calculateTokenCost(
|
func (s *GatewayService) calculateTokenCost(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -1131,7 +1074,6 @@ func (s *GatewayService) calculateTokenCost(
|
|||||||
billingModel string,
|
billingModel string,
|
||||||
multiplier float64,
|
multiplier float64,
|
||||||
pricingAt time.Time,
|
pricingAt time.Time,
|
||||||
opts *recordUsageOpts,
|
|
||||||
) *CostBreakdown {
|
) *CostBreakdown {
|
||||||
tokens := UsageTokens{
|
tokens := UsageTokens{
|
||||||
InputTokens: result.Usage.InputTokens,
|
InputTokens: result.Usage.InputTokens,
|
||||||
@@ -1148,22 +1090,17 @@ func (s *GatewayService) calculateTokenCost(
|
|||||||
gid := apiKey.Group.ID
|
gid := apiKey.Group.ID
|
||||||
resolved = s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid, Group: apiKey.Group})
|
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{
|
cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{
|
||||||
Ctx: ctx,
|
Ctx: ctx,
|
||||||
Model: billingModel,
|
Model: billingModel,
|
||||||
Group: apiKey.Group,
|
Group: apiKey.Group,
|
||||||
Tokens: tokens,
|
Tokens: tokens,
|
||||||
RateMultiplier: multiplier,
|
RateMultiplier: multiplier,
|
||||||
PricingAt: pricingAt,
|
PricingAt: pricingAt,
|
||||||
ServiceTier: optionalStringValue(result.ServiceTier),
|
ServiceTier: optionalStringValue(result.ServiceTier),
|
||||||
Resolver: s.resolver,
|
Resolver: s.resolver,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
LegacyLongContext: legacy,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
|
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
|
||||||
@@ -1172,14 +1109,6 @@ func (s *GatewayService) calculateTokenCost(
|
|||||||
return cost
|
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 构建使用日志并设置计费模式。
|
// buildRecordUsageLog 构建使用日志并设置计费模式。
|
||||||
func (s *GatewayService) buildRecordUsageLog(
|
func (s *GatewayService) buildRecordUsageLog(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -1196,7 +1125,6 @@ func (s *GatewayService) buildRecordUsageLog(
|
|||||||
billingType int8,
|
billingType int8,
|
||||||
cacheTTLOverridden bool,
|
cacheTTLOverridden bool,
|
||||||
cost *CostBreakdown,
|
cost *CostBreakdown,
|
||||||
opts *recordUsageOpts,
|
|
||||||
) *UsageLog {
|
) *UsageLog {
|
||||||
durationMs := int(result.Duration.Milliseconds())
|
durationMs := int(result.Duration.Milliseconds())
|
||||||
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
|
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: 10, Name: "on", Platform: PlatformOpenAI, RateMultiplier: 1, LongContextPricingEnabled: true},
|
||||||
{ID: 20, Name: "off", Platform: PlatformOpenAI, RateMultiplier: 2, LongContextPricingEnabled: false},
|
{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())
|
out, err := svc.ListGroups(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, out, 2)
|
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{{
|
channels := []Channel{{
|
||||||
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10},
|
||||||
ModelMapping: map[string]map[string]string{PlatformGemini: {"gemini-2.5-pro": "gemini-2.5-pro"}},
|
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}}
|
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())
|
out, err := svc.ListGroups(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, out, 1)
|
require.Len(t, out, 1)
|
||||||
m := out[0].Models[0]
|
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.Len(t, m.Pricing.Intervals, 2)
|
||||||
require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
|
require.Equal(t, "≤200K", m.Pricing.Intervals[0].TierLabel)
|
||||||
require.Equal(t, ">200K", m.Pricing.Intervals[1].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, 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.NotNil(t, m.OfficialPricing)
|
||||||
require.Empty(t, m.OfficialPricing.Intervals)
|
require.Len(t, m.OfficialPricing.Intervals, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
|
func TestListGroups_GroupTokenCardOverridesChannelPricing(t *testing.T) {
|
||||||
|
|||||||
@@ -1182,11 +1182,21 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledWhenGrou
|
|||||||
require.Equal(t, 1, userRepo.deductCalls)
|
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) {
|
func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccount(t *testing.T) {
|
||||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
|
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
|
||||||
|
swapInOpenAILadderCatalog(t, svc)
|
||||||
|
|
||||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||||
Result: &OpenAIForwardResult{
|
Result: &OpenAIForwardResult{
|
||||||
@@ -1227,6 +1237,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
|
|||||||
t.Run("group on account off", func(t *testing.T) {
|
t.Run("group on account off", func(t *testing.T) {
|
||||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||||
|
swapInOpenAILadderCatalog(t, svc)
|
||||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||||
Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
||||||
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true),
|
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true),
|
||||||
@@ -1242,6 +1253,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
|
|||||||
t.Run("group off account on", func(t *testing.T) {
|
t.Run("group off account on", func(t *testing.T) {
|
||||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||||
|
swapInOpenAILadderCatalog(t, svc)
|
||||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
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},
|
Result: &OpenAIForwardResult{RequestID: "resp_and_group_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
||||||
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false),
|
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false),
|
||||||
@@ -1260,6 +1272,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupOrAccountLongContextAllows(t *test
|
|||||||
t.Run("group on account on", func(t *testing.T) {
|
t.Run("group on account on", func(t *testing.T) {
|
||||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||||
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
|
||||||
|
swapInOpenAILadderCatalog(t, svc)
|
||||||
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
|
||||||
Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second},
|
||||||
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true),
|
APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true),
|
||||||
@@ -1352,6 +1365,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett
|
|||||||
&openAIRecordUsageSubRepoStub{},
|
&openAIRecordUsageSubRepoStub{},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
swapInOpenAILadderCatalog(t, svc)
|
||||||
svc.accountRepo = accountRepo
|
svc.accountRepo = accountRepo
|
||||||
parentID := int64(4016)
|
parentID := int64(4016)
|
||||||
|
|
||||||
@@ -2784,7 +2798,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCoun
|
|||||||
0.15,
|
0.15,
|
||||||
1.0,
|
1.0,
|
||||||
time.Time{},
|
time.Time{},
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NotNil(t, cost)
|
require.NotNil(t, cost)
|
||||||
@@ -2824,7 +2837,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(
|
|||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
time.Time{},
|
time.Time{},
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NotNil(t, cost)
|
require.NotNil(t, cost)
|
||||||
@@ -2857,7 +2869,6 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI
|
|||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
time.Time{},
|
time.Time{},
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NotNil(t, cost)
|
require.NotNil(t, cost)
|
||||||
@@ -2947,7 +2958,6 @@ func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingNormalizesMis
|
|||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
time.Time{},
|
time.Time{},
|
||||||
nil,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NotNil(t, cost)
|
require.NotNil(t, cost)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,18 +24,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
|
openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
|
||||||
openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
|
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{
|
openAIGPT54FallbackPricing = &LiteLLMModelPricing{
|
||||||
InputCostPerToken: 2.5e-06, // $2.5 per MTok
|
InputCostPerToken: 2.5e-06, // $2.5 per MTok
|
||||||
OutputCostPerToken: 1.5e-05, // $15 per MTok
|
OutputCostPerToken: 1.5e-05, // $15 per MTok
|
||||||
CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
|
CacheReadInputTokenCost: 2.5e-07, // $0.25 per MTok
|
||||||
LongContextInputTokenThreshold: 272000,
|
LiteLLMProvider: "openai",
|
||||||
LongContextInputCostMultiplier: 2.0,
|
Mode: "chat",
|
||||||
LongContextOutputCostMultiplier: 1.5,
|
SupportsPromptCaching: true,
|
||||||
LiteLLMProvider: "openai",
|
|
||||||
Mode: "chat",
|
|
||||||
SupportsPromptCaching: true,
|
|
||||||
}
|
}
|
||||||
openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{
|
openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{
|
||||||
InputCostPerToken: 5e-06,
|
InputCostPerToken: 5e-06,
|
||||||
@@ -44,9 +47,6 @@ var (
|
|||||||
CacheCreationInputTokenCostPriority: 1.25e-05,
|
CacheCreationInputTokenCostPriority: 1.25e-05,
|
||||||
CacheReadInputTokenCost: 5e-07,
|
CacheReadInputTokenCost: 5e-07,
|
||||||
CacheReadInputTokenCostPriority: 1e-06,
|
CacheReadInputTokenCostPriority: 1e-06,
|
||||||
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
SupportsServiceTier: true,
|
SupportsServiceTier: true,
|
||||||
LiteLLMProvider: "openai",
|
LiteLLMProvider: "openai",
|
||||||
Mode: "chat",
|
Mode: "chat",
|
||||||
@@ -61,9 +61,6 @@ var (
|
|||||||
CacheCreationInputTokenCostPriority: 5e-06,
|
CacheCreationInputTokenCostPriority: 5e-06,
|
||||||
CacheReadInputTokenCost: 2e-07,
|
CacheReadInputTokenCost: 2e-07,
|
||||||
CacheReadInputTokenCostPriority: 4e-07,
|
CacheReadInputTokenCostPriority: 4e-07,
|
||||||
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
SupportsServiceTier: true,
|
SupportsServiceTier: true,
|
||||||
LiteLLMProvider: "openai",
|
LiteLLMProvider: "openai",
|
||||||
Mode: "chat",
|
Mode: "chat",
|
||||||
@@ -78,9 +75,6 @@ var (
|
|||||||
CacheCreationInputTokenCostPriority: 5e-07,
|
CacheCreationInputTokenCostPriority: 5e-07,
|
||||||
CacheReadInputTokenCost: 2e-08,
|
CacheReadInputTokenCost: 2e-08,
|
||||||
CacheReadInputTokenCostPriority: 4e-08,
|
CacheReadInputTokenCostPriority: 4e-08,
|
||||||
LongContextInputTokenThreshold: openAIGPT54LongContextInputThreshold,
|
|
||||||
LongContextInputCostMultiplier: openAIGPT54LongContextInputMultiplier,
|
|
||||||
LongContextOutputCostMultiplier: openAIGPT54LongContextOutputMultiplier,
|
|
||||||
SupportsServiceTier: true,
|
SupportsServiceTier: true,
|
||||||
LiteLLMProvider: "openai",
|
LiteLLMProvider: "openai",
|
||||||
Mode: "chat",
|
Mode: "chat",
|
||||||
@@ -408,6 +402,7 @@ func (s *PricingService) downloadPricingData() error {
|
|||||||
|
|
||||||
// 更新内存数据
|
// 更新内存数据
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
warnDroppedLongContextLadders(s.pricingData, data)
|
||||||
s.pricingData = data
|
s.pricingData = data
|
||||||
s.lastUpdated = time.Now()
|
s.lastUpdated = time.Now()
|
||||||
s.localHash = syncHash
|
s.localHash = syncHash
|
||||||
@@ -500,6 +495,13 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
|
|||||||
pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
|
pricing.InputCostPerImageToken = *entry.InputCostPerImageToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasExplicitLongContext := entry.LongContextInputTokenThreshold != nil ||
|
||||||
|
entry.LongContextInputCostMultiplier != nil ||
|
||||||
|
entry.LongContextOutputCostMultiplier != nil
|
||||||
|
if !hasExplicitLongContext {
|
||||||
|
deriveLongContextFromAboveTierFields(rawEntry, pricing)
|
||||||
|
}
|
||||||
|
|
||||||
result[modelName] = pricing
|
result[modelName] = pricing
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +516,81 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
|
|||||||
return result, nil
|
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 从本地文件加载价格数据
|
// loadPricingData 从本地文件加载价格数据
|
||||||
func (s *PricingService) loadPricingData(filePath string) error {
|
func (s *PricingService) loadPricingData(filePath string) error {
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
@@ -533,6 +610,7 @@ func (s *PricingService) loadPricingData(filePath string) error {
|
|||||||
hashStr := hex.EncodeToString(hash[:])
|
hashStr := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
warnDroppedLongContextLadders(s.pricingData, pricingData)
|
||||||
s.pricingData = pricingData
|
s.pricingData = pricingData
|
||||||
s.localHash = hashStr
|
s.localHash = hashStr
|
||||||
|
|
||||||
@@ -579,6 +657,34 @@ func (s *PricingService) mergeFallbackPricingData(data map[string]*LiteLLMModelP
|
|||||||
return data
|
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 使用回退价格文件
|
// useFallbackPricing 使用回退价格文件
|
||||||
func (s *PricingService) useFallbackPricing() error {
|
func (s *PricingService) useFallbackPricing() error {
|
||||||
fallbackFile := s.cfg.Pricing.FallbackFile
|
fallbackFile := s.cfg.Pricing.FallbackFile
|
||||||
|
|||||||
@@ -109,9 +109,8 @@ func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12)
|
require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12)
|
||||||
require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12)
|
require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12)
|
||||||
require.Equal(t, 272000, pricing.LongContextInputThreshold)
|
// 阶梯由目录数据驱动:条目无 above/long_context 字段时不再由策略强补。
|
||||||
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
|
require.Zero(t, pricing.LongContextInputThreshold)
|
||||||
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
|
|
||||||
|
|
||||||
tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100}
|
tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100}
|
||||||
standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "")
|
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) {
|
func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testing.T) {
|
||||||
models := []struct {
|
models := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -157,7 +182,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi
|
|||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
for _, tier := range tiers {
|
for _, tier := range tiers {
|
||||||
t.Run(model.name+"/"+tier.name, func(t *testing.T) {
|
t.Run(model.name+"/"+tier.name, func(t *testing.T) {
|
||||||
svc := NewBillingService(&config.Config{}, nil)
|
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON))
|
||||||
serviceTier := ""
|
serviceTier := ""
|
||||||
if tier.name != "standard" {
|
if tier.name != "standard" {
|
||||||
serviceTier = tier.name
|
serviceTier = tier.name
|
||||||
@@ -174,7 +199,7 @@ func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) {
|
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}
|
tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 100000, CacheReadTokens: 72000, OutputTokens: 10}
|
||||||
|
|
||||||
cost, err := svc.CalculateCost("gpt-5.6-sol", tokens, 1)
|
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, cached, pricing.CacheReadPricePerToken, 1e-12)
|
||||||
require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
|
require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
|
||||||
require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12)
|
require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12)
|
||||||
require.Equal(t, 272000, pricing.LongContextInputThreshold)
|
// 静态兜底只兜基础价;阶梯由目录数据(above_272k 折算或显式字段)驱动。
|
||||||
require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
|
require.Zero(t, pricing.LongContextInputThreshold)
|
||||||
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) {
|
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, 2.5e-6, got.InputCostPerToken, 1e-12)
|
||||||
require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
|
require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
|
||||||
require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 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.Zero(t, got.LongContextInputTokenThreshold)
|
||||||
require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) {
|
func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) {
|
||||||
@@ -719,3 +742,154 @@ func TestListModelNamesByProvider_EmptyCatalog(t *testing.T) {
|
|||||||
require.NotNil(t, got)
|
require.NotNil(t, got)
|
||||||
require.Empty(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
|
||||||
|
}
|
||||||
@@ -1054,8 +1054,8 @@ plugins:
|
|||||||
# 定价数据源(可选)
|
# 定价数据源(可选)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
pricing:
|
pricing:
|
||||||
# URL to fetch model pricing data (default: pinned model-price-repo commit)
|
# URL to fetch model pricing data (default: model-price-repo main branch)
|
||||||
# 获取模型定价数据的 URL(默认:固定 commit 的 model-price-repo)
|
# 获取模型定价数据的 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"
|
remote_url: "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/refs/heads/main//model_prices_and_context_window.json"
|
||||||
# Hash verification URL (optional)
|
# Hash verification URL (optional)
|
||||||
# 哈希校验 URL(可选)
|
# 哈希校验 URL(可选)
|
||||||
|
|||||||
Reference in New Issue
Block a user