mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(stream): suppress malformed textual tool calls
This commit is contained in:
@@ -199,16 +199,19 @@ function stripZeroWidth(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null {
|
||||
function parseTextualToolCallCandidate(
|
||||
text: unknown
|
||||
): { kind: "complete"; name: string; args: unknown } | { kind: "partial" } | null {
|
||||
if (typeof text !== "string") return null;
|
||||
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
const toolCallIndex = normalized.lastIndexOf("[Tool call:");
|
||||
const candidate = toolCallIndex >= 0 ? normalized.slice(toolCallIndex) : normalized;
|
||||
if (toolCallIndex < 0) return null;
|
||||
const candidate = normalized.slice(toolCallIndex);
|
||||
const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/);
|
||||
if (!headerMatch) return null;
|
||||
if (!headerMatch) return { kind: "partial" };
|
||||
const name = headerMatch[1]?.trim();
|
||||
const rawArgs = candidate.slice(headerMatch[0].length).trim();
|
||||
if (!name || !rawArgs) return null;
|
||||
if (!name || !rawArgs) return { kind: "partial" };
|
||||
const decoders = [
|
||||
(value: string) => value,
|
||||
(value: string) => {
|
||||
@@ -223,10 +226,19 @@ function parseTextualToolCallFromContent(text: unknown): { name: string; args: u
|
||||
try {
|
||||
const decoded = decode(rawArgs);
|
||||
const parsed = JSON.parse(decoded);
|
||||
return { name, args: stripZeroWidth(parsed) };
|
||||
return { kind: "complete", name, args: stripZeroWidth(parsed) };
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
return { kind: "partial" };
|
||||
}
|
||||
|
||||
function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null {
|
||||
const candidate = parseTextualToolCallCandidate(text);
|
||||
return candidate?.kind === "complete" ? { name: candidate.name, args: candidate.args } : null;
|
||||
}
|
||||
|
||||
function containsTextualToolCallCandidate(text: unknown): boolean {
|
||||
return parseTextualToolCallCandidate(text) !== null;
|
||||
}
|
||||
|
||||
function collectPassthroughTextualToolCall(
|
||||
@@ -659,6 +671,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// Passthrough: accumulate content and reasoning separately for call log response body
|
||||
let passthroughAccumulatedContent = "";
|
||||
let passthroughAccumulatedReasoning = "";
|
||||
let passthroughBufferedTextualToolCallContent = "";
|
||||
// Passthrough Responses SSE: snapshots of items seen via `response.output_item.done`,
|
||||
// used to backfill `response.completed.response.output` when upstream returns it
|
||||
// empty (which happens when `store: false` — see backfillResponsesCompletedOutput).
|
||||
@@ -1357,14 +1370,38 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
totalContentLength += content.length;
|
||||
}
|
||||
if (typeof delta?.content === "string") {
|
||||
if (collectPassthroughTextualToolCall(delta.content, passthroughToolCalls)) {
|
||||
passthroughHasToolCalls = true;
|
||||
textualToolCallConverted = true;
|
||||
delta.content = "";
|
||||
const incomingContent = delta.content;
|
||||
const bufferedCandidate =
|
||||
passthroughBufferedTextualToolCallContent + incomingContent;
|
||||
if (
|
||||
passthroughBufferedTextualToolCallContent ||
|
||||
containsTextualToolCallCandidate(incomingContent)
|
||||
) {
|
||||
const parsedCandidate = parseTextualToolCallCandidate(bufferedCandidate);
|
||||
if (parsedCandidate?.kind === "complete") {
|
||||
collectPassthroughTextualToolCall(bufferedCandidate, passthroughToolCalls);
|
||||
passthroughHasToolCalls = true;
|
||||
textualToolCallConverted = true;
|
||||
passthroughBufferedTextualToolCallContent = "";
|
||||
delta.content = "";
|
||||
} else if (parsedCandidate?.kind === "partial") {
|
||||
passthroughBufferedTextualToolCallContent = appendBoundedText(
|
||||
passthroughBufferedTextualToolCallContent,
|
||||
incomingContent
|
||||
);
|
||||
textualToolCallConverted = true;
|
||||
delta.content = "";
|
||||
} else {
|
||||
passthroughAccumulatedContent = appendBoundedText(
|
||||
passthroughAccumulatedContent,
|
||||
passthroughBufferedTextualToolCallContent + incomingContent
|
||||
);
|
||||
passthroughBufferedTextualToolCallContent = "";
|
||||
}
|
||||
} else {
|
||||
passthroughAccumulatedContent = appendBoundedText(
|
||||
passthroughAccumulatedContent,
|
||||
delta.content
|
||||
incomingContent
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1693,6 +1730,19 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
|
||||
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
|
||||
let content = passthroughAccumulatedContent.trim() || "";
|
||||
const finalBufferedTextualToolCall =
|
||||
passthroughBufferedTextualToolCallContent.trim();
|
||||
if (finalBufferedTextualToolCall) {
|
||||
if (
|
||||
collectPassthroughTextualToolCall(
|
||||
finalBufferedTextualToolCall,
|
||||
passthroughToolCalls
|
||||
)
|
||||
) {
|
||||
passthroughHasToolCalls = true;
|
||||
}
|
||||
passthroughBufferedTextualToolCallContent = "";
|
||||
}
|
||||
if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) {
|
||||
passthroughHasToolCalls = true;
|
||||
content = "";
|
||||
|
||||
@@ -223,7 +223,48 @@ test("createSSEStream passthrough converts split textual tool-call content at co
|
||||
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), {
|
||||
command: 'sqlite3 ~/.omniroute/omniroute.db ".tables"',
|
||||
});
|
||||
assert.match(text, /\[Tool call: terminal\]/);
|
||||
assert.doesNotMatch(text, /\[Tool call: terminal\]/);
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough suppresses malformed textual tool-call content", async () => {
|
||||
let onCompletePayload = null;
|
||||
const malformedToolText = `(empty)[Tool call: terminal]\nArguments: {"command":"sqlite3 /opt/O\u200dmniRoute/data/o\u200dmniroute.`;
|
||||
|
||||
const text = await readTransformed(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_malformed_textual_tool",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "antigravity/gemini-3.5-flash-low",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: malformedToolText } }],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_malformed_textual_tool",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "antigravity/gemini-3.5-flash-low",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})}\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
provider: "antigravity",
|
||||
model: "antigravity/gemini-3.5-flash-low",
|
||||
body: { messages: [{ role: "user", content: "inspect db" }] },
|
||||
onComplete(payload) {
|
||||
onCompletePayload = payload;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const choice = onCompletePayload.responseBody.choices[0];
|
||||
assert.equal(choice.finish_reason, "stop");
|
||||
assert.equal(choice.message.content, null);
|
||||
assert.equal(choice.message.tool_calls, undefined);
|
||||
assert.doesNotMatch(text, /\[Tool call: terminal\]/);
|
||||
assert.doesNotMatch(JSON.stringify(onCompletePayload.responseBody), /\[Tool call: terminal\]/);
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough flushes a buffered final line without a trailing newline", async () => {
|
||||
|
||||
Reference in New Issue
Block a user