11import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
22import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
33import { randomUUID } from 'crypto'
4+ import { normalizeOpenAIUsage } from './openaiUsage.js'
45
56/**
67 * Adapt an OpenAI streaming response into Anthropic BetaRawMessageStreamEvent.
@@ -13,10 +14,10 @@ import { randomUUID } from 'crypto'
1314 * finish_reason → message_delta(stop_reason) + message_stop
1415 *
1516 * Usage field mapping (OpenAI → Anthropic):
16- * prompt_tokens - cached_tokens → input_tokens (non-cached input only)
17+ * prompt_tokens - cached_tokens - cache_write_tokens → input_tokens
1718 * completion_tokens → output_tokens
1819 * prompt_tokens_details.cached_tokens → cache_read_input_tokens
19- * (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
20+ * prompt_tokens_details.cache_write_tokens → cache_creation_input_tokens
2021 *
2122 * All four fields are emitted in the post-loop message_delta (not message_start)
2223 * so that trailing usage chunks (sent after finish_reason by some
@@ -35,6 +36,7 @@ import { randomUUID } from 'crypto'
3536export async function * adaptOpenAIStreamToAnthropic (
3637 stream : AsyncIterable < ChatCompletionChunk > ,
3738 model : string ,
39+ options ?: { includeCacheWriteTokens ?: boolean } ,
3840) : AsyncGenerator < BetaRawMessageStreamEvent , void > {
3941 const messageId = `msg_${ randomUUID ( ) . replace ( / - / g, '' ) . slice ( 0 , 24 ) } `
4042
@@ -53,13 +55,13 @@ export async function* adaptOpenAIStreamToAnthropic(
5355 // Track text block state
5456 let textBlockOpen = false
5557
56- // Track usage — all four Anthropic fields, populated from OpenAI usage fields:
57- // rawInputTokens tracks the raw prompt_tokens (OpenAI total, including cached).
58- // inputTokens is the derived Anthropic value (non-cached only = rawInputTokens - cachedReadTokens).
58+ // Track raw OpenAI usage across chunks. The normalized Anthropic fields are
59+ // disjoint: ordinary input + cache reads + cache writes = total input.
5960 let rawInputTokens = 0
60- let inputTokens = 0
6161 let outputTokens = 0
62- let cachedReadTokens = 0
62+ let rawCacheReadTokens = 0
63+ let rawCacheWriteTokens = 0
64+ let usage = normalizeOpenAIUsage ( { totalInputTokens : 0 , outputTokens : 0 } )
6365
6466 // Track all open content block indices (for cleanup)
6567 const openBlockIndices = new Set < number > ( )
@@ -75,16 +77,32 @@ export async function* adaptOpenAIStreamToAnthropic(
7577 // Extract usage from any chunk that carries it.
7678 if ( chunk . usage ) {
7779 rawInputTokens = chunk . usage . prompt_tokens ?? rawInputTokens
78- const rawCached =
79- ( ( chunk . usage as any ) . prompt_tokens_details ?. cached_tokens as
80- | number
81- | undefined ) ?? cachedReadTokens
82- // Anthropic's input_tokens = non-cached input only. OpenAI's prompt_tokens
83- // includes cached tokens, so subtract. Clamp to 0 in case cached > total
84- // due to a streaming race.
85- inputTokens = Math . max ( 0 , rawInputTokens - rawCached )
8680 outputTokens = chunk . usage . completion_tokens ?? outputTokens
87- cachedReadTokens = rawCached
81+
82+ const usageRecord = chunk . usage as unknown as Record < string , unknown >
83+ const detailsValue = usageRecord . prompt_tokens_details
84+ const details =
85+ detailsValue && typeof detailsValue === 'object'
86+ ? ( detailsValue as Record < string , unknown > )
87+ : undefined
88+ if ( typeof details ?. cached_tokens === 'number' ) {
89+ rawCacheReadTokens = details . cached_tokens
90+ }
91+ if (
92+ options ?. includeCacheWriteTokens &&
93+ typeof details ?. cache_write_tokens === 'number'
94+ ) {
95+ rawCacheWriteTokens = details . cache_write_tokens
96+ } else if ( ! options ?. includeCacheWriteTokens ) {
97+ rawCacheWriteTokens = 0
98+ }
99+
100+ usage = normalizeOpenAIUsage ( {
101+ totalInputTokens : rawInputTokens ,
102+ outputTokens,
103+ cacheReadTokens : rawCacheReadTokens ,
104+ cacheWriteTokens : rawCacheWriteTokens ,
105+ } )
88106 }
89107
90108 // Emit message_start on first chunk
@@ -102,10 +120,8 @@ export async function* adaptOpenAIStreamToAnthropic(
102120 stop_reason : null ,
103121 stop_sequence : null ,
104122 usage : {
105- input_tokens : inputTokens ,
123+ ... usage ,
106124 output_tokens : 0 ,
107- cache_creation_input_tokens : 0 ,
108- cache_read_input_tokens : cachedReadTokens ,
109125 } ,
110126 } ,
111127 } as unknown as BetaRawMessageStreamEvent
@@ -312,12 +328,7 @@ export async function* adaptOpenAIStreamToAnthropic(
312328 stop_reason : stopReason ,
313329 stop_sequence : null ,
314330 } ,
315- usage : {
316- input_tokens : inputTokens ,
317- output_tokens : outputTokens ,
318- cache_read_input_tokens : cachedReadTokens ,
319- cache_creation_input_tokens : 0 ,
320- } ,
331+ usage,
321332 } as BetaRawMessageStreamEvent
322333
323334 yield {
0 commit comments