@pedrofariasx/qwenproxy 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/models.ts +3 -1
- package/src/api/server.ts +4 -4
- package/src/cache/memory-cache.ts +6 -5
- package/src/core/account-manager.ts +1 -1
- package/src/core/accounts.ts +1 -1
- package/src/core/model-registry.ts +56 -9
- package/src/login.ts +2 -2
- package/src/routes/chat.ts +20 -49
- package/src/routes/upload.ts +1 -1
- package/src/services/playwright.ts +42 -121
- package/src/services/qwen.ts +30 -17
- package/src/tests/concurrency.test.ts +1 -1
- package/src/tests/concurrentChat.test.ts +1 -1
- package/src/tests/contextTruncation.test.ts +142 -0
- package/src/tests/delta.test.ts +80 -10
- package/src/tests/jsonFix.test.ts +127 -98
- package/src/tests/multimodal.test.ts +1 -1
- package/src/tests/parser.test.ts +104 -24
- package/src/tools/parser.ts +94 -23
- package/src/utils/context-truncation.ts +13 -11
- package/src/utils/json.ts +130 -10
- package/src/utils/types.ts +1 -1
package/src/tests/parser.test.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import { StreamingToolParser } from '../tools/parser.
|
|
3
|
+
import { StreamingToolParser } from '../tools/parser.js';
|
|
4
|
+
|
|
5
|
+
const TC_OPEN = '<tool_' + 'call>';
|
|
6
|
+
const TC_CLOSE = '</tool_' + 'call>';
|
|
4
7
|
|
|
5
8
|
test('StreamingToolParser: basic tool call', () => {
|
|
6
9
|
const parser = new StreamingToolParser();
|
|
7
|
-
|
|
8
|
-
const result = parser.feed('Hello! <tool_call>{"name": "t1", "arguments": {"a": 1}}</tool_call>');
|
|
10
|
+
const result = parser.feed(`Hello! ${TC_OPEN}{"name": "t1", "arguments": {"a": 1}}${TC_CLOSE}`);
|
|
9
11
|
assert.strictEqual(result.text, 'Hello! ');
|
|
10
12
|
assert.strictEqual(result.toolCalls.length, 1);
|
|
11
13
|
assert.strictEqual(result.toolCalls[0].name, 't1');
|
|
@@ -13,8 +15,7 @@ test('StreamingToolParser: basic tool call', () => {
|
|
|
13
15
|
|
|
14
16
|
test('StreamingToolParser: multiple tool calls', () => {
|
|
15
17
|
const parser = new StreamingToolParser();
|
|
16
|
-
|
|
17
|
-
const result = parser.feed('<tool_call>{"name": "t2", "arguments": {}}</tool_call><tool_call>{"name": "t3", "arguments": {}}</tool_call>');
|
|
18
|
+
const result = parser.feed(`${TC_OPEN}{"name": "t2", "arguments": {}}${TC_CLOSE}${TC_OPEN}{"name": "t3", "arguments": {}}${TC_CLOSE}`);
|
|
18
19
|
assert.strictEqual(result.text, '');
|
|
19
20
|
assert.strictEqual(result.toolCalls.length, 2);
|
|
20
21
|
assert.strictEqual(result.toolCalls[0].name, 't2');
|
|
@@ -23,11 +24,9 @@ test('StreamingToolParser: multiple tool calls', () => {
|
|
|
23
24
|
|
|
24
25
|
test('StreamingToolParser: fragmented tool call', () => {
|
|
25
26
|
const parser = new StreamingToolParser();
|
|
26
|
-
|
|
27
27
|
assert.strictEqual(parser.feed('Text <tool_').text, 'Text ');
|
|
28
28
|
assert.strictEqual(parser.feed('call>{"name": ').text, '');
|
|
29
|
-
const final = parser.feed(
|
|
30
|
-
|
|
29
|
+
const final = parser.feed(`"frag", "arguments": {}}${TC_CLOSE} trailing`);
|
|
31
30
|
assert.strictEqual(final.toolCalls.length, 1);
|
|
32
31
|
assert.strictEqual(final.toolCalls[0].name, 'frag');
|
|
33
32
|
assert.strictEqual(final.text, ' trailing');
|
|
@@ -35,26 +34,24 @@ test('StreamingToolParser: fragmented tool call', () => {
|
|
|
35
34
|
|
|
36
35
|
test('StreamingToolParser: flush partial content', () => {
|
|
37
36
|
const parser = new StreamingToolParser();
|
|
38
|
-
|
|
39
37
|
parser.feed('Unfinished tag <tool_');
|
|
40
38
|
assert.strictEqual(parser.flush().text, '<tool_');
|
|
41
39
|
|
|
42
40
|
const parser2 = new StreamingToolParser();
|
|
43
|
-
parser2.feed(
|
|
41
|
+
parser2.feed(`${TC_OPEN}{"name": "healable"`);
|
|
44
42
|
const flushed = parser2.flush();
|
|
45
43
|
assert.strictEqual(flushed.toolCalls.length, 1);
|
|
46
44
|
assert.strictEqual(flushed.toolCalls[0].name, 'healable');
|
|
47
|
-
|
|
45
|
+
|
|
48
46
|
const parser3 = new StreamingToolParser();
|
|
49
|
-
parser3.feed(
|
|
47
|
+
parser3.feed(`Invalid ${TC_OPEN}NOT_JSON`);
|
|
50
48
|
const flushed2 = parser3.flush();
|
|
51
|
-
assert.strictEqual(flushed2.text,
|
|
49
|
+
assert.strictEqual(flushed2.text, `${TC_OPEN}NOT_JSON${TC_CLOSE}`);
|
|
52
50
|
});
|
|
53
51
|
|
|
54
52
|
test('StreamingToolParser: robust parsing of malformed JSON', () => {
|
|
55
53
|
const parser = new StreamingToolParser();
|
|
56
|
-
|
|
57
|
-
const res = parser.feed('<tool_call>{"name": "broken", "arguments": {"a": 1</tool_call>');
|
|
54
|
+
const res = parser.feed(`${TC_OPEN}{"name": "broken", "arguments": {"a": 1}${TC_CLOSE}`);
|
|
58
55
|
assert.strictEqual(res.toolCalls.length, 1);
|
|
59
56
|
assert.strictEqual(res.toolCalls[0].name, 'broken');
|
|
60
57
|
assert.deepStrictEqual(res.toolCalls[0].arguments, { a: 1 });
|
|
@@ -62,28 +59,111 @@ test('StreamingToolParser: robust parsing of malformed JSON', () => {
|
|
|
62
59
|
|
|
63
60
|
test('StreamingToolParser: preserves tags in non-tool text', () => {
|
|
64
61
|
const parser = new StreamingToolParser();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
assert.ok(res1.text.includes(
|
|
68
|
-
assert.ok(res1.text.includes('</tool_call>'), 'Should contain end tag');
|
|
62
|
+
const res1 = parser.feed(`Fake: ${TC_OPEN} { "only_args": 1 } ${TC_CLOSE} `);
|
|
63
|
+
assert.ok(res1.text.includes(TC_OPEN), 'Should contain start tag');
|
|
64
|
+
assert.ok(res1.text.includes(TC_CLOSE), 'Should contain close tag');
|
|
69
65
|
assert.strictEqual(res1.toolCalls.length, 0);
|
|
70
66
|
|
|
71
|
-
const res2 = parser.feed(
|
|
67
|
+
const res2 = parser.feed(`Real: ${TC_OPEN}{"name":"r"}${TC_CLOSE}`);
|
|
72
68
|
assert.strictEqual(res2.toolCalls.length, 1);
|
|
73
69
|
assert.strictEqual(res2.toolCalls[0].name, 'r');
|
|
74
70
|
});
|
|
75
71
|
|
|
76
72
|
test('StreamingToolParser: handles multiple tool calls in array format', () => {
|
|
77
73
|
const parser = new StreamingToolParser();
|
|
78
|
-
|
|
79
|
-
const chunk = `<tool_call>[
|
|
74
|
+
const chunk = `${TC_OPEN}[
|
|
80
75
|
{"name": "bash", "arguments": {"command": "ls", "description": "List files"}},
|
|
81
76
|
{"name": "read", "arguments": {"path": "test.txt"}}
|
|
82
|
-
]
|
|
83
|
-
|
|
77
|
+
]${TC_CLOSE}`;
|
|
84
78
|
const result = parser.feed(chunk);
|
|
85
79
|
assert.strictEqual(result.toolCalls.length, 2, 'Should extract both tool calls');
|
|
86
80
|
assert.strictEqual(result.toolCalls[0].name, 'bash');
|
|
87
81
|
assert.strictEqual(result.toolCalls[1].name, 'read');
|
|
88
82
|
assert.strictEqual(result.toolCalls[0].arguments.command, 'ls');
|
|
89
83
|
});
|
|
84
|
+
|
|
85
|
+
test('StreamingToolParser: double-escaped quotes in JSON', () => {
|
|
86
|
+
const parser = new StreamingToolParser();
|
|
87
|
+
const input = `${TC_OPEN}{\\"name\\": \\"edit\\", \\"arguments\\": {\\"filePath\\": \\"/tmp/test.txt\\", \\"content\\": \\"hello\\"}}${TC_CLOSE}`;
|
|
88
|
+
const res = parser.feed(input);
|
|
89
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
90
|
+
assert.strictEqual(res.toolCalls[0].name, 'edit');
|
|
91
|
+
assert.strictEqual(res.toolCalls[0].arguments.filePath, '/tmp/test.txt');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('StreamingToolParser: double-escaped quotes in XML parameters', () => {
|
|
95
|
+
const parser = new StreamingToolParser();
|
|
96
|
+
const input = `${TC_OPEN}\n<name>write</name>\n<parameter name=\\"content\\"><div>hello & world</div></parameter>\n${TC_CLOSE}`;
|
|
97
|
+
const res = parser.feed(input);
|
|
98
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
99
|
+
assert.strictEqual(res.toolCalls[0].name, 'write');
|
|
100
|
+
assert.strictEqual(res.toolCalls[0].arguments.content, '<div>hello & world</div>');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('StreamingToolParser: truncated JSON with unclosed string', () => {
|
|
104
|
+
const parser = new StreamingToolParser();
|
|
105
|
+
const res = parser.feed(`${TC_OPEN}{"name": "bash", "arguments": {"command": "echo hello${TC_CLOSE}`);
|
|
106
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
107
|
+
assert.strictEqual(res.toolCalls[0].name, 'bash');
|
|
108
|
+
assert.strictEqual(typeof res.toolCalls[0].arguments.command, 'string');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('StreamingToolParser: flush double-escaped tool call', () => {
|
|
112
|
+
const parser = new StreamingToolParser();
|
|
113
|
+
parser.feed(`${TC_OPEN}{\\"name\\": \\"recover\\",\\"arguments\\": {\\"a\\": \\"val`);
|
|
114
|
+
const flushed = parser.flush();
|
|
115
|
+
assert.strictEqual(flushed.toolCalls.length, 1);
|
|
116
|
+
assert.strictEqual(flushed.toolCalls[0].name, 'recover');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('StreamingToolParser: handles literal close tag inside JSON string', () => {
|
|
120
|
+
const parser = new StreamingToolParser();
|
|
121
|
+
const toolCallJson = JSON.stringify({
|
|
122
|
+
name: "edit",
|
|
123
|
+
arguments: {
|
|
124
|
+
filePath: "/tmp/test.ts",
|
|
125
|
+
oldString: `some code with ${TC_CLOSE} inside a string value`,
|
|
126
|
+
newString: "replacement code"
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const fullInput = `${TC_OPEN}${toolCallJson}${TC_CLOSE}`;
|
|
130
|
+
const res = parser.feed(fullInput);
|
|
131
|
+
assert.strictEqual(res.toolCalls.length, 1, 'Should parse the tool call despite to literal close tag in string');
|
|
132
|
+
assert.strictEqual(res.toolCalls[0].name, 'edit');
|
|
133
|
+
assert.strictEqual(res.toolCalls[0].arguments.filePath, '/tmp/test.ts');
|
|
134
|
+
assert.ok(
|
|
135
|
+
(res.toolCalls[0].arguments.oldString as string).includes(TC_CLOSE),
|
|
136
|
+
'oldString should contain the literal close tag'
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('StreamingToolParser: unquoted arguments key with nested string values containing colons', () => {
|
|
141
|
+
const parser = new StreamingToolParser();
|
|
142
|
+
const input = `${TC_OPEN}{"name":"todowrite",arguments:{"todos":[{"content":"Add versions/activeVersionIndex to DB schema with migration","status":"completed","priority":"high"},{"content":"Update dbService to handle versions","status":"completed","priority":"high"},{"content":"Update ChatStore types and add regenerateMessage + switchVersion methods","status":"in_progress","priority":"high"},{"content":"Update Chat.tsx handleRegenerate to use new regenerateMessage","status":"pending"}]}}${TC_CLOSE}`;
|
|
143
|
+
const res = parser.feed(input);
|
|
144
|
+
assert.strictEqual(res.toolCalls.length, 1);
|
|
145
|
+
assert.strictEqual(res.toolCalls[0].name, 'todowrite');
|
|
146
|
+
assert.strictEqual((res.toolCalls[0].arguments.todos as any[]).length, 4);
|
|
147
|
+
assert.strictEqual((res.toolCalls[0].arguments.todos as any[])[2].status, 'in_progress');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('StreamingToolParser: handles literal close tag in streamed chunks', () => {
|
|
151
|
+
const parser = new StreamingToolParser();
|
|
152
|
+
const toolCallJson = JSON.stringify({
|
|
153
|
+
name: "edit",
|
|
154
|
+
arguments: {
|
|
155
|
+
filePath: "/tmp/app.ts",
|
|
156
|
+
oldString: `function foo() { return "${TC_CLOSE}"; }`,
|
|
157
|
+
newString: "function bar() {}"
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
const fullInput = `${TC_OPEN}${toolCallJson}${TC_CLOSE}`;
|
|
161
|
+
const mid = Math.floor(fullInput.length / 2);
|
|
162
|
+
const chunk1 = fullInput.substring(0, mid);
|
|
163
|
+
const chunk2 = fullInput.substring(mid);
|
|
164
|
+
|
|
165
|
+
parser.feed(chunk1);
|
|
166
|
+
const res = parser.feed(chunk2);
|
|
167
|
+
assert.strictEqual(res.toolCalls.length, 1, 'Should parse across chunk boundaries');
|
|
168
|
+
assert.strictEqual(res.toolCalls[0].name, 'edit');
|
|
169
|
+
});
|
package/src/tools/parser.ts
CHANGED
|
@@ -30,6 +30,29 @@ function decodeXmlEntities(value: string): string {
|
|
|
30
30
|
.replace(/&/g, '&');
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function unescapeDoubleEscaped(content: string): string {
|
|
34
|
+
const trimmed = content.trim();
|
|
35
|
+
if (!trimmed) return content;
|
|
36
|
+
|
|
37
|
+
const isJsonLike = trimmed.startsWith('{') || trimmed.startsWith('[');
|
|
38
|
+
const isXmlLike = trimmed.startsWith('<');
|
|
39
|
+
|
|
40
|
+
if (!isJsonLike && !isXmlLike) return content;
|
|
41
|
+
|
|
42
|
+
const firstQuoteIdx = trimmed.indexOf('"');
|
|
43
|
+
if (firstQuoteIdx === -1) return content;
|
|
44
|
+
|
|
45
|
+
const firstEscapedQuoteIdx = trimmed.indexOf('\\"');
|
|
46
|
+
|
|
47
|
+
if (firstEscapedQuoteIdx !== -1 && (firstQuoteIdx === -1 || firstEscapedQuoteIdx < firstQuoteIdx)) {
|
|
48
|
+
return content
|
|
49
|
+
.replace(/\\"/g, '"')
|
|
50
|
+
.replace(/\\\\/g, '\\');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function coerceParameterValue(rawValue: string): unknown {
|
|
34
57
|
const value = decodeXmlEntities(rawValue.trim());
|
|
35
58
|
if (value === 'true') return true;
|
|
@@ -136,19 +159,69 @@ function parseRecoverableXmlToolCall(
|
|
|
136
159
|
return { name: toolName, arguments: args };
|
|
137
160
|
}
|
|
138
161
|
|
|
162
|
+
// ─── String-Aware Tag Detection ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function findToolEndIndex(buffer: string): number {
|
|
165
|
+
const tagLen = TOOL_END.length;
|
|
166
|
+
const limit = buffer.length - tagLen;
|
|
167
|
+
let inString = false;
|
|
168
|
+
let escaped = false;
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i <= limit; i++) {
|
|
171
|
+
const ch = buffer[i];
|
|
172
|
+
if (escaped) { escaped = false; continue; }
|
|
173
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
174
|
+
if (ch === '"') { inString = !inString; continue; }
|
|
175
|
+
if (inString || ch !== '<') continue;
|
|
176
|
+
let match = true;
|
|
177
|
+
for (let j = 1; j < tagLen; j++) {
|
|
178
|
+
const c = buffer.charCodeAt(i + j);
|
|
179
|
+
const t = TOOL_END.charCodeAt(j);
|
|
180
|
+
if (c !== t && (c | 0x20) !== (t | 0x20)) { match = false; break; }
|
|
181
|
+
}
|
|
182
|
+
if (match) return i;
|
|
183
|
+
}
|
|
184
|
+
return -1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
139
189
|
// ─── Partial Tag Detection ─────────────────────────────────────────────────────
|
|
140
190
|
|
|
141
191
|
const TOOL_START_LITERAL = '<tool_call>';
|
|
142
192
|
|
|
143
193
|
function findPartialToolOpenIndex(buffer: string): number {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
194
|
+
const prefix = '<tool_call';
|
|
195
|
+
const prefixLen = prefix.length;
|
|
196
|
+
const bufLen = buffer.length;
|
|
197
|
+
|
|
198
|
+
let lastPartialIdx = -1;
|
|
199
|
+
for (let i = bufLen - 1; i >= Math.max(0, bufLen - prefixLen - 1); i--) {
|
|
200
|
+
if (buffer[i] !== '<') continue;
|
|
201
|
+
let match = true;
|
|
202
|
+
for (let j = 1; j < prefixLen && i + j < bufLen; j++) {
|
|
203
|
+
const c = buffer.charCodeAt(i + j);
|
|
204
|
+
const t = prefix.charCodeAt(j);
|
|
205
|
+
if (c !== t && ((c | 0x20) !== (t | 0x20))) { match = false; break; }
|
|
206
|
+
}
|
|
207
|
+
if (match) {
|
|
208
|
+
lastPartialIdx = i;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (lastPartialIdx !== -1 && buffer.indexOf('>', lastPartialIdx) === -1) return lastPartialIdx;
|
|
148
213
|
|
|
149
|
-
// Check for partial prefix at end (e.g. `<tool`, `<tool_`, `<tool_c`)
|
|
150
214
|
for (let i = 1; i < TOOL_START_LITERAL.length; i++) {
|
|
151
|
-
|
|
215
|
+
const sub = TOOL_START_LITERAL.substring(0, i);
|
|
216
|
+
const subLen = sub.length;
|
|
217
|
+
if (bufLen < subLen) continue;
|
|
218
|
+
let match = true;
|
|
219
|
+
for (let j = 0; j < subLen; j++) {
|
|
220
|
+
const c = buffer.charCodeAt(bufLen - subLen + j);
|
|
221
|
+
const t = sub.charCodeAt(j);
|
|
222
|
+
if (c !== t && (c | 0x20) !== (t | 0x20)) { match = false; break; }
|
|
223
|
+
}
|
|
224
|
+
if (match) return bufLen - i;
|
|
152
225
|
}
|
|
153
226
|
return -1;
|
|
154
227
|
}
|
|
@@ -211,11 +284,10 @@ export class StreamingToolParser {
|
|
|
211
284
|
}
|
|
212
285
|
break;
|
|
213
286
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (endIdx !== -1) {
|
|
287
|
+
} else {
|
|
288
|
+
let endIdx = findToolEndIndex(this.buffer);
|
|
289
|
+
if (endIdx === -1) endIdx = this.buffer.indexOf(TOOL_END);
|
|
290
|
+
if (endIdx !== -1) {
|
|
219
291
|
const content = this.buffer.substring(0, endIdx);
|
|
220
292
|
this.buffer = this.buffer.substring(endIdx + TOOL_END.length);
|
|
221
293
|
this.processToolContent(content, result);
|
|
@@ -223,7 +295,7 @@ export class StreamingToolParser {
|
|
|
223
295
|
this.currentOpenTag = TOOL_START_LITERAL;
|
|
224
296
|
if (this.buffer.length > 0) {
|
|
225
297
|
const nextMatch = this.buffer.match(TOOL_OPEN_RE);
|
|
226
|
-
|
|
298
|
+
if (nextMatch && nextMatch.index !== undefined) {
|
|
227
299
|
result.text += this.buffer.substring(0, nextMatch.index);
|
|
228
300
|
this.insideTool = true;
|
|
229
301
|
this.currentOpenTag = nextMatch[0];
|
|
@@ -236,7 +308,7 @@ export class StreamingToolParser {
|
|
|
236
308
|
}
|
|
237
309
|
}
|
|
238
310
|
} else {
|
|
239
|
-
break;
|
|
311
|
+
break;
|
|
240
312
|
}
|
|
241
313
|
}
|
|
242
314
|
}
|
|
@@ -284,9 +356,8 @@ export class StreamingToolParser {
|
|
|
284
356
|
// ─── Internal Methods ──────────────────────────────────────────────────────
|
|
285
357
|
|
|
286
358
|
private processToolContent(content: string, result: ParserResult): void {
|
|
287
|
-
|
|
359
|
+
let t = content.trim();
|
|
288
360
|
if (!t) {
|
|
289
|
-
// Empty tool call - malformed. Restore lead-in if possible.
|
|
290
361
|
logger.warn('[parser] Dropping empty tool call block');
|
|
291
362
|
if (this.emittedToolCallCount === 0 && this.pendingLeadIn.trim().length > 0) {
|
|
292
363
|
result.text += this.pendingLeadIn;
|
|
@@ -295,7 +366,8 @@ export class StreamingToolParser {
|
|
|
295
366
|
return;
|
|
296
367
|
}
|
|
297
368
|
|
|
298
|
-
|
|
369
|
+
t = unescapeDoubleEscaped(t);
|
|
370
|
+
|
|
299
371
|
const xmlParsed = parseXmlParameterToolCall(t, this.currentOpenTag, this.tools);
|
|
300
372
|
if (xmlParsed) {
|
|
301
373
|
result.toolCalls.push({
|
|
@@ -358,8 +430,9 @@ export class StreamingToolParser {
|
|
|
358
430
|
}
|
|
359
431
|
|
|
360
432
|
private tryRecoverToolCall(block: string): ParsedToolCall | null {
|
|
361
|
-
|
|
362
|
-
|
|
433
|
+
const unescaped = unescapeDoubleEscaped(block);
|
|
434
|
+
|
|
435
|
+
const xmlParsed = parseXmlParameterToolCall(unescaped, this.currentOpenTag, this.tools);
|
|
363
436
|
if (xmlParsed) {
|
|
364
437
|
return {
|
|
365
438
|
id: `call_${crypto.randomUUID()}`,
|
|
@@ -368,8 +441,7 @@ export class StreamingToolParser {
|
|
|
368
441
|
};
|
|
369
442
|
}
|
|
370
443
|
|
|
371
|
-
|
|
372
|
-
const recovered = parseRecoverableXmlToolCall(block, this.currentOpenTag, this.tools);
|
|
444
|
+
const recovered = parseRecoverableXmlToolCall(unescaped, this.currentOpenTag, this.tools);
|
|
373
445
|
if (recovered) {
|
|
374
446
|
return {
|
|
375
447
|
id: `call_${crypto.randomUUID()}`,
|
|
@@ -378,11 +450,10 @@ export class StreamingToolParser {
|
|
|
378
450
|
};
|
|
379
451
|
}
|
|
380
452
|
|
|
381
|
-
|
|
382
|
-
const jsonParsed = this.parseToolContent(block);
|
|
453
|
+
const jsonParsed = this.parseToolContent(unescaped);
|
|
383
454
|
if (jsonParsed.length > 0) {
|
|
384
455
|
const first = jsonParsed[0];
|
|
385
|
-
const attrName = extractToolName(this.currentOpenTag,
|
|
456
|
+
const attrName = extractToolName(this.currentOpenTag, unescaped);
|
|
386
457
|
if (attrName && !first.name) first.name = attrName;
|
|
387
458
|
if (first.name) return first;
|
|
388
459
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
return Math.ceil(text.length /
|
|
1
|
+
import { getModelTokenDivisor } from '../core/model-registry.js'
|
|
2
|
+
|
|
3
|
+
export function estimateTokenCount(text: string, modelId?: string): number {
|
|
4
|
+
const divisor = modelId ? getModelTokenDivisor(modelId) : 2.0
|
|
5
|
+
return Math.ceil(text.length / divisor)
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
function truncateSemantically(content: string, maxChars: number): string {
|
|
@@ -31,11 +31,13 @@ function truncateSemantically(content: string, maxChars: number): string {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function truncateMessages(
|
|
34
|
-
messages: Array<{ role: string; content: string | null | any[] }>,
|
|
34
|
+
messages: Array<{ role: string; content: string | null | any[] | Record<string, unknown> }>,
|
|
35
35
|
maxContextLength: number,
|
|
36
|
-
systemPrompt: string = ''
|
|
36
|
+
systemPrompt: string = '',
|
|
37
|
+
modelId?: string
|
|
37
38
|
): Array<{ role: string; content: string }> {
|
|
38
|
-
const
|
|
39
|
+
const divisor = modelId ? getModelTokenDivisor(modelId) : 2.0
|
|
40
|
+
const systemTokens = estimateTokenCount(systemPrompt, modelId);
|
|
39
41
|
const availableTokens = maxContextLength - systemTokens - 500;
|
|
40
42
|
|
|
41
43
|
if (availableTokens <= 0) {
|
|
@@ -59,7 +61,7 @@ export function truncateMessages(
|
|
|
59
61
|
|
|
60
62
|
for (let i = normalizedMessages.length - 1; i >= 0; i--) {
|
|
61
63
|
const msg = normalizedMessages[i];
|
|
62
|
-
const msgTokens = estimateTokenCount(msg.content);
|
|
64
|
+
const msgTokens = estimateTokenCount(msg.content, modelId);
|
|
63
65
|
|
|
64
66
|
if (usedTokens + msgTokens <= availableTokens) {
|
|
65
67
|
result.push(msg);
|
|
@@ -67,7 +69,7 @@ export function truncateMessages(
|
|
|
67
69
|
} else {
|
|
68
70
|
const remainingTokens = availableTokens - usedTokens;
|
|
69
71
|
if (remainingTokens > 100) {
|
|
70
|
-
const maxChars = Math.floor(remainingTokens *
|
|
72
|
+
const maxChars = Math.floor(remainingTokens * divisor);
|
|
71
73
|
const truncatedContent = truncateSemantically(msg.content, maxChars);
|
|
72
74
|
result.push({ role: msg.role, content: `[Truncated] ${truncatedContent}` });
|
|
73
75
|
}
|
|
@@ -77,7 +79,7 @@ export function truncateMessages(
|
|
|
77
79
|
|
|
78
80
|
if (result.length === 0 && normalizedMessages.length > 0) {
|
|
79
81
|
const lastMsg = normalizedMessages[normalizedMessages.length - 1];
|
|
80
|
-
const maxChars = Math.max(200, Math.floor(availableTokens *
|
|
82
|
+
const maxChars = Math.max(200, Math.floor(availableTokens * divisor));
|
|
81
83
|
const truncatedContent = truncateSemantically(lastMsg.content, maxChars);
|
|
82
84
|
result.push({ role: lastMsg.role, content: `[Truncated] ${truncatedContent}` });
|
|
83
85
|
}
|
package/src/utils/json.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Robust JSON parsing utilities
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
function sanitizeAndBalance(input: string): { result: string; openBraces: number; openBrackets: number } {
|
|
7
|
+
function sanitizeAndBalance(input: string): { result: string; openBraces: number; openBrackets: number; inString: boolean } {
|
|
8
8
|
let out = '';
|
|
9
9
|
let openBraces = 0;
|
|
10
10
|
let openBrackets = 0;
|
|
@@ -48,16 +48,135 @@ function sanitizeAndBalance(input: string): { result: string; openBraces: number
|
|
|
48
48
|
if (char === ']') openBrackets--;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
return { result: out, openBraces, openBrackets };
|
|
51
|
+
return { result: out, openBraces, openBrackets, inString };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function closeBraces(input: string, openBraces: number, openBrackets: number): string {
|
|
54
|
+
function closeBraces(input: string, openBraces: number, openBrackets: number, inString: boolean = false): string {
|
|
55
55
|
let out = input;
|
|
56
|
+
if (inString) out += '"';
|
|
56
57
|
if (openBrackets > 0) out += ']'.repeat(openBrackets);
|
|
57
58
|
if (openBraces > 0) out += '}'.repeat(openBraces);
|
|
58
59
|
return out;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
function quoteUnquotedStringValues(input: string): string {
|
|
63
|
+
let out = '';
|
|
64
|
+
let i = 0;
|
|
65
|
+
let inString = false;
|
|
66
|
+
let escaped = false;
|
|
67
|
+
|
|
68
|
+
while (i < input.length) {
|
|
69
|
+
const ch = input[i];
|
|
70
|
+
|
|
71
|
+
if (escaped) { out += ch; escaped = false; i++; continue; }
|
|
72
|
+
if (ch === '\\' && inString) { out += ch; escaped = true; i++; continue; }
|
|
73
|
+
if (ch === '"') { inString = !inString; out += ch; i++; continue; }
|
|
74
|
+
if (inString) { out += ch; i++; continue; }
|
|
75
|
+
|
|
76
|
+
if (ch === ':') {
|
|
77
|
+
out += ch;
|
|
78
|
+
i++;
|
|
79
|
+
let ws = '';
|
|
80
|
+
while (i < input.length && /\s/.test(input[i])) { ws += input[i]; i++; }
|
|
81
|
+
out += ws;
|
|
82
|
+
if (i >= input.length) break;
|
|
83
|
+
|
|
84
|
+
const next = input[i];
|
|
85
|
+
if (next === '"' || next === '{' || next === '[' || next === '-' || /[0-9]/.test(next)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const rest = input.substring(i);
|
|
89
|
+
if (/^(true|false|null)\b/.test(rest)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let val = '';
|
|
94
|
+
let depthBrace = 0;
|
|
95
|
+
let depthBracket = 0;
|
|
96
|
+
let j = i;
|
|
97
|
+
while (j < input.length) {
|
|
98
|
+
const c = input[j];
|
|
99
|
+
if (c === '{') depthBrace++;
|
|
100
|
+
else if (c === '}') {
|
|
101
|
+
if (depthBrace === 0) break;
|
|
102
|
+
depthBrace--;
|
|
103
|
+
} else if (c === '[') depthBracket++;
|
|
104
|
+
else if (c === ']') {
|
|
105
|
+
if (depthBracket === 0) break;
|
|
106
|
+
depthBracket--;
|
|
107
|
+
} else if (c === ',' && depthBrace === 0 && depthBracket === 0) {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
val += c;
|
|
111
|
+
j++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (val.length > 0) {
|
|
115
|
+
const escapedVal = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
|
116
|
+
out += '"' + escapedVal + '"';
|
|
117
|
+
}
|
|
118
|
+
i = j;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
out += ch;
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function quoteUnquotedKeys(input: string): string {
|
|
129
|
+
let out = '';
|
|
130
|
+
let inString = false;
|
|
131
|
+
let escaped = false;
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < input.length; i++) {
|
|
134
|
+
const ch = input[i];
|
|
135
|
+
|
|
136
|
+
if (escaped) {
|
|
137
|
+
out += ch;
|
|
138
|
+
escaped = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (ch === '\\') {
|
|
143
|
+
out += ch;
|
|
144
|
+
escaped = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch === '"') {
|
|
149
|
+
inString = !inString;
|
|
150
|
+
out += ch;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (inString) {
|
|
155
|
+
out += ch;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (/[a-zA-Z_]/.test(ch)) {
|
|
160
|
+
let j = i;
|
|
161
|
+
while (j < input.length && /[a-zA-Z0-9_]/.test(input[j])) j++;
|
|
162
|
+
const ident = input.slice(i, j);
|
|
163
|
+
let k = j;
|
|
164
|
+
while (k < input.length && /\s/.test(input[k])) k++;
|
|
165
|
+
if (k < input.length && input[k] === ':') {
|
|
166
|
+
out += '"' + ident + '"';
|
|
167
|
+
} else {
|
|
168
|
+
out += ident;
|
|
169
|
+
}
|
|
170
|
+
i = j - 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
out += ch;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
61
180
|
export function robustParseJSON(str: string): any {
|
|
62
181
|
let sanitized = str.trim();
|
|
63
182
|
sanitized = sanitized.replace(/^```json\s*/, '').replace(/```$/, '').trim();
|
|
@@ -68,7 +187,8 @@ export function robustParseJSON(str: string): any {
|
|
|
68
187
|
let jsonPart = sanitized.substring(firstBrace);
|
|
69
188
|
try { return JSON.parse(jsonPart); } catch (e) { /* continue */ }
|
|
70
189
|
|
|
71
|
-
let currentJson = jsonPart
|
|
190
|
+
let currentJson = quoteUnquotedKeys(jsonPart);
|
|
191
|
+
currentJson = quoteUnquotedStringValues(currentJson);
|
|
72
192
|
currentJson = currentJson.replace(/([{,]\s*)"([a-zA-Z0-9_]+)"\s*:\s*"\2"\s*:/g, '$1"$2":');
|
|
73
193
|
currentJson = currentJson.replace(/([{,]\s*)([a-zA-Z0-9_]+)\s*:\s*\2\s*:/g, '$1$2:');
|
|
74
194
|
|
|
@@ -79,7 +199,7 @@ export function robustParseJSON(str: string): any {
|
|
|
79
199
|
cleaned = cleaned.slice(0, -1).trim();
|
|
80
200
|
}
|
|
81
201
|
|
|
82
|
-
const { result: fixedJson, openBraces, openBrackets } = sanitizeAndBalance(cleaned);
|
|
202
|
+
const { result: fixedJson, openBraces, openBrackets, inString } = sanitizeAndBalance(cleaned);
|
|
83
203
|
let lastBalancedIndex = -1;
|
|
84
204
|
|
|
85
205
|
{ let ob = 0, bk = 0, ins = false, esc = false;
|
|
@@ -99,15 +219,15 @@ export function robustParseJSON(str: string): any {
|
|
|
99
219
|
let tempJson = fixedJson;
|
|
100
220
|
if (lastBalancedIndex !== -1 && (openBraces !== 0 || openBrackets !== 0 || fixedJson.length > lastBalancedIndex + 1)) {
|
|
101
221
|
tempJson = fixedJson.substring(0, lastBalancedIndex + 1);
|
|
102
|
-
} else if (openBraces > 0 || openBrackets > 0) {
|
|
103
|
-
tempJson = closeBraces(fixedJson, openBraces, openBrackets);
|
|
222
|
+
} else if (openBraces > 0 || openBrackets > 0 || inString) {
|
|
223
|
+
tempJson = closeBraces(fixedJson, openBraces, openBrackets, inString);
|
|
104
224
|
}
|
|
105
225
|
|
|
106
226
|
try { return JSON.parse(tempJson); } catch (e) {
|
|
107
227
|
let aggressive = fixedJson.trim();
|
|
108
|
-
|
|
109
|
-
const { result: aggFixed, openBraces: ob, openBrackets: bk } = sanitizeAndBalance(aggressive);
|
|
110
|
-
try { return JSON.parse(closeBraces(aggFixed, ob, bk)); } catch {
|
|
228
|
+
aggressive = aggressive.replace(/,\s*([}\]])/g, '$1');
|
|
229
|
+
const { result: aggFixed, openBraces: ob, openBrackets: bk, inString: aggInString } = sanitizeAndBalance(aggressive);
|
|
230
|
+
try { return JSON.parse(closeBraces(aggFixed, ob, bk, aggInString)); } catch {
|
|
111
231
|
return null;
|
|
112
232
|
}
|
|
113
233
|
}
|
package/src/utils/types.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Modified By: Pedro Farias
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { JsonSchema, FunctionToolDefinition } from '../tools/types.
|
|
11
|
+
import type { JsonSchema, FunctionToolDefinition } from '../tools/types.js';
|
|
12
12
|
export type { JsonSchema, FunctionToolDefinition };
|
|
13
13
|
|
|
14
14
|
/** Tool choice options */
|