diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 16c33cb73f..602adcdd1c 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -98,6 +98,7 @@ export function createResponsesApiTransformStream(logger = null) { funcItemDone: {}, buffer: "", completedSent: false, + usage: null, }; const encoder = new TextEncoder(); @@ -249,16 +250,52 @@ export function createResponsesApiTransformStream(logger = null) { const sendCompleted = (controller) => { if (!state.completedSent) { state.completedSent = true; + + // Build output from accumulated state + const output = []; + if (state.reasoningId) { + output.push({ + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf }], + }); + } + for (const idx in state.msgItemAdded) { + output.push({ + id: `msg_${state.responseId}_${idx}`, + type: "message", + role: "assistant", + content: [{ type: "output_text", annotations: [], text: state.msgTextBuf[idx] || "" }], + }); + } + for (const idx in state.funcCallIds) { + const callId = state.funcCallIds[idx]; + output.push({ + id: `fc_${callId}`, + type: "function_call", + call_id: callId, + name: state.funcNames[idx] || "", + arguments: state.funcArgsBuf[idx] || "{}", + }); + } + + const response: Record = { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null, + output, + }; + + if (state.usage) { + response.usage = state.usage; + } + emit(controller, "response.completed", { type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null, - }, + response, }); } }; @@ -288,7 +325,12 @@ export function createResponsesApiTransformStream(logger = null) { continue; } - if (!parsed.choices?.length) continue; + if (!parsed.choices?.length) { + if (parsed.usage) { + state.usage = parsed.usage; + } + continue; + } const choice = parsed.choices[0]; const idx = choice.index || 0; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index b22b9f5f46..41e40e4f66 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -14,7 +14,13 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { return flushEvents(state); } - if (!chunk.choices?.length) return []; + if (!chunk.choices?.length) { + // Capture usage from usage-only chunks (stream_options.include_usage) + if (chunk.usage) { + state.usage = chunk.usage; + } + return []; + } const events = []; const nextSeq = () => ++state.seq; @@ -334,16 +340,52 @@ function closeToolCall(state, emit, idx) { function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; + + // Build output from accumulated state + const output = []; + if (state.reasoningId) { + output.push({ + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf }], + }); + } + for (const idx in state.msgItemAdded) { + output.push({ + id: `msg_${state.responseId}_${idx}`, + type: "message", + role: "assistant", + content: [{ type: "output_text", annotations: [], text: state.msgTextBuf[idx] || "" }], + }); + } + for (const idx in state.funcCallIds) { + const callId = state.funcCallIds[idx]; + output.push({ + id: `fc_${callId}`, + type: "function_call", + call_id: callId, + name: state.funcNames[idx] || "", + arguments: state.funcArgsBuf[idx] || "{}", + }); + } + + const response: Record = { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null, + output, + }; + + if (state.usage) { + response.usage = state.usage; + } + emit("response.completed", { type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null, - }, + response, }); } } diff --git a/tests/unit/responses-translation-fixes.test.mjs b/tests/unit/responses-translation-fixes.test.mjs index e3f0850219..20c827f96a 100644 --- a/tests/unit/responses-translation-fixes.test.mjs +++ b/tests/unit/responses-translation-fixes.test.mjs @@ -341,3 +341,50 @@ test("Chat→Responses: deprecated function role message converted to function_c const fcItem = result.input.find((i) => i.type === "function_call"); assert.equal(fcOutput.call_id, fcItem.call_id); }); + +const { openaiToOpenAIResponsesResponse } = await import( + "../../open-sse/translator/response/openai-responses.ts" +); +const { initState } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +test("Chat→Responses streaming: usage-only chunk is captured (not dropped)", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + // First chunk with content + const chunk1 = { choices: [{ index: 0, delta: { content: "hello" }, finish_reason: null }], id: "c1" }; + openaiToOpenAIResponsesResponse(chunk1, state); + + // Usage-only chunk (empty choices, has usage) + const usageChunk = { + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + const usageEvents = openaiToOpenAIResponsesResponse(usageChunk, state); + assert.ok(Array.isArray(usageEvents)); + + // Finish chunk + const finishChunk = { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const finishEvents = openaiToOpenAIResponsesResponse(finishChunk, state); + 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); +}); + +test("Chat→Responses streaming: completed event includes accumulated output", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + // Text content + const chunk = { choices: [{ index: 0, delta: { content: "hello world" }, finish_reason: null }], id: "c1" }; + openaiToOpenAIResponsesResponse(chunk, state); + + // Finish + const finishChunk = { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const events = openaiToOpenAIResponsesResponse(finishChunk, state); + const completedEvent = events.find((e) => e.event === "response.completed"); + assert.ok(completedEvent.data.response.output, "completed should have output"); + assert.ok(completedEvent.data.response.output.length > 0, "output should not be empty"); + const msgOutput = completedEvent.data.response.output.find((o) => o.type === "message"); + assert.ok(msgOutput, "should have message output item"); +});