Merge pull request #909 from christopher-s/fix/responses-api-flush-total-tokens

fix(translator): emit response.completed with total_tokens for Responses API clients
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-02 01:00:34 -03:00
committed by GitHub
3 changed files with 29 additions and 5 deletions

View File

@@ -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;
}
}

View File

@@ -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 [];
}

View File

@@ -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", () => {