mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
fix(stream): normalize split textual tool calls
This commit is contained in:
@@ -202,18 +202,31 @@ function stripZeroWidth(value: unknown): unknown {
|
||||
function parseTextualToolCallFromContent(text: unknown): { name: string; args: unknown } | null {
|
||||
if (typeof text !== "string") return null;
|
||||
const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
const match = normalized.match(
|
||||
/^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/
|
||||
);
|
||||
if (!match) return null;
|
||||
const name = match[1]?.trim();
|
||||
const rawArgs = match[2]?.trim();
|
||||
const toolCallIndex = normalized.lastIndexOf("[Tool call:");
|
||||
const candidate = toolCallIndex >= 0 ? normalized.slice(toolCallIndex) : normalized;
|
||||
const headerMatch = candidate.match(/^\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*/);
|
||||
if (!headerMatch) return null;
|
||||
const name = headerMatch[1]?.trim();
|
||||
const rawArgs = candidate.slice(headerMatch[0].length).trim();
|
||||
if (!name || !rawArgs) return null;
|
||||
try {
|
||||
return { name, args: stripZeroWidth(JSON.parse(rawArgs)) };
|
||||
} catch {
|
||||
return null;
|
||||
const decoders = [
|
||||
(value: string) => value,
|
||||
(value: string) => {
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
const decoded = JSON.parse(value);
|
||||
return typeof decoded === "string" ? decoded : value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
];
|
||||
for (const decode of decoders) {
|
||||
try {
|
||||
const decoded = decode(rawArgs);
|
||||
const parsed = JSON.parse(decoded);
|
||||
return { name, args: stripZeroWidth(parsed) };
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectPassthroughTextualToolCall(
|
||||
@@ -1679,7 +1692,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const u = usage as Record<string, unknown> | null;
|
||||
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
|
||||
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
|
||||
const content = passthroughAccumulatedContent.trim() || "";
|
||||
let content = passthroughAccumulatedContent.trim() || "";
|
||||
if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) {
|
||||
passthroughHasToolCalls = true;
|
||||
content = "";
|
||||
}
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: content || null,
|
||||
@@ -1892,27 +1909,42 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const u = state?.usage as Record<string, unknown> | null | undefined;
|
||||
const prompt = Number(u?.prompt_tokens ?? u?.input_tokens ?? 0);
|
||||
const completion = Number(u?.completion_tokens ?? u?.output_tokens ?? 0);
|
||||
const content = (state?.accumulatedContent ?? "").trim() || "";
|
||||
let content = (state?.accumulatedContent ?? "").trim() || "";
|
||||
const normalizedToolCalls: ToolCall[] = state?.toolCalls?.size
|
||||
? [...state.toolCalls.values()]
|
||||
.map(
|
||||
(tc: Record<string, unknown>): ToolCall => ({
|
||||
id: (tc.id as string) ?? null,
|
||||
index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0,
|
||||
type: (tc.type as string) ?? "function",
|
||||
function: (tc.function as ToolCall["function"]) ?? {
|
||||
name: (tc.name as string) ?? "",
|
||||
arguments: "",
|
||||
},
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.index - b.index)
|
||||
: [];
|
||||
const textualToolCall = parseTextualToolCallFromContent(content);
|
||||
if (textualToolCall) {
|
||||
normalizedToolCalls.push({
|
||||
id: `call_${Date.now()}_${normalizedToolCalls.length}`,
|
||||
index: normalizedToolCalls.length,
|
||||
type: "function",
|
||||
function: {
|
||||
name: textualToolCall.name,
|
||||
arguments: JSON.stringify(textualToolCall.args || {}),
|
||||
},
|
||||
});
|
||||
content = "";
|
||||
}
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: content || null,
|
||||
};
|
||||
const hasToolCalls = state?.toolCalls?.size > 0;
|
||||
const hasToolCalls = normalizedToolCalls.length > 0;
|
||||
if (hasToolCalls) {
|
||||
// Normalize shape — translators may store different structures
|
||||
message.tool_calls = [...state.toolCalls.values()]
|
||||
.map(
|
||||
(tc: Record<string, unknown>): ToolCall => ({
|
||||
id: (tc.id as string) ?? null,
|
||||
index: (tc.index as number) ?? (tc.blockIndex as number) ?? 0,
|
||||
type: (tc.type as string) ?? "function",
|
||||
function: (tc.function as ToolCall["function"]) ?? {
|
||||
name: (tc.name as string) ?? "",
|
||||
arguments: "",
|
||||
},
|
||||
})
|
||||
)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
message.tool_calls = normalizedToolCalls;
|
||||
}
|
||||
const responseBody = {
|
||||
choices: [
|
||||
|
||||
@@ -173,6 +173,59 @@ test("createSSEStream passthrough converts textual tool-call content into struct
|
||||
assert.doesNotMatch(text, /\[Tool call: terminal\]/);
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough converts split textual tool-call content at completion", async () => {
|
||||
let onCompletePayload = null;
|
||||
const splitToolArgs = JSON.stringify({
|
||||
command: 'sqlite3 ~/.o\u200dmniroute/o\u200dmniroute.db ".tables"',
|
||||
});
|
||||
const chunks = ["[Tool call: terminal]\n", `Arguments: ${splitToolArgs}`];
|
||||
|
||||
const text = await readTransformed(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_split_textual_tool",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "antigravity/gemini-3.5-flash-low",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] } }],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_split_textual_tool",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "antigravity/gemini-3.5-flash-low",
|
||||
choices: [{ index: 0, delta: { content: chunks[1] } }],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_split_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, "tool_calls");
|
||||
assert.equal(choice.message.content, null);
|
||||
assert.equal(choice.message.tool_calls[0].function.name, "terminal");
|
||||
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), {
|
||||
command: 'sqlite3 ~/.omniroute/omniroute.db ".tables"',
|
||||
});
|
||||
assert.match(text, /\[Tool call: terminal\]/);
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough flushes a buffered final line without a trailing newline", async () => {
|
||||
const text = await readTransformed(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user