mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
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:
@@ -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))
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user