From 9823ebc7ea4ac7102d5d8478d395c7d7eaf6c285 Mon Sep 17 00:00:00 2001 From: yxkong <5ycode@sina.com> Date: Sun, 25 Oct 2026 17:22:51 +0800 Subject: [PATCH] fix(billing): bill cache_creation_input_tokens reported in prompt_tokens_details (#7909) OpenAI-compatible upstreams with explicit context cache (e.g. Alibaba Bailian) report cache-write tokens as prompt_tokens_details.cache_creation_input_tokens. The field was ignored, so cache-write tokens were billed at the plain input price instead of the cache-creation price. Parse it into InputTokenDetails.CacheCreationInputTokens and include it in CacheCreationTokensTotal using max() with the existing cache-write fields, so the same tokens are never counted twice. --- relaykit/dto/openai_response.go | 17 ++- relaykit/dto/usage_merge.go | 4 + relaykit/dto/usage_merge_test.go | 136 ++++++++++++++++++ .../oai_chat/to_oai_responses_resp.go | 1 + .../oai_chat/to_oai_responses_resp_test.go | 10 ++ service/text_quota_test.go | 54 +++++++ 6 files changed, 216 insertions(+), 6 deletions(-) diff --git a/relaykit/dto/openai_response.go b/relaykit/dto/openai_response.go index 52c5db25e8..4890c8ad63 100644 --- a/relaykit/dto/openai_response.go +++ b/relaykit/dto/openai_response.go @@ -273,6 +273,10 @@ type InputTokenDetails struct { TextTokens int `json:"text_tokens"` AudioTokens int `json:"audio_tokens"` ImageTokens int `json:"image_tokens"` + // CacheCreationInputTokens is already included in prompt_tokens. Billing + // takes the larger of this value, CacheWriteTokens, and CachedCreationTokens, + // and does not add them. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` } // CacheWriteTokenDetails is OpenAI's split of cache writes by lifetime. The @@ -325,13 +329,14 @@ func (d InputTokenDetails) Clone() InputTokenDetails { } // CacheCreationTokensTotal returns the cache-write token count regardless of -// which field the upstream reported it in: Claude-derived conversions populate -// CachedCreationTokens while OpenAI reports cache_write_tokens natively. Both -// are billed at the cache-creation price; when both are present the larger -// value wins so the same tokens are never double-counted. Negative upstream -// values are clamped to zero so they can never lower a charge. +// which field the upstream reported it in. Claude-derived conversions populate +// CachedCreationTokens, OpenAI reports cache_write_tokens, and OpenAI-compatible +// explicit cache reports cache_creation_input_tokens. All are billed at the +// cache-creation price. When more than one is present the larger value wins so +// the same tokens are never double-counted. Negative upstream values are +// clamped to zero so they can never lower a charge. func (d InputTokenDetails) CacheCreationTokensTotal() int { - total := max(d.CacheWriteTokens, d.CachedCreationTokens) + total := max(d.CacheWriteTokens, d.CachedCreationTokens, d.CacheCreationInputTokens) if total < 0 { return 0 } diff --git a/relaykit/dto/usage_merge.go b/relaykit/dto/usage_merge.go index 081b9129a4..eda268ea14 100644 --- a/relaykit/dto/usage_merge.go +++ b/relaykit/dto/usage_merge.go @@ -48,6 +48,7 @@ func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage { if details.CachedTokens > 0 || details.CachedCreationTokens > 0 || details.CacheWriteTokens > 0 || + details.CacheCreationInputTokens > 0 || details.TextTokens > 0 || details.AudioTokens > 0 || details.ImageTokens > 0 || details.CachedTokensDetails != nil || @@ -320,6 +321,9 @@ func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetai if incoming.CacheWriteTokens > 0 { current.CacheWriteTokens = incoming.CacheWriteTokens } + if incoming.CacheCreationInputTokens > 0 { + current.CacheCreationInputTokens = incoming.CacheCreationInputTokens + } if incoming.CacheWriteTokensDetails != nil { // A detached copy; like the scalar counts, a later zero keeps the // earlier value. diff --git a/relaykit/dto/usage_merge_test.go b/relaykit/dto/usage_merge_test.go index 2bd0fef747..0d28dd6b7f 100644 --- a/relaykit/dto/usage_merge_test.go +++ b/relaykit/dto/usage_merge_test.go @@ -3,6 +3,7 @@ package dto import ( "testing" + kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -290,3 +291,138 @@ func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) { assert.Equal(t, 5, merged.CompletionTokens) assert.Equal(t, 20, merged.TotalTokens) } + +func TestInputTokenDetailsCacheCreationInputTokens(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + raw string + total int + }{ + { + name: "scalar only", + raw: `{"cached_tokens":1200,"cache_creation_input_tokens":300}`, + total: 300, + }, + { + name: "scalar larger than cache write", + raw: `{"cache_write_tokens":250,"cache_creation_input_tokens":400}`, + total: 400, + }, + { + name: "cache write larger than scalar", + raw: `{"cache_write_tokens":250,"cache_creation_input_tokens":100}`, + total: 250, + }, + { + name: "scalar larger than cached creation", + raw: `{"cached_creation_tokens":300,"cache_creation_input_tokens":400}`, + total: 400, + }, + { + name: "negative scalar ignored beside cache write", + raw: `{"cache_write_tokens":80,"cache_creation_input_tokens":-1}`, + total: 80, + }, + { + name: "only negative scalar", + raw: `{"cache_creation_input_tokens":-5}`, + total: 0, + }, + { + name: "undocumented creation object ignored", + raw: `{"cached_tokens":1200,"cache_creation":{"ephemeral_5m_input_tokens":300}}`, + total: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var details InputTokenDetails + require.NoError(t, kitutil.Unmarshal([]byte(tc.raw), &details)) + assert.Equal(t, tc.total, details.CacheCreationTokensTotal()) + }) + } + + var withCreation Usage + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens":1510,"completion_tokens":20,"total_tokens":1530,"prompt_tokens_details":{"cached_tokens":1200,"cache_creation_input_tokens":300,"cache_creation":{"ephemeral_5m_input_tokens":300}}}`), &withCreation)) + assert.Equal(t, 1510, withCreation.PromptTokens) + assert.Equal(t, 1200, withCreation.PromptTokensDetails.CachedTokens) + assert.Equal(t, 300, withCreation.PromptTokensDetails.CacheCreationInputTokens) + assert.Equal(t, 300, withCreation.PromptTokensDetails.CacheCreationTokensTotal()) + assert.Zero(t, withCreation.PromptTokensDetails.CachedCreationTokens) + assert.Zero(t, withCreation.PromptTokensDetails.CacheWriteTokens) + assert.Zero(t, withCreation.ClaudeCacheCreation5mTokens) + assert.Zero(t, withCreation.ClaudeCacheCreation1hTokens) + + encoded, err := kitutil.Marshal(withCreation) + require.NoError(t, err) + body := string(encoded) + assert.Contains(t, body, `"cache_creation_input_tokens":300`) + assert.NotContains(t, body, `"cache_creation":`) + assert.NotContains(t, body, "ephemeral_5m_input_tokens") + assert.NotContains(t, body, "cache_write_tokens") + assert.NotContains(t, body, "cached_creation_tokens") + assert.Contains(t, body, `"cached_tokens":1200`) + assert.Contains(t, body, `"prompt_tokens":1510`) + + var withoutCreation Usage + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens":1510,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":1200}}`), &withoutCreation)) + encoded, err = kitutil.Marshal(withoutCreation) + require.NoError(t, err) + absent := string(encoded) + assert.NotContains(t, absent, "cache_creation_input_tokens") + assert.NotContains(t, absent, `"cache_creation":`) + assert.NotContains(t, absent, "ephemeral_5m_input_tokens") + assert.NotContains(t, absent, "cache_write_tokens") + assert.NotContains(t, absent, "cached_creation_tokens") +} + +func TestCacheCreationInputTokensRoundTrip(t *testing.T) { + t.Parallel() + + var usage Usage + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens":1510,"prompt_tokens_details":{"cached_tokens":1200,"cache_creation_input_tokens":300}}`), &usage)) + encoded, err := kitutil.Marshal(usage) + require.NoError(t, err) + var again Usage + require.NoError(t, kitutil.Unmarshal(encoded, &again)) + assert.Equal(t, 300, again.PromptTokensDetails.CacheCreationInputTokens) + assert.Equal(t, 300, again.PromptTokensDetails.CacheCreationTokensTotal()) + assert.Zero(t, again.ClaudeCacheCreation5mTokens) + assert.Zero(t, again.ClaudeCacheCreation1hTokens) +} + +func TestMergeUsageNonZeroCacheCreationInputTokensReplacesWithoutAdding(t *testing.T) { + t.Parallel() + + first := &Usage{} + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens":100,"prompt_tokens_details":{"cached_tokens":10,"cache_creation_input_tokens":100}}`), first)) + second := &Usage{} + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens":200,"prompt_tokens_details":{"cached_tokens":20,"cache_creation_input_tokens":300}}`), second)) + merged := MergeUsageNonZero(first, second) + require.NotNil(t, merged) + assert.Equal(t, 300, merged.PromptTokensDetails.CacheCreationTokensTotal()) + assert.Equal(t, 20, merged.PromptTokensDetails.CachedTokens) + assert.Zero(t, merged.ClaudeCacheCreation5mTokens) + assert.Zero(t, merged.ClaudeCacheCreation1hTokens) + + kept := MergeUsageNonZero(merged, &Usage{}) + assert.Equal(t, 300, kept.PromptTokensDetails.CacheCreationTokensTotal()) + + write := &Usage{} + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens_details":{"cache_write_tokens":250}}`), write)) + scalar := &Usage{} + require.NoError(t, kitutil.Unmarshal([]byte(`{"prompt_tokens_details":{"cache_creation_input_tokens":100}}`), scalar)) + both := MergeUsageNonZero(write, scalar) + assert.Equal(t, 250, both.PromptTokensDetails.CacheWriteTokens) + assert.Equal(t, 250, both.PromptTokensDetails.CacheCreationTokensTotal()) + + responses := &Usage{} + require.NoError(t, kitutil.Unmarshal([]byte(`{"input_tokens_details":{"cache_creation_input_tokens":300}}`), responses)) + overlaid := MergeUsageNonZero(&Usage{}, responses) + require.NotNil(t, overlaid.InputTokensDetails) + assert.Equal(t, 300, overlaid.InputTokensDetails.CacheCreationTokensTotal()) + assert.Zero(t, overlaid.PromptTokensDetails.CacheCreationTokensTotal()) +} diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go index b9fc8fe522..e2017b2fd8 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go +++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp.go @@ -186,6 +186,7 @@ func UsageFromChatUsage(src *dto.Usage) *dto.Usage { src.PromptTokensDetails.AudioTokens != 0 || src.PromptTokensDetails.CachedCreationTokens != 0 || src.PromptTokensDetails.CacheWriteTokens != 0 || + src.PromptTokensDetails.CacheCreationInputTokens != 0 || src.PromptTokensDetails.TextTokens != 0 { details := src.PromptTokensDetails.Clone() usage.InputTokensDetails = &details diff --git a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go index 97ae72e0ce..d1abe113cf 100644 --- a/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go +++ b/relaykit/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go @@ -91,6 +91,16 @@ func TestChatCompletionsResponseToResponsesDropsEmptyNameToolCalls(t *testing.T) assert.Equal(t, usage.BillingUsage, convertedUsage.BillingUsage) } +func TestUsageFromChatUsagePreservesCacheCreationInputTokensAlone(t *testing.T) { + usage := dto.Usage{PromptTokens: 12, TotalTokens: 12} + usage.PromptTokensDetails.CacheCreationInputTokens = 9 + + converted := UsageFromChatUsage(&usage) + + require.NotNil(t, converted.InputTokensDetails) + assert.Equal(t, 9, converted.InputTokensDetails.CacheCreationInputTokens) +} + func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) { message := dto.Message{Role: "assistant", Content: "final answer"} message.ReasoningContent = lo.ToPtr("thinking summary") diff --git a/service/text_quota_test.go b/service/text_quota_test.go index 3dc65bf4c2..b61c96117e 100644 --- a/service/text_quota_test.go +++ b/service/text_quota_test.go @@ -864,6 +864,60 @@ func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) { }) } +func TestCalculateTextQuotaSummaryBillsCacheCreationInputTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + + relayInfo := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatOpenAI, + OriginModelName: "cache-creation-input", + PriceData: hosttypes.PriceData{ + ModelRatio: 1, + CompletionRatio: 1, + CacheRatio: 0.1, + CacheCreationRatio: 1.25, + GroupRatioInfo: hosttypes.GroupRatioInfo{GroupRatio: 1}, + }, + StartTime: time.Now(), + } + + t.Run("creation tokens billed at creation ratio", func(t *testing.T) { + var usage dto.Usage + require.NoError(t, common.Unmarshal([]byte(`{"prompt_tokens":1510,"completion_tokens":20,"total_tokens":1530,"prompt_tokens_details":{"cached_tokens":1200,"cache_creation_input_tokens":300,"cache_creation":{"ephemeral_5m_input_tokens":300}}}`), &usage)) + summary := calculateTextQuotaSummary(ctx, relayInfo, &usage) + + assert.Equal(t, 1510, summary.PromptTokens) + assert.Equal(t, 1200, summary.CacheTokens) + assert.Equal(t, 300, summary.CacheCreationTokens) + assert.Zero(t, summary.CacheCreationTokens5m) + assert.Zero(t, summary.CacheCreationTokens1h) + // (1510-1200-300) + 1200*0.1 + 300*1.25 + 20 = 525 + assert.Equal(t, 525, summary.Quota) + }) + + t.Run("missing creation field stays in prompt price", func(t *testing.T) { + var usage dto.Usage + require.NoError(t, common.Unmarshal([]byte(`{"prompt_tokens":1510,"completion_tokens":20,"total_tokens":1530,"prompt_tokens_details":{"cached_tokens":1200}}`), &usage)) + summary := calculateTextQuotaSummary(ctx, relayInfo, &usage) + + assert.Zero(t, summary.CacheCreationTokens) + // (1510-1200) + 1200*0.1 + 20 = 450 + assert.Equal(t, 450, summary.Quota) + }) + + t.Run("cache write and creation scalar take the larger count", func(t *testing.T) { + var usage dto.Usage + require.NoError(t, common.Unmarshal([]byte(`{"prompt_tokens":1510,"completion_tokens":20,"total_tokens":1530,"prompt_tokens_details":{"cached_tokens":1200,"cache_write_tokens":100,"cache_creation_input_tokens":300}}`), &usage)) + summary := calculateTextQuotaSummary(ctx, relayInfo, &usage) + + assert.Equal(t, 300, summary.CacheCreationTokens) + assert.Zero(t, summary.CacheCreationTokens5m) + assert.Zero(t, summary.CacheCreationTokens1h) + assert.Equal(t, 525, summary.Quota) + }) +} + // OpenAI 12h cache writes bill at twice the write price and keep their own // count in the consume log, with ratio and expression pricing alike. func TestPostTextConsumeQuotaBillsOpenAI12hCacheWrites(t *testing.T) {