feat(openai): support GPT-5.6 cache write billing

This commit is contained in:
benjamin
2026-07-10 09:08:58 +08:00
parent 12d811bd76
commit 4a2b10c94e
18 changed files with 311 additions and 91 deletions
@@ -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
@@ -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",
@@ -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,
}
}
+23 -5
View File
@@ -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"`
+59 -38
View File
@@ -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
}
@@ -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
}
+2 -11
View File
@@ -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(
@@ -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
}
@@ -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
}
@@ -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{}
@@ -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 {
@@ -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) {
@@ -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
}
@@ -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 {
@@ -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
@@ -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)
@@ -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
}
@@ -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(`{