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
This commit is contained in:
Erick Kinnee
2026-07-17 21:37:07 +00:00
parent 2d56cc0b0c
commit 6bbff5eac9
4 changed files with 195 additions and 1 deletions

View File

@@ -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

59
docker-compose.yml.bak Normal file
View File

@@ -0,0 +1,59 @@
services:
redis:
image: docker.io/library/redis:7-alpine
container_name: omniroute-redis
restart: unless-stopped
networks:
- dockernet
volumes:
- redis-data:/data
command: redis-server --save 60 1 --loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
omniroute:
image: diegosouzapw/omniroute:latest
container_name: omniroute
restart: unless-stopped
stop_grace_period: 40s
networks:
- dockernet
depends_on:
redis:
condition: service_healthy
environment:
- JWT_SECRET=6E1dyjMlUIZzIMvN5sH+3/dEzgm99HpxLKtjcAzFg1m3x17wZyF31U2i6rlYJZaF
- API_KEY_SECRET=3b6888cbf398a151972f193402cd193caba6ce926f20040537b82c5efcbcf964
- INITIAL_PASSWORD=CHANGEME
- DATA_DIR=/app/data
- REDIS_URL=redis://redis:6379
- PORT=20128
- NODE_ENV=production
- CONTAINER_HOST=docker
- AUTH_COOKIE_SECURE=false
- ALLOW_API_KEY_REVEAL=true
- OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true
- OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=true
- BASE_URL=http://omniroute:20128
- NEXT_PUBLIC_BASE_URL=http://omniroute:20128
volumes:
- omniroute-data:/app/data
healthcheck:
test: ["CMD", "node", "healthcheck.mjs"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
dockernet:
external: true
volumes:
redis-data:
name: omniroute-redis-data
omniroute-data:
name: omniroute-data

View File

@@ -784,6 +784,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
state.currentToolCallDeferred = false; 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); const toolName = normalizeToolName(item.name);
if (!toolName) { if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names. // Some Responses providers briefly emit placeholder/empty tool names.
@@ -863,6 +867,10 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const toolName = normalizeToolName(item.name); const toolName = normalizeToolName(item.name);
const toolSchema = state.toolSchemas?.get(toolName); 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) { if (state.currentToolCallDeferred) {
state.currentToolCallDeferred = false; state.currentToolCallDeferred = false;
state.currentToolCallArgsBuffer = ""; state.currentToolCallArgsBuffer = "";
@@ -1002,7 +1010,13 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// agent loop for downstream Chat Completions clients. // agent loop for downstream Chat Completions clients.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const outputItems = Array.isArray(data.response?.output) ? data.response.output : []; const outputItems = Array.isArray(data.response?.output) ? data.response.output : [];
const functionCallItems = outputItems.filter((item) => item?.type === "function_call"); // Filter out function_call items whose call_ids were already tracked via
// incremental events (output_item.added → .done). This prevents double-emission
// when the provider streams incrementally AND response.completed echoes the same
// function_call items in its output[] snapshot.
const functionCallItems = outputItems.filter(
(item) => item?.type === "function_call" && !state.toolCallIdsSeen?.has(item.call_id)
);
if (functionCallItems.length > 0 && !state.finishReasonSent) { if (functionCallItems.length > 0 && !state.finishReasonSent) {
const synthesizedChunks: Record<string, unknown>[] = []; const synthesizedChunks: Record<string, unknown>[] = [];

View File

@@ -801,3 +801,123 @@ test("Responses -> OpenAI: response.completed with function_call in output[] set
assert.equal(result[0].choices[0].delta.role, "assistant"); assert.equal(result[0].choices[0].delta.role, "assistant");
assert.equal(state.roleEmitted, true); 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");
});