fix(openai-shim): keep terminal usage from a repeated finish_reason (#2252)

Some OpenAI-compatible providers end a stream twice: one chunk carrying
the finish_reason, then a second, identical but for the usage, with
choices still populated. The final-usage fallback required an EMPTY
choices array, so that second chunk was skipped and its usage dropped.

The streaming loop ignores a repeated finish_reason by design, so nothing
else picked it up: every assistant message from such a provider stored
usage of all zeros. Anything reading the size or the cost 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 cost
tracking recorded nothing. A measured session reached 1763 messages with
preTokens: 183 recorded at its compaction boundary.

Dropping the empty-choices requirement leaves hasEmittedFinalUsage as the
single guard against a double emit, which the existing empty-choices and
both-terminal-chunks tests already cover.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
serafkul
2026-10-06 10:44:45 +08:00
committed by GitHub
co-authored by Claude Opus 5
parent 3ed1c5a191
commit 10254f4140
2 changed files with 68 additions and 6 deletions
@@ -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(
@@ -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',