From 229954bedd0edde338435d74225e046eceb6a6a6 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Wed, 5 Aug 2026 02:17:03 +0200 Subject: [PATCH] fix(provider): accept repeated post-finish usage events Agent runs on OpenAI-compatible providers die mid-turn with: veryfront-cloud request failed: invalid successful stream (stream contained multiple post-finish usage events) The chat stream parser allowed exactly one usage-only chunk after the finish reason and threw on the second. Providers behind veryfront-cloud send more than one: OpenAI's own stream_options.include_usage final chunk uses `choices: []`, while aggregators add a bare `{usage}` summary. Both shapes are valid, and the two throw sites shared one flag, so a stream carrying one of each was rejected too. The throw also contradicted the line above it. mergeOpenAIUsage already ran on every event before the guard, so the second usage payload was merged correctly and then discarded along with the whole run. Merging repeated cumulative totals is safe: mergeRuntimeUsage resolves each field with latestValidValue, so it is last-write-wins rather than additive. The regression test asserts the merged totals stay at the provider's numbers instead of doubling. Every other guard from #3235 is unchanged: data after the done marker, choice data after the finish reason, events with neither choices nor usage, empty-choices events without usage, and malformed shapes all still fail closed. Only the multi-usage assertion is removed, and with it a flag that no longer had a reader. Observed on staging with moonshotai/kimi-k2.6, run 07a460b0-e707-4181-bf31-3cbed7a62554, which died while streaming its first tool call. --- .../src/openai-chat-stream.test.ts | 24 +++++++++++++++++++ .../ext-llm-openai/src/openai-chat-stream.ts | 13 ---------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/extensions/ext-llm-openai/src/openai-chat-stream.test.ts b/extensions/ext-llm-openai/src/openai-chat-stream.test.ts index 0492a758cd..5dd4bfdbad 100644 --- a/extensions/ext-llm-openai/src/openai-chat-stream.test.ts +++ b/extensions/ext-llm-openai/src/openai-chat-stream.test.ts @@ -407,6 +407,30 @@ describe("ext-llm-openai/openai-chat-stream", () => { ); }); + it("accepts repeated post-finish usage events without double counting", async () => { + // OpenAI's include_usage chunk uses `choices: []`; aggregators add a bare + // `{usage}`. Repeated cumulative totals must merge, not accumulate. + const usage = { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 }; + const finish = data({ choices: [{ delta: {}, finish_reason: "stop" }] }); + const emptyChoices = data({ choices: [], usage }); + const bare = data({ usage }); + + for ( + const tail of [[emptyChoices, emptyChoices], [emptyChoices, bare], [bare, bare]] + ) { + assertEquals( + await collectParts( + streamFromText([finish, ...tail, "data: [DONE]\r\n\r\n"].join("")), + ), + [{ + type: "finish", + finishReason: "stop", + usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 }, + }], + ); + } + }); + it("rejects structurally empty and unterminated successful streams", async () => { await assertRejects( () => collectParts(streamFromText(data({}))), diff --git a/extensions/ext-llm-openai/src/openai-chat-stream.ts b/extensions/ext-llm-openai/src/openai-chat-stream.ts index 38aba29e7a..dc064d58c5 100644 --- a/extensions/ext-llm-openai/src/openai-chat-stream.ts +++ b/extensions/ext-llm-openai/src/openai-chat-stream.ts @@ -226,7 +226,6 @@ export async function* streamOpenAICompatibleParts( let sawChoiceEnvelope = false; let sawFinishReason = false; let sawDone = false; - let sawPostFinishUsage = false; const toolArgumentBudget: OpenAIStreamToolArgumentBudget = { bytes: 0, fragments: 0, @@ -258,12 +257,6 @@ export async function* streamOpenAICompatibleParts( if (!usageRecord) { throw invalidOpenAIStream(context, "event had neither choices nor usage"); } - if (sawFinishReason) { - if (sawPostFinishUsage) { - throw invalidOpenAIStream(context, "stream contained multiple post-finish usage events"); - } - sawPostFinishUsage = true; - } return; } if (!Array.isArray(record.choices)) { @@ -273,12 +266,6 @@ export async function* streamOpenAICompatibleParts( if (!usageRecord) { throw invalidOpenAIStream(context, "empty choices event had no usage"); } - if (sawFinishReason) { - if (sawPostFinishUsage) { - throw invalidOpenAIStream(context, "stream contained multiple post-finish usage events"); - } - sawPostFinishUsage = true; - } return; } if (sawFinishReason) {