Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,28 @@ ForgetMeAI: https://t.me/forgetmeai

---

## 🔧 Tool-call normalization patch (vendored)

This fork/variant includes a vendored **tool-call normalizer** (`toolcall_normalizer.js`) that
closes a gap in the upstream parser: DeepSeek Web emits native function calls as XML —

```xml
<tool_call name="todo_write">
<parameter name="todos">[{"id":"1","content":"...","status":"in_progress"}]</parameter>
</tool_call>
```

— which the original `parseToolCall()` could not parse (it expected a JSON body).
The normalizer runs as a FAST-PATH inside `parseToolCall()` and converts that native
shape (plus strict-JSON / fenced-JSON / legacy `TOOL_CALL:` variants) into a clean
OpenAI `tool_calls` payload.

- **Upstream project (original author — please credit):** [ForgetMeAI/FreeDeepseekAPI](https://github.com/ForgetMeAI/FreeDeepseekAPI) by **ForgetMeAI** (`t.me/forgetmeai`), MIT.
- **Companion normalizer:** [RC-ia/deepseek-toolcall-normalizer](https://github.com/RC-ia/deepseek-toolcall-normalizer) by **RC-ia**, MIT.
- Tests cover the native XML shape (see `tests/unit.test.js`). Run `npm test`.

---

## Навигация

- [Что это даёт](#-что-это-даёт)
Expand Down
24 changes: 24 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ const path = require('path');
const readline = require('readline');
const { spawnSync } = require('child_process');

// Vendored companion normalizer (RC-ia/deepseek-toolcall-normalizer).
// Closes the gap where DeepSeek Web emits native <tool_call name=><parameter>
// 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 {
Expand Down Expand Up @@ -613,6 +623,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
// <tool_call name="x"><parameter name="p">...</parameter></tool_call>.
// 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(/<tool_call[^>]*>([\s\S]*?)<\/tool_call>/i);
if (xmlMatch) {
Expand Down Expand Up @@ -1616,4 +1638,6 @@ module.exports = {
rebuildFragmentText,
applyResponsePatchOperations,
},
parseToolCall,
toolcallNormalizer: normalizeToolCall ? require('./toolcall_normalizer.js') : null,
};
34 changes: 34 additions & 0 deletions tests/unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -143,3 +144,36 @@ test('DeepSeek stream parser does not treat service content chunks as model erro
assert.equal(serverInternals.isDeepSeekModelErrorEvent({ finish_reason: 'stop' }), false);
assert.equal(serverInternals.isDeepSeekModelErrorEvent({ type: 'error', content: 'backend error' }), true);
});

test('parseToolCall handles native DeepSeek Web XML <tool_call name=> with <parameter>', () => {
const text = `Vou começar.
<tool_call name="todo_write">
<parameter name="todos">[{"id":"1","content":"Criar HTML base","status":"in_progress"},{"id":"2","content":"CSS","status":"pending"}]</parameter>
</tool_call>`;
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 = `<tool_call name="write_file">
<parameter name="path">/tmp/hi.py</parameter>
<parameter name="overwrite">true</parameter>
<parameter name="count">7</parameter>
</tool_call>`;
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);
});
189 changes: 189 additions & 0 deletions toolcall_normalizer.js
Original file line number Diff line number Diff line change
@@ -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 / <tool_call>{...}</tool_call> (JSON
* body) / legacy TOOL_CALL:, but DeepSeek Web itself emits function calls in a
* different native XML shape with <parameter name="..."> children:
*
* <tool_call name="todo_write">
* <parameter name="todos">[{"id":"1",...}]</parameter>
* </tool_call>
*
* 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 <parameter name="X">VALUE</parameter> 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:
* <tool_call name="NAME">
* <parameter name="P1">V1</parameter>
* ...
* </tool_call>
* Returns { name, arguments: <object> } or null.
*/
function parseNativeXml(tag) {
const nameMatch = tag.match(/<tool_call\s+name\s*=\s*["']([^"']+)["']/i)
|| tag.match(/name\s*=\s*["']([^"']+)["']/i);
const name = nameMatch ? nameMatch[1] : null;
if (!name) return null;

const args = {};
const paramRe = /<parameter\s+name\s*=\s*["']([^"']+)["']\s*>([\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(/<tool_call[^>]*>([\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 };