fix(translator): synthesize tool call chunks from response.completed output[]

When an upstream provider sends a batched response.completed event carrying
function_call items in its data.response.output[] array — without having
sent the individual response.output_item.added / .delta / .done events —
the state variables toolCallIndex and currentToolCallId were never set,
causing computeFinishReason to return 'stop' instead of 'tool_calls'.

This broke the agent loop for downstream Chat Completions clients
(OpenCode, Hermes, etc.) when routing through providers that batch their
output into the completed event.

Fix: parse data.response.output[] for function_call items in the
response.completed handler, synthesize the tool call header + arguments
delta chunks, advance state, and emit finish_reason: 'tool_calls'.

Also updates withAssistantRoleOnFirstDelta to handle array results.

Fixes #180, #3980
Refs: https://github.com/diegosouzapw/OmniRoute/issues/180
Refs: https://github.com/diegosouzapw/OmniRoute/issues/3980
This commit is contained in:
Erick Kinnee
2026-07-17 13:32:32 +00:00
parent ea5862d15b
commit 2d56cc0b0c
3 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **fix(translator):** synthesize tool call chunks from `response.completed` batched output when upstream omits individual `output_item.added`/`done` events, fixing `finish_reason: "stop"` instead of `"tool_calls"` for agentic clients ([#180](https://github.com/diegosouzapw/OmniRoute/issues/180), [#3980](https://github.com/diegosouzapw/OmniRoute/issues/3980))

View File

@@ -622,6 +622,20 @@ function flushEvents(state) {
*/
function withAssistantRoleOnFirstDelta(state, result) {
if (!result || state.roleEmitted) return result;
// Handle arrays of chunks (e.g. synthesized from response.completed output[])
if (Array.isArray(result)) {
for (const chunk of result) {
const delta = chunk?.choices?.[0]?.delta;
if (delta && typeof delta === "object" && !Array.isArray(delta)) {
delta.role = "assistant";
state.roleEmitted = true;
break;
}
}
return result;
}
const delta = result.choices?.[0]?.delta;
if (delta && typeof delta === "object" && !Array.isArray(delta)) {
delta.role = "assistant";
@@ -979,6 +993,129 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
}
}
// ---------------------------------------------------------------------------
// #fix: Synthesize tool call chunks from response.completed output[] when the
// upstream provider sent a batched completed event WITHOUT first emitting the
// individual response.output_item.added / .delta / .done events. Without this,
// state.toolCallIndex stays 0 and state.currentToolCallId stays null, so
// computeFinishReason returns "stop" instead of "tool_calls", breaking the
// agent loop for downstream Chat Completions clients.
// ---------------------------------------------------------------------------
const outputItems = Array.isArray(data.response?.output) ? data.response.output : [];
const functionCallItems = outputItems.filter((item) => item?.type === "function_call");
if (functionCallItems.length > 0 && !state.finishReasonSent) {
const synthesizedChunks: Record<string, unknown>[] = [];
for (const fcItem of functionCallItems) {
const callId = fcItem.call_id || fallbackToolCallId(state.toolCallIndex);
const toolName = normalizeToolName(fcItem.name);
const toolSchema = state.toolSchemas?.get(toolName);
// Set state as output_item.added would
state.currentToolCallId = callId;
state.currentToolCallArgsBuffer = "";
state.currentToolCallDeferred = false;
// Emit the tool call header chunk (id, type, function.name)
const currentIndex = state.toolCallIndex;
synthesizedChunks.push({
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: currentIndex,
id: callId,
type: "function",
function: {
name: toolName || "",
arguments: "",
},
},
],
},
finish_reason: null,
},
],
});
// Process arguments — may be string or object
const rawArgs = fcItem.arguments;
const argsToEmit = stripEmptyOptionalToolArgs(rawArgs, toolName, toolSchema);
const argsStr =
argsToEmit != null
? typeof argsToEmit === "string"
? argsToEmit
: JSON.stringify(argsToEmit)
: rawArgs != null
? typeof rawArgs === "string"
? rawArgs
: JSON.stringify(rawArgs)
: "";
if (argsStr) {
state.currentToolCallArgsBuffer = argsStr;
synthesizedChunks.push({
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: currentIndex,
function: { arguments: argsStr },
},
],
},
finish_reason: null,
},
],
});
}
// Advance state as output_item.done would
state.toolCallIndex++;
state.currentToolCallArgsBuffer = "";
state.currentToolCallId = null;
}
// Now emit the final chunk with finish_reason: "tool_calls" and usage
state.finishReasonSent = true;
const reason = computeFinishReason(state);
state.finishReason = reason;
const finalChunk: Record<string, unknown> = {
id: state.chatId,
object: "chat.completion.chunk",
created: state.created,
model: state.model || "gpt-4",
choices: [
{
index: 0,
delta: {},
finish_reason: reason,
},
],
};
if (state.usage && typeof state.usage === "object") {
finalChunk.usage = state.usage;
}
synthesizedChunks.push(finalChunk);
return synthesizedChunks;
}
if (!state.finishReasonSent) {
state.finishReasonSent = true;
const reason = computeFinishReason(state);

View File

@@ -635,3 +635,169 @@ test("OpenAI -> Responses: parallel tool calls with mixed content survive transl
const outputFcs = completed.data.response.output.filter((item) => item.type === "function_call");
assert.equal(outputFcs.length, 2, "completed output should have both function_calls");
});
test("Responses -> OpenAI: response.completed with function_call in output[] synthesizes tool call chunks", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_1",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_1",
name: "read_file",
arguments: { path: "/tmp/a" },
},
],
usage: {
input_tokens: 5,
output_tokens: 3,
total_tokens: 8,
},
},
},
state
);
// Should return an array of chunks (header + args + final)
assert.ok(Array.isArray(result), "should return array of chunks");
assert.equal(result.length, 3, "should have 3 chunks: header, args, final");
// First chunk: tool call header with id, type, function.name
const header = result[0];
assert.equal(header.choices[0].delta.tool_calls[0].id, "call_1");
assert.equal(header.choices[0].delta.tool_calls[0].type, "function");
assert.equal(header.choices[0].delta.tool_calls[0].function.name, "read_file");
assert.equal(header.choices[0].delta.tool_calls[0].function.arguments, "");
assert.equal(header.choices[0].finish_reason, null);
// Second chunk: arguments delta
const argsChunk = result[1];
assert.equal(argsChunk.choices[0].delta.tool_calls[0].index, 0);
assert.equal(
argsChunk.choices[0].delta.tool_calls[0].function.arguments,
JSON.stringify({ path: "/tmp/a" })
);
assert.equal(argsChunk.choices[0].finish_reason, null);
// Third chunk: final with finish_reason
const final = result[2];
assert.equal(final.choices[0].finish_reason, "tool_calls");
assert.equal(final.usage.prompt_tokens, 5);
assert.equal(final.usage.completion_tokens, 3);
});
test("Responses -> OpenAI: response.completed with multiple function_calls in output[]", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_2",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_a",
name: "read_file",
arguments: { path: "/tmp/a" },
},
{
type: "function_call",
call_id: "call_b",
name: "write_file",
arguments: { path: "/tmp/b", content: "hello" },
},
],
usage: {
input_tokens: 10,
output_tokens: 6,
total_tokens: 16,
},
},
},
state
);
assert.ok(Array.isArray(result), "should return array of chunks");
// 2 tool calls = 2 headers + 2 args + 1 final = 5 chunks
assert.equal(result.length, 5, "should have 5 chunks for 2 tool calls");
// First tool call header
assert.equal(result[0].choices[0].delta.tool_calls[0].id, "call_a");
assert.equal(result[0].choices[0].delta.tool_calls[0].function.name, "read_file");
// Second tool call header
assert.equal(result[2].choices[0].delta.tool_calls[0].id, "call_b");
assert.equal(result[2].choices[0].delta.tool_calls[0].function.name, "write_file");
// Final chunk
const final = result[4];
assert.equal(final.choices[0].finish_reason, "tool_calls");
assert.equal(final.usage.prompt_tokens, 10);
});
test("Responses -> OpenAI: response.completed without function_call in output[] still returns stop", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_3",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Hello!" }],
},
],
usage: {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
},
},
},
state
);
// Should return a single chunk (not array) with finish_reason: "stop"
assert.ok(!Array.isArray(result), "should return single chunk, not array");
assert.equal(result.choices[0].finish_reason, "stop");
assert.equal(result.usage.prompt_tokens, 5);
});
test("Responses -> OpenAI: response.completed with function_call in output[] sets assistant role on first delta", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
id: "resp_4",
status: "completed",
model: "deepseek-v4",
output: [
{
type: "function_call",
call_id: "call_1",
name: "read_file",
arguments: { path: "/tmp/a" },
},
],
},
},
state
);
assert.ok(Array.isArray(result));
// First chunk should have role: "assistant" in delta
assert.equal(result[0].choices[0].delta.role, "assistant");
assert.equal(state.roleEmitted, true);
});