diff --git a/changelog.d/fixes/180-3980-responses-completed-tool-calls.md b/changelog.d/fixes/180-3980-responses-completed-tool-calls.md new file mode 100644 index 0000000000..f9556e0138 --- /dev/null +++ b/changelog.d/fixes/180-3980-responses-completed-tool-calls.md @@ -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)) diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index fdd214d06d..4b2b8c6a7d 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -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[] = []; + + 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 = { + 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); diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3659a82fb5..7fb20ee60c 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -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); +});