diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go index 7f0c6b8be5f2..07d06a623f6d 100644 --- a/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go +++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go @@ -41,11 +41,15 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage { ) 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. Both counts are unadjusted prefixes and may overlap, - // so clamp a negative remainder at zero. + // OpenAI native cache-write / cache-read usage counts both cached and + // cache-creation tokens inside prompt_tokens, while Claude semantics + // reports input_tokens excluding both. Deduplicate so that the sum + // `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` + // equals the true total context size rather than double-counting cached + // tokens (§ref HsMirage, issue #4395). + // - CacheWriteTokens > 0: openai cache-write route (original guard) + // - CachedTokens > 0: pure cache-read (bypassed the old guard) + if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 || oaiUsage.PromptTokensDetails.CachedTokens > 0 { inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens if inputTokens < 0 { inputTokens = 0 diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go b/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go index facd873e709f..9d0aa243ac3d 100644 --- a/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go +++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go @@ -103,6 +103,31 @@ func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) { assert.Equal(t, 3616, usage.BillingUsage.OpenAIUsage.PromptTokensDetails.CacheWriteTokens) } +func TestBuildClaudeUsageFromOpenAIPureCacheRead(t *testing.T) { + // HsMirage's pure cache-read reproduction (issue #4395): + // second identical request hits cache; CacheWriteTokens==0, CachedTokens>0. + usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{ + PromptTokens: 15305, + CompletionTokens: 5, + TotalTokens: 15310, + PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 15104, + CacheWriteTokens: 0, + }, + }) + + require.NotNil(t, usage) + // input_tokens must exclude cached: 15305 - 15104 = 201 + assert.Equal(t, 201, usage.InputTokens) + assert.Equal(t, 15104, usage.CacheReadInputTokens) + assert.Equal(t, 0, usage.CacheCreationInputTokens) + assert.Equal(t, 5, usage.OutputTokens) + // Total context = 201 + 15104 = 15305 (not double-counted) + require.NotNil(t, usage.BillingUsage) + require.NotNil(t, usage.BillingUsage.OpenAIUsage) + assert.Equal(t, 15305, usage.BillingUsage.OpenAIUsage.PromptTokens) +} + func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) { info := &relaycommon.RelayInfo{ ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{