mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(translator): synthesize tool call chunks from response.completed batched output (#7613)
* 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
* fix(translator): guard against double-emission for incrementally-streamed tool calls
Add a guard that skips response.completed synthesis for call_ids already
tracked via incremental output_item.added/.done events. Without this,
providers that stream incrementally AND echo function_call items in the
response.completed output[] snapshot get duplicate tool call chunks.
Also adds a regression test combining both incremental events and a
response.completed snapshot in the same turn.
Refs: diegosouzapw/OmniRoute#7613
* chore: add docker-compose.yml.bak to gitignore
* refactor(translator): extract response.completed synthesis, fix ratchets
Fixes the file-size and complexity/cognitive-complexity ratchet
regressions the dedup-guard commit (6bbff5ea) introduced, so the PR's
own validation block (typecheck, eslint, file-size, complexity,
cognitive-complexity, changelog-integrity, test-discovery) is fully
green, not just its own tests:
- Extract the response.completed batched-tool-call synthesis body into
a new leaf module
open-sse/translator/response/openai-responses/synthesizeCompletedToolCalls.ts,
mirroring this file's own established eventEmitter.ts/toolSchemas.ts/
pureHelpers.ts extraction pattern, further split internally
(buildToolCallChunks/buildFinalChunk/resolveArgsStr/baseChunk) to
keep synthesizeCompletedToolCalls() itself under the complexity/
cognitive-complexity/max-lines-per-function thresholds.
- computeFinishReason moves alongside it (not into the sibling
pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is
guarded by tests/unit/response-openai-responses-purehelpers-split.test.ts
to have NO state coupling at all.
- DRY the withAssistantRoleOnFirstDelta array/single-result branches
into a shared setAssistantRoleIfEligible(state, delta) helper — the
array branch alone pushed this function's cyclomatic complexity to
16 (over the 15 threshold).
- Split the 5 new response.completed tests out of
tests/unit/translator-resp-openai-responses.test.ts (which would
have exceeded the 800-line test-file-size cap) into a new sibling
tests/unit/translator-resp-openai-responses-completed-synthesis.test.ts.
- Bump the frozen open-sse/translator/response/openai-responses.ts
file-size baseline for the small irreducible remainder (dedup-guard
state tracking + call-site wiring) with a justification entry in
config/quality/file-size-baseline.json.
Independently re-verified red-first (temporarily reintroducing the
pre-guard filter reproduces exactly one failure — the dedup test — no
collateral damage) and confirmed check:complexity-ratchets is back to
exactly baseline (2058/890) and check:file-size is fully green.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* refactor(translator): move completed-tool-call glue into module (file-size cap)
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Erick Kinnee <erick@ekinnee.dev>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -249,3 +249,4 @@ _artifacts/ # release-green artifacts
|
||||
tests/homolog/.auth/
|
||||
tests/homolog/ui/.auth/
|
||||
homolog-report/
|
||||
docker-compose.yml.bak
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(translator):** guard against double-emission when `response.completed` echoes `function_call` items already streamed via incremental `output_item.added`/`done` events — skip synthesis for `call_id`s already tracked, preventing duplicate tool call chunks for incrementally-streaming providers
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
getVisibleResponsesReasoningSummaryText,
|
||||
} from "./openai-responses/pureHelpers.ts";
|
||||
import { createEventEmitter } from "./openai-responses/eventEmitter.ts";
|
||||
import {
|
||||
synthesizeCompletedToolCalls,
|
||||
computeFinishReason,
|
||||
withAssistantRoleOnFirstDelta,
|
||||
} from "./openai-responses/synthesizeCompletedToolCalls.ts";
|
||||
|
||||
// normalizeUpstreamFailure is re-exported for external importers (tests).
|
||||
export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts";
|
||||
@@ -609,42 +614,6 @@ function flushEvents(state) {
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions streams announce the assistant role on the FIRST delta
|
||||
* (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant",
|
||||
* "tool_calls": [...] }`). The Responses API has no role-announcement event, so when
|
||||
* translating Responses → Chat we must synthesize it on the first emitted chunk.
|
||||
*
|
||||
* Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk`
|
||||
* (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk.
|
||||
* Without it, streamed tool_call deltas are dropped and the agent returns an empty
|
||||
* response, even though the underlying tool call is well-formed.
|
||||
*/
|
||||
function withAssistantRoleOnFirstDelta(state, result) {
|
||||
if (!result || state.roleEmitted) return result;
|
||||
const delta = result.choices?.[0]?.delta;
|
||||
if (delta && typeof delta === "object" && !Array.isArray(delta)) {
|
||||
delta.role = "assistant";
|
||||
state.roleEmitted = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the terminal finish_reason for a Responses→Chat stream.
|
||||
*
|
||||
* `currentToolCallId` is intentionally sticky for the current turn: it is set when a
|
||||
* function_call item is announced (`response.output_item.added`) and is only cleared once
|
||||
* the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends
|
||||
* (flush or `response.completed`) after a tool call was emitted but BEFORE its
|
||||
* `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set.
|
||||
* Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so
|
||||
* OpenAI-compatible clients continue tool-result processing instead of stopping prematurely.
|
||||
*/
|
||||
function computeFinishReason(state): "tool_calls" | "stop" {
|
||||
return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop";
|
||||
}
|
||||
|
||||
// #5786 — remember that a reasoning delta was streamed for a given reasoning item, so
|
||||
// the terminal `response.output_item.done` snapshot for that item is NOT re-emitted
|
||||
// (which would duplicate the reasoning channel). Keyed by item_id when present, with a
|
||||
@@ -770,6 +739,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
|
||||
state.currentToolCallDeferred = false;
|
||||
|
||||
// Track this call_id so response.completed doesn't synthesize a duplicate
|
||||
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
|
||||
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
if (!toolName) {
|
||||
// Some Responses providers briefly emit placeholder/empty tool names.
|
||||
@@ -849,6 +822,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
const toolName = normalizeToolName(item.name);
|
||||
const toolSchema = state.toolSchemas?.get(toolName);
|
||||
|
||||
// Track this call_id so response.completed doesn't synthesize a duplicate
|
||||
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
|
||||
if (callId) state.toolCallIdsSeen.add(callId);
|
||||
|
||||
if (state.currentToolCallDeferred) {
|
||||
state.currentToolCallDeferred = false;
|
||||
state.currentToolCallArgsBuffer = "";
|
||||
@@ -979,6 +956,15 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
}
|
||||
}
|
||||
|
||||
// #fix: synthesize tool call chunks from response.completed output[] for
|
||||
// providers that batch everything into response.completed without prior
|
||||
// incremental output_item.* events — including the dedup guard against
|
||||
// providers that DO stream incrementally and also echo the same
|
||||
// function_call items here. See synthesizeCompletedToolCalls's own
|
||||
// doc-comment for the full rationale.
|
||||
const synthesized = synthesizeCompletedToolCalls(state, data.response?.output);
|
||||
if (synthesized) return synthesized;
|
||||
|
||||
if (!state.finishReasonSent) {
|
||||
state.finishReasonSent = true;
|
||||
const reason = computeFinishReason(state);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Extracted from response/openai-responses.ts (file-size ratchet) — synthesizes
|
||||
// tool_calls chunks from a response.completed event's output[] snapshot, for
|
||||
// upstream providers that send a single 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.
|
||||
import { fallbackToolCallId } from "../../helpers/toolCallHelper.ts";
|
||||
import { normalizeToolName, stripEmptyOptionalToolArgs } from "./pureHelpers.ts";
|
||||
|
||||
/**
|
||||
* Resolve the terminal finish_reason for a Responses→Chat stream.
|
||||
*
|
||||
* `currentToolCallId` is intentionally sticky for the current turn: it is set when a
|
||||
* function_call item is announced (`response.output_item.added`) and is only cleared once
|
||||
* the matching `response.output_item.done` advances `toolCallIndex`. If the stream ends
|
||||
* (flush or `response.completed`) after a tool call was emitted but BEFORE its
|
||||
* `output_item.done` arrived, `toolCallIndex` is still 0 while `currentToolCallId` is set.
|
||||
* Guarding on it as well lets us still finalize as `tool_calls` instead of `stop`, so
|
||||
* OpenAI-compatible clients continue tool-result processing instead of stopping prematurely.
|
||||
*
|
||||
* Lives here (not pureHelpers.ts) because it takes stream `state` — pureHelpers.ts is
|
||||
* guarded (tests/unit/response-openai-responses-purehelpers-split.test.ts) to have NO
|
||||
* state coupling at all.
|
||||
*/
|
||||
export function computeFinishReason(state): "tool_calls" | "stop" {
|
||||
return (state.toolCallIndex || 0) > 0 || state.currentToolCallId ? "tool_calls" : "stop";
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions streams announce the assistant role on the FIRST delta
|
||||
* (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant",
|
||||
* "tool_calls": [...] }`). The Responses API has no role-announcement event, so when
|
||||
* translating Responses → Chat we must synthesize it on the first emitted chunk.
|
||||
*
|
||||
* Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk`
|
||||
* (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk.
|
||||
* Without it, streamed tool_call deltas are dropped and the agent returns an empty
|
||||
* response, even though the underlying tool call is well-formed.
|
||||
*/
|
||||
// Shared by both branches of withAssistantRoleOnFirstDelta below: stamps
|
||||
// role: "assistant" onto a single delta object when eligible, returning
|
||||
// whether it did so (used to short-circuit the array branch's loop).
|
||||
function setAssistantRoleIfEligible(state, delta) {
|
||||
if (delta && typeof delta === "object" && !Array.isArray(delta)) {
|
||||
delta.role = "assistant";
|
||||
state.roleEmitted = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export 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) {
|
||||
if (setAssistantRoleIfEligible(state, chunk?.choices?.[0]?.delta)) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setAssistantRoleIfEligible(state, result.choices?.[0]?.delta);
|
||||
return result;
|
||||
}
|
||||
|
||||
function baseChunk(state): Record<string, unknown> {
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the arguments string to emit — may arrive as a string or object. */
|
||||
function resolveArgsStr(rawArgs, toolName, toolSchema): string {
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(rawArgs, toolName, toolSchema);
|
||||
if (argsToEmit != null) {
|
||||
return typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit);
|
||||
}
|
||||
if (rawArgs != null) {
|
||||
return typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the header + args chunks for one synthesized function_call item,
|
||||
* mutating `state` exactly as the incremental output_item.added/.done path
|
||||
* would (currentToolCallId, currentToolCallArgsBuffer, currentToolCallDeferred,
|
||||
* toolCallIndex), so downstream chunk math (computeFinishReason, subsequent
|
||||
* incremental events in the same turn) stays consistent.
|
||||
*/
|
||||
function buildToolCallChunks(state, fcItem): Record<string, unknown>[] {
|
||||
const chunks: Record<string, unknown>[] = [];
|
||||
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;
|
||||
chunks.push({
|
||||
...baseChunk(state),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
id: callId,
|
||||
type: "function",
|
||||
function: { name: toolName || "", arguments: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const argsStr = resolveArgsStr(fcItem.arguments, toolName, toolSchema);
|
||||
if (argsStr) {
|
||||
state.currentToolCallArgsBuffer = argsStr;
|
||||
chunks.push({
|
||||
...baseChunk(state),
|
||||
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;
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** Build the terminal chunk (finish_reason + usage) once all tool calls are synthesized. */
|
||||
function buildFinalChunk(state): Record<string, unknown> {
|
||||
state.finishReasonSent = true;
|
||||
const reason = computeFinishReason(state);
|
||||
state.finishReason = reason;
|
||||
|
||||
const finalChunk: Record<string, unknown> = {
|
||||
...baseChunk(state),
|
||||
choices: [{ index: 0, delta: {}, finish_reason: reason }],
|
||||
};
|
||||
if (state.usage && typeof state.usage === "object") {
|
||||
finalChunk.usage = state.usage;
|
||||
}
|
||||
return finalChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize chat-completion-style tool_calls chunks for any `function_call`
|
||||
* items in `output` whose call_id was NOT already tracked via incremental
|
||||
* `output_item.added`/`.done` events (`state.toolCallIdsSeen`). This dedup
|
||||
* guard prevents double-emission when a provider streams incrementally AND
|
||||
* `response.completed` also echoes the same function_call items in its
|
||||
* output[] snapshot (standard Responses-API snapshot behavior).
|
||||
*
|
||||
* Mutates `state` exactly as the incremental path would (toolCallIndex,
|
||||
* currentToolCallId, currentToolCallArgsBuffer, currentToolCallDeferred,
|
||||
* finishReasonSent, finishReason), so downstream chunk math stays consistent.
|
||||
*
|
||||
* Returns the array of synthesized chunks, or `null` when there is nothing to
|
||||
* synthesize (no un-seen function_call items, or finish_reason already sent)
|
||||
* — the caller falls through to its own default finish_reason handling.
|
||||
*/
|
||||
export function synthesizeCompletedToolCalls(state, output): Record<string, unknown>[] | null {
|
||||
const outputItems = Array.isArray(output) ? output : [];
|
||||
const functionCallItems = outputItems.filter(
|
||||
(item) => item?.type === "function_call" && !state.toolCallIdsSeen?.has(item.call_id)
|
||||
);
|
||||
|
||||
if (functionCallItems.length === 0 || state.finishReasonSent) return null;
|
||||
|
||||
const synthesizedChunks: Record<string, unknown>[] = [];
|
||||
for (const fcItem of functionCallItems) {
|
||||
synthesizedChunks.push(...buildToolCallChunks(state, fcItem));
|
||||
}
|
||||
synthesizedChunks.push(buildFinalChunk(state));
|
||||
return synthesizedChunks;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Split out of translator-resp-openai-responses.test.ts (file-size ratchet —
|
||||
// this file plus the new tests would have exceeded both the production and
|
||||
// test-file frozen baselines). Covers the response.completed batched
|
||||
// tool-call synthesis path (no prior incremental output_item.* events) and
|
||||
// its dedup guard against providers that DO stream incrementally and then
|
||||
// echo the same function_call items in response.completed's output[]
|
||||
// snapshot (would otherwise double-emit — see openai-responses.ts's
|
||||
// `toolCallIdsSeen` guard).
|
||||
const { openaiResponsesToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/openai-responses.ts");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("Responses -> OpenAI: incremental tool call events + response.completed snapshot does NOT double-emit", () => {
|
||||
// Regression test: when a provider streams incrementally (output_item.added ->
|
||||
// function_call_arguments.delta -> output_item.done) and then response.completed
|
||||
// echoes the same function_call items in its output[] snapshot, we must NOT
|
||||
// synthesize a second set of tool call chunks for already-seen call_ids.
|
||||
const state = {};
|
||||
|
||||
// Phase 1: incremental events for call_a (identical to real streaming provider)
|
||||
const added = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_a", name: "read_file" },
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.ok(added, "should emit header chunk for incremental tool call");
|
||||
assert.equal(added.choices[0].delta.tool_calls[0].id, "call_a");
|
||||
|
||||
const args = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
delta: '{"path":"/tmp/a"}',
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.ok(args, "should emit args delta chunk");
|
||||
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_a",
|
||||
name: "read_file",
|
||||
arguments: { path: "/tmp/a" },
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// Phase 2: incremental events for call_b
|
||||
const addedB = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_b", name: "write_file" },
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.ok(addedB);
|
||||
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
delta: '{"path":"/tmp/b","content":"hello"}',
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_b",
|
||||
name: "write_file",
|
||||
arguments: { path: "/tmp/b", content: "hello" },
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// Phase 3: response.completed echoes BOTH call_a and call_b in output[]
|
||||
// This is the test: the guard should skip synthesis since both call_ids were
|
||||
// already tracked via the incremental events above.
|
||||
const completed = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_combined",
|
||||
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: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
// response.completed should return a single chunk (not array of chunks)
|
||||
// because both call_ids were already seen — no synthesis needed.
|
||||
assert.ok(!Array.isArray(completed), "should NOT return array — no synthesis for seen call_ids");
|
||||
assert.equal(
|
||||
completed.choices[0].finish_reason,
|
||||
"tool_calls",
|
||||
"finish_reason should still be tool_calls"
|
||||
);
|
||||
assert.equal(completed.usage.prompt_tokens, 10);
|
||||
assert.equal(completed.usage.completion_tokens, 5);
|
||||
|
||||
// toolCallIndex should be 2 (two tool calls were processed via incremental events)
|
||||
assert.equal(state.toolCallIndex, 2, "toolCallIndex should reflect both incremental tool calls");
|
||||
});
|
||||
Reference in New Issue
Block a user