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.
This commit is contained in:
yxkong authored and GitHub committed 2026-10-25 17:22:51 +08:00
1 parent e835b1bfc4
commit 9823ebc7ea
6 files changed
+216 -6

No files matched your search

+11 -6
View File
@@ -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
}
+4
View File
@@ -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.
+136
View File
@@ -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())
}
@@ -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
@@ -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")
+54
View File
@@ -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) {