From dfeea532be1205636ba47f913aa155a3395213ad Mon Sep 17 00:00:00 2001
From: RC-ia <221199316+RC-ia@users.noreply.github.com>
Date: Sun, 12 Jul 2026 22:48:29 -0300
Subject: [PATCH 1/3] feat: vendor native DeepSeek Web XML tool-call normalizer
(closes parseToolCall gap)
DeepSeek Web emits native function calls as XML:
...
The original parseToolCall() expected a JSON body and dropped these as plain text.
Adds toolcall_normalizer.js as a FAST-PATH inside parseToolCall() that parses the
native shape (plus strict/fenced JSON and legacy TOOL_CALL:) into clean OpenAI
tool_calls. Zero new npm deps.
- toolcall_normalizer.js: deterministic multi-shape parser (companion of
RC-ia/deepseek-toolcall-normalizer)
- server.js: require normalizer + pre-pass in parseToolCall(); export parseToolCall
- tests/unit.test.js: +3 tests (native XML, plain text null, multi-param)
- README:adds a Tool-call normalization patch section (credits ForgetMeAI upstream
+ companion normalizer)
All 12 unit tests pass (9 original + 3 new). Credits to ForgetMeAI (t.me/forgetmeai).
---
README.md | 22 +++++
server.js | 24 ++++++
tests/unit.test.js | 34 ++++++++
toolcall_normalizer.js | 189 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 269 insertions(+)
create mode 100644 toolcall_normalizer.js
diff --git a/README.md b/README.md
index 00c5ee9..9a64087 100644
--- a/README.md
+++ b/README.md
@@ -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
+
+ [{"id":"1","content":"...","status":"in_progress"}]
+
+```
+
+— 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`.
+
+---
+
## Навигация
- [Что это даёт](#-что-это-даёт)
diff --git a/server.js b/server.js
index 442feef..accd99d 100755
--- a/server.js
+++ b/server.js
@@ -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
+// 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 {
@@ -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
+ // ....
+ // 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) {
@@ -1616,4 +1638,6 @@ module.exports = {
rebuildFragmentText,
applyResponsePatchOperations,
},
+ parseToolCall,
+ toolcallNormalizer: normalizeToolCall ? require('./toolcall_normalizer.js') : null,
};
diff --git a/tests/unit.test.js b/tests/unit.test.js
index a02bc23..5697b09 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-'));
@@ -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 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);
+});
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: