feat: bill OpenAI cache_write_tokens at cache-creation price with zero clamp
Parse OpenAI's native cache_write_tokens (chat prompt_tokens_details / responses input_tokens_details), bill it at the cache-creation ratio, and clamp the uncached prompt remainder at zero since cached + cache-write can exceed prompt_tokens. Propagate the field through chat/responses/claude format conversions and tiered expression billing (cc variable).
This commit is contained in:
@@ -216,6 +216,9 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||
usage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||
// Expose the standard OpenAI cache-write field alongside the legacy
|
||||
// cached_creation_tokens so OpenAI-format clients can bill cache writes.
|
||||
clone.PromptTokensDetails.CacheWriteTokens = cacheCreationTokens
|
||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||
clone.PromptTokens = totalInputTokens
|
||||
clone.InputTokens = totalInputTokens
|
||||
|
||||
@@ -39,10 +39,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
cacheCreationTokens := oaiUsage.PromptTokensDetails.CacheCreationTokensTotal()
|
||||
inputTokens := oaiUsage.PromptTokens
|
||||
if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 {
|
||||
// OpenAI native cache-write usage counts cached and cache-write tokens
|
||||
// inside prompt_tokens, while Claude semantics reports input_tokens
|
||||
// excluding both; the uncached remainder clamps at zero.
|
||||
inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
|
||||
if inputTokens < 0 {
|
||||
inputTokens = 0
|
||||
}
|
||||
}
|
||||
usage := &dto.ClaudeUsage{
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheCreationInputTokens: cacheCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
BillingUsage: billingUsage,
|
||||
}
|
||||
|
||||
@@ -79,6 +79,30 @@ func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
|
||||
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
|
||||
}
|
||||
|
||||
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
|
||||
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
|
||||
PromptTokens: 3619,
|
||||
CompletionTokens: 36,
|
||||
TotalTokens: 3655,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 2921,
|
||||
CacheWriteTokens: 3616,
|
||||
},
|
||||
})
|
||||
|
||||
require.NotNil(t, usage)
|
||||
// Claude semantics reports input_tokens excluding cache read/write; the
|
||||
// remainder 3619-2921-3616 clamps to 0.
|
||||
assert.Equal(t, 0, usage.InputTokens)
|
||||
assert.Equal(t, 2921, usage.CacheReadInputTokens)
|
||||
assert.Equal(t, 3616, usage.CacheCreationInputTokens)
|
||||
assert.Equal(t, 36, usage.OutputTokens)
|
||||
require.NotNil(t, usage.BillingUsage)
|
||||
require.NotNil(t, usage.BillingUsage.OpenAIUsage)
|
||||
assert.Equal(t, dto.BillingUsageSemanticOpenAI, usage.BillingUsage.Semantic)
|
||||
assert.Equal(t, 3616, usage.BillingUsage.OpenAIUsage.PromptTokensDetails.CacheWriteTokens)
|
||||
}
|
||||
|
||||
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||
|
||||
@@ -137,6 +137,7 @@ func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
||||
src.PromptTokensDetails.ImageTokens != 0 ||
|
||||
src.PromptTokensDetails.AudioTokens != 0 ||
|
||||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||
src.PromptTokensDetails.CacheWriteTokens != 0 ||
|
||||
src.PromptTokensDetails.TextTokens != 0 {
|
||||
details := src.PromptTokensDetails
|
||||
usage.InputTokensDetails = &details
|
||||
|
||||
@@ -156,6 +156,7 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||
if src.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
|
||||
usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens
|
||||
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
|
||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||
|
||||
@@ -277,6 +277,7 @@ func TestConvertResponseProviderToOAIChatUsage(t *testing.T) {
|
||||
assert.Equal(t, 22, toChat.Usage.TotalTokens)
|
||||
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CacheWriteTokens)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
||||
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
|
||||
@@ -539,6 +540,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 3,
|
||||
CachedCreationTokens: 2,
|
||||
CacheWriteTokens: 6,
|
||||
TextTokens: 4,
|
||||
AudioTokens: 1,
|
||||
ImageTokens: 5,
|
||||
@@ -558,6 +560,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
||||
require.NotNil(t, result.Usage.InputTokensDetails)
|
||||
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
|
||||
assert.Equal(t, 6, result.Usage.InputTokensDetails.CacheWriteTokens)
|
||||
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
|
||||
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
|
||||
@@ -579,6 +582,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
||||
InputTokensDetails: &dto.InputTokenDetails{
|
||||
CachedTokens: 4,
|
||||
CachedCreationTokens: 1,
|
||||
CacheWriteTokens: 7,
|
||||
TextTokens: 5,
|
||||
AudioTokens: 2,
|
||||
ImageTokens: 1,
|
||||
@@ -598,6 +602,7 @@ func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
||||
assert.Equal(t, 21, result.Usage.TotalTokens)
|
||||
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
|
||||
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||
assert.Equal(t, 7, result.Usage.PromptTokensDetails.CacheWriteTokens)
|
||||
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
|
||||
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
|
||||
|
||||
@@ -208,7 +208,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
|
||||
summary.CompletionTokens = usage.CompletionTokens
|
||||
summary.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
summary.CacheTokens = usage.PromptTokensDetails.CachedTokens
|
||||
summary.CacheCreationTokens = usage.PromptTokensDetails.CachedCreationTokens
|
||||
summary.CacheCreationTokens = usage.PromptTokensDetails.CacheCreationTokensTotal()
|
||||
summary.CacheCreationTokens5m = usage.ClaudeCacheCreation5mTokens
|
||||
summary.CacheCreationTokens1h = usage.ClaudeCacheCreation1hTokens
|
||||
summary.ImageTokens = usage.PromptTokensDetails.ImageTokens
|
||||
@@ -294,6 +294,13 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI cache-write usage can report cached_tokens + cache_write_tokens
|
||||
// exceeding prompt_tokens; the uncached remainder must clamp at zero so
|
||||
// billing never subtracts more than the reported input.
|
||||
if baseTokens.IsNegative() {
|
||||
baseTokens = decimal.Zero
|
||||
}
|
||||
|
||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio).Add(cachedCreationTokensWithRatio)
|
||||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||
quotaCalculateDecimal := promptQuota.Add(completionQuota).Mul(ratio)
|
||||
|
||||
@@ -375,6 +375,62 @@ func TestCalculateTextQuotaSummaryHandlesLegacyClaudeDerivedOpenAIUsage(t *testi
|
||||
require.Equal(t, 1624, summary.Quota)
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummaryBillsOpenAICacheWriteTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
relayInfo := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
OriginModelName: "gpt-5.1",
|
||||
PriceData: types.PriceData{
|
||||
ModelRatio: 1,
|
||||
CompletionRatio: 2,
|
||||
CacheRatio: 0.1,
|
||||
CacheCreationRatio: 1.25,
|
||||
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||
},
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("uncached remainder stays positive", func(t *testing.T) {
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 1473,
|
||||
CompletionTokens: 19,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CacheWriteTokens: 1470,
|
||||
},
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
require.Equal(t, 1470, summary.CacheCreationTokens)
|
||||
// (1473-0-1470) + 1470*1.25 + 19*2 = 3 + 1837.5 + 38 = 1878.5 => 1879
|
||||
require.Equal(t, 1879, summary.Quota)
|
||||
})
|
||||
|
||||
t.Run("uncached remainder clamps to zero", func(t *testing.T) {
|
||||
// Real OpenAI payload shape: cached_tokens + cache_write_tokens exceeds
|
||||
// prompt_tokens, so the uncached remainder must clamp to 0 instead of
|
||||
// producing a negative charge component.
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 3619,
|
||||
CompletionTokens: 36,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 2921,
|
||||
CacheWriteTokens: 3616,
|
||||
},
|
||||
}
|
||||
|
||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
||||
|
||||
require.Equal(t, 3619, summary.PromptTokens)
|
||||
require.Equal(t, 3616, summary.CacheCreationTokens)
|
||||
// max(3619-2921-3616, 0) + 2921*0.1 + 3616*1.25 + 36*2 = 4884.1 => 4884
|
||||
require.Equal(t, 4884, summary.Quota)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCalculateTextQuotaSummarySeparatesOpenRouterCacheReadFromPromptBilling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -22,7 +22,7 @@ func BuildTieredTokenParams(usage *dto.Usage, isClaudeUsageSemantic bool, usedVa
|
||||
p := float64(usage.PromptTokens)
|
||||
c := float64(usage.CompletionTokens)
|
||||
cr := float64(usage.PromptTokensDetails.CachedTokens)
|
||||
cc5m := float64(usage.PromptTokensDetails.CachedCreationTokens)
|
||||
cc5m := float64(usage.PromptTokensDetails.CacheCreationTokensTotal())
|
||||
cc1h := float64(0)
|
||||
|
||||
if usage.UsageSemantic == "anthropic" {
|
||||
|
||||
Reference in New Issue
Block a user