diff --git a/src/services/api/openaiShim/streamConversion.test.ts b/src/services/api/openaiShim/streamConversion.test.ts index 4140697e9..dc925a9cb 100644 --- a/src/services/api/openaiShim/streamConversion.test.ts +++ b/src/services/api/openaiShim/streamConversion.test.ts @@ -324,6 +324,47 @@ test('emits usage supplied by a final empty-choices chunk', async () => { expect(usageIndex).toBeLessThan(messageStopIndex) }) +test('emits terminal usage when the provider repeats finish_reason', async () => { + // Some providers end a stream twice: one chunk carrying the finish_reason, + // then a second, identical but for the usage, with choices still populated. + // The loop ignores a repeated finish_reason, so that usage was dropped and + // every assistant message stored zeros — which is what anything reading the + // size or the cost of a conversation reads. + const events = await collect( + openaiStreamToAnthropic( + makeSseResponse([ + makeOpenAIChunk({ content: 'done' }), + makeOpenAIChunk({}, 'stop'), + makeOpenAIChunk({ content: '' }, 'stop', { + prompt_tokens: 84, + completion_tokens: 1, + }), + ]), + 'test-model', + undefined, + false, + undefined, + createStreamDependencies(), + ), + ) + + expect(events).toContainEqual({ + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { + input_tokens: 84, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }) + expect( + events.filter( + event => event.type === 'message_delta' && event.usage !== undefined, + ), + ).toHaveLength(1) +}) + test('emits terminal usage once when both terminal chunks report it', async () => { const usage = { prompt_tokens: 10, completion_tokens: 2 } const events = await collect( diff --git a/src/services/api/openaiShim/streamConversion.ts b/src/services/api/openaiShim/streamConversion.ts index a709ca530..ec89c768d 100644 --- a/src/services/api/openaiShim/streamConversion.ts +++ b/src/services/api/openaiShim/streamConversion.ts @@ -1090,12 +1090,33 @@ export async function* openaiStreamToAnthropic( } } - if ( - !hasEmittedFinalUsage && - chunkUsage && - (chunk.choices?.length ?? 0) === 0 && - lastStopReason !== null - ) { + // FINAL USAGE, however the provider chooses to deliver it. + // + // Two shapes are in the wild. Some providers send one terminal chunk + // carrying both the finish_reason and the usage — handled in the loop + // above. Others repeat the finish_reason: one chunk to end the message, + // then a second, identical but for the usage. OpenRouter does the + // second, measured on the wire: + // + // chunks carrying a finish_reason: 2 + // chunks carrying usage: 1 (the later of the two) + // + // The loop above deliberately ignores a repeated finish_reason, so that + // second chunk never produced a message_delta and its usage was + // dropped. The condition here used to require an EMPTY choices array, + // which is the third shape, and so it missed this one too. + // + // What that cost: every assistant message stored usage of all zeros. + // Anything reading the size of a conversation reads it from there — + // `tokenCountWithEstimation` walks back to the newest message carrying + // usage and trusts it — so automatic compaction on size could not fire, + // the context warning never warned, and the cost tracker recorded + // nothing. A real session went to 1 763 messages and recorded + // `preTokens: 183` at its compaction boundary. + // + // `hasEmittedFinalUsage` keeps this from double-emitting for the + // providers already served by the loop. + if (!hasEmittedFinalUsage && chunkUsage && lastStopReason !== null) { throwIfStreamAborted(signal) yield { type: 'message_delta',