mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
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:
1 parent
e835b1bfc4
commit
9823ebc7ea
6 files changed
+216
-6
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user