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: } 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 }; From b0ccd8ce5222e91ff75cdbe264b079c714b09417 Mon Sep 17 00:00:00 2001 From: RC-ia <221199316+RC-ia@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:37:52 -0300 Subject: [PATCH 2/3] fix(normalizer): parse with extra attrs + inline-JSON body so Qwen TodoWrite gets todos array DeepSeek Web emits tool calls like [...] or inline-JSON bodies; the previous regex required '>' right after the name attr, so args came back empty and Qwen Code rejected 'todos must be an array'. - tolerate extra attrs (type=, etc.) on - fallback to inline-JSON body when no children - add WARN log dumping raw text if args are empty (diagnostics) --- server.js | 7 ++++++- tests/unit.test.js | 26 ++++++++++++++++++++++++++ toolcall_normalizer.js | 22 ++++++++++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index 3b470c3..d5a8352 100755 --- a/server.js +++ b/server.js @@ -645,7 +645,12 @@ function parseToolCall(text) { if (normalizeToolCall) { const norm = normalizeToolCall(text); if (norm && norm.name) { - console.log(`[parseToolCall] SUCCESS normalized (native/companion): ${norm.name}`); + const argObj = typeof norm.arguments === 'string' ? safeParse(norm.arguments) : norm.arguments; + if (!argObj || Object.keys(argObj).length === 0) { + console.log(`[parseToolCall] WARN normalized (native/companion): ${norm.name} args EMPTY — raw text was: ${text.substring(0, 600).replace(/\n/g, '\\n')}`); + } else { + console.log(`[parseToolCall] SUCCESS normalized (native/companion): ${norm.name}`); + } return { name: norm.name, arguments: typeof norm.arguments === 'string' ? norm.arguments : JSON.stringify(norm.arguments) }; } } diff --git a/tests/unit.test.js b/tests/unit.test.js index 7651b5d..2bddf6b 100644 --- a/tests/unit.test.js +++ b/tests/unit.test.js @@ -178,6 +178,32 @@ test('parseToolCall multi-parameter mixed types', () => { assert.equal(args.count, 7); }); +// Regression: DeepSeek Web emits with +// extra type attribute — the normalizer must still extract the array (Qwen +// Code's TodoWrite/TodoList requires `todos` to be an array, not empty). +test('parseToolCall tolerates extra type attr on and yields array', () => { + const text = ` + [{"id":"1","content":"Criar HTML base","status":"in_progress"},{"id":"2","content":"CSS","status":"pending"}] + `; + const tc = parseToolCall(text); + assert.equal(tc.name, 'todo_write'); + const args = JSON.parse(tc.arguments); + assert.ok(Array.isArray(args.todos), 'todos must be an array'); + assert.equal(args.todos.length, 2); + assert.equal(args.todos[0].status, 'in_progress'); +}); + +// Regression: tool-call body as inline JSON (no children) must +// also be parsed, not returned as empty args. +test('parseToolCall handles inline-JSON tool-call body', () => { + const text = `{"todos":[{"id":"1","content":"X","status":"in_progress"}]}`; + const tc = parseToolCall(text); + assert.equal(tc.name, 'todo_write'); + const args = JSON.parse(tc.arguments); + assert.ok(Array.isArray(args.todos), 'todos must be an array'); + assert.equal(args.todos.length, 1); +}); + 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 index 839b453..0ce7a99 100644 --- a/toolcall_normalizer.js +++ b/toolcall_normalizer.js @@ -75,7 +75,9 @@ function parseNativeXml(tag) { if (!name) return null; const args = {}; - const paramRe = /([\s\S]*?)<\/parameter>/gi; + // NOTE: tolerate extra attributes on (e.g. type="array"), and + // capture the value non-greedily up to the matching . + const paramRe = /]*>([\s\S]*?)<\/parameter>/gi; let m; let found = false; while ((m = paramRe.exec(tag)) !== null) { @@ -83,6 +85,22 @@ function parseNativeXml(tag) { args[m[1]] = parseParameterValue(m[2]); } + if (!found) { + // No children: the tool-call body may be inline JSON, e.g. + // {"todos":[...]} + const body = tag + .replace(/^]*>/i, '') + .replace(/<\/tool_call>\s*$/i, '') + .trim(); + if (body) { + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed === 'object') return { name, arguments: parsed }; + } catch (e) { /* not JSON; fall through below */ } + } + return { name, arguments: {} }; + } + // Single generic param whose value is already an object/array -> promote. const keys = Object.keys(args); if (keys.length === 1) { @@ -93,7 +111,7 @@ function parseNativeXml(tag) { } } - return { name, arguments: found ? args : {} }; + return { name, arguments: args }; } /** Extract balanced JSON starting at index i (handles nested braces/strings). */ From d2cf4b1bb01726cf7ca68e0eb9a88515762754ba Mon Sep 17 00:00:00 2001 From: RC-ia <221199316+RC-ia@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:52:05 -0300 Subject: [PATCH 3/3] feat(accounts): round-robin requests across accounts when >1 ready (reduce per-account ban risk) Previously each agent session stuck to a single account (sticky accountId), so all traffic from a conversation hit one account. Now, with >1 ready account, every request rotates to the next account (round-robin) and the web session is reset when the account changes (chat_session belongs to the account that created it). Conversation context is preserved because the full history is re-sent in the request payload. Single-account behavior is unchanged (sticky, no reset). Tests: round-robin alternation + single-account sticky regression. --- server.js | 39 ++++++++++++++++++++++++--------------- tests/unit.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/server.js b/server.js index d5a8352..4cb61d0 100755 --- a/server.js +++ b/server.js @@ -162,20 +162,6 @@ function accountStatus(account) { } function selectAccountForSession(session) { const now = Date.now(); - if (session.accountId) { - const sticky = accounts.find(a => a.id === session.accountId); - if (sticky && sticky.config.token && sticky.config.cookie && sticky.cooldownUntil <= now) return sticky; - if (sticky && sticky.cooldownUntil > now) { - // A DeepSeek chat_session belongs to the auth account that created it. - // If that account is rate-limited/expired, do not keep hammering it; - // reset the web session and let a healthy account take over. - session.id = null; - session.parentMessageId = null; - session.createdAt = null; - session.messageCount = 0; - } - session.accountId = null; - } const ready = accounts.filter(a => a.config.token && a.config.cookie && a.cooldownUntil <= now); if (ready.length === 0) { const waiting = accounts.filter(a => a.config.token && a.config.cookie).sort((a, b) => a.cooldownUntil - b.cooldownUntil)[0]; @@ -191,8 +177,28 @@ function selectAccountForSession(session) { noAuth.status = 503; noAuth.type = 'no_auth'; throw noAuth; } + + // SINGLE ACCOUNT: keep the existing sticky behavior (no need to rotate). + if (ready.length === 1) { + const account = ready[0]; + session.accountId = account.id; + return account; + } + + // MULTI-ACCOUNT: distribute every request across accounts (round-robin) so + // no single account absorbs all traffic (reduces per-account ban/rate-limit + // risk). A DeepSeek chat_session belongs to the account that created it, so + // when we move to a different account we reset the web session and let the + // new account create its own. Conversation context is preserved because the + // full message history is re-sent in the request payload. const account = ready[accountRoundRobin % ready.length]; accountRoundRobin++; + if (session.accountId && session.accountId !== account.id) { + session.id = null; + session.parentMessageId = null; + session.createdAt = null; + session.messageCount = 0; + } session.accountId = account.id; return account; } @@ -1717,6 +1723,7 @@ if (require.main === module) { } module.exports = { + parseToolCall, __test: { isAssistantOutputFragment, isReasoningFragment, @@ -1726,7 +1733,9 @@ module.exports = { createSession, sweepIdleSessions, sessions, + selectAccountForSession, + _setAccountsForTest: (arr) => { accounts.length = 0; for (const a of arr) accounts.push(a); }, + _resetRoundRobin: () => { accountRoundRobin = 0; }, }, - parseToolCall, toolcallNormalizer: normalizeToolCall ? require('./toolcall_normalizer.js') : null, }; diff --git a/tests/unit.test.js b/tests/unit.test.js index 2bddf6b..7c25a88 100644 --- a/tests/unit.test.js +++ b/tests/unit.test.js @@ -204,6 +204,45 @@ test('parseToolCall handles inline-JSON tool-call body', () => { assert.equal(args.todos.length, 1); }); +// Regression: with >1 ready account, every request must rotate to a DIFFERENT +// account (round-robin) to dilute per-account traffic and reduce ban risk, and +// reset the web session when the account changes. +test('selectAccountForSession round-robins across multiple ready accounts', () => { + const mk = (id) => ({ id, config: { token: 't', cookie: 'c' }, cooldownUntil: 0, failures: 0, lastUsedAt: 0 }); + serverInternals._setAccountsForTest([mk('account_1'), mk('account_2')]); + serverInternals._resetRoundRobin(); + const session = { id: 'sess-x', parentMessageId: 'p', createdAt: Date.now(), messageCount: 3 }; + + const seen = []; + let sessionResetWhenChanged = true; + for (let i = 0; i < 4; i++) { + const acct = serverInternals.selectAccountForSession(session); + seen.push(acct.id); + // account must differ from the previous request's account + if (i > 0 && seen[i] === seen[i - 1]) { + sessionResetWhenChanged = false; + } + } + // 4 requests over 2 accounts -> alternates: account_1, account_2, account_1, account_2 + assert.deepEqual(seen, ['account_1', 'account_2', 'account_1', 'account_2']); + assert.equal(sessionResetWhenChanged, true, 'each request must hit a different account'); + serverInternals._setAccountsForTest([]); +}); + +// Regression: with a single account, selection stays sticky (no rotation, no reset). +test('selectAccountForSession stays sticky with a single account', () => { + const mk = (id) => ({ id, config: { token: 't', cookie: 'c' }, cooldownUntil: 0, failures: 0, lastUsedAt: 0 }); + serverInternals._setAccountsForTest([mk('account_1')]); + serverInternals._resetRoundRobin(); + const session = { id: 'sess-y', parentMessageId: 'p', createdAt: Date.now(), messageCount: 3 }; + const a = serverInternals.selectAccountForSession(session); + const b = serverInternals.selectAccountForSession(session); + assert.equal(a.id, 'account_1'); + assert.equal(b.id, 'account_1'); + assert.equal(session.id, 'sess-y', 'session must not be reset with a single account'); + serverInternals._setAccountsForTest([]); +}); + test('sweepIdleSessions evicts only idle entries', () => { serverInternals.sessions.set('stale-x', { lastActivityAt: 1 }); serverInternals.sessions.set('fresh-x', { lastActivityAt: Date.now() });