From 4a2b10c94e91c275a43b61ee68b6fe89855d1953 Mon Sep 17 00:00:00 2001 From: benjamin Date: Fri, 10 Jul 2026 09:08:58 +0800 Subject: [PATCH] feat(openai): support GPT-5.6 cache write billing --- .../chatcompletions_responses_bridge.go | 12 ++- .../chatcompletions_responses_test.go | 21 ++++ .../apicompat/responses_to_chatcompletions.go | 16 ++- backend/internal/pkg/apicompat/types.go | 28 +++++- backend/internal/service/billing_service.go | 97 +++++++++++-------- .../service/model_pricing_resolver.go | 2 + backend/internal/service/openai_embeddings.go | 13 +-- .../openai_gateway_chat_completions_raw.go | 9 +- .../service/openai_gateway_messages.go | 12 ++- .../openai_gateway_record_usage_test.go | 45 ++++++++- .../openai_gateway_response_handling.go | 33 +++++-- .../service/openai_gateway_service_test.go | 12 ++- .../internal/service/openai_gateway_usage.go | 6 +- .../service/openai_ws_forwarder_support.go | 12 +-- .../service/openai_ws_v2/passthrough_relay.go | 20 +++- .../passthrough_relay_internal_test.go | 2 +- backend/internal/service/pricing_service.go | 5 + .../internal/service/pricing_service_test.go | 57 +++++++++++ 18 files changed, 311 insertions(+), 91 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index eeeedd29aa..13a044926b 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -608,9 +608,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 b30330863c..358aa9ac8a 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 a0fd07a0d1..6980d52b7c 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. @@ -545,6 +561,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/service/billing_service.go b/backend/internal/service/billing_service.go index 8dceebc250..e82c38a14f 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -90,22 +90,23 @@ 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) + 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 +123,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 { @@ -739,20 +741,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 +797,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing } if channelPricing.CacheWritePrice != nil { pricing.CacheCreationPricePerToken = *channelPricing.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = *channelPricing.CacheWritePrice pricing.CacheCreation5mPrice = *channelPricing.CacheWritePrice pricing.CacheCreation1hPrice = *channelPricing.CacheWritePrice } @@ -867,7 +871,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 +901,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 +915,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 +971,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 +992,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 +1001,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 +1018,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,13 +1065,26 @@ func (s *BillingService) applyModelSpecificPricingPolicy(model string, pricing * if pricing == nil { return nil } + normalized := normalizeKnownOpenAICodexModel(model) if !isOpenAIGPT54Model(model) { return pricing } - if pricing.LongContextInputThreshold > 0 && pricing.LongContextInputMultiplier > 0 && pricing.LongContextOutputMultiplier > 0 { + isGPT56 := normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" || normalized == "gpt-5.6-luna" + needsLongContextPolicy := pricing.LongContextInputThreshold <= 0 || pricing.LongContextInputMultiplier <= 0 || pricing.LongContextOutputMultiplier <= 0 + needsCacheCreationPolicy := isGPT56 && (pricing.CacheCreationPricePerToken <= 0 || + (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) + if !needsLongContextPolicy && !needsCacheCreationPolicy { return pricing } cloned := *pricing + if isGPT56 { + if cloned.CacheCreationPricePerToken <= 0 { + cloned.CacheCreationPricePerToken = cloned.InputPricePerToken + } + if cloned.CacheCreationPricePerTokenPriority <= 0 { + cloned.CacheCreationPricePerTokenPriority = cloned.InputPricePerTokenPriority + } + } if cloned.LongContextInputThreshold <= 0 { cloned.LongContextInputThreshold = openAIGPT54LongContextInputThreshold } @@ -1083,7 +1104,7 @@ 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 } diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 0cc7a0ac47..7ddf6ea475 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -183,6 +183,7 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } if chPricing.CacheWritePrice != nil { resolved.BasePricing.CacheCreationPricePerToken = *chPricing.CacheWritePrice + resolved.BasePricing.CacheCreationPricePerTokenPriority = *chPricing.CacheWritePrice resolved.BasePricing.CacheCreation5mPrice = *chPricing.CacheWritePrice resolved.BasePricing.CacheCreation1hPrice = *chPricing.CacheWritePrice } @@ -251,6 +252,7 @@ func intervalToModelPricing(iv *PricingInterval, supportsCacheBreakdown bool, ch } if iv.CacheWritePrice != nil { pricing.CacheCreationPricePerToken = *iv.CacheWritePrice + pricing.CacheCreationPricePerTokenPriority = *iv.CacheWritePrice pricing.CacheCreation5mPrice = *iv.CacheWritePrice pricing.CacheCreation1hPrice = *iv.CacheWritePrice } 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 9b31b803d2..d73bdd1284 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..f749c9c1f7 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*5e-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 cb4b780cdf..77d212dbe9 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("cache_creation_input_tokens"), + value.Get("cache_write_input_tokens"), + value.Get("cache_creation_tokens"), + value.Get("cache_write_tokens"), + value.Get("input_tokens_details.cache_creation_tokens"), + value.Get("input_tokens_details.cache_write_tokens"), + value.Get("prompt_tokens_details.cache_creation_tokens"), + value.Get("prompt_tokens_details.cache_write_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..1628ca3d2b 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -2800,17 +2800,23 @@ 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) } 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_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..874c618a29 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{ + "cache_creation_input_tokens", + "cache_write_input_tokens", + "cache_creation_tokens", + "cache_write_tokens", + "input_tokens_details.cache_creation_tokens", + "input_tokens_details.cache_write_tokens", + "prompt_tokens_details.cache_creation_tokens", + "prompt_tokens_details.cache_write_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..a21cb98783 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -61,6 +61,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 +94,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 +408,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 } diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index 4bf8f2379e..e8f00ae3b2 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,66 @@ 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_GPT56CacheWritePricingUsesInputTier(t *testing.T) { + for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} { + t.Run(model, func(t *testing.T) { + pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + model: { + InputCostPerToken: 5e-6, + InputCostPerTokenPriority: 10e-6, + OutputCostPerToken: 30e-6, + OutputCostPerTokenPriority: 60e-6, + CacheReadInputTokenCost: 0.5e-6, + CacheReadInputTokenCostPriority: 1e-6, + }, + }} + svc := NewBillingService(&config.Config{}, pricingSvc) + + pricing, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 10e-6, pricing.CacheCreationPricePerTokenPriority, 1e-12) + + tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100} + standard, err := svc.CalculateCostWithServiceTier(model, tokens, 1, "") + require.NoError(t, err) + require.InDelta(t, 200*5e-6, standard.CacheCreationCost, 1e-12) + + priority, err := svc.CalculateCostWithServiceTier(model, tokens, 1, "priority") + require.NoError(t, err) + require.InDelta(t, 200*10e-6, priority.CacheCreationCost, 1e-12) + + flex, err := svc.CalculateCostWithServiceTier(model, tokens, 1, "flex") + require.NoError(t, err) + require.InDelta(t, 200*2.5e-6, flex.CacheCreationCost, 1e-12) + }) + } +} + +func TestBillingService_GPT56CacheWriteContributesToLongContextThreshold(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*10e-6, cost.InputCost, 1e-12) + require.InDelta(t, 173000*10e-6, cost.CacheCreationCost, 1e-12) + require.InDelta(t, 10*45e-6, cost.OutputCost, 1e-12) +} + func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) { svc := &PricingService{} body := []byte(`{