mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
co-authored by
Claude Opus 5
parent
3ed1c5a191
commit
10254f4140
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user