mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix: capture usage and accumulate output in response.completed event
This commit is contained in:
@@ -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<string, unknown> = {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user