mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
fix(web-tools): parse bare JSON tool calls (#3157)
Parse bare JSON tool calls for deepseek-web (#2820) + fuzzy tool-name matching. Integrated into release/v3.8.10.
This commit is contained in:
@@ -1031,7 +1031,8 @@ export class DeepSeekWebExecutor extends BaseExecutor {
|
||||
await cleanupFn();
|
||||
const { content: cleanedContent, toolCalls } = parseToolCallsFromText(
|
||||
content,
|
||||
`call-${Date.now()}`
|
||||
`call-${Date.now()}`,
|
||||
requestedTools
|
||||
);
|
||||
return buildToolAwareResult({
|
||||
stream: stream !== false,
|
||||
|
||||
@@ -23,6 +23,306 @@ interface OpenAIToolDef {
|
||||
|
||||
const TOOL_BLOCK_RE = /<tool>\s*([\s\S]*?)\s*<\/tool>/g;
|
||||
|
||||
interface ToolParseCandidate {
|
||||
raw: string;
|
||||
start: number;
|
||||
end: number;
|
||||
requireRequestedTool: boolean;
|
||||
}
|
||||
|
||||
interface RequestedToolName {
|
||||
original: string;
|
||||
normalized: string;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getRequestedToolNames(tools: unknown): RequestedToolName[] {
|
||||
if (!Array.isArray(tools)) return [];
|
||||
const names: RequestedToolName[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
const record = toRecord(tool);
|
||||
const fn = toRecord(record?.function);
|
||||
const name = typeof fn?.name === "string" ? fn.name.trim() : "";
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
names.push({ original: name, normalized: normalizeToolName(name) });
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function normalizeToolName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (!a) return b.length;
|
||||
if (!b) return a.length;
|
||||
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
||||
let current = Array<number>(b.length + 1);
|
||||
for (let i = 1; i <= a.length; i += 1) {
|
||||
current[0] = i;
|
||||
for (let j = 1; j <= b.length; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
|
||||
}
|
||||
const temp = previous;
|
||||
previous = current;
|
||||
current = temp;
|
||||
}
|
||||
return previous[b.length];
|
||||
}
|
||||
|
||||
function scoreToolName(emitted: string, requested: RequestedToolName): number {
|
||||
if (emitted === requested.original) return 1;
|
||||
const normalized = normalizeToolName(emitted);
|
||||
if (!normalized || !requested.normalized) return 0;
|
||||
if (normalized === requested.normalized) return 0.98;
|
||||
|
||||
const shorter = Math.min(normalized.length, requested.normalized.length);
|
||||
const longer = Math.max(normalized.length, requested.normalized.length);
|
||||
if (shorter >= 4) {
|
||||
if (normalized.includes(requested.normalized) || requested.normalized.includes(normalized)) {
|
||||
return 0.86 - (longer - shorter) / Math.max(longer, 1) / 4;
|
||||
}
|
||||
}
|
||||
|
||||
const distance = levenshteinDistance(normalized, requested.normalized);
|
||||
const similarity = 1 - distance / Math.max(longer, 1);
|
||||
return similarity >= 0.72 ? similarity : 0;
|
||||
}
|
||||
|
||||
function resolveRequestedToolName(emitted: string, requestedTools: RequestedToolName[]): string | null {
|
||||
if (requestedTools.length === 0) return emitted;
|
||||
|
||||
let best: { name: string; score: number } | null = null;
|
||||
let secondBest = 0;
|
||||
for (const requested of requestedTools) {
|
||||
const score = scoreToolName(emitted, requested);
|
||||
if (!best || score > best.score) {
|
||||
secondBest = best?.score ?? 0;
|
||||
best = { name: requested.original, score };
|
||||
} else if (score > secondBest) {
|
||||
secondBest = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || best.score < 0.72) return null;
|
||||
// Avoid correcting to an arbitrary tool when the fuzzy match is ambiguous.
|
||||
if (best.score < 0.98 && best.score - secondBest < 0.08) return null;
|
||||
return best.name;
|
||||
}
|
||||
|
||||
function stripCodeFence(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^```(?:json|javascript|js|python)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function convertSingleQuotedStrings(value: string): string {
|
||||
let result = "";
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
|
||||
for (const ch of value) {
|
||||
if (escaped) {
|
||||
result += ch === '"' && inSingle ? '\\"' : ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
result += ch;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
if (inSingle) {
|
||||
result += '\\"';
|
||||
} else {
|
||||
inDouble = !inDouble;
|
||||
result += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
result += '"';
|
||||
continue;
|
||||
}
|
||||
|
||||
result += ch;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function replacePythonLiterals(value: string): string {
|
||||
let result = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let token = "";
|
||||
|
||||
const flushToken = () => {
|
||||
if (token === "True") result += "true";
|
||||
else if (token === "False") result += "false";
|
||||
else if (token === "None") result += "null";
|
||||
else result += token;
|
||||
token = "";
|
||||
};
|
||||
|
||||
for (const ch of value) {
|
||||
if (escaped) {
|
||||
if (token) flushToken();
|
||||
result += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
if (token) flushToken();
|
||||
result += ch;
|
||||
escaped = inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
if (token) flushToken();
|
||||
inString = !inString;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString && /[A-Za-z]/.test(ch)) {
|
||||
token += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token) flushToken();
|
||||
result += ch;
|
||||
}
|
||||
|
||||
if (token) flushToken();
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeLooseJson(value: string): string {
|
||||
return replacePythonLiterals(convertSingleQuotedStrings(value))
|
||||
.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)(\s*:)/g, '$1"$2"$3')
|
||||
.replace(/,\s*([}\]])/g, "$1");
|
||||
}
|
||||
|
||||
function parseLooseJsonObject(raw: string): Record<string, unknown> | null {
|
||||
const trimmed = stripCodeFence(raw);
|
||||
for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) {
|
||||
try {
|
||||
return toRecord(JSON.parse(candidate));
|
||||
} catch {
|
||||
// Try the next, more permissive form.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findBareJsonCandidates(text: string): ToolParseCandidate[] {
|
||||
const candidates: ToolParseCandidate[] = [];
|
||||
let start = -1;
|
||||
let depth = 0;
|
||||
let quote: '"' | "'" | "" = "";
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
|
||||
if (depth === 0 && ch !== "{") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === quote) {
|
||||
quote = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "{") {
|
||||
if (depth === 0) start = i;
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "}" && depth > 0) {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start >= 0) {
|
||||
const raw = text.slice(start, i + 1);
|
||||
if (/[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && /[{,]\s*["']?arguments["']?\s*:/i.test(raw)) {
|
||||
candidates.push({ raw, start, end: i + 1, requireRequestedTool: true });
|
||||
}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
|
||||
return a.start < b.end && b.start < a.end;
|
||||
}
|
||||
|
||||
function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string {
|
||||
let content = text;
|
||||
const sorted = [...ranges].sort((a, b) => b.start - a.start);
|
||||
for (const range of sorted) {
|
||||
const lineStart = content.lastIndexOf("\n", range.start - 1) + 1;
|
||||
const nextLineBreak = content.indexOf("\n", range.end);
|
||||
const lineEnd = nextLineBreak === -1 ? content.length : nextLineBreak;
|
||||
const beforeOnLine = content.slice(lineStart, range.start);
|
||||
const afterOnLine = content.slice(range.end, lineEnd);
|
||||
const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === "";
|
||||
const start = removeWholeLine ? lineStart : range.start;
|
||||
const end = removeWholeLine && nextLineBreak !== -1 ? nextLineBreak + 1 : removeWholeLine ? lineEnd : range.end;
|
||||
content = `${content.slice(0, start)}${content.slice(end)}`;
|
||||
}
|
||||
return content.replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
function toArgumentsString(value: unknown): string {
|
||||
if (value === undefined) return "{}";
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseLooseJsonObject(value);
|
||||
return parsed ? JSON.stringify(parsed) : value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an OpenAI `tools` array into a system-prompt block that instructs the
|
||||
* web UI model how to invoke a tool (emit a `<tool>{...}</tool>` block). Returns an
|
||||
@@ -59,6 +359,8 @@ export function serializeToolsToPrompt(tools: unknown): string {
|
||||
|
||||
/**
|
||||
* Parse `<tool>{...}</tool>` blocks out of upstream text into OpenAI `tool_calls`.
|
||||
* When a requested `tools[]` set is provided, also accepts bare JSON tool-call
|
||||
* objects emitted by web models that ignored the `<tool>` wrapper contract.
|
||||
* Returns the content with the blocks stripped, plus the tool calls (or null when
|
||||
* there are none). `arguments` is always a JSON *string*, matching the OpenAI API.
|
||||
*
|
||||
@@ -67,43 +369,69 @@ export function serializeToolsToPrompt(tools: unknown): string {
|
||||
*/
|
||||
export function parseToolCallsFromText(
|
||||
text: string,
|
||||
idSeed = "call"
|
||||
idSeed = "call",
|
||||
requestedTools?: unknown
|
||||
): { content: string; toolCalls: OpenAIToolCall[] | null } {
|
||||
if (typeof text !== "string" || !text.includes("<tool>")) {
|
||||
const requestedToolNames = getRequestedToolNames(requestedTools);
|
||||
const canParseBareJson = requestedToolNames.length > 0;
|
||||
if (typeof text !== "string" || (!text.includes("<tool>") && !canParseBareJson)) {
|
||||
return { content: text ?? "", toolCalls: null };
|
||||
}
|
||||
|
||||
const toolCalls: OpenAIToolCall[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const candidates: ToolParseCandidate[] = [];
|
||||
const toolBlockRanges: Array<{ start: number; end: number }> = [];
|
||||
|
||||
let blockMatch: RegExpExecArray | null;
|
||||
TOOL_BLOCK_RE.lastIndex = 0;
|
||||
while ((match = TOOL_BLOCK_RE.exec(text)) !== null) {
|
||||
const raw = match[1].trim();
|
||||
let parsed: { name?: unknown; arguments?: unknown } | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const name = parsed && typeof parsed.name === "string" ? parsed.name : null;
|
||||
if (!name) continue;
|
||||
let args = "{}";
|
||||
if (parsed && parsed.arguments !== undefined) {
|
||||
args =
|
||||
typeof parsed.arguments === "string"
|
||||
? parsed.arguments
|
||||
: JSON.stringify(parsed.arguments);
|
||||
while ((blockMatch = TOOL_BLOCK_RE.exec(text)) !== null) {
|
||||
const range = { start: blockMatch.index, end: TOOL_BLOCK_RE.lastIndex };
|
||||
toolBlockRanges.push(range);
|
||||
candidates.push({
|
||||
raw: blockMatch[1].trim(),
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
requireRequestedTool: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (canParseBareJson) {
|
||||
for (const candidate of findBareJsonCandidates(text)) {
|
||||
if (!toolBlockRanges.some((range) => rangesOverlap(range, candidate))) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.start - b.start);
|
||||
|
||||
const toolCalls: OpenAIToolCall[] = [];
|
||||
const acceptedRanges: Array<{ start: number; end: number }> = [];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseLooseJsonObject(candidate.raw);
|
||||
const emittedName =
|
||||
parsed && typeof parsed.name === "string"
|
||||
? parsed.name
|
||||
: parsed && typeof parsed.command === "string"
|
||||
? parsed.command
|
||||
: null;
|
||||
if (!emittedName) continue;
|
||||
const name =
|
||||
resolveRequestedToolName(emittedName, requestedToolNames) ||
|
||||
(candidate.requireRequestedTool ? null : emittedName);
|
||||
if (!name || (candidate.requireRequestedTool && requestedToolNames.length === 0)) continue;
|
||||
const args = toArgumentsString(parsed?.arguments);
|
||||
toolCalls.push({
|
||||
id: `${idSeed}_${toolCalls.length}`,
|
||||
type: "function",
|
||||
function: { name, arguments: args },
|
||||
});
|
||||
acceptedRanges.push({ start: candidate.start, end: candidate.end });
|
||||
}
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
return { content: text, toolCalls: null };
|
||||
}
|
||||
|
||||
const content = text.replace(TOOL_BLOCK_RE, "").replace(/\n{3,}/g, "\n\n").trim();
|
||||
const content = stripRanges(text, acceptedRanges);
|
||||
return { content, toolCalls };
|
||||
}
|
||||
|
||||
@@ -142,6 +142,30 @@ test("execute (non-stream) parses <tool> reply into OpenAI tool_calls", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("execute (non-stream) parses bare JSON reply into OpenAI tool_calls", async () => {
|
||||
const mock = installMock('{"name":"getWeather","arguments":{"city":"Paris"}}');
|
||||
try {
|
||||
const executor = new DeepSeekWebExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "default",
|
||||
body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
|
||||
stream: false,
|
||||
credentials: { apiKey: "tkn-tools-bare-json" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
assert.ok(result.response.ok);
|
||||
const json = JSON.parse(await result.response.text());
|
||||
const choice = json.choices[0];
|
||||
assert.equal(choice.finish_reason, "tool_calls");
|
||||
assert.equal(choice.message.tool_calls.length, 1);
|
||||
assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
|
||||
assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { city: "Paris" });
|
||||
assert.equal(choice.message.content, null, "bare JSON tool call is stripped from content");
|
||||
} finally {
|
||||
mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("execute (stream) emits tool_calls + finish_reason tool_calls in the SSE", async () => {
|
||||
const mock = installMock(TOOL_REPLY);
|
||||
try {
|
||||
|
||||
@@ -58,6 +58,70 @@ test("parseToolCallsFromText returns null toolCalls when there is no tool block"
|
||||
assert.equal(content, "just a normal answer");
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText detects bare JSON tool calls when requested tools are present", () => {
|
||||
const text = '{"name":"get_weather","arguments":{"city":"Paris"}}';
|
||||
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
|
||||
|
||||
assert.equal(content, "");
|
||||
assert.equal(toolCalls?.length, 1);
|
||||
assert.equal(toolCalls?.[0].function.name, "get_weather");
|
||||
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), { city: "Paris" });
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText does not parse bare JSON without requested tools", () => {
|
||||
const text = '{"name":"get_weather","arguments":{"city":"Paris"}}';
|
||||
const { content, toolCalls } = parseToolCallsFromText(text);
|
||||
|
||||
assert.equal(toolCalls, null);
|
||||
assert.equal(content, text);
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText tolerates Python-dict-ish bare tool JSON", () => {
|
||||
const text = "{'command': 'get_weather', 'arguments': {'city': 'Paris', 'units': 'metric', 'fresh': True}}";
|
||||
const { toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
|
||||
|
||||
assert.equal(toolCalls?.length, 1);
|
||||
assert.equal(toolCalls?.[0].function.name, "get_weather");
|
||||
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), {
|
||||
city: "Paris",
|
||||
units: "metric",
|
||||
fresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText escapes double quotes inside single-quoted strings", () => {
|
||||
const text = "{'command': 'get_weather', 'arguments': {'city': 'Paris \"City\"'}}";
|
||||
const { toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
|
||||
|
||||
assert.equal(toolCalls?.length, 1);
|
||||
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), { city: 'Paris "City"' });
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText fuzzy-matches emitted tool names to requested tools", () => {
|
||||
const text = '{"name":"getWeather","arguments":{"city":"Paris"}}';
|
||||
const { toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
|
||||
|
||||
assert.equal(toolCalls?.length, 1);
|
||||
assert.equal(toolCalls?.[0].function.name, "get_weather");
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText strips bare JSON while preserving surrounding text", () => {
|
||||
const text = 'I will check now.\n{"name":"get_weather","arguments":"{\\"city\\":\\"Paris\\"}"}\nDone.';
|
||||
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
|
||||
|
||||
assert.equal(toolCalls?.length, 1);
|
||||
assert.deepEqual(JSON.parse(toolCalls?.[0].function.arguments || "{}"), { city: "Paris" });
|
||||
assert.equal(content, "I will check now.\nDone.");
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText ignores bare JSON whose tool is not requested", () => {
|
||||
const text = '{"name":"delete_everything","arguments":{"force":true}}';
|
||||
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
|
||||
|
||||
assert.equal(toolCalls, null);
|
||||
assert.equal(content, text);
|
||||
});
|
||||
|
||||
test("parseToolCallsFromText parses multiple tool calls", () => {
|
||||
const text =
|
||||
'<tool>{"name": "a", "arguments": {"x": 1}}</tool>\n<tool>{"name": "b", "arguments": {}}</tool>';
|
||||
|
||||
Reference in New Issue
Block a user