mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 15:12:23 +03:00
fix(sse): split concatenated tool_call arguments from same-name index collisions (#11043)
⭐5 — Providers que não bumpam index/id em tool calls repetidas do mesmo nome colam N arguments JSON num só ({...}{...}{...}); leitores a jusante pegam só o primeiro e dropam o resto em silêncio. Detecta N objetos concatenados e divide de volta em N tool_calls. TDD 20/20 + 86/86 irmãos. Fecha #11044. Base-red #9985 inherited.
This commit is contained in:
@@ -128,6 +128,75 @@ function tryParseJson(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a tool_call `arguments` string that is actually multiple back-to-back JSON
|
||||
* objects glued together with no separator, into its individual object substrings.
|
||||
*
|
||||
* Root cause (observed on opencode/muse-spark-1.2-contributor-free via the zen
|
||||
* provider): some upstreams never vary `index`/`id` across a 2nd/3rd/… tool_call of
|
||||
* the SAME name emitted in one turn, so every delta in `buildOpenAISummary` above
|
||||
* resolves to the same accumulator key and `arguments` ends up as N JSON objects
|
||||
* concatenated with no delimiter — invalid as a single JSON value, but each object is
|
||||
* individually well-formed. Structural, not provider-specific: applies to whichever
|
||||
* upstream exhibits the same index-collision streaming bug.
|
||||
*
|
||||
* Returns `null` when `raw` is empty, already valid single JSON, or does not scan as
|
||||
* ≥2 back-to-back valid JSON values — callers must leave `arguments` untouched in
|
||||
* that case (never regress a value that used to reach the client as-is).
|
||||
*/
|
||||
export function splitConcatenatedToolCallArguments(raw: string): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
return null; // Already a single valid JSON value — nothing to split.
|
||||
} catch {
|
||||
// Fall through to the multi-value scan below.
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let start = -1;
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i];
|
||||
if (start === -1) {
|
||||
if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") continue;
|
||||
if (ch !== "{" && ch !== "[") return null; // Not a value boundary — bail, leave untouched.
|
||||
start = i;
|
||||
}
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === "\\") escaped = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{" || ch === "[") depth++;
|
||||
else if (ch === "}" || ch === "]") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
parts.push(raw.slice(start, i + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (start !== -1 || depth !== 0 || parts.length < 2) return null;
|
||||
|
||||
for (const part of parts) {
|
||||
try {
|
||||
JSON.parse(part);
|
||||
} catch {
|
||||
return null; // One of the scanned segments isn't valid JSON — bail entirely.
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ─── Per-format live reducers ────────────────────────────────────────────────
|
||||
// Each reducer mirrors the corresponding build*Summary()'s original for-loop
|
||||
// body exactly (ingest = one loop iteration, finalize = the post-loop return),
|
||||
@@ -262,7 +331,27 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
message.reasoning_content = joinedReasoning;
|
||||
}
|
||||
|
||||
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
||||
const mergedToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
||||
// Expand any entry whose accumulated `arguments` turned out to be multiple
|
||||
// concatenated JSON objects (upstream never varied index/id across repeated
|
||||
// same-name tool_calls) into its own separate tool_calls entries.
|
||||
const finalToolCalls: ToolCall[] = [];
|
||||
let nextIndex = 0;
|
||||
for (const tc of mergedToolCalls) {
|
||||
const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments);
|
||||
if (!splitArgs) {
|
||||
finalToolCalls.push({ ...tc, index: nextIndex++ });
|
||||
continue;
|
||||
}
|
||||
for (const [i, args] of splitArgs.entries()) {
|
||||
finalToolCalls.push({
|
||||
id: tc.id ? `${tc.id}_split${i}` : null,
|
||||
index: nextIndex++,
|
||||
type: tc.type,
|
||||
function: { name: tc.function.name, arguments: args },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (finalToolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
message.tool_calls = finalToolCalls;
|
||||
|
||||
@@ -42,7 +42,7 @@ test("buildStreamSummaryFromEvents handles empty array", () => {
|
||||
});
|
||||
|
||||
test("buildStreamSummaryFromEvents handles single event", () => {
|
||||
const events = [{ data: { choices: [{ delta: { content: "hello" } }] } }];
|
||||
const events = [{ index: 0, data: { choices: [{ delta: { content: "hello" } }] } }];
|
||||
const result = collector.buildStreamSummaryFromEvents(events) as any;
|
||||
assert.ok(result !== null);
|
||||
assert.ok(typeof result === "object");
|
||||
@@ -50,8 +50,8 @@ test("buildStreamSummaryFromEvents handles single event", () => {
|
||||
|
||||
test("buildStreamSummaryFromEvents handles multiple events", () => {
|
||||
const events = [
|
||||
{ data: { choices: [{ delta: { content: "hello" } }] } },
|
||||
{ data: { choices: [{ delta: { content: " world" } }] } },
|
||||
{ index: 0, data: { choices: [{ delta: { content: "hello" } }] } },
|
||||
{ index: 1, data: { choices: [{ delta: { content: " world" } }] } },
|
||||
];
|
||||
const result = collector.buildStreamSummaryFromEvents(events) as any;
|
||||
assert.ok(result !== null);
|
||||
@@ -218,6 +218,106 @@ test("buildStreamSummaryFromEvents keeps two genuinely different interleaved too
|
||||
assert.equal(toolCalls[1].function.arguments, '{"path":"b"}');
|
||||
});
|
||||
|
||||
// opencode/muse-spark-1.2-contributor-free (zen provider): the upstream SSE stream
|
||||
// never varies `index`/`id` for a 2nd/3rd tool_call of the SAME name in one turn —
|
||||
// every delta lands on the same accumulator key, so 3 distinct `task` calls
|
||||
// concatenate into a single malformed `arguments` string containing 3 back-to-back
|
||||
// JSON objects (the model emits the whole 3rd call already glued to the first two
|
||||
// in one delta — no true streaming needed to trigger it).
|
||||
test("buildStreamSummaryFromEvents splits a tool_call whose arguments are multiple concatenated JSON objects under the same id/index (muse-spark SSE index bug)", () => {
|
||||
const glued =
|
||||
'{"description":"Subagent OK 1","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}' +
|
||||
'{"description":"Subagent OK 2","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}' +
|
||||
'{"description":"Subagent OK 3","prompt":"Reply only \\"OK\\". Nothing else.","subagent_type":"general"}';
|
||||
|
||||
const events = [
|
||||
toolCallEvent({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_task_glued",
|
||||
type: "function",
|
||||
function: { name: "task", arguments: glued },
|
||||
},
|
||||
],
|
||||
}),
|
||||
toolCallEvent({}, "tool_calls"),
|
||||
];
|
||||
|
||||
const summary = collector.buildStreamSummaryFromEvents(
|
||||
events,
|
||||
"openai",
|
||||
"opencode/muse-spark-1.2-contributor-free"
|
||||
) as ToolCallSummary;
|
||||
const toolCalls = summary.choices[0].message.tool_calls;
|
||||
|
||||
assert.equal(
|
||||
toolCalls.length,
|
||||
3,
|
||||
`expected 3 split tool_calls, got ${toolCalls.length}: ${JSON.stringify(toolCalls)}`
|
||||
);
|
||||
for (const [i, tc] of toolCalls.entries()) {
|
||||
assert.equal(tc.function.name, "task");
|
||||
const parsed = JSON.parse(tc.function.arguments);
|
||||
assert.equal(parsed.description, `Subagent OK ${i + 1}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildStreamSummaryFromEvents leaves a single valid JSON arguments string untouched (no false-positive split)", () => {
|
||||
const events = [
|
||||
toolCallEvent({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_single",
|
||||
type: "function",
|
||||
function: { name: "write", arguments: '{"path":"a.txt","content":"{}"}' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
toolCallEvent({}, "tool_calls"),
|
||||
];
|
||||
|
||||
const summary = collector.buildStreamSummaryFromEvents(
|
||||
events,
|
||||
"openai",
|
||||
"deepseek-v4-flash-free"
|
||||
) as ToolCallSummary;
|
||||
const toolCalls = summary.choices[0].message.tool_calls;
|
||||
|
||||
assert.equal(toolCalls.length, 1);
|
||||
assert.equal(toolCalls[0].function.arguments, '{"path":"a.txt","content":"{}"}');
|
||||
});
|
||||
|
||||
test("buildStreamSummaryFromEvents leaves genuinely malformed (non-concatenated) JSON arguments untouched (no worse than before)", () => {
|
||||
const events = [
|
||||
toolCallEvent({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_broken",
|
||||
type: "function",
|
||||
function: { name: "write", arguments: '{"path":"a.txt", "content": tr' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
toolCallEvent({}, "tool_calls"),
|
||||
];
|
||||
|
||||
const summary = collector.buildStreamSummaryFromEvents(
|
||||
events,
|
||||
"openai",
|
||||
"deepseek-v4-flash-free"
|
||||
) as ToolCallSummary;
|
||||
const toolCalls = summary.choices[0].message.tool_calls;
|
||||
|
||||
assert.equal(toolCalls.length, 1);
|
||||
assert.equal(toolCalls[0].function.arguments, '{"path":"a.txt", "content": tr');
|
||||
});
|
||||
|
||||
type OpenAIStreamSummary = {
|
||||
choices: Array<{
|
||||
finish_reason: string;
|
||||
|
||||
Reference in New Issue
Block a user