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:
CaIon
2026-07-11 21:18:49 +08:00
parent c36418c863
commit 48068ce923
18 changed files with 158 additions and 20 deletions
@@ -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)