diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 2043246de0..62b3a885cd 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -609,9 +609,17 @@ func ChatUsageToResponsesUsage(usage *ChatUsage) *ResponsesUsage { if out.TotalTokens == 0 { out.TotalTokens = out.InputTokens + out.OutputTokens } - if usage.PromptTokensDetails != nil && usage.PromptTokensDetails.CachedTokens > 0 { + if usage.PromptTokensDetails != nil && (usage.PromptTokensDetails.CachedTokens > 0 || + usage.PromptTokensDetails.CacheCreationTokens > 0 || usage.PromptTokensDetails.CacheWriteTokens > 0) { out.InputTokensDetails = &ResponsesInputTokensDetails{ - CachedTokens: usage.PromptTokensDetails.CachedTokens, + CachedTokens: usage.PromptTokensDetails.CachedTokens, + CacheCreationTokens: usage.PromptTokensDetails.CacheCreationTokens, + CacheWriteTokens: usage.PromptTokensDetails.CacheWriteTokens, + } + if usage.PromptTokensDetails.CacheWriteTokens > 0 { + out.CacheCreationInputTokens = usage.PromptTokensDetails.CacheWriteTokens + } else { + out.CacheCreationInputTokens = usage.PromptTokensDetails.CacheCreationTokens } } return out diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 8772ec0f4e..d63ce664a7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -32,6 +32,27 @@ func TestChatCompletionsToResponses_BasicText(t *testing.T) { assert.Equal(t, "user", items[0].Role) } +func TestUsageConversionsPreserveCacheWriteTokens(t *testing.T) { + var responsesUsage ResponsesUsage + require.NoError(t, json.Unmarshal([]byte(`{ + "input_tokens":1000, + "output_tokens":50, + "input_tokens_details":{"cached_tokens":100,"cache_write_tokens":200} + }`), &responsesUsage)) + require.NotNil(t, responsesUsage.InputTokensDetails) + require.Equal(t, 200, responsesUsage.InputTokensDetails.CacheWriteTokens) + + chatUsage := chatUsageFromResponsesUsage(&responsesUsage) + require.NotNil(t, chatUsage.PromptTokensDetails) + require.Equal(t, 100, chatUsage.PromptTokensDetails.CachedTokens) + require.Equal(t, 200, chatUsage.PromptTokensDetails.CacheWriteTokens) + + roundTrip := ChatUsageToResponsesUsage(chatUsage) + require.NotNil(t, roundTrip.InputTokensDetails) + require.Equal(t, 200, roundTrip.CacheCreationInputTokens) + require.Equal(t, 200, roundTrip.InputTokensDetails.CacheWriteTokens) +} + func TestChatCompletionsToResponses_SystemMessage(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index 13a89ab04c..2ae6f8ac3f 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -338,6 +338,14 @@ func chatUsageFromResponsesUsage(u *ResponsesUsage) *ChatUsage { TotalTokens: u.InputTokens + u.OutputTokens, } usage.PromptTokensDetails = promptDetailsFromResponses(u.InputTokensDetails) + if u.CacheCreationInputTokens > 0 { + if usage.PromptTokensDetails == nil { + usage.PromptTokensDetails = &ChatTokenDetails{} + } + if usage.PromptTokensDetails.CacheWriteTokens == 0 && usage.PromptTokensDetails.CacheCreationTokens == 0 { + usage.PromptTokensDetails.CacheCreationTokens = u.CacheCreationInputTokens + } + } usage.CompletionTokensDetails = completionDetailsFromResponses(u.OutputTokensDetails) return usage } @@ -349,12 +357,14 @@ func promptDetailsFromResponses(src *ResponsesInputTokensDetails) *ChatTokenDeta if src == nil { return nil } - if src.CachedTokens == 0 && src.AudioTokens == 0 { + if src.CachedTokens == 0 && src.AudioTokens == 0 && src.CacheCreationTokens == 0 && src.CacheWriteTokens == 0 { return nil } return &ChatTokenDetails{ - CachedTokens: src.CachedTokens, - AudioTokens: src.AudioTokens, + CachedTokens: src.CachedTokens, + AudioTokens: src.AudioTokens, + CacheCreationTokens: src.CacheCreationTokens, + CacheWriteTokens: src.CacheWriteTokens, } } diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index f7955c70c0..c020be94f8 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -320,9 +320,10 @@ type ResponsesSummary struct { // ResponsesUsage holds token counts in Responses API format. type ResponsesUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // Optional detailed breakdown InputTokensDetails *ResponsesInputTokensDetails `json:"input_tokens_details,omitempty"` @@ -335,6 +336,9 @@ func (u *ResponsesUsage) UnmarshalJSON(data []byte) error { responsesUsageAlias PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens"` + CacheWriteInputTokens int `json:"cache_write_input_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` PromptTokensDetails *ResponsesInputTokensDetails `json:"prompt_tokens_details,omitempty"` CompletionTokensDetails *ResponsesOutputTokensDetails `json:"completion_tokens_details,omitempty"` } @@ -348,6 +352,16 @@ func (u *ResponsesUsage) UnmarshalJSON(data []byte) error { if u.OutputTokens == 0 && aux.CompletionTokens != 0 { u.OutputTokens = aux.CompletionTokens } + if u.CacheCreationInputTokens == 0 { + switch { + case aux.CacheWriteInputTokens > 0: + u.CacheCreationInputTokens = aux.CacheWriteInputTokens + case aux.CacheCreationTokens > 0: + u.CacheCreationInputTokens = aux.CacheCreationTokens + case aux.CacheWriteTokens > 0: + u.CacheCreationInputTokens = aux.CacheWriteTokens + } + } if u.InputTokensDetails == nil && aux.PromptTokensDetails != nil { u.InputTokensDetails = aux.PromptTokensDetails } @@ -362,8 +376,10 @@ func (u *ResponsesUsage) UnmarshalJSON(data []byte) error { // ResponsesInputTokensDetails breaks down input token usage. type ResponsesInputTokensDetails struct { - CachedTokens int `json:"cached_tokens,omitempty"` - AudioTokens int `json:"audio_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` } // ResponsesOutputTokensDetails breaks down output token usage. @@ -546,6 +562,8 @@ type ChatUsage struct { type ChatTokenDetails struct { CachedTokens int `json:"cached_tokens,omitempty"` AudioTokens int `json:"audio_tokens,omitempty"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` ReasoningTokens int `json:"reasoning_tokens,omitempty"` AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` diff --git a/backend/internal/repository/user_msg_queue_cache_integration_test.go b/backend/internal/repository/user_msg_queue_cache_integration_test.go index e683b44aa3..f57206efe8 100644 --- a/backend/internal/repository/user_msg_queue_cache_integration_test.go +++ b/backend/internal/repository/user_msg_queue_cache_integration_test.go @@ -53,11 +53,11 @@ func (s *UserMsgQueueCacheSuite) TestReconcileExpiredLockCandidatesRemovesNatura require.NoError(s.T(), err) require.True(s.T(), acquired) - score, err := s.rdb.ZScore(s.ctx, umqLockIndexKey, "702").Result() + _, err = s.rdb.ZScore(s.ctx, umqLockIndexKey, "702").Result() require.NoError(s.T(), err) require.Eventually(s.T(), func() bool { - nowMs, err := s.cache.GetCurrentTimeMs(s.ctx) - return err == nil && nowMs >= int64(score) + _, err := s.rdb.Get(s.ctx, umqLockKey(accountID)).Result() + return errors.Is(err, redis.Nil) }, time.Second, 10*time.Millisecond) cleaned, err := s.cache.ReconcileExpiredLockCandidates(s.ctx, 1000) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 8dceebc250..3c8b52af97 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -90,22 +90,24 @@ type BillingCache interface { // ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致) type ModelPricing struct { - InputPricePerToken float64 // 每token输入价格 (USD) - InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) - ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken - OutputPricePerToken float64 // 每token输出价格 (USD) - OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) - CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) - CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) - CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) - CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) - CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) - SupportsCacheBreakdown bool // 是否支持详细的缓存分类 - LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 - LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 - LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 - ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) - ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) + InputPricePerToken float64 // 每token输入价格 (USD) + InputPricePerTokenPriority float64 // priority service tier 下每token输入价格 (USD) + ImageInputPricePerToken float64 // 图片输入 token 价格 (USD),用于多模态 embedding 等图文不同价场景;为 0 时回退到 InputPricePerToken + OutputPricePerToken float64 // 每token输出价格 (USD) + OutputPricePerTokenPriority float64 // priority service tier 下每token输出价格 (USD) + CacheCreationPricePerToken float64 // 缓存创建每token价格 (USD) + CacheCreationPricePerTokenPriority float64 // priority service tier 下缓存创建每token价格 (USD) + CacheCreationPriceExplicit bool // 是否由渠道/区间定价显式设定(为 true 时即使 == 0 也不回退) + CacheReadPricePerToken float64 // 缓存读取每token价格 (USD) + CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) + CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) + CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) + SupportsCacheBreakdown bool // 是否支持详细的缓存分类 + LongContextInputThreshold int // 超过阈值后按整次会话提升输入价格 + LongContextInputMultiplier float64 // 长上下文整次会话输入倍率 + LongContextOutputMultiplier float64 // 长上下文整次会话输出倍率 + ImageOutputPricePerToken float64 // 图片输出 token 价格 (USD) + ImageOutputPriceExplicit bool // 是否由渠道定价显式设定(为 true 时即使 == 0 也不回退) } const ( @@ -122,7 +124,8 @@ func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bo if pricing == nil || normalizeBillingServiceTier(serviceTier) != "priority" { return false } - return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 || pricing.CacheReadPricePerTokenPriority > 0 + return pricing.InputPricePerTokenPriority > 0 || pricing.OutputPricePerTokenPriority > 0 || + pricing.CacheCreationPricePerTokenPriority > 0 || pricing.CacheReadPricePerTokenPriority > 0 } func serviceTierCostMultiplier(serviceTier string) float64 { @@ -280,10 +283,37 @@ func (s *BillingService) initFallbackPricing() { s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"] s.fallbackPrices["gpt-5.5-pro"] = s.fallbackPrices["gpt-5.4"] - // GPT-5.6(sol / terra / luna)暂无独立定价,回退到 GPT-5.4。 - s.fallbackPrices["gpt-5.6-sol"] = s.fallbackPrices["gpt-5.4"] - s.fallbackPrices["gpt-5.6-terra"] = s.fallbackPrices["gpt-5.4"] - s.fallbackPrices["gpt-5.6-luna"] = s.fallbackPrices["gpt-5.4"] + // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 + s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ + InputPricePerToken: 5e-6, + InputPricePerTokenPriority: 10e-6, + OutputPricePerToken: 30e-6, + OutputPricePerTokenPriority: 60e-6, + CacheCreationPricePerToken: 6.25e-6, + CacheCreationPricePerTokenPriority: 12.5e-6, + CacheReadPricePerToken: 0.5e-6, + CacheReadPricePerTokenPriority: 1e-6, + } + s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{ + InputPricePerToken: 2.5e-6, + InputPricePerTokenPriority: 5e-6, + OutputPricePerToken: 15e-6, + OutputPricePerTokenPriority: 30e-6, + CacheCreationPricePerToken: 3.125e-6, + CacheCreationPricePerTokenPriority: 6.25e-6, + CacheReadPricePerToken: 0.25e-6, + CacheReadPricePerTokenPriority: 0.5e-6, + } + s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{ + InputPricePerToken: 1e-6, + InputPricePerTokenPriority: 2e-6, + OutputPricePerToken: 6e-6, + OutputPricePerTokenPriority: 12e-6, + CacheCreationPricePerToken: 1.25e-6, + CacheCreationPricePerTokenPriority: 2.5e-6, + CacheReadPricePerToken: 0.1e-6, + CacheReadPricePerTokenPriority: 0.2e-6, + } s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{ InputPricePerToken: 7.5e-7, @@ -739,20 +769,21 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) { price1h := litellmPricing.CacheCreationInputTokenCostAbove1hr enableBreakdown := price1h > 0 && price1h > price5m return s.applyModelSpecificPricingPolicy(model, &ModelPricing{ - InputPricePerToken: litellmPricing.InputCostPerToken, - InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority, - OutputPricePerToken: litellmPricing.OutputCostPerToken, - OutputPricePerTokenPriority: litellmPricing.OutputCostPerTokenPriority, - CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost, - CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost, - CacheReadPricePerTokenPriority: litellmPricing.CacheReadInputTokenCostPriority, - CacheCreation5mPrice: price5m, - CacheCreation1hPrice: price1h, - SupportsCacheBreakdown: enableBreakdown, - LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold, - LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, - LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, - ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, + InputPricePerToken: litellmPricing.InputCostPerToken, + InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority, + OutputPricePerToken: litellmPricing.OutputCostPerToken, + OutputPricePerTokenPriority: litellmPricing.OutputCostPerTokenPriority, + CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost, + CacheCreationPricePerTokenPriority: litellmPricing.CacheCreationInputTokenCostPriority, + CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost, + CacheReadPricePerTokenPriority: litellmPricing.CacheReadInputTokenCostPriority, + CacheCreation5mPrice: price5m, + CacheCreation1hPrice: price1h, + SupportsCacheBreakdown: enableBreakdown, + LongContextInputThreshold: litellmPricing.LongContextInputTokenThreshold, + LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier, + LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier, + ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken, }), nil } } @@ -794,6 +825,8 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing } if channelPricing.CacheWritePrice != nil { pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = *channelPricing.CacheWritePrice + pricing.CacheCreationPriceExplicit = true pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice } @@ -867,7 +900,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, // calculateTokenCost 按 token 区间计费 func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input CostInput) (*CostBreakdown, error) { - totalContext := input.Tokens.InputTokens + input.Tokens.CacheReadTokens + totalContext := input.Tokens.InputTokens + input.Tokens.CacheCreationTokens + input.Tokens.CacheReadTokens pricing := input.Resolver.GetIntervalPricing(resolved, totalContext) if pricing == nil { @@ -897,6 +930,7 @@ func (s *BillingService) computeTokenBreakdown( inputPrice := pricing.InputPricePerToken outputPrice := pricing.OutputPricePerToken cacheReadPrice := pricing.CacheReadPricePerToken + cacheCreationPrice := pricing.CacheCreationPricePerToken cacheCreationMultiplier := 1.0 tierMultiplier := 1.0 @@ -910,6 +944,9 @@ func (s *BillingService) computeTokenBreakdown( if pricing.CacheReadPricePerTokenPriority > 0 { cacheReadPrice = pricing.CacheReadPricePerTokenPriority } + if pricing.CacheCreationPricePerTokenPriority > 0 { + cacheCreationPrice = pricing.CacheCreationPricePerTokenPriority + } } else { tierMultiplier = serviceTierCostMultiplier(serviceTier) } @@ -963,7 +1000,7 @@ func (s *BillingService) computeTokenBreakdown( } // 缓存创建费用 - bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens, cacheCreationMultiplier) + bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens, cacheCreationPrice, cacheCreationMultiplier) bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice @@ -984,7 +1021,7 @@ func (s *BillingService) computeTokenBreakdown( // computeCacheCreationCost 计算缓存创建费用(支持 5m/1h 分类或标准计费)。 // multiplier 用于长上下文等场景下的整体价格缩放(普通调用传 1.0 即可)。 -func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, multiplier float64) float64 { +func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens UsageTokens, price, multiplier float64) float64 { if pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0) { if tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0 && tokens.CacheCreationTokens > 0 { // API 未返回 ephemeral 明细,回退到全部按 5m 单价计费 @@ -993,7 +1030,7 @@ func (s *BillingService) computeCacheCreationCost(pricing *ModelPricing, tokens return float64(tokens.CacheCreation5mTokens)*pricing.CacheCreation5mPrice*multiplier + float64(tokens.CacheCreation1hTokens)*pricing.CacheCreation1hPrice*multiplier } - return float64(tokens.CacheCreationTokens) * pricing.CacheCreationPricePerToken * multiplier + return float64(tokens.CacheCreationTokens) * price * multiplier } // calculatePerRequestCost 按次/图片计费 @@ -1010,7 +1047,7 @@ func (s *BillingService) calculatePerRequestCost(resolved *ResolvedPricing, inpu } if unitPrice == 0 { - totalContext := input.Tokens.InputTokens + input.Tokens.CacheReadTokens + totalContext := input.Tokens.InputTokens + input.Tokens.CacheCreationTokens + input.Tokens.CacheReadTokens unitPrice = input.Resolver.GetRequestTierPriceByContext(resolved, totalContext) } @@ -1057,21 +1094,38 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * if pricing == nil { return nil } - if !isOpenAIGPT54Model(model) { + normalized := normalizeKnownOpenAICodexModel(model) + isGPT56 := isOpenAIGPT56Model(normalized) + usesLegacyLongContextPricing := usesOpenAILegacyLongContextPricing(normalized) + if !isGPT56 && !usesLegacyLongContextPricing { return pricing } - if pricing.LongContextInputThreshold > 0 && pricing.LongContextInputMultiplier > 0 && pricing.LongContextOutputMultiplier > 0 { + needsLongContextPolicy := usesLegacyLongContextPricing && + (pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0) + needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || + (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) + if !needsLongContextPolicy && !needsCacheCreationPolicy { return pricing } cloned := *pricing - if cloned.LongContextInputThreshold <= 0 { - cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold + if isGPT56 { + if cloned.CacheCreationPricePerToken <= 0 { + cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 + } + if cloned.CacheCreationPricePerTokenPriority <= 0 { + cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority * 1.25 + } } - if cloned.LongContextInputMultiplier <= 0 { - cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier - } - if cloned.LongContextOutputMultiplier <= 0 { - cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier + if usesLegacyLongContextPricing { + if cloned.LongContextInputThreshold <= 0 { + cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold + } + if cloned.LongContextInputMultiplier <= 0 { + cloned.LongContextInputMultiplier = openAIGPT54LongContextInputMultiplier + } + if cloned.LongContextOutputMultiplier <= 0 { + cloned.LongContextOutputMultiplier = openAIGPT54LongContextOutputMultiplier + } } return &cloned } @@ -1083,17 +1137,16 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens if pricing.LongContextInputMultiplier <= 1 && pricing.LongContextOutputMultiplier <= 1 { return false } - totalInputTokens := tokens.InputTokens + tokens.CacheReadTokens + totalInputTokens := tokens.InputTokens + tokens.CacheCreationTokens + tokens.CacheReadTokens return totalInputTokens > pricing.LongContextInputThreshold } -func isOpenAIGPT54Model(model string) bool { - // 仅当模型字符串实际属于已知 GPT-5/Codex 族时才做归一判定,避免 - // normalizeCodexModel 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o) - // 误识别为 gpt-5.4。 - normalized := normalizeKnownOpenAICodexModel(model) - return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro" || - normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" || normalized == "gpt-5.6-luna" +func isOpenAIGPT56Model(normalized string) bool { + return normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" || normalized == "gpt-5.6-luna" +} + +func usesOpenAILegacyLongContextPricing(normalized string) bool { + return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro" } // CalculateCostWithConfig 使用配置中的默认倍率计算费用 diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 0cc7a0ac47..a9f603113b 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -183,6 +183,8 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } if chPricing.CacheWritePrice != nil { resolved.BasePricing.CacheCreationPricePerToken = *chPricing.CacheWritePrice + resolved.BasePricing.CacheCreationPricePerTokenPriority = *chPricing.CacheWritePrice + resolved.BasePricing.CacheCreationPriceExplicit = true resolved.BasePricing.CacheCreation5mPrice = *chPricing.CacheWritePrice resolved.BasePricing.CacheCreation1hPrice = *chPricing.CacheWritePrice } @@ -251,6 +253,8 @@ func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, ch } if iv.CacheWritePrice != nil { pricing.CacheCreationPricePerToken = *iv.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = *iv.CacheWritePrice + pricing.CacheCreationPriceExplicit = true pricing.CacheCreation5mPrice = *iv.CacheWritePrice pricing.CacheCreation1hPrice = *iv.CacheWritePrice } diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index d4169e5f0e..71ba9904c2 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -114,6 +114,52 @@ func TestGetIntervalPricing_NoMatch_FallsBackToBase(t *testing.T) { require.Equal(t, basePricing, result) } +func TestGPT56ExplicitZeroCacheWritePriceIsPreserved(t *testing.T) { + bs := &BillingService{} + resolver := NewModelPricingResolver(nil, bs) + zero := 0.0 + + t.Run("flat channel price", func(t *testing.T) { + resolved := &ResolvedPricing{ + Mode: BillingModeToken, + BasePricing: &ModelPricing{ + InputPricePerToken: 5e-6, + OutputPricePerToken: 30e-6, + }, + } + resolver.applyTokenOverrides(&ChannelModelPricing{CacheWritePrice: &zero}, resolved) + + require.True(t, resolved.BasePricing.CacheCreationPriceExplicit) + cost, err := bs.CalculateCostUnified(CostInput{ + Model: "gpt-5.6-sol", + Tokens: UsageTokens{CacheCreationTokens: 100}, + RateMultiplier: 1, + Resolver: resolver, + Resolved: resolved, + }) + require.NoError(t, err) + require.Zero(t, cost.CacheCreationCost) + }) + + t.Run("interval price", func(t *testing.T) { + pricing := intervalToModelPricing(&PricingInterval{CacheWritePrice: &zero}, false, nil) + require.True(t, pricing.CacheCreationPriceExplicit) + + cost, err := bs.CalculateCostUnified(CostInput{ + Model: "gpt-5.6-sol", + Tokens: UsageTokens{CacheCreationTokens: 100}, + RateMultiplier: 1, + Resolver: resolver, + Resolved: &ResolvedPricing{ + Mode: BillingModeToken, + BasePricing: pricing, + }, + }) + require.NoError(t, err) + require.Zero(t, cost.CacheCreationCost) + }) +} + func TestGetRequestTierPrice(t *testing.T) { bs := newTestBillingServiceForResolver() r := NewModelPricingResolver(&ChannelService{}, bs) diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index fb2dc5ccbb..fa821d8f31 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -206,17 +206,8 @@ func extractOpenAIEmbeddingsUsage(body []byte) OpenAIUsage { usage.Get("completion_tokens"), usage.Get("output_tokens"), ) - cacheReadTokens := firstPositiveGJSONInt( - usage.Get("prompt_tokens_details.cached_tokens"), - usage.Get("input_tokens_details.cached_tokens"), - usage.Get("cache_read_tokens"), - usage.Get("cache_read_input_tokens"), - ) - cacheCreationTokens := firstPositiveGJSONInt( - usage.Get("cache_creation_tokens"), - usage.Get("cache_creation_input_tokens"), - usage.Get("input_tokens_details.cache_creation_tokens"), - ) + cacheReadTokens := openAICacheReadTokensFromUsage(usage) + cacheCreationTokens := openAICacheCreationTokensFromUsage(usage) // 多模态 embedding(如 doubao-embedding-vision)回传图文 token 拆分, // 用于图文不同价计费;纯文本 embedding 该字段为 0,行为不变。 imageInputTokens := firstPositiveGJSONInt( diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 4e3cfb440e..28c95f7ff8 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -383,12 +383,9 @@ func extractCCStreamUsage(payload string) *OpenAIUsage { if !usageResult.Exists() || !usageResult.IsObject() { return nil } - u := OpenAIUsage{ - InputTokens: int(gjson.Get(payload, "usage.prompt_tokens").Int()), - OutputTokens: int(gjson.Get(payload, "usage.completion_tokens").Int()), - } - if cached := gjson.Get(payload, "usage.prompt_tokens_details.cached_tokens"); cached.Exists() { - u.CacheReadInputTokens = int(cached.Int()) + u, ok := openAIUsageFromGJSON(usageResult) + if !ok { + return nil } return &u } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b621a96aee..52ec34f27e 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -1114,11 +1114,19 @@ func copyOpenAIUsageFromResponsesUsage(usage *apicompat.ResponsesUsage) OpenAIUs return OpenAIUsage{} } result := OpenAIUsage{ - InputTokens: usage.InputTokens, - OutputTokens: usage.OutputTokens, + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheCreationInputTokens: usage.CacheCreationInputTokens, } if usage.InputTokensDetails != nil { result.CacheReadInputTokens = usage.InputTokensDetails.CachedTokens + if result.CacheCreationInputTokens == 0 { + if usage.InputTokensDetails.CacheWriteTokens > 0 { + result.CacheCreationInputTokens = usage.InputTokensDetails.CacheWriteTokens + } else { + result.CacheCreationInputTokens = usage.InputTokensDetails.CacheCreationTokens + } + } } return result } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b9bbd137c6..81578f630f 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -247,7 +247,7 @@ func expectedOpenAICost(t *testing.T, svc *OpenAIGatewayService, model string, u t.Helper() cost, err := svc.billingService.CalculateCost(model, UsageTokens{ - InputTokens: max(usage.InputTokens-usage.CacheReadInputTokens, 0), + InputTokens: max(usage.InputTokens-usage.CacheReadInputTokens-usage.CacheCreationInputTokens, 0), OutputTokens: usage.OutputTokens, CacheCreationTokens: usage.CacheCreationInputTokens, CacheReadTokens: usage.CacheReadInputTokens, @@ -1002,6 +1002,49 @@ func TestOpenAIGatewayServiceRecordUsage_ClampsActualInputTokensToZero(t *testin require.Equal(t, 0, usageRepo.lastLog.InputTokens) } +func TestOpenAIGatewayServiceRecordUsage_GPT56SeparatesCacheWriteForBillingAndStats(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + svc.billingService = NewBillingService(svc.cfg, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-5.6-sol": { + InputCostPerToken: 5e-6, + OutputCostPerToken: 30e-6, + CacheReadInputTokenCost: 0.5e-6, + }, + }}) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_gpt56_cache_write", + Usage: OpenAIUsage{ + InputTokens: 1000, + OutputTokens: 50, + CacheCreationInputTokens: 200, + CacheReadInputTokens: 100, + }, + Model: "gpt-5.6-sol", + Duration: time.Second, + }, + APIKey: &APIKey{ID: 1056}, + User: &User{ID: 2056}, + Account: &Account{ID: 3056}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, 700, usageRepo.lastLog.InputTokens) + require.Equal(t, 200, usageRepo.lastLog.CacheCreationTokens) + require.Equal(t, 100, usageRepo.lastLog.CacheReadTokens) + require.Equal(t, 1050, usageRepo.lastLog.TotalTokens()) + require.InDelta(t, 700*5e-6, usageRepo.lastLog.InputCost, 1e-12) + require.InDelta(t, 200*6.25e-6, usageRepo.lastLog.CacheCreationCost, 1e-12) + require.InDelta(t, 100*0.5e-6, usageRepo.lastLog.CacheReadCost, 1e-12) + require.InDelta(t, 50*30e-6, usageRepo.lastLog.OutputCost, 1e-12) + require.InDelta(t, usageRepo.lastLog.TotalCost*1.1, usageRepo.lastLog.ActualCost, 1e-12) +} + func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillsWholeSession(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 45c948f56f..313a255341 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -756,10 +756,8 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { if outputTokens == 0 { outputTokens = value.Get("completion_tokens").Int() } - cacheReadTokens := value.Get("input_tokens_details.cached_tokens").Int() - if cacheReadTokens == 0 { - cacheReadTokens = value.Get("prompt_tokens_details.cached_tokens").Int() - } + cacheReadTokens := openAICacheReadTokensFromUsage(value) + cacheCreationTokens := openAICacheCreationTokensFromUsage(value) imageOutputTokens := value.Get("output_tokens_details.image_tokens").Int() if imageOutputTokens == 0 { imageOutputTokens = value.Get("completion_tokens_details.image_tokens").Int() @@ -767,12 +765,35 @@ func openAIUsageFromGJSON(value gjson.Result) (OpenAIUsage, bool) { return OpenAIUsage{ InputTokens: int(inputTokens), OutputTokens: int(outputTokens), - CacheCreationInputTokens: int(value.Get("cache_creation_input_tokens").Int()), - CacheReadInputTokens: int(cacheReadTokens), + CacheCreationInputTokens: cacheCreationTokens, + CacheReadInputTokens: cacheReadTokens, ImageOutputTokens: int(imageOutputTokens), }, true } +func openAICacheReadTokensFromUsage(value gjson.Result) int { + return firstPositiveGJSONInt( + value.Get("input_tokens_details.cached_tokens"), + value.Get("prompt_tokens_details.cached_tokens"), + value.Get("cache_read_input_tokens"), + value.Get("cache_read_tokens"), + value.Get("cached_tokens"), + ) +} + +func openAICacheCreationTokensFromUsage(value gjson.Result) int { + return firstPositiveGJSONInt( + value.Get("input_tokens_details.cache_write_tokens"), + value.Get("prompt_tokens_details.cache_write_tokens"), + value.Get("input_tokens_details.cache_creation_tokens"), + value.Get("prompt_tokens_details.cache_creation_tokens"), + value.Get("cache_write_tokens"), + value.Get("cache_creation_input_tokens"), + value.Get("cache_write_input_tokens"), + value.Get("cache_creation_tokens"), + ) +} + func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 9f42a82312..3fdef698db 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -2800,17 +2800,27 @@ func TestParseSSEUsage_SelectiveParsing(t *testing.T) { } func TestExtractOpenAIUsageFromJSONBytes_AcceptsResponseAndChatUsageShapes(t *testing.T) { - usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5,"input_tokens_details":{"cached_tokens":2}}}`)) + usage, ok := extractOpenAIUsageFromJSONBytes([]byte(`{"id":"resp_1","usage":{"input_tokens":9,"output_tokens":5,"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":4}}}`)) require.True(t, ok) - require.Equal(t, 3, usage.InputTokens) + require.Equal(t, 9, usage.InputTokens) require.Equal(t, 5, usage.OutputTokens) require.Equal(t, 2, usage.CacheReadInputTokens) + require.Equal(t, 4, usage.CacheCreationInputTokens) - usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"type":"response.completed","response":{"usage":{"prompt_tokens":13,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4}}}}`)) + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"type":"response.completed","response":{"usage":{"prompt_tokens":13,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4,"cache_creation_tokens":3}}}}`)) require.True(t, ok) require.Equal(t, 13, usage.InputTokens) require.Equal(t, 7, usage.OutputTokens) require.Equal(t, 4, usage.CacheReadInputTokens) + require.Equal(t, 3, usage.CacheCreationInputTokens) + + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":11,"output_tokens":2,"cache_write_input_tokens":6}}`)) + require.True(t, ok) + require.Equal(t, 6, usage.CacheCreationInputTokens) + + usage, ok = extractOpenAIUsageFromJSONBytes([]byte(`{"usage":{"input_tokens":20,"output_tokens":2,"cache_creation_input_tokens":19,"input_tokens_details":{"cache_write_tokens":7}}}`)) + require.True(t, ok) + require.Equal(t, 7, usage.CacheCreationInputTokens, "官方嵌套字段应优先于兼容顶层别名") } func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 4a16facccd..f96b679cf6 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -119,9 +119,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ApplyOpenAIImageBillingResolution(result) } - // 计算实际的新输入token(减去缓存读取的token) - // 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费 - actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens + // OpenAI input_tokens 是总输入,包含缓存读取和缓存写入明细。 + // 将三类 token 拆成互斥桶,避免缓存写入同时按普通输入和 cache_write 重复计费。 + actualInputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens - result.Usage.CacheCreationInputTokens if actualInputTokens < 0 { actualInputTokens = 0 } diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index ca7c36aaa7..1d18c46fca 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -33,6 +33,7 @@ func TestIsOpenAIWSClientDisconnectError(t *testing.T) { {name: "ws_policy_violation", err: coderws.CloseError{Code: coderws.StatusPolicyViolation}, want: false}, {name: "wrapped_eof_message", err: errors.New("failed to get reader: failed to read frame header: EOF"), want: true}, {name: "connection_reset_by_peer", err: errors.New("failed to read frame header: read tcp 127.0.0.1:1234->127.0.0.1:5678: read: connection reset by peer"), want: true}, + {name: "windows_connection_reset", err: errors.New("failed to get reader: failed to read frame header: read tcp 127.0.0.1:1234->127.0.0.1:5678: wsarecv: An existing connection was forcibly closed by the remote host."), want: true}, {name: "broken_pipe", err: errors.New("write tcp 127.0.0.1:1234->127.0.0.1:5678: write: broken pipe"), want: true}, } diff --git a/backend/internal/service/openai_ws_forwarder_logutil.go b/backend/internal/service/openai_ws_forwarder_logutil.go index 611938091d..04d1fb4a75 100644 --- a/backend/internal/service/openai_ws_forwarder_logutil.go +++ b/backend/internal/service/openai_ws_forwarder_logutil.go @@ -660,6 +660,7 @@ func isOpenAIWSClientDisconnectError(err error) bool { strings.Contains(message, "use of closed network connection") || strings.Contains(message, "connection reset by peer") || strings.Contains(message, "broken pipe") || + strings.Contains(message, "an existing connection was forcibly closed by the remote host") || strings.Contains(message, "an established connection was aborted") } diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index a2fc28d9e7..31c63e214b 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -262,15 +262,9 @@ func populateOpenAIUsageFromResponseJSON(body []byte, usage *OpenAIUsage) { if usage == nil || len(body) == 0 { return } - values := gjson.GetManyBytes( - body, - "usage.input_tokens", - "usage.output_tokens", - "usage.input_tokens_details.cached_tokens", - ) - usage.InputTokens = int(values[0].Int()) - usage.OutputTokens = int(values[1].Int()) - usage.CacheReadInputTokens = int(values[2].Int()) + if parsed, ok := extractOpenAIUsageFromJSONBytes(body); ok { + *usage = parsed + } } func getOpenAIGroupIDFromContext(c *gin.Context) int64 { diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index 6aba3b7dbb..be19438eaa 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -787,7 +787,7 @@ func parseUsageAndAccumulate( parsedUsage := Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, - CacheCreationInputTokens: int(usageResult.Get("cache_creation_input_tokens").Int()), + CacheCreationInputTokens: openAICacheCreationTokensFromUsage(usageResult), CacheReadInputTokens: cachedTokens, ImageOutputTokens: int(imageTokens), } @@ -810,6 +810,24 @@ func parseUsageIntField(value gjson.Result, required bool) (int, bool) { return int(value.Int()), true } +func openAICacheCreationTokensFromUsage(value gjson.Result) int { + for _, field := range []string{ + "input_tokens_details.cache_write_tokens", + "prompt_tokens_details.cache_write_tokens", + "input_tokens_details.cache_creation_tokens", + "prompt_tokens_details.cache_creation_tokens", + "cache_write_tokens", + "cache_creation_input_tokens", + "cache_write_input_tokens", + "cache_creation_tokens", + } { + if tokens := int(value.Get(field).Int()); tokens > 0 { + return tokens + } + } + return 0 +} + func enrichResult(result *RelayResult, state *relayState, duration time.Duration) { if result == nil { return diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go index 13c51f663f..cb2bd9cc31 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_internal_test.go @@ -300,7 +300,7 @@ func TestParseUsageAndEnrichCoverage(t *testing.T) { require.Equal(t, 0, state.usage.OutputTokens) require.Equal(t, 0, state.usage.CacheReadInputTokens) - parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":2,"output_tokens":1,"input_tokens_details":{"cached_tokens":1},"cache_creation_input_tokens":4,"output_tokens_details":{"image_tokens":3}}}}`), "response.completed", nil) + parseUsageAndAccumulate(state, []byte(`{"type":"response.completed","response":{"usage":{"input_tokens":2,"output_tokens":1,"input_tokens_details":{"cached_tokens":1,"cache_write_tokens":4},"output_tokens_details":{"image_tokens":3}}}}`), "response.completed", nil) require.Equal(t, 2, state.usage.InputTokens) require.Equal(t, 1, state.usage.OutputTokens) require.Equal(t, 1, state.usage.CacheReadInputTokens) diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 2ae15df507..4528a8886f 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -35,6 +35,48 @@ var ( Mode: "chat", SupportsPromptCaching: true, } + openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 5e-06, + InputCostPerTokenPriority: 1e-05, + OutputCostPerToken: 3e-05, + OutputCostPerTokenPriority: 6e-05, + CacheCreationInputTokenCost: 6.25e-06, + CacheCreationInputTokenCostPriority: 1.25e-05, + CacheReadInputTokenCost: 5e-07, + CacheReadInputTokenCostPriority: 1e-06, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + openAIGPT56TerraFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 2.5e-06, + InputCostPerTokenPriority: 5e-06, + OutputCostPerToken: 1.5e-05, + OutputCostPerTokenPriority: 3e-05, + CacheCreationInputTokenCost: 3.125e-06, + CacheCreationInputTokenCostPriority: 6.25e-06, + CacheReadInputTokenCost: 2.5e-07, + CacheReadInputTokenCostPriority: 5e-07, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + openAIGPT56LunaFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 1e-06, + InputCostPerTokenPriority: 2e-06, + OutputCostPerToken: 6e-06, + OutputCostPerTokenPriority: 1.2e-05, + CacheCreationInputTokenCost: 1.25e-06, + CacheCreationInputTokenCostPriority: 2.5e-06, + CacheReadInputTokenCost: 1e-07, + CacheReadInputTokenCostPriority: 2e-07, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } openAIGPT54MiniFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 7.5e-07, OutputCostPerToken: 4.5e-06, @@ -61,6 +103,7 @@ type LiteLLMModelPricing struct { OutputCostPerToken float64 `json:"output_cost_per_token"` OutputCostPerTokenPriority float64 `json:"output_cost_per_token_priority"` CacheCreationInputTokenCost float64 `json:"cache_creation_input_token_cost"` + CacheCreationInputTokenCostPriority float64 `json:"cache_creation_input_token_cost_priority"` CacheCreationInputTokenCostAbove1hr float64 `json:"cache_creation_input_token_cost_above_1hr"` CacheReadInputTokenCost float64 `json:"cache_read_input_token_cost"` CacheReadInputTokenCostPriority float64 `json:"cache_read_input_token_cost_priority"` @@ -93,6 +136,7 @@ type LiteLLMRawEntry struct { OutputCostPerToken *float64 `json:"output_cost_per_token"` OutputCostPerTokenPriority *float64 `json:"output_cost_per_token_priority"` CacheCreationInputTokenCost *float64 `json:"cache_creation_input_token_cost"` + CacheCreationInputTokenCostPriority *float64 `json:"cache_creation_input_token_cost_priority"` CacheCreationInputTokenCostAbove1hr *float64 `json:"cache_creation_input_token_cost_above_1hr"` CacheReadInputTokenCost *float64 `json:"cache_read_input_token_cost"` CacheReadInputTokenCostPriority *float64 `json:"cache_read_input_token_cost_priority"` @@ -406,6 +450,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel if entry.CacheCreationInputTokenCost != nil { pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost } + if entry.CacheCreationInputTokenCostPriority != nil { + pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority + } if entry.CacheCreationInputTokenCostAbove1hr != nil { pricing.CacheCreationInputTokenCostAbove1hr = *entry.CacheCreationInputTokenCostAbove1hr } @@ -837,11 +884,20 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { } } - // GPT-5.6(sol / terra / luna)回退到 GPT-5.4 定价 - if strings.HasPrefix(model, "gpt-5.6") { + if strings.HasPrefix(model, "gpt-5.6-sol") { logger.With(zap.String("component", "service.pricing")). - Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.4(static)")) - return openAIGPT54FallbackPricing + Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-sol(static)")) + return openAIGPT56SolFallbackPricing + } + if strings.HasPrefix(model, "gpt-5.6-terra") { + logger.With(zap.String("component", "service.pricing")). + Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-terra(static)")) + return openAIGPT56TerraFallbackPricing + } + if strings.HasPrefix(model, "gpt-5.6-luna") { + logger.With(zap.String("component", "service.pricing")). + Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-luna(static)")) + return openAIGPT56LunaFallbackPricing } // GPT-5.5 回退到 GPT-5.4 定价 diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 4bf8f2379e..19098df917 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -19,6 +19,7 @@ func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) { "output_cost_per_token": 0.000015, "output_cost_per_token_priority": 0.00003, "cache_creation_input_token_cost": 0.0000025, + "cache_creation_input_token_cost_priority": 0.000005, "cache_read_input_token_cost": 0.00000025, "cache_read_input_token_cost_priority": 0.0000005, "supports_service_tier": true, @@ -34,10 +35,155 @@ func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) { require.NotNil(t, pricing) require.InDelta(t, 5e-6, pricing.InputCostPerTokenPriority, 1e-12) require.InDelta(t, 3e-5, pricing.OutputCostPerTokenPriority, 1e-12) + require.InDelta(t, 5e-6, pricing.CacheCreationInputTokenCostPriority, 1e-12) require.InDelta(t, 5e-7, pricing.CacheReadInputTokenCostPriority, 1e-12) require.True(t, pricing.SupportsServiceTier) } +func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.T) { + tests := []struct { + model string + input float64 + inputPriority float64 + output float64 + outputPriority float64 + cacheRead float64 + cacheReadPriority float64 + }{ + {model: "gpt-5.6-sol", input: 5e-6, inputPriority: 10e-6, output: 30e-6, outputPriority: 60e-6, cacheRead: 0.5e-6, cacheReadPriority: 1e-6}, + {model: "gpt-5.6-terra", input: 2.5e-6, inputPriority: 5e-6, output: 15e-6, outputPriority: 30e-6, cacheRead: 0.25e-6, cacheReadPriority: 0.5e-6}, + {model: "gpt-5.6-luna", input: 1e-6, inputPriority: 2e-6, output: 6e-6, outputPriority: 12e-6, cacheRead: 0.1e-6, cacheReadPriority: 0.2e-6}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + tt.model: { + InputCostPerToken: tt.input, + InputCostPerTokenPriority: tt.inputPriority, + OutputCostPerToken: tt.output, + OutputCostPerTokenPriority: tt.outputPriority, + CacheReadInputTokenCost: tt.cacheRead, + CacheReadInputTokenCostPriority: tt.cacheReadPriority, + }, + }} + svc := NewBillingService(&config.Config{}, pricingSvc) + + pricing, err := svc.GetModelPricing(tt.model) + require.NoError(t, err) + require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.LongContextInputThreshold) + + tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100} + standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "") + require.NoError(t, err) + require.InDelta(t, 200*tt.input*1.25, standard.CacheCreationCost, 1e-12) + + priority, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "priority") + require.NoError(t, err) + require.InDelta(t, 200*tt.inputPriority*1.25, priority.CacheCreationCost, 1e-12) + + flex, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "flex") + require.NoError(t, err) + require.InDelta(t, 200*tt.input*1.25*0.5, flex.CacheCreationCost, 1e-12) + }) + } +} + +func TestBillingService_GPT56DoesNotUseLegacyLongContextMultiplier(t *testing.T) { + model := "gpt-5.6-sol" + pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + model: { + InputCostPerToken: 5e-6, + OutputCostPerToken: 30e-6, + CacheReadInputTokenCost: 0.5e-6, + }, + }} + svc := NewBillingService(&config.Config{}, pricingSvc) + tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 173000, OutputTokens: 10} + + cost, err := svc.CalculateCost(model, tokens, 1) + require.NoError(t, err) + require.InDelta(t, 100000*5e-6, cost.InputCost, 1e-12) + require.InDelta(t, 173000*6.25e-6, cost.CacheCreationCost, 1e-12) + require.InDelta(t, 10*30e-6, cost.OutputCost, 1e-12) +} + +func TestDefaultPricingIncludesOfficialGPT56Rates(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json")) + require.NoError(t, err) + + pricingSvc := &PricingService{} + pricingData, err := pricingSvc.parsePricingData(data) + require.NoError(t, err) + pricingSvc.pricingData = pricingData + billingSvc := NewBillingService(&config.Config{}, pricingSvc) + + tests := []struct { + model string + input, cached, cacheWrite, output float64 + inputPriority, cachedPriority, cacheWritePriority, outputPriority float64 + }{ + {model: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6, inputPriority: 10e-6, cachedPriority: 1e-6, cacheWritePriority: 12.5e-6, outputPriority: 60e-6}, + {model: "gpt-5.6-terra", input: 2.5e-6, cached: 0.25e-6, cacheWrite: 3.125e-6, output: 15e-6, inputPriority: 5e-6, cachedPriority: 0.5e-6, cacheWritePriority: 6.25e-6, outputPriority: 30e-6}, + {model: "gpt-5.6-luna", input: 1e-6, cached: 0.1e-6, cacheWrite: 1.25e-6, output: 6e-6, inputPriority: 2e-6, cachedPriority: 0.2e-6, cacheWritePriority: 2.5e-6, outputPriority: 12e-6}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + pricing, err := billingSvc.GetModelPricing(tt.model) + require.NoError(t, err) + require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, tt.cached, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, tt.cacheWrite, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, tt.inputPriority, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, tt.cachedPriority, pricing.CacheReadPricePerTokenPriority, 1e-12) + require.InDelta(t, tt.cacheWritePriority, pricing.CacheCreationPricePerTokenPriority, 1e-12) + require.InDelta(t, tt.outputPriority, pricing.OutputPricePerTokenPriority, 1e-12) + require.Zero(t, pricing.LongContextInputThreshold) + }) + } +} + +func TestGPT56DedicatedFallbacksUseOfficialRates(t *testing.T) { + tests := []struct { + model string + input, cached, cacheWrite, output float64 + }{ + {model: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6}, + {model: "gpt-5.6-terra", input: 2.5e-6, cached: 0.25e-6, cacheWrite: 3.125e-6, output: 15e-6}, + {model: "gpt-5.6-luna", input: 1e-6, cached: 0.1e-6, cacheWrite: 1.25e-6, output: 6e-6}, + } + + for _, tt := range tests { + t.Run(tt.model+"/pricing_service", func(t *testing.T) { + pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-5.1-codex": {InputCostPerToken: 1.25e-6}, + }} + svc := NewBillingService(&config.Config{}, pricingSvc) + pricing, err := svc.GetModelPricing(tt.model + "-preview") + require.NoError(t, err) + assertGPT56FallbackPricing(t, pricing, tt.input, tt.cached, tt.cacheWrite, tt.output) + }) + + t.Run(tt.model+"/billing_service", func(t *testing.T) { + svc := NewBillingService(&config.Config{}, nil) + pricing, err := svc.GetModelPricing(tt.model) + require.NoError(t, err) + assertGPT56FallbackPricing(t, pricing, tt.input, tt.cached, tt.cacheWrite, tt.output) + }) + } +} + +func assertGPT56FallbackPricing(t *testing.T, pricing *ModelPricing, input, cached, cacheWrite, output float64) { + t.Helper() + require.InDelta(t, input, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, cached, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12) + require.Zero(t, pricing.LongContextInputThreshold) +} + func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) { svc := &PricingService{} body := []byte(`{ diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index b9d18f8bf5..439988a53d 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -4961,12 +4961,14 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_batches": 3.125e-06, + "cache_creation_input_token_cost_flex": 3.125e-06, + "cache_creation_input_token_cost_priority": 1.25e-05, "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_flex": 2.5e-07, "cache_read_input_token_cost_priority": 1e-06, "input_cost_per_token": 5e-06, - "input_cost_per_token_above_272k_tokens": 1e-05, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_flex": 2.5e-06, "input_cost_per_token_priority": 1e-05, @@ -4976,7 +4978,6 @@ "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-05, - "output_cost_per_token_above_272k_tokens": 4.5e-05, "output_cost_per_token_batches": 1.5e-05, "output_cost_per_token_flex": 1.5e-05, "output_cost_per_token_priority": 6e-05, @@ -5009,25 +5010,26 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-terra": { - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, - "input_cost_per_token": 5e-06, - "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_batches": 2.5e-06, - "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "cache_creation_input_token_cost": 3.125e-06, + "cache_creation_input_token_cost_batches": 1.5625e-06, + "cache_creation_input_token_cost_flex": 1.5625e-06, + "cache_creation_input_token_cost_priority": 6.25e-06, + "cache_read_input_token_cost": 2.5e-07, + "cache_read_input_token_cost_flex": 1.25e-07, + "cache_read_input_token_cost_priority": 5e-07, + "input_cost_per_token": 2.5e-06, + "input_cost_per_token_batches": 1.25e-06, + "input_cost_per_token_flex": 1.25e-06, + "input_cost_per_token_priority": 5e-06, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-05, - "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_batches": 1.5e-05, - "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token": 1.5e-05, + "output_cost_per_token_batches": 7.5e-06, + "output_cost_per_token_flex": 7.5e-06, + "output_cost_per_token_priority": 3e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", @@ -5057,25 +5059,26 @@ "supports_xhigh_reasoning_effort": true }, "gpt-5.6-luna": { - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_272k_tokens": 1e-06, - "cache_read_input_token_cost_flex": 2.5e-07, - "cache_read_input_token_cost_priority": 1e-06, - "input_cost_per_token": 5e-06, - "input_cost_per_token_above_272k_tokens": 1e-05, - "input_cost_per_token_batches": 2.5e-06, - "input_cost_per_token_flex": 2.5e-06, - "input_cost_per_token_priority": 1e-05, + "cache_creation_input_token_cost": 1.25e-06, + "cache_creation_input_token_cost_batches": 6.25e-07, + "cache_creation_input_token_cost_flex": 6.25e-07, + "cache_creation_input_token_cost_priority": 2.5e-06, + "cache_read_input_token_cost": 1e-07, + "cache_read_input_token_cost_flex": 5e-08, + "cache_read_input_token_cost_priority": 2e-07, + "input_cost_per_token": 1e-06, + "input_cost_per_token_batches": 5e-07, + "input_cost_per_token_flex": 5e-07, + "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", "max_input_tokens": 1050000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-05, - "output_cost_per_token_above_272k_tokens": 4.5e-05, - "output_cost_per_token_batches": 1.5e-05, - "output_cost_per_token_flex": 1.5e-05, - "output_cost_per_token_priority": 6e-05, + "output_cost_per_token": 6e-06, + "output_cost_per_token_batches": 3e-06, + "output_cost_per_token_flex": 3e-06, + "output_cost_per_token_priority": 1.2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch",