diff --git a/server.js b/server.js index 31b8740..3b470c3 100755 --- a/server.js +++ b/server.js @@ -25,6 +25,16 @@ function dsFetch(url, options = {}, timeoutMs = DS_FETCH_TIMEOUT_MS) { return fetch(url, { ...options, signal: options.signal || AbortSignal.timeout(timeoutMs) }); } +// Vendored companion normalizer (RC-ia/deepseek-toolcall-normalizer). +// Closes the gap where DeepSeek Web emits native +// XML that the original parseToolCall() could not parse. See toolcall_normalizer.js. +let normalizeToolCall = null; +try { + ({ normalizeToolCall } = require('./toolcall_normalizer.js')); +} catch (e) { + console.log('[DS-API] toolcall_normalizer.js not found — native DeepSeek XML tool calls will be skipped.'); +} + const SERVER_HOST = os.hostname(); // Dynamic hostname detection const SERVER_PUBLIC_IP = (() => { try { @@ -628,6 +638,18 @@ function parseJsonToolCandidate(raw, label = 'json') { function parseToolCall(text) { if (!text || typeof text !== 'string') return null; + // FAST-PATH (vendored normalizer): DeepSeek Web emits native tool calls as + // .... + // The legacy branches below treat that XML body as JSON and fail. If the + // normalizer recognizes a real tool call, prefer it and short-circuit. + if (normalizeToolCall) { + const norm = normalizeToolCall(text); + if (norm && norm.name) { + console.log(`[parseToolCall] SUCCESS normalized (native/companion): ${norm.name}`); + return { name: norm.name, arguments: typeof norm.arguments === 'string' ? norm.arguments : JSON.stringify(norm.arguments) }; + } + } + // XML-ish wrappers used by some agent prompts. const xmlMatch = text.match(/]*>([\s\S]*?)<\/tool_call>/i); if (xmlMatch) { @@ -1700,4 +1722,6 @@ module.exports = { sweepIdleSessions, sessions, }, + parseToolCall, + toolcallNormalizer: normalizeToolCall ? require('./toolcall_normalizer.js') : null, }; diff --git a/tests/unit.test.js b/tests/unit.test.js index a313fb9..7651b5d 100644 --- a/tests/unit.test.js +++ b/tests/unit.test.js @@ -7,6 +7,7 @@ const { spawnSync } = require('node:child_process'); const ROOT = path.resolve(__dirname, '..'); const serverInternals = require('../server.js').__test; +const { parseToolCall } = require('../server.js'); function tmpdir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'fdsapi-test-')); @@ -144,6 +145,39 @@ test('DeepSeek stream parser does not treat service content chunks as model erro assert.equal(serverInternals.isDeepSeekModelErrorEvent({ type: 'error', content: 'backend error' }), true); }); +test('parseToolCall handles native DeepSeek Web XML with ', () => { + const text = `Vou começar. + + + [{"id":"1","content":"Criar HTML base","status":"in_progress"},{"id":"2","content":"CSS","status":"pending"}] +`; + const tc = parseToolCall(text); + assert.ok(tc, 'should detect tool call'); + assert.equal(tc.name, 'todo_write'); + const args = JSON.parse(tc.arguments); + assert.ok(Array.isArray(args.todos), 'todos should be an array'); + assert.equal(args.todos.length, 2); + assert.equal(args.todos[0].status, 'in_progress'); +}); + +test('parseToolCall still returns null for plain text', () => { + assert.equal(parseToolCall('Just talking, no tool call here.'), null); +}); + +test('parseToolCall multi-parameter mixed types', () => { + const text = ` + /tmp/hi.py + true + 7 + `; + const tc = parseToolCall(text); + assert.equal(tc.name, 'write_file'); + const args = JSON.parse(tc.arguments); + assert.equal(args.path, '/tmp/hi.py'); + assert.equal(args.overwrite, true); + assert.equal(args.count, 7); +}); + test('sweepIdleSessions evicts only idle entries', () => { serverInternals.sessions.set('stale-x', { lastActivityAt: 1 }); serverInternals.sessions.set('fresh-x', { lastActivityAt: Date.now() }); diff --git a/toolcall_normalizer.js b/toolcall_normalizer.js new file mode 100644 index 0000000..839b453 --- /dev/null +++ b/toolcall_normalizer.js @@ -0,0 +1,189 @@ +'use strict'; + +/** + * toolcall_normalizer.js + * ---------------------- + * Deterministic parser that normalizes DeepSeek Web's NATIVE tool-call format + * into clean OpenAI-compatible { name, arguments } tool calls. + * + * Vendored into FreeDeepseekAPI to fix a gap: the upstream parseToolCall() + * only handled strict-JSON / fenced-JSON / {...} (JSON + * body) / legacy TOOL_CALL:, but DeepSeek Web itself emits function calls in a + * different native XML shape with children: + * + * + * [{"id":"1",...}] + * + * + * The upstream parser tried JSON.parse on the XML body, failed, and fell back + * to plain text. This module adds a FAST-PATH that parses that exact native + * shape (plus the other variants) into a real { name, arguments } object. + * + * ORIGINAL upstream proxy (credits / license): + * ForgetMeAI/FreeDeepseekAPI - https://github.com/ForgetMeAI/FreeDeepseekAPI + * Author: ForgetMeAI (t.me/forgetmeai) - MIT licensed. + * + * This normalizer is a companion drop-in patch, not a fork replacement. + */ + +/** + * Coerce an unknown value into { name, arguments } where arguments is a + * stringified JSON object. Handles common LLM wrapper shapes. + */ +function coerceToolCallObject(obj) { + if (!obj || typeof obj !== 'object') return null; + const candidate = obj.tool_call || obj.tool || obj.function_call || obj; + if (!candidate || typeof candidate !== 'object') return null; + const fn = candidate.function && typeof candidate.function === 'object' ? candidate.function : candidate; + const name = fn.name || candidate.name || obj.name; + if (!name || typeof name !== 'string') return null; + let args = fn.arguments ?? candidate.arguments ?? candidate.input ?? obj.arguments ?? obj.input ?? {}; + if (typeof args === 'string') { + try { args = JSON.parse(args); } catch (e) { args = { raw: args }; } + } + if (!args || typeof args !== 'object' || Array.isArray(args)) args = { value: args }; + return { name, arguments: JSON.stringify(args) }; +} + +/** + * Parse a single VALUE value. + * VALUE may be JSON (object/array/number/bool) or a plain string. + */ +function parseParameterValue(raw) { + const text = raw.trim(); + if (text === '') return ''; + try { return JSON.parse(text); } catch (e) { /* not JSON */ } + if (text === 'true') return true; + if (text === 'false') return false; + if (text === 'null') return null; + if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text); + return text; +} + +/** + * Parse the native DeepSeek Web XML tool-call: + * + * V1 + * ... + * + * Returns { name, arguments: } or null. + */ +function parseNativeXml(tag) { + const nameMatch = tag.match(/([\s\S]*?)<\/parameter>/gi; + let m; + let found = false; + while ((m = paramRe.exec(tag)) !== null) { + found = true; + args[m[1]] = parseParameterValue(m[2]); + } + + // Single generic param whose value is already an object/array -> promote. + const keys = Object.keys(args); + if (keys.length === 1) { + const only = keys[0]; + const generic = /^(input|arguments|argument|value|params|parameters|body|content)$/i.test(only); + if (generic && args[only] && typeof args[only] === 'object') { + return { name, arguments: args[only] }; + } + } + + return { name, arguments: found ? args : {} }; +} + +/** Extract balanced JSON starting at index i (handles nested braces/strings). */ +function extractBalancedJsonAt(text, startIndex) { + let braceDepth = 0; + let inString = false; + let escape = false; + for (let i = startIndex; i < text.length; i++) { + const ch = text[i]; + if (escape) { escape = false; continue; } + if (ch === '\\' && inString) { escape = true; continue; } + if (ch === '"') { inString = !inString; continue; } + if (!inString) { + if (ch === '{' || ch === '[') braceDepth++; + else if (ch === '}' || ch === ']') { + braceDepth--; + if (braceDepth === 0) { + const slice = text.substring(startIndex, i + 1); + try { return JSON.parse(slice); } catch (e) { return null; } + } + } + } + } + return null; +} + +function parseJsonToolCandidate(raw) { + if (!raw) return null; + try { + const parsed = JSON.parse(raw.trim()); + return coerceToolCallObject(parsed); + } catch (e) { return null; } +} + +/** + * Main entry: normalize raw model text into a tool call. + * @param {string} text + * @returns {{name:string, arguments:object}|null} + */ +function normalizeToolCall(text) { + if (!text || typeof text !== 'string') return null; + + // 1) Native DeepSeek Web XML + const xmlMatch = text.match(/]*>([\s\S]*?)<\/tool_call>/i); + if (xmlMatch) { + const native = parseNativeXml(xmlMatch[0]); + if (native && native.name) return native; + } + + // 2) Fenced JSON + const fenceRe = /```(?:json)?\s*([\s\S]*?)```/gi; + let fence; + while ((fence = fenceRe.exec(text)) !== null) { + const tc = parseJsonToolCandidate(fence[1].trim()); + if (tc) return { name: tc.name, arguments: safeParse(tc.arguments) }; + } + + // 3) Legacy TOOL_CALL: name + const legacy = text.match(/TOOL_CALL:\s*([\w-]+)\s*/i); + if (legacy) { + const name = legacy[1]; + const after = text.substring(legacy.index + legacy[0].length); + const braceIdx = after.indexOf('{'); + if (braceIdx !== -1) { + const obj = extractBalancedJsonAt(after, braceIdx); + if (obj) return { name, arguments: obj }; + } + } + + // 4) First balanced JSON object + for (let i = 0; i < text.length; i++) { + if (text[i] !== '{' && text[i] !== '[') continue; + const obj = extractBalancedJsonAt(text, i); + if (!obj || typeof obj !== 'object') continue; + if (obj.tool_call || obj.tool || obj.function_call || obj.name) { + const tc = coerceToolCallObject(obj); + if (tc) return { name: tc.name, arguments: safeParse(tc.arguments) }; + } + } + + return null; +} + +function safeParse(s) { + if (typeof s !== 'string') return s; + try { return JSON.parse(s); } catch (e) { return s; } +} + +function isToolCall(text) { + return normalizeToolCall(text) !== null; +} + +module.exports = { normalizeToolCall, isToolCall, parseNativeXml, parseParameterValue };