Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2279,7 +2279,7 @@ func setDefaults() {
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)

// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
viper.SetDefault("pricing.data_dir", "./data")
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/handler/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {

// ──────────────────────────────────────────────────────────
// Context helpers — used by handlers before building
// RecordUsageInput / RecordUsageLongContextInput.
// RecordUsageInput.
// ──────────────────────────────────────────────────────────

// GetInboundEndpoint returns the canonical inbound endpoint stored by
Expand Down
44 changes: 18 additions & 26 deletions backend/internal/handler/gemini_v1beta_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,33 +569,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
var longContextThreshold int
var longContextMultiplier float64
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
longContextThreshold = rule.Threshold
longContextMultiplier = rule.Multiplier
}
// 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
QuotaPlatform: quotaPlatform,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
LongContextThreshold: longContextThreshold,
LongContextMultiplier: longContextMultiplier,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
QuotaPlatform: quotaPlatform,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gemini_v1beta.models"),
Expand Down
52 changes: 15 additions & 37 deletions backend/internal/service/billing_context_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,12 @@ import (
"time"
)

// ContextPricingBasis 阶梯的计价基准。
// ContextPricingBasis 阶梯的计价基准。当前只有整单口径;历史上的
// Gemini 边际口径("marginal")已随平台旧规则一并移除。
type ContextPricingBasis string

const (
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"
// ContextPricingBasisMarginal 仅超出阈值的部分按该档单价计价(平台旧规则)。
ContextPricingBasisMarginal ContextPricingBasis = "marginal"
)
// ContextPricingBasisWholeRequest 整单按所在档单价计价(目录阶梯、渠道区间)。
const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request"

// ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。
// nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。
Expand Down Expand Up @@ -73,8 +70,8 @@ const contextProbeDelta = 1000
// ResolveContextPricingSchedule 解析分组+模型的上下文阶梯单价表。
//
// 解析链与扣费完全一致:Resolver.Resolve(分组卡 → 渠道 → 目录 → 策略)给出定价,
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 平台旧规则 → 内置目录)。
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值、旧规则阈值),
// CalculateTokenCostForRequest 给出路径(分组/渠道定价 → 内置目录)。
// 断点只取自计费自身的规则输入(渠道区间边界、目录阶梯阈值),
// 每一段的单价由真实计费函数在该段内两点探针的差商得到,因此倍率、策略等
// 规则变更无需同步到这里;相邻同价段会合并。
//
Expand Down Expand Up @@ -103,30 +100,21 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
return nil, nil
}

var legacy *LegacyLongContextRule
if in.Group != nil {
legacy = s.LegacyLongContextRule(in.Platform)
}
if !legacyLongContextApplies(resolved, in.Group, legacy) {
legacy = nil
}

req := TokenCostRequest{
Ctx: ctx,
Model: in.Model,
Group: in.Group,
RateMultiplier: 1,
Resolver: resolver,
Resolved: resolved,
LegacyLongContext: legacy,
Ctx: ctx,
Model: in.Model,
Group: in.Group,
RateMultiplier: 1,
Resolver: resolver,
Resolved: resolved,
}
probe := func(tokens UsageTokens) (*CostBreakdown, error) {
r := req
r.Tokens = tokens
return s.CalculateTokenCostForRequest(r)
}

plan := s.contextPricingBreakpoints(resolver, resolved, in.Model, legacy)
plan := s.contextPricingBreakpoints(resolver, resolved, in.Model)
segments := buildContextSegments(plan.bounds)

tiers := make([]ContextPricingTier, 0, len(segments))
Expand All @@ -140,11 +128,7 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso
tiers = mergeEqualContextTiers(tiers)
applyContextTierLabels(tiers, plan)

basis := ContextPricingBasisWholeRequest
if legacy != nil {
basis = ContextPricingBasisMarginal
}
return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
return &ContextPricingSchedule{Basis: ContextPricingBasisWholeRequest, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil
}

// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。
Expand Down Expand Up @@ -210,14 +194,8 @@ type contextBreakpointPlan struct {
}

// contextPricingBreakpoints 从计费自身的规则输入收集价格断点(不读取任何倍率)。
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string, legacy *LegacyLongContextRule) contextBreakpointPlan {
func (s *BillingService) contextPricingBreakpoints(resolver *ModelPricingResolver, resolved *ResolvedPricing, model string) contextBreakpointPlan {
plan := contextBreakpointPlan{}
if legacy != nil {
plan.bounds = []int{legacy.Threshold}
plan.thresholdBound = legacy.Threshold
plan.threshold = legacy.Threshold
return plan
}
if !resolved.longContextPricingEnabled {
return plan
}
Expand Down
82 changes: 43 additions & 39 deletions backend/internal/service/billing_context_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,25 @@ func requirePrice(t *testing.T, want, got *float64, field string) {
require.InDelta(t, *want, *got, 1e-15, field)
}

// mustCatalogFromJSON 走生产解析路径(含 above_XXXk 折算)构造目录 stub;场景表无 *testing.T,解析失败直接 panic。
func mustCatalogFromJSON(body string) *PricingService {
s := &PricingService{}
data, err := s.parsePricingData([]byte(body))
if err != nil {
panic(err)
}
s.pricingData = data
return s
}

func openAILadderCatalog() *PricingService { return mustCatalogFromJSON(openAILadderCatalogJSON) }

func scheduleScenarios() []scheduleScenario {
p := testPtrFloat64
return []scheduleScenario{
{
name: "官方阶梯 gpt-5.4 整单两档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
group: enabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
group: enabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
requireTier(t, s.Tiers[0], 0, intPtr(272000), "≤272K", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
Expand All @@ -92,15 +105,15 @@ func scheduleScenarios() []scheduleScenario {
},
{
name: "分组关闭阶梯只剩基础档", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
group: disabledGroup(PlatformOpenAI), wantBasis: ContextPricingBasisWholeRequest,
group: disabledGroup(PlatformOpenAI), catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 1)
requireTier(t, s.Tiers[0], 0, nil, "", p(2.5e-6), p(15e-6), p(2.5e-6), p(0.25e-6))
},
},
{
name: "官方参考价(无分组)带目录阶梯", model: "gpt-5.4", platform: "", groupPlatform: PlatformOpenAI,
group: nil, wantBasis: ContextPricingBasisWholeRequest,
group: nil, catalog: openAILadderCatalog(), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
requireTier(t, s.Tiers[1], 272000, nil, ">272K", p(5e-6), p(22.5e-6), p(5e-6), p(0.5e-6))
Expand Down Expand Up @@ -210,6 +223,7 @@ func scheduleScenarios() []scheduleScenario {
group: &Group{ID: 100, Platform: PlatformOpenAI, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{
Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
}}},
catalog: openAILadderCatalog(),
wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
Expand All @@ -219,46 +233,55 @@ func scheduleScenarios() []scheduleScenario {
},
},
{
name: "Gemini 旧规则按超出部分计价", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisMarginal,
name: "Gemini 目录阶梯整单换档", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
requireTier(t, s.Tiers[0], 0, intPtr(200000), "≤200K", p(1.25e-6), p(10e-6), nil, p(0.3125e-6))
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(10e-6), nil, p(0.625e-6))
requireTier(t, s.Tiers[1], 200000, nil, ">200K", p(2.5e-6), p(15e-6), nil, p(0.625e-6))
},
},
{
name: "Gemini 分组关闭时不用旧规则", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: disabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
name: "Gemini 分组关闭时无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: disabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 1)
},
},
{
name: "Gemini 有渠道定价时旧规则让位", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(),
name: "Gemini 渠道平价之上叠加目录阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: enabledGroup(PlatformGemini), catalog: mustCatalogFromJSON(geminiLadderCatalogJSON),
channel: []ChannelModelPricing{{
Platform: PlatformGemini, Models: []string{"gemini-2.5-pro"}, BillingMode: BillingModeToken, InputPrice: p(3e-6),
}},
wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 1)
require.Len(t, s.Tiers, 2)
requirePrice(t, p(3e-6), s.Tiers[0].Input, "input")
requirePrice(t, p(6e-6), s.Tiers[1].Input, "input")
},
},
{
name: "Gemini 官方参考不套用站内旧规则", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
group: nil, catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
name: "Gemini 目录无阶梯字段时开关开启也无阶梯", model: "gemini-2.5-pro", platform: PlatformGemini, groupPlatform: PlatformGemini,
group: enabledGroup(PlatformGemini), catalog: geminiCatalogStub(), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 1)
},
},
{
name: "Gemini 官方参考价与目录数据同源", model: "gemini-2.5-pro", platform: "", groupPlatform: PlatformGemini,
group: nil, catalog: mustCatalogFromJSON(geminiLadderCatalogJSON), wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
},
},
{
name: "composite 分组按模型平台取渠道定价并叠加官方阶梯", model: "gpt-5.4", platform: PlatformOpenAI, groupPlatform: PlatformComposite,
group: enabledGroup(PlatformComposite),
channel: []ChannelModelPricing{{
Platform: PlatformOpenAI, Models: []string{"gpt-5.4"}, BillingMode: BillingModeToken, InputPrice: p(1e-6),
}},
catalog: openAILadderCatalog(),
wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
Expand All @@ -283,9 +306,11 @@ func scheduleScenarios() []scheduleScenario {
{
name: "gpt-5.6 缺 cache_write 时按策略补 1.25 倍并带阶梯", model: "gpt-5.6-sol", platform: PlatformOpenAI, groupPlatform: PlatformOpenAI,
group: enabledGroup(PlatformOpenAI),
catalog: newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-5.6-sol": {Mode: "chat", InputCostPerToken: 5e-6, OutputCostPerToken: 30e-6, CacheReadInputTokenCost: 0.5e-6},
}),
catalog: mustCatalogFromJSON(`{"gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
"input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05, "cache_read_input_token_cost": 5e-07,
"input_cost_per_token_above_272k_tokens": 1e-05,
"output_cost_per_token_above_272k_tokens": 4.5e-05,
"cache_read_input_token_cost_above_272k_tokens": 1e-06}}`),
wantBasis: ContextPricingBasisWholeRequest,
check: func(t *testing.T, s *ContextPricingSchedule) {
require.Len(t, s.Tiers, 2)
Expand Down Expand Up @@ -398,22 +423,8 @@ func tierAt(tiers []ContextPricingTier, contextTokens int) ContextPricingTier {
}

// expectedCostFromSchedule 按阶梯表推算 contextTokens 个某类 token 的费用:
// 整单基准取所在档单价 × 全量;边际基准逐段累加
// 整单基准取所在档单价 × 全量。
func expectedCostFromSchedule(s *ContextPricingSchedule, kind tokenKind, contextTokens int) float64 {
if s.Basis == ContextPricingBasisMarginal {
total := 0.0
for _, tier := range s.Tiers {
if contextTokens <= tier.MinTokens {
break
}
upper := contextTokens
if tier.MaxTokens != nil && *tier.MaxTokens < upper {
upper = *tier.MaxTokens
}
total += float64(upper-tier.MinTokens) * tierPrice(tier, kind)
}
return total
}
return float64(contextTokens) * tierPrice(tierAt(s.Tiers, contextTokens), kind)
}

Expand Down Expand Up @@ -443,17 +454,10 @@ func TestResolveContextPricingSchedule_ParityWithBilling(t *testing.T) {
pricingInput.GroupID = &gid
}
resolved := resolver.Resolve(ctx, pricingInput)
var legacy *LegacyLongContextRule
if sc.group != nil {
legacy = bs.LegacyLongContextRule(sc.platform)
}
if !legacyLongContextApplies(resolved, sc.group, legacy) {
legacy = nil
}
cost := func(tokens UsageTokens) float64 {
bd, err := bs.CalculateTokenCostForRequest(TokenCostRequest{
Ctx: ctx, Model: sc.model, Group: sc.group, Tokens: tokens, RateMultiplier: 1,
Resolver: resolver, Resolved: resolved, LegacyLongContext: legacy,
Resolver: resolver, Resolved: resolved,
})
require.NoError(t, err)
return bd.ActualCost
Expand Down
Loading
Loading