mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
feat: Implement streaming incremental tool call deltas with a new tool sieve and standalone parser.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const {
|
||||
extractToolNames,
|
||||
createToolSieveState,
|
||||
processToolSieveChunk,
|
||||
flushToolSieve,
|
||||
parseToolCalls,
|
||||
parseStandaloneToolCalls,
|
||||
formatOpenAIStreamToolCalls,
|
||||
} = require('./helpers/stream-tool-sieve');
|
||||
|
||||
@@ -90,16 +92,49 @@ module.exports = async function handler(req, res) {
|
||||
return;
|
||||
}
|
||||
const releaseLease = createLeaseReleaser(req, leaseID);
|
||||
const upstreamController = new AbortController();
|
||||
let clientClosed = false;
|
||||
let reader = null;
|
||||
const markClientClosed = () => {
|
||||
if (clientClosed) {
|
||||
return;
|
||||
}
|
||||
clientClosed = true;
|
||||
upstreamController.abort();
|
||||
if (reader && typeof reader.cancel === 'function') {
|
||||
Promise.resolve(reader.cancel()).catch(() => {});
|
||||
}
|
||||
};
|
||||
const onReqAborted = () => markClientClosed();
|
||||
const onResClose = () => {
|
||||
if (!res.writableEnded) {
|
||||
markClientClosed();
|
||||
}
|
||||
};
|
||||
req.on('aborted', onReqAborted);
|
||||
res.on('close', onResClose);
|
||||
try {
|
||||
const completionRes = await fetch(DEEPSEEK_COMPLETION_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...BASE_HEADERS,
|
||||
authorization: `Bearer ${deepseekToken}`,
|
||||
'x-ds-pow-response': powHeader,
|
||||
},
|
||||
body: JSON.stringify(completionPayload),
|
||||
});
|
||||
let completionRes;
|
||||
try {
|
||||
completionRes = await fetch(DEEPSEEK_COMPLETION_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...BASE_HEADERS,
|
||||
authorization: `Bearer ${deepseekToken}`,
|
||||
'x-ds-pow-response': powHeader,
|
||||
},
|
||||
body: JSON.stringify(completionPayload),
|
||||
signal: upstreamController.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (clientClosed || isAbortError(err)) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (clientClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!completionRes.ok || !completionRes.body) {
|
||||
const detail = await safeReadText(completionRes);
|
||||
@@ -124,12 +159,16 @@ module.exports = async function handler(req, res) {
|
||||
const toolSieveEnabled = toolNames.length > 0;
|
||||
const toolSieveState = createToolSieveState();
|
||||
let toolCallsEmitted = false;
|
||||
const streamToolCallIDs = new Map();
|
||||
const decoder = new TextDecoder();
|
||||
const reader = completionRes.body.getReader();
|
||||
reader = completionRes.body.getReader();
|
||||
let buffered = '';
|
||||
let ended = false;
|
||||
|
||||
const sendFrame = (obj) => {
|
||||
if (clientClosed || res.writableEnded || res.destroyed) {
|
||||
return;
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||||
if (typeof res.flush === 'function') {
|
||||
res.flush();
|
||||
@@ -156,7 +195,11 @@ module.exports = async function handler(req, res) {
|
||||
return;
|
||||
}
|
||||
ended = true;
|
||||
const detected = parseToolCalls(outputText, toolNames);
|
||||
if (clientClosed || res.writableEnded || res.destroyed) {
|
||||
await releaseLease();
|
||||
return;
|
||||
}
|
||||
const detected = parseStandaloneToolCalls(outputText, toolNames);
|
||||
if (detected.length > 0 && !toolCallsEmitted) {
|
||||
toolCallsEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected) });
|
||||
@@ -179,14 +222,22 @@ module.exports = async function handler(req, res) {
|
||||
choices: [{ delta: {}, index: 0, finish_reason: reason }],
|
||||
usage: buildUsage(finalPrompt, thinkingText, outputText),
|
||||
});
|
||||
res.write('data: [DONE]\n\n');
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.write('data: [DONE]\n\n');
|
||||
}
|
||||
await releaseLease();
|
||||
res.end();
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (clientClosed) {
|
||||
await finish('stop');
|
||||
return;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
@@ -245,6 +296,11 @@ module.exports = async function handler(req, res) {
|
||||
}
|
||||
const events = processToolSieveChunk(toolSieveState, p.text, toolNames);
|
||||
for (const evt of events) {
|
||||
if (evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0) {
|
||||
toolCallsEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatIncrementalToolCallDeltas(evt.deltas, streamToolCallIDs) });
|
||||
continue;
|
||||
}
|
||||
if (evt.type === 'tool_calls') {
|
||||
toolCallsEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls) });
|
||||
@@ -259,10 +315,16 @@ module.exports = async function handler(req, res) {
|
||||
}
|
||||
}
|
||||
await finish('stop');
|
||||
} catch (_err) {
|
||||
} catch (err) {
|
||||
if (clientClosed || isAbortError(err)) {
|
||||
await finish('stop');
|
||||
return;
|
||||
}
|
||||
await finish('stop');
|
||||
}
|
||||
} finally {
|
||||
req.removeListener('aborted', onReqAborted);
|
||||
res.removeListener('close', onResClose);
|
||||
await releaseLease();
|
||||
}
|
||||
};
|
||||
@@ -656,6 +718,55 @@ function buildUsage(prompt, thinking, output) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatIncrementalToolCallDeltas(deltas, idStore) {
|
||||
if (!Array.isArray(deltas) || deltas.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const d of deltas) {
|
||||
if (!d || typeof d !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const index = Number.isInteger(d.index) ? d.index : 0;
|
||||
const id = ensureStreamToolCallID(idStore, index);
|
||||
const item = {
|
||||
index,
|
||||
id,
|
||||
type: 'function',
|
||||
};
|
||||
const fn = {};
|
||||
if (asString(d.name)) {
|
||||
fn.name = asString(d.name);
|
||||
}
|
||||
if (typeof d.arguments === 'string' && d.arguments !== '') {
|
||||
fn.arguments = d.arguments;
|
||||
}
|
||||
if (Object.keys(fn).length > 0) {
|
||||
item.function = fn;
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ensureStreamToolCallID(idStore, index) {
|
||||
const key = Number.isInteger(index) ? index : 0;
|
||||
const existing = idStore.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next = `call_${newCallID()}`;
|
||||
idStore.set(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function newCallID() {
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID().replace(/-/g, '');
|
||||
}
|
||||
return `${Date.now()}${Math.floor(Math.random() * 1e9)}`;
|
||||
}
|
||||
|
||||
function estimateTokens(text) {
|
||||
const t = asString(text);
|
||||
if (!t) {
|
||||
@@ -667,44 +778,92 @@ function estimateTokens(text) {
|
||||
|
||||
async function proxyToGo(req, res, rawBody) {
|
||||
const url = buildInternalGoURL(req);
|
||||
|
||||
const upstream = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: buildInternalGoHeaders(req, { withContentType: true }),
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
res.statusCode = upstream.status;
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === 'content-length') {
|
||||
const controller = new AbortController();
|
||||
let clientClosed = false;
|
||||
const markClientClosed = () => {
|
||||
if (clientClosed) {
|
||||
return;
|
||||
}
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
clientClosed = true;
|
||||
controller.abort();
|
||||
};
|
||||
const onReqAborted = () => markClientClosed();
|
||||
const onResClose = () => {
|
||||
if (!res.writableEnded) {
|
||||
markClientClosed();
|
||||
}
|
||||
};
|
||||
req.on('aborted', onReqAborted);
|
||||
res.on('close', onResClose);
|
||||
|
||||
if (!upstream.body || typeof upstream.body.getReader !== 'function') {
|
||||
const bytes = Buffer.from(await upstream.arrayBuffer());
|
||||
res.end(bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = upstream.body.getReader();
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: buildInternalGoHeaders(req, { withContentType: true }),
|
||||
body: rawBody,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (clientClosed || isAbortError(err)) {
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value && value.length > 0) {
|
||||
res.write(Buffer.from(value));
|
||||
if (typeof res.flush === 'function') {
|
||||
res.flush();
|
||||
throw err;
|
||||
}
|
||||
if (clientClosed) {
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = upstream.status;
|
||||
upstream.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === 'content-length') {
|
||||
return;
|
||||
}
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (!upstream.body || typeof upstream.body.getReader !== 'function') {
|
||||
const bytes = Buffer.from(await upstream.arrayBuffer());
|
||||
res.end(bytes);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = upstream.body.getReader();
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (clientClosed) {
|
||||
break;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (value && value.length > 0) {
|
||||
res.write(Buffer.from(value));
|
||||
if (typeof res.flush === 'function') {
|
||||
res.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAbortError(err) && !res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
res.end();
|
||||
} catch (_err) {
|
||||
} finally {
|
||||
req.removeListener('aborted', onReqAborted);
|
||||
res.removeListener('close', onResClose);
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
@@ -762,6 +921,13 @@ function asString(v) {
|
||||
return String(v).trim();
|
||||
}
|
||||
|
||||
function isAbortError(err) {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return err.name === 'AbortError' || err.code === 'ABORT_ERR';
|
||||
}
|
||||
|
||||
module.exports.__test = {
|
||||
parseChunkForContent,
|
||||
extractContentRecursive,
|
||||
|
||||
@@ -49,12 +49,13 @@ test('parseChunkForContent + sieve does not leak suspicious prefix in split tool
|
||||
events.push(...flushToolSieve(state, ['read_file']));
|
||||
|
||||
const hasToolCalls = events.some((evt) => evt.type === 'tool_calls' && evt.calls && evt.calls.length > 0);
|
||||
const hasToolDeltas = events.some((evt) => evt.type === 'tool_call_deltas' && evt.deltas && evt.deltas.length > 0);
|
||||
const leakedText = events
|
||||
.filter((evt) => evt.type === 'text' && evt.text)
|
||||
.map((evt) => evt.text)
|
||||
.join('');
|
||||
|
||||
assert.equal(hasToolCalls, true);
|
||||
assert.equal(hasToolCalls || hasToolDeltas, true);
|
||||
assert.equal(leakedText.includes('{'), false);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const TOOL_CALL_PATTERN = /\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}/s;
|
||||
const TOOL_SIEVE_CAPTURE_LIMIT = 8 * 1024;
|
||||
|
||||
function extractToolNames(tools) {
|
||||
if (!Array.isArray(tools) || tools.length === 0) {
|
||||
@@ -26,9 +27,25 @@ function createToolSieveState() {
|
||||
pending: '',
|
||||
capture: '',
|
||||
capturing: false,
|
||||
hasMeaningfulText: false,
|
||||
toolNameSent: false,
|
||||
toolName: '',
|
||||
toolArgsStart: -1,
|
||||
toolArgsSent: -1,
|
||||
toolArgsString: false,
|
||||
toolArgsDone: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resetIncrementalToolState(state) {
|
||||
state.toolNameSent = false;
|
||||
state.toolName = '';
|
||||
state.toolArgsStart = -1;
|
||||
state.toolArgsSent = -1;
|
||||
state.toolArgsString = false;
|
||||
state.toolArgsDone = false;
|
||||
}
|
||||
|
||||
function processToolSieveChunk(state, chunk, toolNames) {
|
||||
if (!state) {
|
||||
return [];
|
||||
@@ -44,13 +61,31 @@ function processToolSieveChunk(state, chunk, toolNames) {
|
||||
state.capture += state.pending;
|
||||
state.pending = '';
|
||||
}
|
||||
const consumed = consumeToolCapture(state.capture, toolNames);
|
||||
const deltas = buildIncrementalToolDeltas(state);
|
||||
if (deltas.length > 0) {
|
||||
events.push({ type: 'tool_call_deltas', deltas });
|
||||
}
|
||||
const consumed = consumeToolCapture(state, toolNames);
|
||||
if (!consumed.ready) {
|
||||
if (state.capture.length > TOOL_SIEVE_CAPTURE_LIMIT) {
|
||||
if (hasMeaningfulText(state.capture)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: state.capture });
|
||||
state.capture = '';
|
||||
state.capturing = false;
|
||||
resetIncrementalToolState(state);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
state.capture = '';
|
||||
state.capturing = false;
|
||||
resetIncrementalToolState(state);
|
||||
if (consumed.prefix) {
|
||||
if (hasMeaningfulText(consumed.prefix)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: consumed.prefix });
|
||||
}
|
||||
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
|
||||
@@ -70,11 +105,15 @@ function processToolSieveChunk(state, chunk, toolNames) {
|
||||
if (start >= 0) {
|
||||
const prefix = state.pending.slice(0, start);
|
||||
if (prefix) {
|
||||
if (hasMeaningfulText(prefix)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: prefix });
|
||||
}
|
||||
state.capture = state.pending.slice(start);
|
||||
state.pending = '';
|
||||
state.capturing = true;
|
||||
resetIncrementalToolState(state);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -83,6 +122,9 @@ function processToolSieveChunk(state, chunk, toolNames) {
|
||||
break;
|
||||
}
|
||||
state.pending = hold;
|
||||
if (hasMeaningfulText(safe)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: safe });
|
||||
}
|
||||
return events;
|
||||
@@ -94,24 +136,37 @@ function flushToolSieve(state, toolNames) {
|
||||
}
|
||||
const events = processToolSieveChunk(state, '', toolNames);
|
||||
if (state.capturing) {
|
||||
const consumed = consumeToolCapture(state.capture, toolNames);
|
||||
const consumed = consumeToolCapture(state, toolNames);
|
||||
if (consumed.ready) {
|
||||
if (consumed.prefix) {
|
||||
if (hasMeaningfulText(consumed.prefix)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: consumed.prefix });
|
||||
}
|
||||
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
|
||||
events.push({ type: 'tool_calls', calls: consumed.calls });
|
||||
}
|
||||
if (consumed.suffix) {
|
||||
if (hasMeaningfulText(consumed.suffix)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: consumed.suffix });
|
||||
}
|
||||
} else if (state.capture) {
|
||||
// Incomplete captured tool JSON at stream end: suppress raw capture.
|
||||
if (hasMeaningfulText(state.capture)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: state.capture });
|
||||
}
|
||||
state.capture = '';
|
||||
state.capturing = false;
|
||||
resetIncrementalToolState(state);
|
||||
}
|
||||
if (state.pending) {
|
||||
if (hasMeaningfulText(state.pending)) {
|
||||
state.hasMeaningfulText = true;
|
||||
}
|
||||
events.push({ type: 'text', text: state.pending });
|
||||
state.pending = '';
|
||||
}
|
||||
@@ -159,7 +214,8 @@ function findToolSegmentStart(s) {
|
||||
return start >= 0 ? start : keyIdx;
|
||||
}
|
||||
|
||||
function consumeToolCapture(captured, toolNames) {
|
||||
function consumeToolCapture(state, toolNames) {
|
||||
const captured = state.capture;
|
||||
if (!captured) {
|
||||
return { ready: false, prefix: '', calls: [], suffix: '' };
|
||||
}
|
||||
@@ -176,25 +232,361 @@ function consumeToolCapture(captured, toolNames) {
|
||||
if (!obj.ok) {
|
||||
return { ready: false, prefix: '', calls: [], suffix: '' };
|
||||
}
|
||||
const parsed = parseToolCalls(captured.slice(start, obj.end), toolNames);
|
||||
if (parsed.length === 0) {
|
||||
// `tool_calls` key exists but strict JSON parse failed.
|
||||
// Drop the captured object body to avoid leaking raw tool JSON.
|
||||
const prefixPart = captured.slice(0, start);
|
||||
const suffixPart = captured.slice(obj.end);
|
||||
if (!state.toolNameSent && (state.hasMeaningfulText || hasMeaningfulText(prefixPart) || hasMeaningfulText(suffixPart))) {
|
||||
return {
|
||||
ready: true,
|
||||
prefix: captured.slice(0, start),
|
||||
prefix: captured,
|
||||
calls: [],
|
||||
suffix: captured.slice(obj.end),
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
const parsed = parseStandaloneToolCalls(captured.slice(start, obj.end), toolNames);
|
||||
if (parsed.length === 0) {
|
||||
if (state.toolNameSent) {
|
||||
return {
|
||||
ready: true,
|
||||
prefix: prefixPart,
|
||||
calls: [],
|
||||
suffix: suffixPart,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
prefix: captured,
|
||||
calls: [],
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
if (state.toolNameSent) {
|
||||
if (parsed.length > 1) {
|
||||
return {
|
||||
ready: true,
|
||||
prefix: prefixPart,
|
||||
calls: parsed.slice(1),
|
||||
suffix: suffixPart,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
prefix: prefixPart,
|
||||
calls: [],
|
||||
suffix: suffixPart,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: true,
|
||||
prefix: captured.slice(0, start),
|
||||
prefix: prefixPart,
|
||||
calls: parsed,
|
||||
suffix: captured.slice(obj.end),
|
||||
suffix: suffixPart,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIncrementalToolDeltas(state) {
|
||||
const captured = state.capture || '';
|
||||
if (!captured || state.hasMeaningfulText) {
|
||||
return [];
|
||||
}
|
||||
const lower = captured.toLowerCase();
|
||||
const keyIdx = lower.indexOf('tool_calls');
|
||||
if (keyIdx < 0) {
|
||||
return [];
|
||||
}
|
||||
const start = captured.slice(0, keyIdx).lastIndexOf('{');
|
||||
if (start < 0 || hasMeaningfulText(captured.slice(0, start))) {
|
||||
return [];
|
||||
}
|
||||
const callStart = findFirstToolCallObjectStart(captured, keyIdx);
|
||||
if (callStart < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deltas = [];
|
||||
if (!state.toolName) {
|
||||
const name = extractToolCallName(captured, callStart);
|
||||
if (!name) {
|
||||
return [];
|
||||
}
|
||||
state.toolName = name;
|
||||
}
|
||||
|
||||
if (state.toolArgsStart < 0) {
|
||||
const args = findToolCallArgsStart(captured, callStart);
|
||||
if (args) {
|
||||
state.toolArgsString = Boolean(args.stringMode);
|
||||
state.toolArgsStart = state.toolArgsString ? args.start + 1 : args.start;
|
||||
state.toolArgsSent = state.toolArgsStart;
|
||||
}
|
||||
}
|
||||
if (!state.toolNameSent) {
|
||||
if (state.toolArgsStart < 0) {
|
||||
return [];
|
||||
}
|
||||
state.toolNameSent = true;
|
||||
deltas.push({ index: 0, name: state.toolName });
|
||||
}
|
||||
if (state.toolArgsStart < 0 || state.toolArgsDone) {
|
||||
return deltas;
|
||||
}
|
||||
const progress = scanToolCallArgsProgress(captured, state.toolArgsStart, state.toolArgsString);
|
||||
if (!progress) {
|
||||
return deltas;
|
||||
}
|
||||
if (progress.end > state.toolArgsSent) {
|
||||
deltas.push({
|
||||
index: 0,
|
||||
arguments: captured.slice(state.toolArgsSent, progress.end),
|
||||
});
|
||||
state.toolArgsSent = progress.end;
|
||||
}
|
||||
if (progress.complete) {
|
||||
state.toolArgsDone = true;
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
function findFirstToolCallObjectStart(text, keyIdx) {
|
||||
const arrStart = findToolCallsArrayStart(text, keyIdx);
|
||||
if (arrStart < 0) {
|
||||
return -1;
|
||||
}
|
||||
const i = skipSpaces(text, arrStart + 1);
|
||||
if (i >= text.length || text[i] !== '{') {
|
||||
return -1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function findToolCallsArrayStart(text, keyIdx) {
|
||||
let i = keyIdx + 'tool_calls'.length;
|
||||
while (i < text.length && text[i] !== ':') {
|
||||
i += 1;
|
||||
}
|
||||
if (i >= text.length) {
|
||||
return -1;
|
||||
}
|
||||
i = skipSpaces(text, i + 1);
|
||||
if (i >= text.length || text[i] !== '[') {
|
||||
return -1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function extractToolCallName(text, callStart) {
|
||||
let valueStart = findObjectFieldValueStart(text, callStart, ['name']);
|
||||
if (valueStart < 0 || text[valueStart] !== '"') {
|
||||
const fnStart = findFunctionObjectStart(text, callStart);
|
||||
if (fnStart < 0) {
|
||||
return '';
|
||||
}
|
||||
valueStart = findObjectFieldValueStart(text, fnStart, ['name']);
|
||||
if (valueStart < 0 || text[valueStart] !== '"') {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
const parsed = parseJSONStringLiteral(text, valueStart);
|
||||
if (!parsed) {
|
||||
return '';
|
||||
}
|
||||
return parsed.value;
|
||||
}
|
||||
|
||||
function findToolCallArgsStart(text, callStart) {
|
||||
const keys = ['input', 'arguments', 'args', 'parameters', 'params'];
|
||||
let valueStart = findObjectFieldValueStart(text, callStart, keys);
|
||||
if (valueStart < 0) {
|
||||
const fnStart = findFunctionObjectStart(text, callStart);
|
||||
if (fnStart < 0) {
|
||||
return null;
|
||||
}
|
||||
valueStart = findObjectFieldValueStart(text, fnStart, keys);
|
||||
if (valueStart < 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (valueStart >= text.length) {
|
||||
return null;
|
||||
}
|
||||
const ch = text[valueStart];
|
||||
if (ch === '{' || ch === '[') {
|
||||
return { start: valueStart, stringMode: false };
|
||||
}
|
||||
if (ch === '"') {
|
||||
return { start: valueStart, stringMode: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanToolCallArgsProgress(text, start, stringMode) {
|
||||
if (start < 0 || start > text.length) {
|
||||
return null;
|
||||
}
|
||||
if (stringMode) {
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
return { end: i, complete: true };
|
||||
}
|
||||
}
|
||||
return { end: text.length, complete: false };
|
||||
}
|
||||
if (start >= text.length || (text[start] !== '{' && text[start] !== '[')) {
|
||||
return null;
|
||||
}
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '}' || ch === ']') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return { end: i + 1, complete: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { end: text.length, complete: false };
|
||||
}
|
||||
|
||||
function findObjectFieldValueStart(text, objStart, keys) {
|
||||
if (!text || objStart < 0 || objStart >= text.length || text[objStart] !== '{') {
|
||||
return -1;
|
||||
}
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
for (let i = objStart; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
if (depth === 1) {
|
||||
const parsed = parseJSONStringLiteral(text, i);
|
||||
if (!parsed) {
|
||||
return -1;
|
||||
}
|
||||
let j = skipSpaces(text, parsed.end);
|
||||
if (j >= text.length || text[j] !== ':') {
|
||||
i = parsed.end - 1;
|
||||
continue;
|
||||
}
|
||||
j = skipSpaces(text, j + 1);
|
||||
if (j >= text.length) {
|
||||
return -1;
|
||||
}
|
||||
if (keys.includes(parsed.value)) {
|
||||
return j;
|
||||
}
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findFunctionObjectStart(text, callStart) {
|
||||
const valueStart = findObjectFieldValueStart(text, callStart, ['function']);
|
||||
if (valueStart < 0 || valueStart >= text.length || text[valueStart] !== '{') {
|
||||
return -1;
|
||||
}
|
||||
return valueStart;
|
||||
}
|
||||
|
||||
function parseJSONStringLiteral(text, start) {
|
||||
if (!text || start < 0 || start >= text.length || text[start] !== '"') {
|
||||
return null;
|
||||
}
|
||||
let out = '';
|
||||
let escaped = false;
|
||||
for (let i = start + 1; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
out += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
return { value: out, end: i + 1 };
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function skipSpaces(text, i) {
|
||||
let idx = i;
|
||||
while (idx < text.length) {
|
||||
const ch = text[idx];
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function extractJSONObjectFrom(text, start) {
|
||||
if (!text || start < 0 || start >= text.length || text[start] !== '{') {
|
||||
return { ok: false, end: 0 };
|
||||
@@ -251,26 +643,35 @@ function parseToolCalls(text, toolNames) {
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const allowed = new Set((toolNames || []).filter(Boolean));
|
||||
const out = [];
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
if (allowed.size > 0 && !allowed.has(tc.name)) {
|
||||
continue;
|
||||
}
|
||||
out.push({ name: tc.name, input: tc.input || {} });
|
||||
return filterToolCalls(parsed, toolNames);
|
||||
}
|
||||
|
||||
function parseStandaloneToolCalls(text, toolNames) {
|
||||
const trimmed = toStringSafe(text);
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
if (out.length === 0 && parsed.length > 0) {
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
out.push({ name: tc.name, input: tc.input || {} });
|
||||
const candidates = [trimmed];
|
||||
if (trimmed.startsWith('```') && trimmed.endsWith('```')) {
|
||||
const m = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
if (m && m[1]) {
|
||||
candidates.push(toStringSafe(m[1]));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
for (const candidate of candidates) {
|
||||
const c = toStringSafe(candidate);
|
||||
if (!c) {
|
||||
continue;
|
||||
}
|
||||
if (!c.startsWith('{') && !c.startsWith('[')) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseToolCallsPayload(c);
|
||||
if (parsed.length > 0) {
|
||||
return filterToolCalls(parsed, toolNames);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildToolCallCandidates(text) {
|
||||
@@ -432,6 +833,33 @@ function parseToolCallInput(v) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function filterToolCalls(parsed, toolNames) {
|
||||
const allowed = new Set((toolNames || []).filter(Boolean));
|
||||
const out = [];
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
if (allowed.size > 0 && !allowed.has(tc.name)) {
|
||||
continue;
|
||||
}
|
||||
out.push({ name: tc.name, input: tc.input || {} });
|
||||
}
|
||||
if (out.length === 0 && parsed.length > 0) {
|
||||
for (const tc of parsed) {
|
||||
if (!tc || !tc.name) {
|
||||
continue;
|
||||
}
|
||||
out.push({ name: tc.name, input: tc.input || {} });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasMeaningfulText(text) {
|
||||
return toStringSafe(text) !== '';
|
||||
}
|
||||
|
||||
function formatOpenAIStreamToolCalls(calls) {
|
||||
if (!Array.isArray(calls) || calls.length === 0) {
|
||||
return [];
|
||||
@@ -473,5 +901,6 @@ module.exports = {
|
||||
processToolSieveChunk,
|
||||
flushToolSieve,
|
||||
parseToolCalls,
|
||||
parseStandaloneToolCalls,
|
||||
formatOpenAIStreamToolCalls,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
processToolSieveChunk,
|
||||
flushToolSieve,
|
||||
parseToolCalls,
|
||||
parseStandaloneToolCalls,
|
||||
} = require('./stream-tool-sieve');
|
||||
|
||||
function runSieve(chunks, toolNames) {
|
||||
@@ -73,6 +74,15 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
|
||||
assert.deepEqual(calls[0].input, { path: 'README.md' });
|
||||
});
|
||||
|
||||
test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => {
|
||||
const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。';
|
||||
const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}';
|
||||
const mixedCalls = parseStandaloneToolCalls(mixed, ['read_file']);
|
||||
const standaloneCalls = parseStandaloneToolCalls(standalone, ['read_file']);
|
||||
assert.equal(mixedCalls.length, 0);
|
||||
assert.equal(standaloneCalls.length, 1);
|
||||
});
|
||||
|
||||
test('sieve emits tool_calls and does not leak suspicious prefix on late key convergence', () => {
|
||||
const events = runSieve(
|
||||
[
|
||||
@@ -84,13 +94,14 @@ test('sieve emits tool_calls and does not leak suspicious prefix on late key con
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0);
|
||||
assert.equal(hasToolCall, true);
|
||||
const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0);
|
||||
assert.equal(hasToolCall || hasToolDelta, true);
|
||||
assert.equal(leakedText.includes('{'), false);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
assert.equal(leakedText.includes('后置正文C。'), true);
|
||||
});
|
||||
|
||||
test('sieve drops invalid tool json body while preserving surrounding text', () => {
|
||||
test('sieve keeps embedded invalid tool-like json as normal text to avoid stream stalls', () => {
|
||||
const events = runSieve(
|
||||
[
|
||||
'前置正文D。',
|
||||
@@ -104,18 +115,18 @@ test('sieve drops invalid tool json body while preserving surrounding text', ()
|
||||
assert.equal(hasToolCall, false);
|
||||
assert.equal(leakedText.includes('前置正文D。'), true);
|
||||
assert.equal(leakedText.includes('后置正文E。'), true);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
|
||||
});
|
||||
|
||||
test('sieve suppresses incomplete captured tool json on stream finalize', () => {
|
||||
test('sieve flushes incomplete captured tool json as text on stream finalize', () => {
|
||||
const events = runSieve(
|
||||
['前置正文F。', '{"tool_calls":[{"name":"read_file"'],
|
||||
['read_file'],
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
assert.equal(leakedText.includes('前置正文F。'), true);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
assert.equal(leakedText.includes('{'), false);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
|
||||
assert.equal(leakedText.includes('{'), true);
|
||||
});
|
||||
|
||||
test('sieve keeps plain text intact in tool mode when no tool call appears', () => {
|
||||
@@ -128,3 +139,29 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
|
||||
assert.equal(hasToolCall, false);
|
||||
assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
|
||||
});
|
||||
|
||||
test('sieve emits incremental tool_call_deltas for split arguments payload', () => {
|
||||
const state = createToolSieveState();
|
||||
const first = processToolSieveChunk(
|
||||
state,
|
||||
'{"tool_calls":[{"name":"read_file","input":{"path":"READ',
|
||||
['read_file'],
|
||||
);
|
||||
const second = processToolSieveChunk(
|
||||
state,
|
||||
'ME.MD","mode":"head"}}]}',
|
||||
['read_file'],
|
||||
);
|
||||
const tail = flushToolSieve(state, ['read_file']);
|
||||
const events = [...first, ...second, ...tail];
|
||||
const deltaEvents = events.filter((evt) => evt.type === 'tool_call_deltas');
|
||||
assert.equal(deltaEvents.length > 0, true);
|
||||
const merged = deltaEvents.flatMap((evt) => evt.deltas || []);
|
||||
const hasName = merged.some((d) => d.name === 'read_file');
|
||||
const argsJoined = merged
|
||||
.map((d) => d.arguments || '')
|
||||
.join('');
|
||||
assert.equal(hasName, true);
|
||||
assert.equal(argsJoined.includes('"path":"README.MD"'), true);
|
||||
assert.equal(argsJoined.includes('"mode":"head"'), true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user