@pedrofariasx/qwenproxy 1.1.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/LICENSE +13 -0
- package/README.md +292 -0
- package/bin/qwenproxy.mjs +11 -0
- package/package.json +56 -0
- package/src/api/models.ts +183 -0
- package/src/api/server.ts +126 -0
- package/src/cache/memory-cache.ts +186 -0
- package/src/core/account-manager.ts +132 -0
- package/src/core/accounts.ts +78 -0
- package/src/core/config.ts +91 -0
- package/src/core/database.ts +92 -0
- package/src/core/logger.ts +96 -0
- package/src/core/metrics.ts +169 -0
- package/src/core/model-registry.ts +30 -0
- package/src/core/stream-registry.ts +40 -0
- package/src/core/watchdog.ts +130 -0
- package/src/index.ts +7 -0
- package/src/linter/extraction-engine.ts +165 -0
- package/src/linter/index.ts +258 -0
- package/src/linter/repair-normalize.ts +245 -0
- package/src/linter/safety-gate.ts +219 -0
- package/src/linter/streaming-state-machine.ts +252 -0
- package/src/linter/structural-parser.ts +352 -0
- package/src/linter/types.ts +74 -0
- package/src/login.ts +228 -0
- package/src/routes/chat.ts +801 -0
- package/src/routes/upload.ts +700 -0
- package/src/services/playwright.ts +778 -0
- package/src/services/qwen.ts +500 -0
- package/src/tests/advanced.test.ts +227 -0
- package/src/tests/agenticStress.test.ts +360 -0
- package/src/tests/concurrency.test.ts +103 -0
- package/src/tests/concurrentChat.test.ts +71 -0
- package/src/tests/delta.test.ts +63 -0
- package/src/tests/index.test.ts +356 -0
- package/src/tests/jsonFix.test.ts +98 -0
- package/src/tests/linter.test.ts +151 -0
- package/src/tests/parallel.test.ts +42 -0
- package/src/tests/parser.test.ts +89 -0
- package/src/tests/rotation.test.ts +45 -0
- package/src/tests/streamingOptimizations.test.ts +328 -0
- package/src/tests/structureVerification.test.ts +176 -0
- package/src/tools/ast.ts +15 -0
- package/src/tools/coercion.ts +67 -0
- package/src/tools/confidence.ts +48 -0
- package/src/tools/detector.ts +40 -0
- package/src/tools/executor.ts +236 -0
- package/src/tools/parser.ts +446 -0
- package/src/tools/pipeline.ts +122 -0
- package/src/tools/registry-runtime.ts +34 -0
- package/src/tools/registry.ts +142 -0
- package/src/tools/repair.ts +42 -0
- package/src/tools/schema.ts +285 -0
- package/src/tools/types.ts +104 -0
- package/src/tools/validator.ts +33 -0
- package/src/utils/context-truncation.ts +61 -0
- package/src/utils/json.ts +114 -0
- package/src/utils/qwen-stream-parser.ts +286 -0
- package/src/utils/types.ts +101 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
process.env.TEST_MOCK_PLAYWRIGHT = 'true';
|
|
5
|
+
|
|
6
|
+
delete process.env.API_KEY;
|
|
7
|
+
|
|
8
|
+
import { app } from '../api/server.js';
|
|
9
|
+
|
|
10
|
+
function setupFetchMock(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
|
|
11
|
+
const originalFetch = globalThis.fetch;
|
|
12
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
13
|
+
const urlStr = typeof input === 'string' ? input : ('url' in input ? input.url : String(input));
|
|
14
|
+
if (urlStr.includes('chat.qwen.ai')) {
|
|
15
|
+
if (urlStr.includes('/api/models')) {
|
|
16
|
+
return new Response(JSON.stringify({ data: [{ id: 'qwen3.6-plus', owned_by: 'qwen' }] }), { status: 200 });
|
|
17
|
+
}
|
|
18
|
+
return handler(urlStr, init);
|
|
19
|
+
}
|
|
20
|
+
return originalFetch(input);
|
|
21
|
+
};
|
|
22
|
+
return () => { globalThis.fetch = originalFetch; };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('multiturn-thinking-tools: maintains reasoning_content history', async () => {
|
|
26
|
+
let capturedBody = '';
|
|
27
|
+
|
|
28
|
+
const restore = setupFetchMock((url, init) => {
|
|
29
|
+
capturedBody = init?.body as string || '';
|
|
30
|
+
const stream = new ReadableStream({
|
|
31
|
+
start(c) {
|
|
32
|
+
c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
|
33
|
+
c.close();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return new Response(stream, { status: 200 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const req = new Request('http://localhost/v1/chat/completions', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
model: 'qwen3.6-plus',
|
|
45
|
+
messages: [
|
|
46
|
+
{ role: 'user', content: 'hello' },
|
|
47
|
+
{ role: 'assistant', content: 'doing something', reasoning_content: 'thinking about hello', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }] },
|
|
48
|
+
{ role: 'tool', name: 'test', content: 'success' }
|
|
49
|
+
]
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const res = await app.fetch(req);
|
|
54
|
+
assert.strictEqual(res.status, 200);
|
|
55
|
+
|
|
56
|
+
// The proxy transforms messages into a Qwen-compatible prompt.
|
|
57
|
+
// Verify the prompt (in the request body) contains context from all messages.
|
|
58
|
+
assert.ok(capturedBody.includes('hello') || capturedBody.includes('User: hello'), 'Must include user message');
|
|
59
|
+
assert.ok(capturedBody.includes('thinking about hello'), 'Must include reasoning content');
|
|
60
|
+
assert.ok(capturedBody.includes('tool_call') || capturedBody.includes('"name": "test"'), 'Must include tool call info');
|
|
61
|
+
assert.ok(capturedBody.includes('Tool Response (test): success') || capturedBody.includes('success'), 'Must include tool response');
|
|
62
|
+
} finally {
|
|
63
|
+
restore();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('streaming-whitespace: preserves exact whitespace', async () => {
|
|
68
|
+
const restore = setupFetchMock((url) => {
|
|
69
|
+
const stream = new ReadableStream({
|
|
70
|
+
start(c) {
|
|
71
|
+
c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": " ", "phase": "answer"}}]}\n\n'));
|
|
72
|
+
c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": " hello ", "phase": "answer"}}]}\n\n'));
|
|
73
|
+
c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": "\\n\\n ", "phase": "answer"}}]}\n\n'));
|
|
74
|
+
c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
|
75
|
+
c.close();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return new Response(stream, { status: 200 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const req = new Request('http://localhost/v1/chat/completions', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ model: 'qwen3.6-plus', messages: [{role: 'user', content: 'test'}], stream: true })
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const res = await app.fetch(req);
|
|
89
|
+
const reader = res.body?.getReader();
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let full = '';
|
|
92
|
+
while (true) {
|
|
93
|
+
const { done, value } = await reader!.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
const chunk = decoder.decode(value);
|
|
96
|
+
for (const line of chunk.split('\n')) {
|
|
97
|
+
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(line.slice(6));
|
|
100
|
+
if (data.choices?.[0]?.delta?.content) {
|
|
101
|
+
full += data.choices[0].delta.content;
|
|
102
|
+
}
|
|
103
|
+
} catch(e) {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// We expect exactly: " hello \n\n "
|
|
109
|
+
assert.strictEqual(full, " hello \n\n ");
|
|
110
|
+
} finally {
|
|
111
|
+
restore();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('caching-streaming and cache-control: returns prompt_tokens_details', async () => {
|
|
116
|
+
const restore = setupFetchMock((url) => {
|
|
117
|
+
const stream = new ReadableStream({
|
|
118
|
+
start(c) {
|
|
119
|
+
c.enqueue(new TextEncoder().encode('data: {"choices": [{"delta": {"content": "done", "phase": "answer"}}], "usage": {"output_tokens": 10}}\n\n'));
|
|
120
|
+
c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
|
121
|
+
c.close();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return new Response(stream, { status: 200 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const req = new Request('http://localhost/v1/chat/completions', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ model: 'qwen3.6-plus', messages: [{role: 'user', content: 'test'}], stream: true })
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const res = await app.fetch(req);
|
|
135
|
+
const reader = res.body?.getReader();
|
|
136
|
+
const decoder = new TextDecoder();
|
|
137
|
+
let usageBlock = null;
|
|
138
|
+
while (true) {
|
|
139
|
+
const { done, value } = await reader!.read();
|
|
140
|
+
if (done) break;
|
|
141
|
+
const chunk = decoder.decode(value);
|
|
142
|
+
for (const line of chunk.split('\n')) {
|
|
143
|
+
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
|
144
|
+
try {
|
|
145
|
+
const data = JSON.parse(line.slice(6));
|
|
146
|
+
if (data.usage) {
|
|
147
|
+
usageBlock = data.usage;
|
|
148
|
+
}
|
|
149
|
+
} catch(e) {}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
assert.ok(usageBlock);
|
|
155
|
+
assert.strictEqual(usageBlock.completion_tokens, 10);
|
|
156
|
+
assert.ok(usageBlock.prompt_tokens > 0);
|
|
157
|
+
assert.strictEqual(usageBlock.prompt_tokens_details.cached_tokens, 0); // Tests caching-streaming shape!
|
|
158
|
+
} finally {
|
|
159
|
+
restore();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('session-parent-tracking: appends messages using response message_id as parent', async () => {
|
|
164
|
+
let capturedPayloads: any[] = [];
|
|
165
|
+
|
|
166
|
+
const restore = setupFetchMock((url, init) => {
|
|
167
|
+
const bodyObj = JSON.parse(init?.body as string || '{}');
|
|
168
|
+
capturedPayloads.push(bodyObj);
|
|
169
|
+
|
|
170
|
+
// Simulate Qwen returning a response_id
|
|
171
|
+
const mockMessageId = capturedPayloads.length === 1 ? 'qwen-1001' : 'qwen-1002';
|
|
172
|
+
|
|
173
|
+
const stream = new ReadableStream({
|
|
174
|
+
start(c) {
|
|
175
|
+
c.enqueue(new TextEncoder().encode(`data: {"response.created":{"response_id":"${mockMessageId}"}}\n\n`));
|
|
176
|
+
c.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
|
177
|
+
c.close();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return new Response(stream, { status: 200 });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
process.env.TEST_SESSION_ID = 'test-session-parent-tracking';
|
|
185
|
+
// Turn 1
|
|
186
|
+
const req1 = new Request('http://localhost/v1/chat/completions', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
model: 'qwen3.6-plus',
|
|
191
|
+
messages: [{ role: 'user', content: 'Turn 1' }]
|
|
192
|
+
})
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const res1 = await app.fetch(req1);
|
|
196
|
+
assert.strictEqual(res1.status, 200);
|
|
197
|
+
// Consume the stream to ensure the message_id is processed
|
|
198
|
+
await res1.text();
|
|
199
|
+
|
|
200
|
+
// Turn 2
|
|
201
|
+
const req2 = new Request('http://localhost/v1/chat/completions', {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: { 'Content-Type': 'application/json' },
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
model: 'qwen3.6-plus',
|
|
206
|
+
messages: [
|
|
207
|
+
{ role: 'user', content: 'Turn 1' },
|
|
208
|
+
{ role: 'assistant', content: 'Response 1' },
|
|
209
|
+
{ role: 'user', content: 'Turn 2' }
|
|
210
|
+
]
|
|
211
|
+
})
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const res2 = await app.fetch(req2);
|
|
215
|
+
assert.strictEqual(res2.status, 200);
|
|
216
|
+
await res2.text();
|
|
217
|
+
|
|
218
|
+
assert.strictEqual(capturedPayloads.length, 2);
|
|
219
|
+
// In Turn 1, parent_id should be null (mock-session is fresh)
|
|
220
|
+
assert.strictEqual(capturedPayloads[0].parent_id, null);
|
|
221
|
+
// In Turn 2, parent_id should be qwen-1001 (the ID returned in Turn 1)
|
|
222
|
+
assert.strictEqual(capturedPayloads[1].parent_id, 'qwen-1001', 'Turn 2 should use response_id from Turn 1 as parent');
|
|
223
|
+
assert.strictEqual(capturedPayloads[1].messages[0].content, 'User: Turn 1\n\nAssistant: Response 1\n\nUser: Turn 2\n\n', 'Should send the full OpenAI message history');
|
|
224
|
+
} finally {
|
|
225
|
+
restore();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { serve } from '@hono/node-server';
|
|
7
|
+
import { app } from '../api/server.js';
|
|
8
|
+
import { initPlaywright, closePlaywright } from '../services/playwright.ts';
|
|
9
|
+
|
|
10
|
+
const SANDBOX_DIR = '/tmp/kilo/sandbox';
|
|
11
|
+
|
|
12
|
+
function isPortAvailable(port: number): Promise<boolean> {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const server = net.createServer();
|
|
15
|
+
server.once('error', () => {
|
|
16
|
+
resolve(false);
|
|
17
|
+
});
|
|
18
|
+
server.once('listening', () => {
|
|
19
|
+
server.close(() => {
|
|
20
|
+
resolve(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
server.listen(port);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getFreePort(startPort: number): Promise<number> {
|
|
28
|
+
let port = startPort;
|
|
29
|
+
while (true) {
|
|
30
|
+
const available = await isPortAvailable(port);
|
|
31
|
+
if (available) return port;
|
|
32
|
+
port++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Clean and recreate physical sandbox directory
|
|
37
|
+
if (fs.existsSync(SANDBOX_DIR)) {
|
|
38
|
+
fs.rmSync(SANDBOX_DIR, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
fs.mkdirSync(SANDBOX_DIR, { recursive: true });
|
|
41
|
+
|
|
42
|
+
function normalizePath(p: string): string {
|
|
43
|
+
return p.replace(/\\/g, '/').toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Local real file system tool handlers
|
|
47
|
+
const localTools = {
|
|
48
|
+
list_files: () => {
|
|
49
|
+
const files = fs.readdirSync(SANDBOX_DIR);
|
|
50
|
+
return JSON.stringify(files);
|
|
51
|
+
},
|
|
52
|
+
create_file: (args: { path: string; content: string }) => {
|
|
53
|
+
const filePath = path.join(SANDBOX_DIR, args.path);
|
|
54
|
+
const basePath = normalizePath(SANDBOX_DIR) + '/';
|
|
55
|
+
if (!normalizePath(filePath).startsWith(basePath)) {
|
|
56
|
+
return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
|
|
57
|
+
}
|
|
58
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
59
|
+
fs.writeFileSync(filePath, args.content, 'utf8');
|
|
60
|
+
return JSON.stringify({ status: 'success', path: args.path });
|
|
61
|
+
},
|
|
62
|
+
read_file: (args: { path: string }) => {
|
|
63
|
+
const filePath = path.join(SANDBOX_DIR, args.path);
|
|
64
|
+
const basePath = normalizePath(SANDBOX_DIR) + '/';
|
|
65
|
+
if (!normalizePath(filePath).startsWith(basePath)) {
|
|
66
|
+
return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
|
|
67
|
+
}
|
|
68
|
+
if (!fs.existsSync(filePath)) {
|
|
69
|
+
return JSON.stringify({ error: `File ${args.path} not found` });
|
|
70
|
+
}
|
|
71
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
72
|
+
return JSON.stringify({ content });
|
|
73
|
+
},
|
|
74
|
+
edit_file: (args: { path: string; oldText: string; newText: string }) => {
|
|
75
|
+
const filePath = path.join(SANDBOX_DIR, args.path);
|
|
76
|
+
const basePath = normalizePath(SANDBOX_DIR) + '/';
|
|
77
|
+
if (!normalizePath(filePath).startsWith(basePath)) {
|
|
78
|
+
return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
|
|
79
|
+
}
|
|
80
|
+
if (!fs.existsSync(filePath)) {
|
|
81
|
+
return JSON.stringify({ error: `File ${args.path} not found` });
|
|
82
|
+
}
|
|
83
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
84
|
+
if (!content.includes(args.oldText)) {
|
|
85
|
+
return JSON.stringify({ error: `Old text not found in ${args.path}` });
|
|
86
|
+
}
|
|
87
|
+
fs.writeFileSync(filePath, content.replace(args.oldText, args.newText), 'utf8');
|
|
88
|
+
return JSON.stringify({ status: 'success', path: args.path });
|
|
89
|
+
},
|
|
90
|
+
delete_file: (args: { path: string }) => {
|
|
91
|
+
const filePath = path.join(SANDBOX_DIR, args.path);
|
|
92
|
+
const basePath = normalizePath(SANDBOX_DIR) + '/';
|
|
93
|
+
if (!normalizePath(filePath).startsWith(basePath)) {
|
|
94
|
+
return JSON.stringify({ error: 'Access denied: Directory traversal detected' });
|
|
95
|
+
}
|
|
96
|
+
if (!fs.existsSync(filePath)) {
|
|
97
|
+
return JSON.stringify({ error: `File ${args.path} not found` });
|
|
98
|
+
}
|
|
99
|
+
fs.unlinkSync(filePath);
|
|
100
|
+
return JSON.stringify({ status: 'success', path: args.path });
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Declares standard tool definitions in OpenAI format
|
|
105
|
+
const toolDefinitions = [
|
|
106
|
+
{
|
|
107
|
+
type: 'function',
|
|
108
|
+
function: {
|
|
109
|
+
name: 'list_files',
|
|
110
|
+
description: 'List all files in the sandbox',
|
|
111
|
+
parameters: { type: 'object', properties: {} }
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'function',
|
|
116
|
+
function: {
|
|
117
|
+
name: 'create_file',
|
|
118
|
+
description: 'Create a new file with content',
|
|
119
|
+
parameters: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: {
|
|
122
|
+
path: { type: 'string' },
|
|
123
|
+
content: { type: 'string' }
|
|
124
|
+
},
|
|
125
|
+
required: ['path', 'content']
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: 'function',
|
|
131
|
+
function: {
|
|
132
|
+
name: 'read_file',
|
|
133
|
+
description: 'Read the content of a file',
|
|
134
|
+
parameters: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
path: { type: 'string' }
|
|
138
|
+
},
|
|
139
|
+
required: ['path']
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: 'function',
|
|
145
|
+
function: {
|
|
146
|
+
name: 'edit_file',
|
|
147
|
+
description: 'Edit a file replacing oldText with newText',
|
|
148
|
+
parameters: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
path: { type: 'string' },
|
|
152
|
+
oldText: { type: 'string' },
|
|
153
|
+
newText: { type: 'string' }
|
|
154
|
+
},
|
|
155
|
+
required: ['path', 'oldText', 'newText']
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: 'function',
|
|
161
|
+
function: {
|
|
162
|
+
name: 'delete_file',
|
|
163
|
+
description: 'Delete a file',
|
|
164
|
+
parameters: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
path: { type: 'string' }
|
|
168
|
+
},
|
|
169
|
+
required: ['path']
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
test('Agentic Stress Test: >30 messages multi-turn using the REAL live API', { skip: process.env.CI ? 'Requires real accounts - skipped in CI' : false }, async () => {
|
|
176
|
+
const DEFAULT_PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
|
177
|
+
const port = await getFreePort(DEFAULT_PORT);
|
|
178
|
+
|
|
179
|
+
const server = serve({
|
|
180
|
+
fetch: app.fetch,
|
|
181
|
+
port: port
|
|
182
|
+
});
|
|
183
|
+
console.log(`[RealTest] Local Hono server started on port ${port}`);
|
|
184
|
+
|
|
185
|
+
// Initialize Playwright with headless mode to fetch real active session headers
|
|
186
|
+
console.log('[RealTest] Initializing Playwright browser context...');
|
|
187
|
+
await initPlaywright(true);
|
|
188
|
+
|
|
189
|
+
// Dynamic conversation prompt sequence (explicitly instructing tool calls)
|
|
190
|
+
const conversationScenario = [
|
|
191
|
+
"Please call the 'list_files' tool to check if the sandbox is currently empty.",
|
|
192
|
+
"Great. Create a file named 'a.txt' with content 'Hello A' and a file named 'b.txt' with content 'Hello B' by calling the 'create_file' tool for both.",
|
|
193
|
+
"Please call the 'read_file' tool for 'b.txt' to verify its content.",
|
|
194
|
+
"Now, call the 'edit_file' tool to replace 'Hello A' with 'Hello Awesome World' in 'a.txt'.",
|
|
195
|
+
"Please call the 'read_file' tool for 'a.txt' to check if the change was applied successfully.",
|
|
196
|
+
"Great! Now call the 'list_files' tool to see both of them.",
|
|
197
|
+
"Excellent. Now delete both files by calling the 'delete_file' tool for both.",
|
|
198
|
+
"Please call the 'list_files' tool one last time to make sure they are gone.",
|
|
199
|
+
"Perfect! Thank you so much."
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const messages: any[] = [
|
|
203
|
+
{ role: 'system', content: 'You are an agentic file helper. You have access to tools to manage files in a sandbox directory. You MUST call the requested tools in every response to perform the file operations. Do not guess or assume results without executing the tool first. Wrap your tool calls exactly in <tool_call>...</tool_call> tags.' }
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
for (const userPrompt of conversationScenario) {
|
|
208
|
+
console.log(`\n👤 [User]: ${userPrompt}`);
|
|
209
|
+
messages.push({ role: 'user', content: userPrompt });
|
|
210
|
+
|
|
211
|
+
let agentTurnDone = false;
|
|
212
|
+
let loopLimiter = 0;
|
|
213
|
+
|
|
214
|
+
while (!agentTurnDone) {
|
|
215
|
+
loopLimiter++;
|
|
216
|
+
assert.ok(loopLimiter <= 10, 'Agent got stuck in an infinite tool calling loop');
|
|
217
|
+
|
|
218
|
+
// Add a 2.5 second delay to let the Qwen backend settle and prevent "The chat is in progress!" errors
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, 2500));
|
|
220
|
+
|
|
221
|
+
console.log(`[RealTest] Sending request to proxy completions endpoint (messages: ${messages.length})...`);
|
|
222
|
+
|
|
223
|
+
// Send request to real proxy completions API
|
|
224
|
+
const response = await fetch(`http://localhost:${port}/v1/chat/completions`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'Content-Type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
model: 'qwen3.6-plus',
|
|
229
|
+
messages: messages,
|
|
230
|
+
tools: toolDefinitions,
|
|
231
|
+
stream: true
|
|
232
|
+
})
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
assert.strictEqual(response.status, 200, `Expected 200, got ${response.status}`);
|
|
236
|
+
|
|
237
|
+
const reader = response.body?.getReader();
|
|
238
|
+
assert.ok(reader, 'Response should have stream body');
|
|
239
|
+
|
|
240
|
+
const decoder = new TextDecoder();
|
|
241
|
+
let content = '';
|
|
242
|
+
let reasoning = '';
|
|
243
|
+
let toolCalls: any[] = [];
|
|
244
|
+
let buffer = '';
|
|
245
|
+
|
|
246
|
+
while (true) {
|
|
247
|
+
const { done, value } = await reader.read();
|
|
248
|
+
if (done) break;
|
|
249
|
+
|
|
250
|
+
buffer += decoder.decode(value, { stream: true });
|
|
251
|
+
const lines = buffer.split('\n');
|
|
252
|
+
buffer = lines.pop() || '';
|
|
253
|
+
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
const trimmed = line.trim();
|
|
256
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
257
|
+
const dataStr = trimmed.slice(6);
|
|
258
|
+
if (dataStr === '[DONE]') continue;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const chunk = JSON.parse(dataStr);
|
|
262
|
+
if (chunk.choices && chunk.choices[0] && chunk.choices[0].delta) {
|
|
263
|
+
const delta = chunk.choices[0].delta;
|
|
264
|
+
if (delta.content) {
|
|
265
|
+
content += delta.content;
|
|
266
|
+
}
|
|
267
|
+
if (delta.reasoning_content) {
|
|
268
|
+
reasoning += delta.reasoning_content;
|
|
269
|
+
}
|
|
270
|
+
if (delta.tool_calls) {
|
|
271
|
+
for (const tc of delta.tool_calls) {
|
|
272
|
+
const idx = tc.index ?? 0;
|
|
273
|
+
if (!toolCalls[idx]) {
|
|
274
|
+
toolCalls[idx] = { id: tc.id, name: tc.function?.name || '', arguments: '' };
|
|
275
|
+
}
|
|
276
|
+
if (tc.function?.arguments) {
|
|
277
|
+
toolCalls[idx].arguments += tc.function.arguments;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
// ignore partial chunk parsing errors
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Output and construct assistant message
|
|
289
|
+
const assistantMessage: any = { role: 'assistant' };
|
|
290
|
+
if (content) assistantMessage.content = content;
|
|
291
|
+
if (reasoning) assistantMessage.reasoning_content = reasoning;
|
|
292
|
+
|
|
293
|
+
if (reasoning) {
|
|
294
|
+
console.log(`💭 [Thinking]: ${reasoning}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (toolCalls.length > 0) {
|
|
298
|
+
assistantMessage.tool_calls = toolCalls.map(tc => ({
|
|
299
|
+
id: tc.id,
|
|
300
|
+
type: 'function',
|
|
301
|
+
function: {
|
|
302
|
+
name: tc.name,
|
|
303
|
+
arguments: tc.arguments
|
|
304
|
+
}
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
messages.push(assistantMessage);
|
|
308
|
+
|
|
309
|
+
for (const tc of assistantMessage.tool_calls) {
|
|
310
|
+
const toolName = tc.function.name as keyof typeof localTools;
|
|
311
|
+
const toolArgs = JSON.parse(tc.function.arguments || '{}');
|
|
312
|
+
console.log(`🛠️ [Tool Call]: ${toolName} with args:`, toolArgs);
|
|
313
|
+
|
|
314
|
+
let result = '';
|
|
315
|
+
if (typeof localTools[toolName] === 'function') {
|
|
316
|
+
try {
|
|
317
|
+
result = localTools[toolName](toolArgs as any);
|
|
318
|
+
} catch (err: any) {
|
|
319
|
+
result = JSON.stringify({ error: `Tool execution failed: ${err.message}` });
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
result = JSON.stringify({
|
|
323
|
+
error: `Tool '${toolName}' is not available. Please use one of the available tools: list_files, create_file, read_file, edit_file, delete_file.`
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
console.log(`🟢 [Tool Result]: ${result}`);
|
|
327
|
+
|
|
328
|
+
messages.push({
|
|
329
|
+
role: 'tool',
|
|
330
|
+
tool_call_id: tc.id,
|
|
331
|
+
name: toolName,
|
|
332
|
+
content: result
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
console.log(`🤖 [Agent]: ${content}`);
|
|
337
|
+
messages.push(assistantMessage);
|
|
338
|
+
agentTurnDone = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
console.log(`\n[RealTest] Integration Test complete! Total chat history size: ${messages.length} messages.`);
|
|
344
|
+
assert.ok(messages.length > 30, `Expected conversation history to contain >30 messages, got ${messages.length}`);
|
|
345
|
+
|
|
346
|
+
// Sandbox must be clean at the end
|
|
347
|
+
const remainingFiles = fs.readdirSync(SANDBOX_DIR);
|
|
348
|
+
assert.strictEqual(remainingFiles.length, 0, 'Sandbox directory must be empty at the end');
|
|
349
|
+
|
|
350
|
+
} finally {
|
|
351
|
+
// Teardown browser context and Hono server
|
|
352
|
+
await closePlaywright();
|
|
353
|
+
if (server) {
|
|
354
|
+
server.close();
|
|
355
|
+
console.log('[RealTest] Server stopped and Playwright closed.');
|
|
356
|
+
} else {
|
|
357
|
+
console.log('[RealTest] Playwright closed.');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
process.env.TEST_MOCK_PLAYWRIGHT = 'true';
|
|
5
|
+
|
|
6
|
+
import { app } from '../api/server.js';
|
|
7
|
+
import { initPlaywright, closePlaywright } from '../services/playwright.ts';
|
|
8
|
+
|
|
9
|
+
test('Concurrent requests are serialized by mutex', async () => {
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
|
|
12
|
+
globalThis.fetch = async (input: any) => {
|
|
13
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
14
|
+
if (url.includes('/api/models')) {
|
|
15
|
+
return new Response(JSON.stringify({
|
|
16
|
+
data: [{ id: 'qwen3.6-plus', owned_by: 'qwen', info: { created_at: Date.now(), meta: {} } }]
|
|
17
|
+
}), { status: 200 });
|
|
18
|
+
}
|
|
19
|
+
if (url.includes('/api/v2/chat/completions')) {
|
|
20
|
+
return new Response(
|
|
21
|
+
'data: {"choices": [{"delta": {"phase": "answer", "content": "OK"}}]}\n\ndata: [DONE]\n\n',
|
|
22
|
+
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return originalFetch(input);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
await initPlaywright(false);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const promises = Array.from({ length: 5 }, (_, i) =>
|
|
32
|
+
app.fetch(
|
|
33
|
+
new Request('http://localhost/v1/chat/completions', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
model: 'qwen3.6-plus',
|
|
38
|
+
messages: [{ role: 'user', content: `Request ${i}` }],
|
|
39
|
+
stream: false
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const responses = await Promise.all(promises);
|
|
46
|
+
|
|
47
|
+
// All requests should complete (serialized by mutex)
|
|
48
|
+
for (const res of responses) {
|
|
49
|
+
assert.ok(
|
|
50
|
+
res.status === 200 || res.status === 429 || res.status === 502,
|
|
51
|
+
`Unexpected status: ${res.status}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
globalThis.fetch = originalFetch;
|
|
56
|
+
await closePlaywright();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('No-thinking model variant is accepted', async () => {
|
|
61
|
+
const originalFetch = globalThis.fetch;
|
|
62
|
+
|
|
63
|
+
globalThis.fetch = async (input: any) => {
|
|
64
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
65
|
+
if (url.includes('/api/models')) {
|
|
66
|
+
return new Response(JSON.stringify({
|
|
67
|
+
data: [{ id: 'qwen3.6-plus', owned_by: 'qwen', info: { created_at: Date.now(), meta: { max_context_length: 1000000 } } }]
|
|
68
|
+
}), { status: 200 });
|
|
69
|
+
}
|
|
70
|
+
if (url.includes('/api/v2/chat/completions')) {
|
|
71
|
+
return new Response(
|
|
72
|
+
'data: {"choices": [{"delta": {"phase": "answer", "content": "OK"}}]}\n\ndata: [DONE]\n\n',
|
|
73
|
+
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return originalFetch(input);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
await initPlaywright(false);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Test no-thinking model is accepted without error
|
|
83
|
+
const res = await app.fetch(
|
|
84
|
+
new Request('http://localhost/v1/chat/completions', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
model: 'qwen3.6-plus-no-thinking',
|
|
89
|
+
messages: [{ role: 'user', content: 'Test' }],
|
|
90
|
+
stream: false
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
assert.ok(
|
|
96
|
+
res.status === 200 || res.status === 429 || res.status === 502,
|
|
97
|
+
`No-thinking model should be accepted, got status: ${res.status}`
|
|
98
|
+
);
|
|
99
|
+
} finally {
|
|
100
|
+
globalThis.fetch = originalFetch;
|
|
101
|
+
await closePlaywright();
|
|
102
|
+
}
|
|
103
|
+
});
|