diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 79be284b99..f00005c632 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -267,6 +267,15 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { finalResults.push(...(Array.isArray(converted) ? converted : [converted])); } } + // Flush: pass null to source-format translator even when Step 1 produced no output. + // This is critical for formats like openai-responses that emit terminal events + // (e.g., response.completed with total_tokens) in their flush handler. + if (chunk === null && results.length === 0) { + const converted = fromOpenAI(null, state); + if (converted) { + finalResults.push(...(Array.isArray(converted) ? converted : [converted])); + } + } results = finalResults; } } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 40ec923bd4..25a4de58c9 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -14,11 +14,24 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { return flushEvents(state); } - if (!chunk.choices?.length) { - // Capture usage from usage-only chunks (stream_options.include_usage) - if (chunk.usage) { - state.usage = chunk.usage; + // Capture usage from any chunk that carries it (usage-only chunks OR final chunks with finish_reason) + // Normalize Chat Completions format (prompt_tokens/completion_tokens) to Responses API format + // (input_tokens/output_tokens) so response.completed always has the fields Codex expects. + if (chunk.usage) { + const u = chunk.usage; + const input_tokens = u.input_tokens ?? u.prompt_tokens ?? 0; + const output_tokens = u.output_tokens ?? u.completion_tokens ?? 0; + state.usage = { + input_tokens, + output_tokens, + total_tokens: u.total_tokens ?? input_tokens + output_tokens, + }; + if (u.prompt_tokens_details?.cached_tokens) { + state.usage.input_tokens_details = { cached_tokens: u.prompt_tokens_details.cached_tokens }; } + } + + if (!chunk.choices?.length) { return []; } diff --git a/tests/unit/responses-translation-fixes.test.mjs b/tests/unit/responses-translation-fixes.test.mjs index 6d66f7ed73..61785b04c6 100644 --- a/tests/unit/responses-translation-fixes.test.mjs +++ b/tests/unit/responses-translation-fixes.test.mjs @@ -369,7 +369,9 @@ test("Chat→Responses streaming: usage-only chunk is captured (not dropped)", ( const completedEvent = finishEvents.find((e) => e.event === "response.completed"); assert.ok(completedEvent, "should have completed event"); assert.ok(completedEvent.data.response.usage, "completed event should include usage"); - assert.equal(completedEvent.data.response.usage.prompt_tokens, 10); + assert.equal(completedEvent.data.response.usage.input_tokens, 10); + assert.equal(completedEvent.data.response.usage.output_tokens, 5); + assert.equal(completedEvent.data.response.usage.total_tokens, 15); }); test("Chat→Responses streaming: completed event includes accumulated output", () => {