@bxb1337/windsurf-fast-context 1.1.0 → 1.1.2
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/dist/cjs/conversion/response-converter.js +25 -216
- package/dist/cjs/conversion/response-converter.test.js +32 -96
- package/dist/cjs/model/devstral-language-model.js +12 -2
- package/dist/cjs/model/devstral-language-model.test.js +79 -2
- package/dist/esm/conversion/response-converter.js +25 -216
- package/dist/esm/conversion/response-converter.test.js +32 -96
- package/dist/esm/model/devstral-language-model.js +12 -2
- package/dist/esm/model/devstral-language-model.test.js +79 -2
- package/package.json +1 -1
|
@@ -3,192 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.convertResponse = convertResponse;
|
|
4
4
|
const node_zlib_1 = require("node:zlib");
|
|
5
5
|
const protobuf_js_1 = require("../protocol/protobuf.js");
|
|
6
|
-
const TOOL_CALL_PREFIX = '[TOOL_CALLS]';
|
|
7
|
-
const ARGS_PREFIX = '[ARGS]';
|
|
8
6
|
const STOP_TOKEN = '</s>';
|
|
9
|
-
function
|
|
10
|
-
|
|
7
|
+
function parseStrictOpenAIToolCalls(responseText) {
|
|
8
|
+
const trimmed = responseText.trim();
|
|
9
|
+
if (!trimmed.startsWith('['))
|
|
11
10
|
return null;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(trimmed);
|
|
13
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const toolCalls = [];
|
|
16
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
17
|
+
const item = parsed[i];
|
|
18
|
+
if (item.type !== 'function' || !item.function?.name)
|
|
19
|
+
continue;
|
|
20
|
+
toolCalls.push({
|
|
21
|
+
type: 'tool-call',
|
|
22
|
+
toolCallId: `toolcall_${i + 1}`,
|
|
23
|
+
toolName: String(item.function.name),
|
|
24
|
+
input: item.function.parameters ?? {},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return toolCalls.length > 0 ? toolCalls : null;
|
|
12
28
|
}
|
|
13
|
-
|
|
14
|
-
if (!jsonPart.startsWith('{')) {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
const toolCalls = [];
|
|
18
|
-
let cursor = 0;
|
|
19
|
-
while (cursor < jsonPart.length) {
|
|
20
|
-
if (jsonPart[cursor] !== '{') {
|
|
21
|
-
cursor++;
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
const endResult = parseBalancedEnd(jsonPart, cursor);
|
|
25
|
-
if (endResult == null) {
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
const jsonStr = jsonPart.slice(cursor, endResult);
|
|
29
|
-
cursor = endResult;
|
|
30
|
-
if (jsonStr === '{}') {
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
const parsed = JSON.parse(jsonStr);
|
|
35
|
-
if (parsed.type === 'function' && parsed.function) {
|
|
36
|
-
toolCalls.push(parsed);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
if (toolCalls.length === 0) {
|
|
29
|
+
catch {
|
|
44
30
|
return null;
|
|
45
31
|
}
|
|
46
|
-
return toolCalls.map((call, index) => {
|
|
47
|
-
const toolId = call.function.name;
|
|
48
|
-
const toolName = typeof toolId === 'number' ? mapToolIdToName(toolId) : String(toolId);
|
|
49
|
-
const args = call.function.parameters ?? {};
|
|
50
|
-
return {
|
|
51
|
-
type: 'tool-call',
|
|
52
|
-
toolCallId: `toolcall_${index + 1}`,
|
|
53
|
-
toolName,
|
|
54
|
-
input: args,
|
|
55
|
-
};
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
function mapToolIdToName(id) {
|
|
59
|
-
switch (id) {
|
|
60
|
-
case 1:
|
|
61
|
-
return 'read';
|
|
62
|
-
case 2:
|
|
63
|
-
return 'glob';
|
|
64
|
-
case 3:
|
|
65
|
-
return 'grep';
|
|
66
|
-
default:
|
|
67
|
-
return `tool_${id}`;
|
|
68
|
-
}
|
|
69
32
|
}
|
|
70
33
|
function pushText(parts, text) {
|
|
71
34
|
if (text.length > 0) {
|
|
72
35
|
parts.push({ type: 'text', text });
|
|
73
36
|
}
|
|
74
37
|
}
|
|
75
|
-
function parseStringEnd(value, startIndex) {
|
|
76
|
-
let index = startIndex + 1;
|
|
77
|
-
let escaping = false;
|
|
78
|
-
while (index < value.length) {
|
|
79
|
-
const char = value[index];
|
|
80
|
-
if (escaping) {
|
|
81
|
-
escaping = false;
|
|
82
|
-
}
|
|
83
|
-
else if (char === '\\') {
|
|
84
|
-
escaping = true;
|
|
85
|
-
}
|
|
86
|
-
else if (char === '"') {
|
|
87
|
-
return index + 1;
|
|
88
|
-
}
|
|
89
|
-
index += 1;
|
|
90
|
-
}
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
function parseBalancedEnd(value, startIndex) {
|
|
94
|
-
const stack = [value[startIndex] === '{' ? '}' : ']'];
|
|
95
|
-
let index = startIndex + 1;
|
|
96
|
-
let inString = false;
|
|
97
|
-
let escaping = false;
|
|
98
|
-
while (index < value.length) {
|
|
99
|
-
const char = value[index];
|
|
100
|
-
if (inString) {
|
|
101
|
-
if (escaping) {
|
|
102
|
-
escaping = false;
|
|
103
|
-
}
|
|
104
|
-
else if (char === '\\') {
|
|
105
|
-
escaping = true;
|
|
106
|
-
}
|
|
107
|
-
else if (char === '"') {
|
|
108
|
-
inString = false;
|
|
109
|
-
}
|
|
110
|
-
index += 1;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (char === '"') {
|
|
114
|
-
inString = true;
|
|
115
|
-
index += 1;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (char === '{') {
|
|
119
|
-
stack.push('}');
|
|
120
|
-
index += 1;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (char === '[') {
|
|
124
|
-
stack.push(']');
|
|
125
|
-
index += 1;
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (char === '}' || char === ']') {
|
|
129
|
-
const expected = stack[stack.length - 1];
|
|
130
|
-
if (expected !== char) {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
stack.pop();
|
|
134
|
-
index += 1;
|
|
135
|
-
if (stack.length === 0) {
|
|
136
|
-
return index;
|
|
137
|
-
}
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
index += 1;
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
function parsePrimitiveEnd(value, startIndex) {
|
|
145
|
-
let index = startIndex;
|
|
146
|
-
while (index < value.length) {
|
|
147
|
-
const char = value[index];
|
|
148
|
-
if (char === ' ' || char === '\n' || char === '\r' || char === '\t') {
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
index += 1;
|
|
152
|
-
}
|
|
153
|
-
return index;
|
|
154
|
-
}
|
|
155
|
-
function parseJsonValue(value, startIndex) {
|
|
156
|
-
let jsonStart = startIndex;
|
|
157
|
-
while (jsonStart < value.length) {
|
|
158
|
-
const char = value[jsonStart];
|
|
159
|
-
if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') {
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
jsonStart += 1;
|
|
163
|
-
}
|
|
164
|
-
if (jsonStart >= value.length) {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
const firstChar = value[jsonStart];
|
|
168
|
-
let endIndex;
|
|
169
|
-
if (firstChar === '{' || firstChar === '[') {
|
|
170
|
-
endIndex = parseBalancedEnd(value, jsonStart);
|
|
171
|
-
}
|
|
172
|
-
else if (firstChar === '"') {
|
|
173
|
-
endIndex = parseStringEnd(value, jsonStart);
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
endIndex = parsePrimitiveEnd(value, jsonStart);
|
|
177
|
-
}
|
|
178
|
-
if (endIndex == null || endIndex <= jsonStart) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
const rawJson = value.slice(jsonStart, endIndex);
|
|
182
|
-
try {
|
|
183
|
-
return {
|
|
184
|
-
parsed: JSON.parse(rawJson),
|
|
185
|
-
endIndex,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
38
|
function hasControlChars(value) {
|
|
193
39
|
for (const char of value) {
|
|
194
40
|
const code = char.charCodeAt(0);
|
|
@@ -216,10 +62,6 @@ function isLikelyMetadata(value) {
|
|
|
216
62
|
return /^[A-Za-z0-9._:-]{1,32}$/.test(value);
|
|
217
63
|
}
|
|
218
64
|
function pickBestExtractedText(values) {
|
|
219
|
-
const markerValues = values.filter((value) => value.includes(TOOL_CALL_PREFIX) || value.includes(ARGS_PREFIX));
|
|
220
|
-
if (markerValues.length > 0) {
|
|
221
|
-
return markerValues.join('');
|
|
222
|
-
}
|
|
223
65
|
const nonMetadata = values.filter((value) => !isLikelyMetadata(value));
|
|
224
66
|
const candidates = nonMetadata.length > 0 ? nonMetadata : values;
|
|
225
67
|
return candidates.reduce((best, current) => (current.length > best.length ? current : best), candidates[0] ?? '');
|
|
@@ -239,43 +81,10 @@ function decodeResponseText(buffer) {
|
|
|
239
81
|
function convertResponse(buffer) {
|
|
240
82
|
let responseText = decodeResponseText(buffer);
|
|
241
83
|
responseText = responseText.replace(STOP_TOKEN, '');
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
return
|
|
245
|
-
}
|
|
84
|
+
const strictToolCalls = parseStrictOpenAIToolCalls(responseText);
|
|
85
|
+
if (strictToolCalls)
|
|
86
|
+
return strictToolCalls;
|
|
246
87
|
const parts = [];
|
|
247
|
-
|
|
248
|
-
let toolCallCount = 0;
|
|
249
|
-
while (cursor < responseText.length) {
|
|
250
|
-
const markerStart = responseText.indexOf(TOOL_CALL_PREFIX, cursor);
|
|
251
|
-
if (markerStart === -1) {
|
|
252
|
-
pushText(parts, responseText.slice(cursor));
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
pushText(parts, responseText.slice(cursor, markerStart));
|
|
256
|
-
const toolNameStart = markerStart + TOOL_CALL_PREFIX.length;
|
|
257
|
-
const argsStart = responseText.indexOf(ARGS_PREFIX, toolNameStart);
|
|
258
|
-
if (argsStart === -1) {
|
|
259
|
-
pushText(parts, responseText.slice(markerStart));
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
const toolName = responseText.slice(toolNameStart, argsStart);
|
|
263
|
-
const parsedArgs = parseJsonValue(responseText, argsStart + ARGS_PREFIX.length);
|
|
264
|
-
if (parsedArgs == null) {
|
|
265
|
-
const nextMarker = responseText.indexOf(TOOL_CALL_PREFIX, markerStart + TOOL_CALL_PREFIX.length);
|
|
266
|
-
const malformedEnd = nextMarker === -1 ? responseText.length : nextMarker;
|
|
267
|
-
pushText(parts, responseText.slice(markerStart, malformedEnd));
|
|
268
|
-
cursor = malformedEnd;
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
toolCallCount += 1;
|
|
272
|
-
parts.push({
|
|
273
|
-
type: 'tool-call',
|
|
274
|
-
toolCallId: `toolcall_${toolCallCount}`,
|
|
275
|
-
toolName,
|
|
276
|
-
input: parsedArgs.parsed,
|
|
277
|
-
});
|
|
278
|
-
cursor = parsedArgs.endIndex;
|
|
279
|
-
}
|
|
88
|
+
pushText(parts, responseText);
|
|
280
89
|
return parts;
|
|
281
90
|
}
|
|
@@ -9,38 +9,6 @@ const response_converter_js_1 = require("./response-converter.js");
|
|
|
9
9
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from('plain response text', 'utf8'));
|
|
10
10
|
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: 'plain response text' }]);
|
|
11
11
|
});
|
|
12
|
-
(0, vitest_1.it)('tool-call - parses tool markers and preserves surrounding text', () => {
|
|
13
|
-
const input = 'Before [TOOL_CALLS]searchDocs[ARGS]{"query":"prompt converter"} between [TOOL_CALLS]answer[ARGS]{"answer":"final answer"} after';
|
|
14
|
-
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
15
|
-
(0, vitest_1.expect)(result).toEqual([
|
|
16
|
-
{ type: 'text', text: 'Before ' },
|
|
17
|
-
{
|
|
18
|
-
type: 'tool-call',
|
|
19
|
-
toolCallId: 'toolcall_1',
|
|
20
|
-
toolName: 'searchDocs',
|
|
21
|
-
input: { query: 'prompt converter' },
|
|
22
|
-
},
|
|
23
|
-
{ type: 'text', text: ' between ' },
|
|
24
|
-
{
|
|
25
|
-
type: 'tool-call',
|
|
26
|
-
toolCallId: 'toolcall_2',
|
|
27
|
-
toolName: 'answer',
|
|
28
|
-
input: { answer: 'final answer' },
|
|
29
|
-
},
|
|
30
|
-
{ type: 'text', text: ' after' },
|
|
31
|
-
]);
|
|
32
|
-
});
|
|
33
|
-
(0, vitest_1.it)('malformed - invalid marker json remains text and does not throw', () => {
|
|
34
|
-
const input = 'prefix [TOOL_CALLS]searchDocs[ARGS]{"query": nope} suffix';
|
|
35
|
-
(0, vitest_1.expect)(() => (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'))).not.toThrow();
|
|
36
|
-
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
37
|
-
const combinedText = result
|
|
38
|
-
.filter((part) => part.type === 'text')
|
|
39
|
-
.map((part) => part.text)
|
|
40
|
-
.join('');
|
|
41
|
-
(0, vitest_1.expect)(result.every((part) => part.type === 'text')).toBe(true);
|
|
42
|
-
(0, vitest_1.expect)(combinedText).toBe(input);
|
|
43
|
-
});
|
|
44
12
|
(0, vitest_1.it)('protobuf payload - extracts clean utf8 text without binary mojibake prefix', () => {
|
|
45
13
|
const payload = new protobuf_js_1.ProtobufEncoder();
|
|
46
14
|
payload.writeVarint(1, 150);
|
|
@@ -48,20 +16,6 @@ const response_converter_js_1 = require("./response-converter.js");
|
|
|
48
16
|
const result = (0, response_converter_js_1.convertResponse)(payload.toBuffer());
|
|
49
17
|
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: '你好,TypeScript' }]);
|
|
50
18
|
});
|
|
51
|
-
(0, vitest_1.it)('protobuf payload - still parses tool-call markers from extracted strings', () => {
|
|
52
|
-
const payload = new protobuf_js_1.ProtobufEncoder();
|
|
53
|
-
payload.writeVarint(1, 150);
|
|
54
|
-
payload.writeString(2, '[TOOL_CALLS]answer[ARGS]{"answer":"final answer"}');
|
|
55
|
-
const result = (0, response_converter_js_1.convertResponse)(payload.toBuffer());
|
|
56
|
-
(0, vitest_1.expect)(result).toEqual([
|
|
57
|
-
{
|
|
58
|
-
type: 'tool-call',
|
|
59
|
-
toolCallId: 'toolcall_1',
|
|
60
|
-
toolName: 'answer',
|
|
61
|
-
input: { answer: 'final answer' },
|
|
62
|
-
},
|
|
63
|
-
]);
|
|
64
|
-
});
|
|
65
19
|
(0, vitest_1.it)('protobuf payload - ignores metadata strings and keeps main text field', () => {
|
|
66
20
|
const payload = new protobuf_js_1.ProtobufEncoder();
|
|
67
21
|
payload.writeString(1, 'meta');
|
|
@@ -79,70 +33,52 @@ const response_converter_js_1 = require("./response-converter.js");
|
|
|
79
33
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
80
34
|
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: 'Hello world' }]);
|
|
81
35
|
});
|
|
82
|
-
|
|
83
|
-
|
|
36
|
+
// Strict OpenAI array format tests
|
|
37
|
+
(0, vitest_1.it)('parses strict OpenAI array with single tool call', () => {
|
|
38
|
+
const input = '[{"type":"function","function":{"name":"search","parameters":{"q":"test"}}}]';
|
|
84
39
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
85
40
|
(0, vitest_1.expect)(result).toEqual([
|
|
86
|
-
{
|
|
87
|
-
type: 'tool-call',
|
|
88
|
-
toolCallId: 'toolcall_1',
|
|
89
|
-
toolName: 'grep',
|
|
90
|
-
input: { file_path: '/home/test', search_pattern: 'binance' },
|
|
91
|
-
},
|
|
41
|
+
{ type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'search', input: { q: 'test' } }
|
|
92
42
|
]);
|
|
93
43
|
});
|
|
94
|
-
(0, vitest_1.it)('parses
|
|
95
|
-
const input = '
|
|
44
|
+
(0, vitest_1.it)('parses strict OpenAI array with multiple tool calls', () => {
|
|
45
|
+
const input = '[{"type":"function","function":{"name":"read","parameters":{"path":"/a"}}},{"type":"function","function":{"name":"grep","parameters":{"pattern":"foo"}}}]';
|
|
96
46
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
97
47
|
(0, vitest_1.expect)(result).toEqual([
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
toolCallId: 'toolcall_1',
|
|
101
|
-
toolName: 'read',
|
|
102
|
-
input: { file_path: '/home/test' },
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
type: 'tool-call',
|
|
106
|
-
toolCallId: 'toolcall_2',
|
|
107
|
-
toolName: 'glob',
|
|
108
|
-
input: { pattern: '*.ts' },
|
|
109
|
-
},
|
|
48
|
+
{ type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'read', input: { path: '/a' } },
|
|
49
|
+
{ type: 'tool-call', toolCallId: 'toolcall_2', toolName: 'grep', input: { pattern: 'foo' } }
|
|
110
50
|
]);
|
|
111
51
|
});
|
|
112
|
-
(0, vitest_1.it)('
|
|
113
|
-
const input = '
|
|
52
|
+
(0, vitest_1.it)('parses strict OpenAI array with empty parameters', () => {
|
|
53
|
+
const input = '[{"type":"function","function":{"name":"list","parameters":{}}}]';
|
|
114
54
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
115
55
|
(0, vitest_1.expect)(result).toEqual([
|
|
116
|
-
{
|
|
117
|
-
type: 'tool-call',
|
|
118
|
-
toolCallId: 'toolcall_1',
|
|
119
|
-
toolName: 'tool_99',
|
|
120
|
-
input: { arg: 'value' },
|
|
121
|
-
},
|
|
56
|
+
{ type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'list', input: {} }
|
|
122
57
|
]);
|
|
123
58
|
});
|
|
124
|
-
(0, vitest_1.it)('
|
|
125
|
-
const input = 'TOOL_CALLS{"
|
|
59
|
+
(0, vitest_1.it)('returns text for old marker format (no backward compat)', () => {
|
|
60
|
+
const input = '[TOOL_CALLS]search[ARGS]{"q":"test"}';
|
|
126
61
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
127
|
-
(0, vitest_1.expect)(result).toEqual([
|
|
128
|
-
{
|
|
129
|
-
type: 'tool-call',
|
|
130
|
-
toolCallId: 'toolcall_1',
|
|
131
|
-
toolName: 'custom_tool',
|
|
132
|
-
input: { key: 'value' },
|
|
133
|
-
},
|
|
134
|
-
]);
|
|
62
|
+
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: '[TOOL_CALLS]search[ARGS]{"q":"test"}' }]);
|
|
135
63
|
});
|
|
136
|
-
(0, vitest_1.it)('
|
|
137
|
-
const input = 'TOOL_CALLS{"type":"function","function":{"name":
|
|
64
|
+
(0, vitest_1.it)('returns text for old TOOL_CALLS format (no backward compat)', () => {
|
|
65
|
+
const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"q":"test"}}}{}';
|
|
138
66
|
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
139
|
-
(0, vitest_1.expect)(result).toEqual([
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
67
|
+
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: input }]);
|
|
68
|
+
});
|
|
69
|
+
(0, vitest_1.it)('returns text for non-array JSON', () => {
|
|
70
|
+
const input = '{"type":"function","function":{"name":"search","parameters":{}}}';
|
|
71
|
+
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
72
|
+
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: input }]);
|
|
73
|
+
});
|
|
74
|
+
(0, vitest_1.it)('returns text for empty array', () => {
|
|
75
|
+
const input = '[]';
|
|
76
|
+
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
77
|
+
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: '[]' }]);
|
|
78
|
+
});
|
|
79
|
+
(0, vitest_1.it)('returns text for malformed JSON array', () => {
|
|
80
|
+
const input = '[{"type":"function","function":{"name":"search","parameters":';
|
|
81
|
+
const result = (0, response_converter_js_1.convertResponse)(Buffer.from(input, 'utf8'));
|
|
82
|
+
(0, vitest_1.expect)(result).toEqual([{ type: 'text', text: input }]);
|
|
147
83
|
});
|
|
148
84
|
});
|
|
@@ -20,6 +20,13 @@ const WS_APP_VER = process.env.WS_APP_VER ?? '1.48.2';
|
|
|
20
20
|
const WS_LS_VER = process.env.WS_LS_VER ?? '1.9544.35';
|
|
21
21
|
const SENTRY_PUBLIC_KEY = 'b813f73488da69eedec534dba1029111';
|
|
22
22
|
const CONNECT_USER_AGENT = 'connect-go/1.18.1 (go1.25.5)';
|
|
23
|
+
const TOOL_FORMAT_INSTRUCTION = `When you need to call tools, you must format your response as a JSON array of tool calls following the OpenAI function calling format. Each tool call must be an object with "type" set to "function" and a "function" object containing "name" (string matching one of the provided tool names) and "parameters" (object with the tool's arguments). Output ONLY the JSON array, no surrounding text.
|
|
24
|
+
|
|
25
|
+
Example format:
|
|
26
|
+
[{"type": "function", "function": {"name": "tool_name", "parameters": {"arg1": "value1"}}}]
|
|
27
|
+
|
|
28
|
+
For multiple tool calls:
|
|
29
|
+
[{"type": "function", "function": {"name": "tool1", "parameters": {"arg1": "value1"}}}, {"type": "function", "function": {"name": "tool2", "parameters": {"arg2": "value2"}}}]`;
|
|
23
30
|
class DevstralLanguageModel {
|
|
24
31
|
specificationVersion = 'v2';
|
|
25
32
|
provider = 'windsurf';
|
|
@@ -271,10 +278,13 @@ function isFunctionTool(tool) {
|
|
|
271
278
|
function buildGenerateRequest(input) {
|
|
272
279
|
const request = new protobuf_js_1.ProtobufEncoder();
|
|
273
280
|
request.writeMessage(1, buildMetadata(input.apiKey, input.jwt));
|
|
274
|
-
|
|
281
|
+
const functionTools = input.tools?.filter(isFunctionTool) ?? [];
|
|
282
|
+
const messages = functionTools.length > 0
|
|
283
|
+
? [{ role: 5, content: TOOL_FORMAT_INSTRUCTION }, ...input.messages]
|
|
284
|
+
: input.messages;
|
|
285
|
+
for (const message of messages) {
|
|
275
286
|
request.writeMessage(2, buildMessage(message));
|
|
276
287
|
}
|
|
277
|
-
const functionTools = input.tools?.filter(isFunctionTool) ?? [];
|
|
278
288
|
if (functionTools.length > 0) {
|
|
279
289
|
const toolsArray = functionTools.map((tool) => ({
|
|
280
290
|
type: 'function',
|
|
@@ -163,7 +163,7 @@ async function collectStreamParts(stream) {
|
|
|
163
163
|
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
164
164
|
}
|
|
165
165
|
requestBodies.push(bufferFromBody(init?.body));
|
|
166
|
-
const payload = Buffer.from('[
|
|
166
|
+
const payload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
|
|
167
167
|
return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(payload)), { status: 200 });
|
|
168
168
|
};
|
|
169
169
|
const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
@@ -275,6 +275,83 @@ async function collectStreamParts(stream) {
|
|
|
275
275
|
const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
276
276
|
(0, vitest_1.expect)(extractToolsPayload(strings)).toBeUndefined();
|
|
277
277
|
});
|
|
278
|
+
(0, vitest_1.it)('injects tool format instruction into request when tools are present', async () => {
|
|
279
|
+
const requestBodies = [];
|
|
280
|
+
const jwt = makeJwt(4_200_000_100, 'instruction');
|
|
281
|
+
const fakeFetch = async (input, init) => {
|
|
282
|
+
const url = String(input);
|
|
283
|
+
if (url.endsWith('/GetUserJwt')) {
|
|
284
|
+
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
285
|
+
}
|
|
286
|
+
requestBodies.push(bufferFromBody(init?.body));
|
|
287
|
+
return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(Buffer.from('ok', 'utf8'))), { status: 200 });
|
|
288
|
+
};
|
|
289
|
+
const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
290
|
+
await model.doGenerate({
|
|
291
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Search for auth.' }] }],
|
|
292
|
+
tools: [
|
|
293
|
+
{
|
|
294
|
+
type: 'function',
|
|
295
|
+
name: 'searchRepo',
|
|
296
|
+
description: 'Search repository files',
|
|
297
|
+
inputSchema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: { query: { type: 'string' } },
|
|
300
|
+
required: ['query'],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
306
|
+
const combined = strings.join('\n');
|
|
307
|
+
(0, vitest_1.expect)(combined).toContain('When you need to call tools');
|
|
308
|
+
});
|
|
309
|
+
(0, vitest_1.it)('does not inject tool format instruction when no tools provided', async () => {
|
|
310
|
+
const requestBodies = [];
|
|
311
|
+
const jwt = makeJwt(4_200_000_101, 'no-instruction');
|
|
312
|
+
const fakeFetch = async (input, init) => {
|
|
313
|
+
const url = String(input);
|
|
314
|
+
if (url.endsWith('/GetUserJwt')) {
|
|
315
|
+
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
316
|
+
}
|
|
317
|
+
requestBodies.push(bufferFromBody(init?.body));
|
|
318
|
+
return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(Buffer.from('ok', 'utf8'))), { status: 200 });
|
|
319
|
+
};
|
|
320
|
+
const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
321
|
+
await model.doGenerate({
|
|
322
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Find auth logic.' }] }],
|
|
323
|
+
});
|
|
324
|
+
const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
325
|
+
const combined = strings.join('\n');
|
|
326
|
+
(0, vitest_1.expect)(combined).not.toContain('When you need to call tools');
|
|
327
|
+
});
|
|
328
|
+
(0, vitest_1.it)('does not inject tool format instruction when only provider-defined tools', async () => {
|
|
329
|
+
const requestBodies = [];
|
|
330
|
+
const jwt = makeJwt(4_200_000_102, 'provider-only');
|
|
331
|
+
const fakeFetch = async (input, init) => {
|
|
332
|
+
const url = String(input);
|
|
333
|
+
if (url.endsWith('/GetUserJwt')) {
|
|
334
|
+
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
335
|
+
}
|
|
336
|
+
requestBodies.push(bufferFromBody(init?.body));
|
|
337
|
+
return new Response(Uint8Array.from((0, connect_frame_js_1.connectFrameEncode)(Buffer.from('ok', 'utf8'))), { status: 200 });
|
|
338
|
+
};
|
|
339
|
+
const model = new devstral_language_model_js_1.DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
340
|
+
await model.doGenerate({
|
|
341
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use provider tools.' }] }],
|
|
342
|
+
tools: [
|
|
343
|
+
{
|
|
344
|
+
type: 'provider-defined',
|
|
345
|
+
id: 'some-provider-tool',
|
|
346
|
+
name: 'providerTool',
|
|
347
|
+
args: {},
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
});
|
|
351
|
+
const strings = (0, protobuf_js_1.extractStrings)(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
352
|
+
const combined = strings.join('\n');
|
|
353
|
+
(0, vitest_1.expect)(combined).not.toContain('When you need to call tools');
|
|
354
|
+
});
|
|
278
355
|
});
|
|
279
356
|
(0, vitest_1.describe)('DevstralLanguageModel doStream', () => {
|
|
280
357
|
(0, vitest_1.it)('stream-text resolves before full body arrives and emits parts incrementally', async () => {
|
|
@@ -379,7 +456,7 @@ async function collectStreamParts(stream) {
|
|
|
379
456
|
});
|
|
380
457
|
(0, vitest_1.it)('stream-tool emits tool-input deltas before final tool-call and finish', async () => {
|
|
381
458
|
const jwt = makeJwt(4_300_000_001, 'stream-tool');
|
|
382
|
-
const toolPayload = Buffer.from('[
|
|
459
|
+
const toolPayload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
|
|
383
460
|
const frames = Buffer.concat([(0, connect_frame_js_1.connectFrameEncode)(toolPayload)]);
|
|
384
461
|
const fakeFetch = async (input) => {
|
|
385
462
|
const url = String(input);
|
|
@@ -1,191 +1,37 @@
|
|
|
1
1
|
import { gunzipSync } from 'node:zlib';
|
|
2
2
|
import { extractStrings } from '../protocol/protobuf.js';
|
|
3
|
-
const TOOL_CALL_PREFIX = '[TOOL_CALLS]';
|
|
4
|
-
const ARGS_PREFIX = '[ARGS]';
|
|
5
3
|
const STOP_TOKEN = '</s>';
|
|
6
|
-
function
|
|
7
|
-
|
|
4
|
+
function parseStrictOpenAIToolCalls(responseText) {
|
|
5
|
+
const trimmed = responseText.trim();
|
|
6
|
+
if (!trimmed.startsWith('['))
|
|
8
7
|
return null;
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(trimmed);
|
|
10
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
const toolCalls = [];
|
|
13
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
14
|
+
const item = parsed[i];
|
|
15
|
+
if (item.type !== 'function' || !item.function?.name)
|
|
16
|
+
continue;
|
|
17
|
+
toolCalls.push({
|
|
18
|
+
type: 'tool-call',
|
|
19
|
+
toolCallId: `toolcall_${i + 1}`,
|
|
20
|
+
toolName: String(item.function.name),
|
|
21
|
+
input: item.function.parameters ?? {},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return toolCalls.length > 0 ? toolCalls : null;
|
|
9
25
|
}
|
|
10
|
-
|
|
11
|
-
if (!jsonPart.startsWith('{')) {
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
const toolCalls = [];
|
|
15
|
-
let cursor = 0;
|
|
16
|
-
while (cursor < jsonPart.length) {
|
|
17
|
-
if (jsonPart[cursor] !== '{') {
|
|
18
|
-
cursor++;
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
const endResult = parseBalancedEnd(jsonPart, cursor);
|
|
22
|
-
if (endResult == null) {
|
|
23
|
-
break;
|
|
24
|
-
}
|
|
25
|
-
const jsonStr = jsonPart.slice(cursor, endResult);
|
|
26
|
-
cursor = endResult;
|
|
27
|
-
if (jsonStr === '{}') {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
const parsed = JSON.parse(jsonStr);
|
|
32
|
-
if (parsed.type === 'function' && parsed.function) {
|
|
33
|
-
toolCalls.push(parsed);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
if (toolCalls.length === 0) {
|
|
26
|
+
catch {
|
|
41
27
|
return null;
|
|
42
28
|
}
|
|
43
|
-
return toolCalls.map((call, index) => {
|
|
44
|
-
const toolId = call.function.name;
|
|
45
|
-
const toolName = typeof toolId === 'number' ? mapToolIdToName(toolId) : String(toolId);
|
|
46
|
-
const args = call.function.parameters ?? {};
|
|
47
|
-
return {
|
|
48
|
-
type: 'tool-call',
|
|
49
|
-
toolCallId: `toolcall_${index + 1}`,
|
|
50
|
-
toolName,
|
|
51
|
-
input: args,
|
|
52
|
-
};
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
function mapToolIdToName(id) {
|
|
56
|
-
switch (id) {
|
|
57
|
-
case 1:
|
|
58
|
-
return 'read';
|
|
59
|
-
case 2:
|
|
60
|
-
return 'glob';
|
|
61
|
-
case 3:
|
|
62
|
-
return 'grep';
|
|
63
|
-
default:
|
|
64
|
-
return `tool_${id}`;
|
|
65
|
-
}
|
|
66
29
|
}
|
|
67
30
|
function pushText(parts, text) {
|
|
68
31
|
if (text.length > 0) {
|
|
69
32
|
parts.push({ type: 'text', text });
|
|
70
33
|
}
|
|
71
34
|
}
|
|
72
|
-
function parseStringEnd(value, startIndex) {
|
|
73
|
-
let index = startIndex + 1;
|
|
74
|
-
let escaping = false;
|
|
75
|
-
while (index < value.length) {
|
|
76
|
-
const char = value[index];
|
|
77
|
-
if (escaping) {
|
|
78
|
-
escaping = false;
|
|
79
|
-
}
|
|
80
|
-
else if (char === '\\') {
|
|
81
|
-
escaping = true;
|
|
82
|
-
}
|
|
83
|
-
else if (char === '"') {
|
|
84
|
-
return index + 1;
|
|
85
|
-
}
|
|
86
|
-
index += 1;
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
function parseBalancedEnd(value, startIndex) {
|
|
91
|
-
const stack = [value[startIndex] === '{' ? '}' : ']'];
|
|
92
|
-
let index = startIndex + 1;
|
|
93
|
-
let inString = false;
|
|
94
|
-
let escaping = false;
|
|
95
|
-
while (index < value.length) {
|
|
96
|
-
const char = value[index];
|
|
97
|
-
if (inString) {
|
|
98
|
-
if (escaping) {
|
|
99
|
-
escaping = false;
|
|
100
|
-
}
|
|
101
|
-
else if (char === '\\') {
|
|
102
|
-
escaping = true;
|
|
103
|
-
}
|
|
104
|
-
else if (char === '"') {
|
|
105
|
-
inString = false;
|
|
106
|
-
}
|
|
107
|
-
index += 1;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (char === '"') {
|
|
111
|
-
inString = true;
|
|
112
|
-
index += 1;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
if (char === '{') {
|
|
116
|
-
stack.push('}');
|
|
117
|
-
index += 1;
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (char === '[') {
|
|
121
|
-
stack.push(']');
|
|
122
|
-
index += 1;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (char === '}' || char === ']') {
|
|
126
|
-
const expected = stack[stack.length - 1];
|
|
127
|
-
if (expected !== char) {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
stack.pop();
|
|
131
|
-
index += 1;
|
|
132
|
-
if (stack.length === 0) {
|
|
133
|
-
return index;
|
|
134
|
-
}
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
index += 1;
|
|
138
|
-
}
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
141
|
-
function parsePrimitiveEnd(value, startIndex) {
|
|
142
|
-
let index = startIndex;
|
|
143
|
-
while (index < value.length) {
|
|
144
|
-
const char = value[index];
|
|
145
|
-
if (char === ' ' || char === '\n' || char === '\r' || char === '\t') {
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
index += 1;
|
|
149
|
-
}
|
|
150
|
-
return index;
|
|
151
|
-
}
|
|
152
|
-
function parseJsonValue(value, startIndex) {
|
|
153
|
-
let jsonStart = startIndex;
|
|
154
|
-
while (jsonStart < value.length) {
|
|
155
|
-
const char = value[jsonStart];
|
|
156
|
-
if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') {
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
jsonStart += 1;
|
|
160
|
-
}
|
|
161
|
-
if (jsonStart >= value.length) {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
const firstChar = value[jsonStart];
|
|
165
|
-
let endIndex;
|
|
166
|
-
if (firstChar === '{' || firstChar === '[') {
|
|
167
|
-
endIndex = parseBalancedEnd(value, jsonStart);
|
|
168
|
-
}
|
|
169
|
-
else if (firstChar === '"') {
|
|
170
|
-
endIndex = parseStringEnd(value, jsonStart);
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
endIndex = parsePrimitiveEnd(value, jsonStart);
|
|
174
|
-
}
|
|
175
|
-
if (endIndex == null || endIndex <= jsonStart) {
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
const rawJson = value.slice(jsonStart, endIndex);
|
|
179
|
-
try {
|
|
180
|
-
return {
|
|
181
|
-
parsed: JSON.parse(rawJson),
|
|
182
|
-
endIndex,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
35
|
function hasControlChars(value) {
|
|
190
36
|
for (const char of value) {
|
|
191
37
|
const code = char.charCodeAt(0);
|
|
@@ -213,10 +59,6 @@ function isLikelyMetadata(value) {
|
|
|
213
59
|
return /^[A-Za-z0-9._:-]{1,32}$/.test(value);
|
|
214
60
|
}
|
|
215
61
|
function pickBestExtractedText(values) {
|
|
216
|
-
const markerValues = values.filter((value) => value.includes(TOOL_CALL_PREFIX) || value.includes(ARGS_PREFIX));
|
|
217
|
-
if (markerValues.length > 0) {
|
|
218
|
-
return markerValues.join('');
|
|
219
|
-
}
|
|
220
62
|
const nonMetadata = values.filter((value) => !isLikelyMetadata(value));
|
|
221
63
|
const candidates = nonMetadata.length > 0 ? nonMetadata : values;
|
|
222
64
|
return candidates.reduce((best, current) => (current.length > best.length ? current : best), candidates[0] ?? '');
|
|
@@ -236,43 +78,10 @@ function decodeResponseText(buffer) {
|
|
|
236
78
|
export function convertResponse(buffer) {
|
|
237
79
|
let responseText = decodeResponseText(buffer);
|
|
238
80
|
responseText = responseText.replace(STOP_TOKEN, '');
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
return
|
|
242
|
-
}
|
|
81
|
+
const strictToolCalls = parseStrictOpenAIToolCalls(responseText);
|
|
82
|
+
if (strictToolCalls)
|
|
83
|
+
return strictToolCalls;
|
|
243
84
|
const parts = [];
|
|
244
|
-
|
|
245
|
-
let toolCallCount = 0;
|
|
246
|
-
while (cursor < responseText.length) {
|
|
247
|
-
const markerStart = responseText.indexOf(TOOL_CALL_PREFIX, cursor);
|
|
248
|
-
if (markerStart === -1) {
|
|
249
|
-
pushText(parts, responseText.slice(cursor));
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
pushText(parts, responseText.slice(cursor, markerStart));
|
|
253
|
-
const toolNameStart = markerStart + TOOL_CALL_PREFIX.length;
|
|
254
|
-
const argsStart = responseText.indexOf(ARGS_PREFIX, toolNameStart);
|
|
255
|
-
if (argsStart === -1) {
|
|
256
|
-
pushText(parts, responseText.slice(markerStart));
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
const toolName = responseText.slice(toolNameStart, argsStart);
|
|
260
|
-
const parsedArgs = parseJsonValue(responseText, argsStart + ARGS_PREFIX.length);
|
|
261
|
-
if (parsedArgs == null) {
|
|
262
|
-
const nextMarker = responseText.indexOf(TOOL_CALL_PREFIX, markerStart + TOOL_CALL_PREFIX.length);
|
|
263
|
-
const malformedEnd = nextMarker === -1 ? responseText.length : nextMarker;
|
|
264
|
-
pushText(parts, responseText.slice(markerStart, malformedEnd));
|
|
265
|
-
cursor = malformedEnd;
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
toolCallCount += 1;
|
|
269
|
-
parts.push({
|
|
270
|
-
type: 'tool-call',
|
|
271
|
-
toolCallId: `toolcall_${toolCallCount}`,
|
|
272
|
-
toolName,
|
|
273
|
-
input: parsedArgs.parsed,
|
|
274
|
-
});
|
|
275
|
-
cursor = parsedArgs.endIndex;
|
|
276
|
-
}
|
|
85
|
+
pushText(parts, responseText);
|
|
277
86
|
return parts;
|
|
278
87
|
}
|
|
@@ -7,38 +7,6 @@ describe('convertResponse', () => {
|
|
|
7
7
|
const result = convertResponse(Buffer.from('plain response text', 'utf8'));
|
|
8
8
|
expect(result).toEqual([{ type: 'text', text: 'plain response text' }]);
|
|
9
9
|
});
|
|
10
|
-
it('tool-call - parses tool markers and preserves surrounding text', () => {
|
|
11
|
-
const input = 'Before [TOOL_CALLS]searchDocs[ARGS]{"query":"prompt converter"} between [TOOL_CALLS]answer[ARGS]{"answer":"final answer"} after';
|
|
12
|
-
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
13
|
-
expect(result).toEqual([
|
|
14
|
-
{ type: 'text', text: 'Before ' },
|
|
15
|
-
{
|
|
16
|
-
type: 'tool-call',
|
|
17
|
-
toolCallId: 'toolcall_1',
|
|
18
|
-
toolName: 'searchDocs',
|
|
19
|
-
input: { query: 'prompt converter' },
|
|
20
|
-
},
|
|
21
|
-
{ type: 'text', text: ' between ' },
|
|
22
|
-
{
|
|
23
|
-
type: 'tool-call',
|
|
24
|
-
toolCallId: 'toolcall_2',
|
|
25
|
-
toolName: 'answer',
|
|
26
|
-
input: { answer: 'final answer' },
|
|
27
|
-
},
|
|
28
|
-
{ type: 'text', text: ' after' },
|
|
29
|
-
]);
|
|
30
|
-
});
|
|
31
|
-
it('malformed - invalid marker json remains text and does not throw', () => {
|
|
32
|
-
const input = 'prefix [TOOL_CALLS]searchDocs[ARGS]{"query": nope} suffix';
|
|
33
|
-
expect(() => convertResponse(Buffer.from(input, 'utf8'))).not.toThrow();
|
|
34
|
-
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
35
|
-
const combinedText = result
|
|
36
|
-
.filter((part) => part.type === 'text')
|
|
37
|
-
.map((part) => part.text)
|
|
38
|
-
.join('');
|
|
39
|
-
expect(result.every((part) => part.type === 'text')).toBe(true);
|
|
40
|
-
expect(combinedText).toBe(input);
|
|
41
|
-
});
|
|
42
10
|
it('protobuf payload - extracts clean utf8 text without binary mojibake prefix', () => {
|
|
43
11
|
const payload = new ProtobufEncoder();
|
|
44
12
|
payload.writeVarint(1, 150);
|
|
@@ -46,20 +14,6 @@ describe('convertResponse', () => {
|
|
|
46
14
|
const result = convertResponse(payload.toBuffer());
|
|
47
15
|
expect(result).toEqual([{ type: 'text', text: '你好,TypeScript' }]);
|
|
48
16
|
});
|
|
49
|
-
it('protobuf payload - still parses tool-call markers from extracted strings', () => {
|
|
50
|
-
const payload = new ProtobufEncoder();
|
|
51
|
-
payload.writeVarint(1, 150);
|
|
52
|
-
payload.writeString(2, '[TOOL_CALLS]answer[ARGS]{"answer":"final answer"}');
|
|
53
|
-
const result = convertResponse(payload.toBuffer());
|
|
54
|
-
expect(result).toEqual([
|
|
55
|
-
{
|
|
56
|
-
type: 'tool-call',
|
|
57
|
-
toolCallId: 'toolcall_1',
|
|
58
|
-
toolName: 'answer',
|
|
59
|
-
input: { answer: 'final answer' },
|
|
60
|
-
},
|
|
61
|
-
]);
|
|
62
|
-
});
|
|
63
17
|
it('protobuf payload - ignores metadata strings and keeps main text field', () => {
|
|
64
18
|
const payload = new ProtobufEncoder();
|
|
65
19
|
payload.writeString(1, 'meta');
|
|
@@ -77,70 +31,52 @@ describe('convertResponse', () => {
|
|
|
77
31
|
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
78
32
|
expect(result).toEqual([{ type: 'text', text: 'Hello world' }]);
|
|
79
33
|
});
|
|
80
|
-
|
|
81
|
-
|
|
34
|
+
// Strict OpenAI array format tests
|
|
35
|
+
it('parses strict OpenAI array with single tool call', () => {
|
|
36
|
+
const input = '[{"type":"function","function":{"name":"search","parameters":{"q":"test"}}}]';
|
|
82
37
|
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
83
38
|
expect(result).toEqual([
|
|
84
|
-
{
|
|
85
|
-
type: 'tool-call',
|
|
86
|
-
toolCallId: 'toolcall_1',
|
|
87
|
-
toolName: 'grep',
|
|
88
|
-
input: { file_path: '/home/test', search_pattern: 'binance' },
|
|
89
|
-
},
|
|
39
|
+
{ type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'search', input: { q: 'test' } }
|
|
90
40
|
]);
|
|
91
41
|
});
|
|
92
|
-
it('parses
|
|
93
|
-
const input = '
|
|
42
|
+
it('parses strict OpenAI array with multiple tool calls', () => {
|
|
43
|
+
const input = '[{"type":"function","function":{"name":"read","parameters":{"path":"/a"}}},{"type":"function","function":{"name":"grep","parameters":{"pattern":"foo"}}}]';
|
|
94
44
|
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
95
45
|
expect(result).toEqual([
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
toolCallId: 'toolcall_1',
|
|
99
|
-
toolName: 'read',
|
|
100
|
-
input: { file_path: '/home/test' },
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
type: 'tool-call',
|
|
104
|
-
toolCallId: 'toolcall_2',
|
|
105
|
-
toolName: 'glob',
|
|
106
|
-
input: { pattern: '*.ts' },
|
|
107
|
-
},
|
|
46
|
+
{ type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'read', input: { path: '/a' } },
|
|
47
|
+
{ type: 'tool-call', toolCallId: 'toolcall_2', toolName: 'grep', input: { pattern: 'foo' } }
|
|
108
48
|
]);
|
|
109
49
|
});
|
|
110
|
-
it('
|
|
111
|
-
const input = '
|
|
50
|
+
it('parses strict OpenAI array with empty parameters', () => {
|
|
51
|
+
const input = '[{"type":"function","function":{"name":"list","parameters":{}}}]';
|
|
112
52
|
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
113
53
|
expect(result).toEqual([
|
|
114
|
-
{
|
|
115
|
-
type: 'tool-call',
|
|
116
|
-
toolCallId: 'toolcall_1',
|
|
117
|
-
toolName: 'tool_99',
|
|
118
|
-
input: { arg: 'value' },
|
|
119
|
-
},
|
|
54
|
+
{ type: 'tool-call', toolCallId: 'toolcall_1', toolName: 'list', input: {} }
|
|
120
55
|
]);
|
|
121
56
|
});
|
|
122
|
-
it('
|
|
123
|
-
const input = 'TOOL_CALLS{"
|
|
57
|
+
it('returns text for old marker format (no backward compat)', () => {
|
|
58
|
+
const input = '[TOOL_CALLS]search[ARGS]{"q":"test"}';
|
|
124
59
|
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
125
|
-
expect(result).toEqual([
|
|
126
|
-
{
|
|
127
|
-
type: 'tool-call',
|
|
128
|
-
toolCallId: 'toolcall_1',
|
|
129
|
-
toolName: 'custom_tool',
|
|
130
|
-
input: { key: 'value' },
|
|
131
|
-
},
|
|
132
|
-
]);
|
|
60
|
+
expect(result).toEqual([{ type: 'text', text: '[TOOL_CALLS]search[ARGS]{"q":"test"}' }]);
|
|
133
61
|
});
|
|
134
|
-
it('
|
|
135
|
-
const input = 'TOOL_CALLS{"type":"function","function":{"name":
|
|
62
|
+
it('returns text for old TOOL_CALLS format (no backward compat)', () => {
|
|
63
|
+
const input = 'TOOL_CALLS{"type":"function","function":{"name":3,"parameters":{"q":"test"}}}{}';
|
|
136
64
|
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
137
|
-
expect(result).toEqual([
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
65
|
+
expect(result).toEqual([{ type: 'text', text: input }]);
|
|
66
|
+
});
|
|
67
|
+
it('returns text for non-array JSON', () => {
|
|
68
|
+
const input = '{"type":"function","function":{"name":"search","parameters":{}}}';
|
|
69
|
+
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
70
|
+
expect(result).toEqual([{ type: 'text', text: input }]);
|
|
71
|
+
});
|
|
72
|
+
it('returns text for empty array', () => {
|
|
73
|
+
const input = '[]';
|
|
74
|
+
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
75
|
+
expect(result).toEqual([{ type: 'text', text: '[]' }]);
|
|
76
|
+
});
|
|
77
|
+
it('returns text for malformed JSON array', () => {
|
|
78
|
+
const input = '[{"type":"function","function":{"name":"search","parameters":';
|
|
79
|
+
const result = convertResponse(Buffer.from(input, 'utf8'));
|
|
80
|
+
expect(result).toEqual([{ type: 'text', text: input }]);
|
|
145
81
|
});
|
|
146
82
|
});
|
|
@@ -17,6 +17,13 @@ const WS_APP_VER = process.env.WS_APP_VER ?? '1.48.2';
|
|
|
17
17
|
const WS_LS_VER = process.env.WS_LS_VER ?? '1.9544.35';
|
|
18
18
|
const SENTRY_PUBLIC_KEY = 'b813f73488da69eedec534dba1029111';
|
|
19
19
|
const CONNECT_USER_AGENT = 'connect-go/1.18.1 (go1.25.5)';
|
|
20
|
+
const TOOL_FORMAT_INSTRUCTION = `When you need to call tools, you must format your response as a JSON array of tool calls following the OpenAI function calling format. Each tool call must be an object with "type" set to "function" and a "function" object containing "name" (string matching one of the provided tool names) and "parameters" (object with the tool's arguments). Output ONLY the JSON array, no surrounding text.
|
|
21
|
+
|
|
22
|
+
Example format:
|
|
23
|
+
[{"type": "function", "function": {"name": "tool_name", "parameters": {"arg1": "value1"}}}]
|
|
24
|
+
|
|
25
|
+
For multiple tool calls:
|
|
26
|
+
[{"type": "function", "function": {"name": "tool1", "parameters": {"arg1": "value1"}}}, {"type": "function", "function": {"name": "tool2", "parameters": {"arg2": "value2"}}}]`;
|
|
20
27
|
export class DevstralLanguageModel {
|
|
21
28
|
specificationVersion = 'v2';
|
|
22
29
|
provider = 'windsurf';
|
|
@@ -267,10 +274,13 @@ function isFunctionTool(tool) {
|
|
|
267
274
|
function buildGenerateRequest(input) {
|
|
268
275
|
const request = new ProtobufEncoder();
|
|
269
276
|
request.writeMessage(1, buildMetadata(input.apiKey, input.jwt));
|
|
270
|
-
|
|
277
|
+
const functionTools = input.tools?.filter(isFunctionTool) ?? [];
|
|
278
|
+
const messages = functionTools.length > 0
|
|
279
|
+
? [{ role: 5, content: TOOL_FORMAT_INSTRUCTION }, ...input.messages]
|
|
280
|
+
: input.messages;
|
|
281
|
+
for (const message of messages) {
|
|
271
282
|
request.writeMessage(2, buildMessage(message));
|
|
272
283
|
}
|
|
273
|
-
const functionTools = input.tools?.filter(isFunctionTool) ?? [];
|
|
274
284
|
if (functionTools.length > 0) {
|
|
275
285
|
const toolsArray = functionTools.map((tool) => ({
|
|
276
286
|
type: 'function',
|
|
@@ -161,7 +161,7 @@ describe('DevstralLanguageModel doGenerate', () => {
|
|
|
161
161
|
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
162
162
|
}
|
|
163
163
|
requestBodies.push(bufferFromBody(init?.body));
|
|
164
|
-
const payload = Buffer.from('[
|
|
164
|
+
const payload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
|
|
165
165
|
return new Response(Uint8Array.from(connectFrameEncode(payload)), { status: 200 });
|
|
166
166
|
};
|
|
167
167
|
const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
@@ -273,6 +273,83 @@ describe('DevstralLanguageModel doGenerate', () => {
|
|
|
273
273
|
const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
274
274
|
expect(extractToolsPayload(strings)).toBeUndefined();
|
|
275
275
|
});
|
|
276
|
+
it('injects tool format instruction into request when tools are present', async () => {
|
|
277
|
+
const requestBodies = [];
|
|
278
|
+
const jwt = makeJwt(4_200_000_100, 'instruction');
|
|
279
|
+
const fakeFetch = async (input, init) => {
|
|
280
|
+
const url = String(input);
|
|
281
|
+
if (url.endsWith('/GetUserJwt')) {
|
|
282
|
+
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
283
|
+
}
|
|
284
|
+
requestBodies.push(bufferFromBody(init?.body));
|
|
285
|
+
return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
|
|
286
|
+
};
|
|
287
|
+
const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
288
|
+
await model.doGenerate({
|
|
289
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Search for auth.' }] }],
|
|
290
|
+
tools: [
|
|
291
|
+
{
|
|
292
|
+
type: 'function',
|
|
293
|
+
name: 'searchRepo',
|
|
294
|
+
description: 'Search repository files',
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
properties: { query: { type: 'string' } },
|
|
298
|
+
required: ['query'],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
304
|
+
const combined = strings.join('\n');
|
|
305
|
+
expect(combined).toContain('When you need to call tools');
|
|
306
|
+
});
|
|
307
|
+
it('does not inject tool format instruction when no tools provided', async () => {
|
|
308
|
+
const requestBodies = [];
|
|
309
|
+
const jwt = makeJwt(4_200_000_101, 'no-instruction');
|
|
310
|
+
const fakeFetch = async (input, init) => {
|
|
311
|
+
const url = String(input);
|
|
312
|
+
if (url.endsWith('/GetUserJwt')) {
|
|
313
|
+
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
314
|
+
}
|
|
315
|
+
requestBodies.push(bufferFromBody(init?.body));
|
|
316
|
+
return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
|
|
317
|
+
};
|
|
318
|
+
const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
319
|
+
await model.doGenerate({
|
|
320
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Find auth logic.' }] }],
|
|
321
|
+
});
|
|
322
|
+
const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
323
|
+
const combined = strings.join('\n');
|
|
324
|
+
expect(combined).not.toContain('When you need to call tools');
|
|
325
|
+
});
|
|
326
|
+
it('does not inject tool format instruction when only provider-defined tools', async () => {
|
|
327
|
+
const requestBodies = [];
|
|
328
|
+
const jwt = makeJwt(4_200_000_102, 'provider-only');
|
|
329
|
+
const fakeFetch = async (input, init) => {
|
|
330
|
+
const url = String(input);
|
|
331
|
+
if (url.endsWith('/GetUserJwt')) {
|
|
332
|
+
return new Response(Uint8Array.from(Buffer.from(jwt, 'utf8')), { status: 200 });
|
|
333
|
+
}
|
|
334
|
+
requestBodies.push(bufferFromBody(init?.body));
|
|
335
|
+
return new Response(Uint8Array.from(connectFrameEncode(Buffer.from('ok', 'utf8'))), { status: 200 });
|
|
336
|
+
};
|
|
337
|
+
const model = new DevstralLanguageModel({ apiKey: 'tools-key', fetch: fakeFetch, baseURL: 'https://windsurf.test' });
|
|
338
|
+
await model.doGenerate({
|
|
339
|
+
prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use provider tools.' }] }],
|
|
340
|
+
tools: [
|
|
341
|
+
{
|
|
342
|
+
type: 'provider-defined',
|
|
343
|
+
id: 'some-provider-tool',
|
|
344
|
+
name: 'providerTool',
|
|
345
|
+
args: {},
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
const strings = extractStrings(decodeRequestPayload(requestBodies[0] ?? Buffer.alloc(0)));
|
|
350
|
+
const combined = strings.join('\n');
|
|
351
|
+
expect(combined).not.toContain('When you need to call tools');
|
|
352
|
+
});
|
|
276
353
|
});
|
|
277
354
|
describe('DevstralLanguageModel doStream', () => {
|
|
278
355
|
it('stream-text resolves before full body arrives and emits parts incrementally', async () => {
|
|
@@ -377,7 +454,7 @@ describe('DevstralLanguageModel doStream', () => {
|
|
|
377
454
|
});
|
|
378
455
|
it('stream-tool emits tool-input deltas before final tool-call and finish', async () => {
|
|
379
456
|
const jwt = makeJwt(4_300_000_001, 'stream-tool');
|
|
380
|
-
const toolPayload = Buffer.from('[
|
|
457
|
+
const toolPayload = Buffer.from('[{"type":"function","function":{"name":"searchRepo","parameters":{"query":"jwt manager"}}}]', 'utf8');
|
|
381
458
|
const frames = Buffer.concat([connectFrameEncode(toolPayload)]);
|
|
382
459
|
const fakeFetch = async (input) => {
|
|
383
460
|
const url = String(input);
|