llm-switcher 1.1.11 → 1.2.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/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// Real-world user interaction and coding tool simulation battery.
|
|
2
|
+
// Simulates end-to-end interactions from Claude Code and Codex clients
|
|
3
|
+
// calling LLM providers through the llm-switcher gateway.
|
|
4
|
+
import test, { before, after } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
+
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
|
|
18
|
+
let upstream;
|
|
19
|
+
let upstreamPort;
|
|
20
|
+
let proxy;
|
|
21
|
+
let proxyPort;
|
|
22
|
+
let fakeInterceptor;
|
|
23
|
+
let blindfoldPort;
|
|
24
|
+
let tmpDir;
|
|
25
|
+
const received = [];
|
|
26
|
+
|
|
27
|
+
function freePort() {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const s = net.createServer();
|
|
30
|
+
s.listen(0, '127.0.0.1', () => {
|
|
31
|
+
const { port } = s.address();
|
|
32
|
+
s.close(() => resolve(port));
|
|
33
|
+
});
|
|
34
|
+
s.on('error', reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sse = (res, objs, { raw = [] } = {}) => {
|
|
39
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
40
|
+
for (const o of objs) res.write(`data: ${JSON.stringify(o)}\n\n`);
|
|
41
|
+
for (const r of raw) res.write(r);
|
|
42
|
+
res.end();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function startUpstream() {
|
|
46
|
+
upstream = http.createServer((req, res) => {
|
|
47
|
+
let body = '';
|
|
48
|
+
req.on('data', c => { body += c; });
|
|
49
|
+
req.on('end', () => {
|
|
50
|
+
const json = body ? JSON.parse(body) : {};
|
|
51
|
+
received.push({ url: req.url, headers: req.headers, body: json });
|
|
52
|
+
const lastUser = JSON.stringify(json.messages?.at(-1) ?? json.contents?.at(-1) ?? '');
|
|
53
|
+
|
|
54
|
+
if (req.url.startsWith('/chat/v1/chat/completions')) {
|
|
55
|
+
if (lastUser.includes('SIM_429')) {
|
|
56
|
+
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '15' });
|
|
57
|
+
return res.end(JSON.stringify({ error: { message: 'Rate limit reached, please slow down' } }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (lastUser.includes('SIM_CUT_STREAM')) {
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
62
|
+
res.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: 'partial thinking...' } }] })}\n\n`);
|
|
63
|
+
return setTimeout(() => res.destroy(), 30);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (lastUser.includes('SIM_THINKING_BUDGET')) {
|
|
67
|
+
return sse(res, [
|
|
68
|
+
{ choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: 'Step 1: Inspect requirement.\nStep 2: Formulate plan.' } }] },
|
|
69
|
+
{ choices: [{ index: 0, delta: { content: 'Here is the plan.' } }] },
|
|
70
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
|
71
|
+
{ choices: [], usage: { prompt_tokens: 150, completion_tokens: 45, prompt_tokens_details: { cached_tokens: 50 } } }
|
|
72
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (lastUser.includes('SIM_TOOL_TURN_1')) {
|
|
76
|
+
return sse(res, [
|
|
77
|
+
{ choices: [{ index: 0, delta: { role: 'assistant', content: 'Let me search files.' } }] },
|
|
78
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_grep_01', type: 'function', function: { name: 'Grep', arguments: '{"pattern":"ensureAdminToken"}' } }] } }] },
|
|
79
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
|
|
80
|
+
{ choices: [], usage: { prompt_tokens: 200, completion_tokens: 30 } }
|
|
81
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (lastUser.includes('SIM_TOOL_TURN_2')) {
|
|
85
|
+
return sse(res, [
|
|
86
|
+
{ choices: [{ index: 0, delta: { content: 'Found ensureAdminToken in state.mjs: line 42.' } }] },
|
|
87
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
|
88
|
+
{ choices: [], usage: { prompt_tokens: 350, completion_tokens: 25 } }
|
|
89
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (lastUser.includes('SIM_CODEX_PATCH')) {
|
|
93
|
+
return sse(res, [
|
|
94
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_patch_1', type: 'function', function: { name: 'apply_patch', arguments: '{"input":"*** Begin Patch\\n+console.log(\\"patched\\");\\n*** End Patch"}' } }] } }] },
|
|
95
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (lastUser.includes('SIM_CODEX_DONE')) {
|
|
100
|
+
return sse(res, [
|
|
101
|
+
{ choices: [{ index: 0, delta: { content: 'Patch verified and test passes.' } }] },
|
|
102
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Generic stream response
|
|
107
|
+
return sse(res, [
|
|
108
|
+
{ choices: [{ index: 0, delta: { content: 'Simulation ok' } }] },
|
|
109
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
|
110
|
+
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 5 } }
|
|
111
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (req.url.startsWith('/vtx/models/')) {
|
|
115
|
+
// Vertex simulation: check tools received
|
|
116
|
+
return sse(res, [
|
|
117
|
+
{ candidates: [{ content: { role: 'model', parts: [{ text: 'Vertex received valid schemas' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 100, candidatesTokenCount: 15 } }
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
res.writeHead(404);
|
|
122
|
+
res.end('{}');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return new Promise(r => upstream.listen(0, '127.0.0.1', () => { upstreamPort = upstream.address().port; r(); }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
before(async () => {
|
|
129
|
+
await startUpstream();
|
|
130
|
+
proxyPort = await freePort();
|
|
131
|
+
blindfoldPort = await freePort();
|
|
132
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sim-'));
|
|
133
|
+
|
|
134
|
+
const base = `http://127.0.0.1:${upstreamPort}`;
|
|
135
|
+
const models = { opus: 'up-opus', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' };
|
|
136
|
+
const cfg = {
|
|
137
|
+
port: proxyPort,
|
|
138
|
+
blindfold: { port: blindfoldPort },
|
|
139
|
+
activeProfiles: { claude: 'chat', codex: 'codex-chat' },
|
|
140
|
+
profiles: {
|
|
141
|
+
chat: { name: 'Sim Chat', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-sim-chat', defaultModels: models },
|
|
142
|
+
vtx: { name: 'Sim Vertex', mode: 'convert', inFormat: 'auto', outFormat: 'vertex', baseURL: `${base}/vtx`, apiKey: 'sk-sim-vtx', defaultModels: models },
|
|
143
|
+
'codex-chat': { name: 'Sim Codex', mode: 'convert', tool: 'codex', inFormat: 'responses', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-sim-codex', defaultModels: { main: 'up-sonnet' } }
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify(cfg, null, 2));
|
|
147
|
+
|
|
148
|
+
proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(proxyPort)], {
|
|
149
|
+
env: {
|
|
150
|
+
...process.env,
|
|
151
|
+
LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'),
|
|
152
|
+
LLM_SWITCHER_STATE_DIR: tmpDir,
|
|
153
|
+
CLAUDE_CONFIG_DIR: path.join(tmpDir, 'claude'),
|
|
154
|
+
LLM_SWITCHER_PORT: ''
|
|
155
|
+
},
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
let started = false;
|
|
160
|
+
for (let i = 0; i < 50 && !started; i++) {
|
|
161
|
+
try {
|
|
162
|
+
const r = await fetch(`http://127.0.0.1:${proxyPort}/health`);
|
|
163
|
+
if (r.ok) started = true;
|
|
164
|
+
} catch {}
|
|
165
|
+
if (!started) await new Promise(r => setTimeout(r, 100));
|
|
166
|
+
}
|
|
167
|
+
if (!started) throw new Error('proxy did not start in simulation fixture');
|
|
168
|
+
|
|
169
|
+
// Stand-in mock interceptor on blindfoldPort
|
|
170
|
+
fakeInterceptor = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
171
|
+
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
172
|
+
const http = await import('node:http');
|
|
173
|
+
const port = ${blindfoldPort};
|
|
174
|
+
http.createServer((req, res) => {
|
|
175
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
176
|
+
const ch = u.searchParams.get('challenge');
|
|
177
|
+
const proof = s.identityProof(ch || '', { role: 'blindfold', port, pid: process.pid, gatewayPort: ${proxyPort}, activeTools: 'claude,codex' });
|
|
178
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
179
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', role: 'blindfold', port, pid: process.pid, gatewayPort: ${proxyPort}, activeTools: 'claude,codex', proof }));
|
|
180
|
+
}).listen(port, '127.0.0.1');
|
|
181
|
+
`], {
|
|
182
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'), LLM_SWITCHER_STATE_DIR: tmpDir },
|
|
183
|
+
stdio: 'ignore'
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const deadline = Date.now() + 8000;
|
|
187
|
+
for (;;) {
|
|
188
|
+
const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
|
|
189
|
+
if (probe?.ok) break;
|
|
190
|
+
if (Date.now() > deadline) throw new Error('fake interceptor never started');
|
|
191
|
+
await new Promise(r => setTimeout(r, 100));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
after(() => {
|
|
196
|
+
fakeInterceptor?.kill();
|
|
197
|
+
try {
|
|
198
|
+
const bfFile = path.join(tmpDir, 'blindfold.json');
|
|
199
|
+
if (fs.existsSync(bfFile)) {
|
|
200
|
+
const bfState = JSON.parse(fs.readFileSync(bfFile, 'utf8'));
|
|
201
|
+
if (bfState?.pid) {
|
|
202
|
+
if (process.platform === 'win32') execFileSync('taskkill', ['/F', '/PID', String(bfState.pid)], { stdio: 'ignore' });
|
|
203
|
+
else process.kill(bfState.pid, 'SIGTERM');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch {}
|
|
207
|
+
proxy?.kill();
|
|
208
|
+
upstream?.close();
|
|
209
|
+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const url = (p) => `http://127.0.0.1:${proxyPort}${p}`;
|
|
213
|
+
const adminToken = () => fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
|
|
214
|
+
const post = (p, body, headers = {}) => fetch(url(p), {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: {
|
|
217
|
+
'Content-Type': 'application/json',
|
|
218
|
+
...(p.startsWith('/api/') ? { 'x-llm-switcher-token': adminToken() } : {}),
|
|
219
|
+
...headers
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify(body)
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function parseSSE(text) {
|
|
225
|
+
return text.split(/\n\n/).filter(Boolean).map(block => {
|
|
226
|
+
let event = null;
|
|
227
|
+
let data = '';
|
|
228
|
+
for (const line of block.split('\n')) {
|
|
229
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
230
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
231
|
+
}
|
|
232
|
+
return { event, raw: data, data: data && data !== '[DONE]' ? JSON.parse(data) : null };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
test('Real User Sim 1: Claude Code multi-turn tool interaction (Tool Call -> Tool Result -> Final Answer)', async () => {
|
|
237
|
+
// Turn 1: User prompt requests finding a function
|
|
238
|
+
const res1 = await post('/v1/messages', {
|
|
239
|
+
model: 'claude-opus-4-6',
|
|
240
|
+
max_tokens: 2048,
|
|
241
|
+
stream: true,
|
|
242
|
+
messages: [{ role: 'user', content: 'SIM_TOOL_TURN_1 find ensureAdminToken' }],
|
|
243
|
+
tools: [
|
|
244
|
+
{ name: 'Grep', description: 'Search files', input_schema: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] } }
|
|
245
|
+
]
|
|
246
|
+
});
|
|
247
|
+
assert.equal(res1.status, 200);
|
|
248
|
+
const text1 = await res1.text();
|
|
249
|
+
const events1 = parseSSE(text1);
|
|
250
|
+
assertValidAnthropicEvents(events1);
|
|
251
|
+
|
|
252
|
+
const blockStart = events1.find(e => e.event === 'content_block_start' && e.data?.content_block?.type === 'tool_use');
|
|
253
|
+
assert.ok(blockStart, 'must emit content_block_start with tool_use');
|
|
254
|
+
assert.equal(blockStart.data.content_block.name, 'Grep');
|
|
255
|
+
const toolId = blockStart.data.content_block.id;
|
|
256
|
+
assert.ok(toolId, 'tool_use must have an id');
|
|
257
|
+
|
|
258
|
+
// Turn 2: Claude Code runs the tool and returns tool_result
|
|
259
|
+
const res2 = await post('/v1/messages', {
|
|
260
|
+
model: 'claude-opus-4-6',
|
|
261
|
+
max_tokens: 2048,
|
|
262
|
+
stream: true,
|
|
263
|
+
messages: [
|
|
264
|
+
{ role: 'user', content: 'SIM_TOOL_TURN_1 find ensureAdminToken' },
|
|
265
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: toolId, name: 'Grep', input: { pattern: 'ensureAdminToken' } }] },
|
|
266
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: toolId, content: 'SIM_TOOL_TURN_2 state.mjs:42: export function ensureAdminToken' }] }
|
|
267
|
+
]
|
|
268
|
+
});
|
|
269
|
+
assert.equal(res2.status, 200);
|
|
270
|
+
const text2 = await res2.text();
|
|
271
|
+
const events2 = parseSSE(text2);
|
|
272
|
+
assertValidAnthropicEvents(events2);
|
|
273
|
+
|
|
274
|
+
const deltas = events2.filter(e => e.event === 'content_block_delta').map(e => e.data?.delta?.text).filter(Boolean);
|
|
275
|
+
assert.ok(deltas.join('').includes('Found ensureAdminToken in state.mjs: line 42.'));
|
|
276
|
+
const msgStop = events2.find(e => e.event === 'message_stop');
|
|
277
|
+
assert.ok(msgStop, 'must cleanly complete turn with message_stop');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('Real User Sim 2: Claude Code thinking block flow with budget tokens', async () => {
|
|
281
|
+
const res = await post('/v1/messages', {
|
|
282
|
+
model: 'claude-opus-4-6',
|
|
283
|
+
max_tokens: 4096,
|
|
284
|
+
thinking: { type: 'enabled', budget_tokens: 1024 },
|
|
285
|
+
stream: true,
|
|
286
|
+
messages: [{ role: 'user', content: 'SIM_THINKING_BUDGET Plan the refactoring' }]
|
|
287
|
+
});
|
|
288
|
+
assert.equal(res.status, 200);
|
|
289
|
+
const text = await res.text();
|
|
290
|
+
const events = parseSSE(text);
|
|
291
|
+
assertValidAnthropicEvents(events);
|
|
292
|
+
|
|
293
|
+
const thinkingBlock = events.find(e => e.event === 'content_block_start' && e.data?.content_block?.type === 'thinking');
|
|
294
|
+
assert.ok(thinkingBlock, 'must emit thinking block for reasoning upstream');
|
|
295
|
+
const thinkingDeltas = events.filter(e => e.event === 'content_block_delta' && e.data?.delta?.type === 'thinking_delta');
|
|
296
|
+
assert.ok(thinkingDeltas.length > 0, 'must emit thinking deltas');
|
|
297
|
+
const textBlock = events.find(e => e.event === 'content_block_start' && e.data?.content_block?.type === 'text');
|
|
298
|
+
assert.ok(textBlock, 'text block follows thinking block');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('Real User Sim 3: Gemini / Vertex healing of empty & complex tool schemas', async () => {
|
|
302
|
+
// Test switching profile to vertex and sending empty schema
|
|
303
|
+
const switchRes = await post('/api/switch', { tool: 'claude', profile: 'vtx' });
|
|
304
|
+
assert.equal(switchRes.status, 200);
|
|
305
|
+
|
|
306
|
+
const res = await post('/v1/messages', {
|
|
307
|
+
model: 'claude-sonnet-4',
|
|
308
|
+
max_tokens: 1024,
|
|
309
|
+
stream: true,
|
|
310
|
+
messages: [{ role: 'user', content: 'Call tool with empty schema' }],
|
|
311
|
+
tools: [
|
|
312
|
+
{ name: 'empty_tool', description: 'Tool with no parameters', input_schema: {} },
|
|
313
|
+
{ name: 'nested_tool', description: 'Tool with complex types', input_schema: { type: 'object', properties: { flag: { type: 'boolean' } } } }
|
|
314
|
+
]
|
|
315
|
+
});
|
|
316
|
+
assert.equal(res.status, 200);
|
|
317
|
+
const text = await res.text();
|
|
318
|
+
assert.ok(text.includes('Vertex received valid schemas'), 'vertex must receive healed non-empty schemas');
|
|
319
|
+
|
|
320
|
+
// Switch back to chat profile
|
|
321
|
+
await post('/api/switch', { tool: 'claude', profile: 'chat' });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('Real User Sim 4: Codex WebSocket turns (warmup -> prompt -> apply_patch -> continue)', async () => {
|
|
325
|
+
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
|
|
326
|
+
const messages = [];
|
|
327
|
+
|
|
328
|
+
await new Promise((resolve, reject) => {
|
|
329
|
+
ws.onopen = resolve;
|
|
330
|
+
ws.onerror = reject;
|
|
331
|
+
});
|
|
332
|
+
ws.onmessage = (event) => {
|
|
333
|
+
try { messages.push(JSON.parse(event.data)); } catch {}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Step 1: Warmup with tools declaration
|
|
337
|
+
ws.send(JSON.stringify({
|
|
338
|
+
type: 'response.create',
|
|
339
|
+
generate: false,
|
|
340
|
+
input: [
|
|
341
|
+
{ type: 'additional_tools', id: 'at_1', role: 'developer', tools: [
|
|
342
|
+
{ type: 'custom', name: 'apply_patch', description: 'Patch files' }
|
|
343
|
+
] }
|
|
344
|
+
]
|
|
345
|
+
}));
|
|
346
|
+
await new Promise(r => setTimeout(r, 100));
|
|
347
|
+
const warmup = messages.find(m => m.type === 'response.completed');
|
|
348
|
+
assert.ok(warmup, 'warmup answered locally without upstream call');
|
|
349
|
+
|
|
350
|
+
// Step 2: Task with custom patch tool
|
|
351
|
+
messages.length = 0;
|
|
352
|
+
ws.send(JSON.stringify({
|
|
353
|
+
type: 'response.create',
|
|
354
|
+
previous_response_id: warmup.response.id,
|
|
355
|
+
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'SIM_CODEX_PATCH please patch bug' }] }]
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
const deadline = Date.now() + 3000;
|
|
359
|
+
while (!messages.some(m => m.type === 'response.completed') && Date.now() < deadline) {
|
|
360
|
+
await new Promise(r => setTimeout(r, 50));
|
|
361
|
+
}
|
|
362
|
+
const patchTurn = messages.find(m => m.type === 'response.completed');
|
|
363
|
+
assert.ok(patchTurn, 'patch turn completed');
|
|
364
|
+
const patchItem = messages.find(m => m.type === 'response.output_item.done' && m.item?.type === 'custom_tool_call');
|
|
365
|
+
assert.ok(patchItem, 'emitted custom_tool_call for apply_patch');
|
|
366
|
+
assert.equal(patchItem.item.name, 'apply_patch');
|
|
367
|
+
const respId = patchTurn.response?.id;
|
|
368
|
+
|
|
369
|
+
// Step 3: Continuation with previous_response_id
|
|
370
|
+
messages.length = 0;
|
|
371
|
+
ws.send(JSON.stringify({
|
|
372
|
+
type: 'response.create',
|
|
373
|
+
previous_response_id: respId,
|
|
374
|
+
input: [{ type: 'custom_tool_call_output', output: 'Patch applied successfully' }]
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
const deadline2 = Date.now() + 3000;
|
|
378
|
+
while (!messages.some(m => m.type === 'response.completed') && Date.now() < deadline2) {
|
|
379
|
+
await new Promise(r => setTimeout(r, 50));
|
|
380
|
+
}
|
|
381
|
+
const doneTurn = messages.find(m => m.type === 'response.completed');
|
|
382
|
+
assert.ok(doneTurn, 'continuation turn completed');
|
|
383
|
+
|
|
384
|
+
ws.close();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('Real User Sim 5: High-concurrency dual-tool execution (Claude streaming + Codex WS simultaneously)', async () => {
|
|
388
|
+
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
|
|
389
|
+
await new Promise(r => { ws.onopen = r; });
|
|
390
|
+
const wsMessages = [];
|
|
391
|
+
ws.onmessage = (e) => { try { wsMessages.push(JSON.parse(e.data)); } catch {} };
|
|
392
|
+
|
|
393
|
+
// Trigger both simultaneously
|
|
394
|
+
const claudePromise = post('/v1/messages', {
|
|
395
|
+
model: 'claude-opus-4-6',
|
|
396
|
+
max_tokens: 1024,
|
|
397
|
+
stream: true,
|
|
398
|
+
messages: [{ role: 'user', content: 'SIM_CONCURRENCY Claude request' }]
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
ws.send(JSON.stringify({
|
|
402
|
+
type: 'response.create',
|
|
403
|
+
input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'SIM_CONCURRENCY Codex request' }] }]
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
const [claudeRes] = await Promise.all([claudePromise]);
|
|
407
|
+
assert.equal(claudeRes.status, 200);
|
|
408
|
+
const claudeText = await claudeRes.text();
|
|
409
|
+
assert.ok(claudeText.includes('Simulation ok'));
|
|
410
|
+
|
|
411
|
+
const deadline = Date.now() + 3000;
|
|
412
|
+
while (!wsMessages.some(m => m.type === 'response.completed') && Date.now() < deadline) {
|
|
413
|
+
await new Promise(r => setTimeout(r, 50));
|
|
414
|
+
}
|
|
415
|
+
assert.ok(wsMessages.some(m => m.type === 'response.completed'), 'Codex completed concurrently without interference');
|
|
416
|
+
ws.close();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('Real User Sim 6: Upstream 429 Rate Limit mapping with Retry-After header', async () => {
|
|
420
|
+
const res = await post('/v1/messages', {
|
|
421
|
+
model: 'claude-opus-4-6',
|
|
422
|
+
messages: [{ role: 'user', content: 'SIM_429 test rate limit' }]
|
|
423
|
+
});
|
|
424
|
+
assert.equal(res.status, 429);
|
|
425
|
+
assert.equal(res.headers.get('retry-after'), '15', 'preserves retry-after header');
|
|
426
|
+
const body = await res.json();
|
|
427
|
+
assert.equal(body.type, 'error');
|
|
428
|
+
assert.equal(body.error?.type, 'rate_limit_error');
|
|
429
|
+
assert.ok(body.error?.message.includes('Rate limit reached'));
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('Real User Sim 7: Mid-stream network failure emitted as Anthropic error event', async () => {
|
|
433
|
+
const res = await post('/v1/messages', {
|
|
434
|
+
model: 'claude-opus-4-6',
|
|
435
|
+
stream: true,
|
|
436
|
+
messages: [{ role: 'user', content: 'SIM_CUT_STREAM disconnect mid stream' }]
|
|
437
|
+
});
|
|
438
|
+
assert.equal(res.status, 200);
|
|
439
|
+
const text = await res.text();
|
|
440
|
+
const events = parseSSE(text);
|
|
441
|
+
const errEvent = events.find(e => e.event === 'error');
|
|
442
|
+
assert.ok(errEvent, 'must emit error event when upstream cuts stream abruptly');
|
|
443
|
+
assert.ok(!events.some(e => e.event === 'message_stop'), 'must not emit fake message_stop when cut');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('Real User Sim 8: Dynamic profile switching during session preserves system integrity', async () => {
|
|
447
|
+
// Check status before
|
|
448
|
+
const st1 = await (await fetch(url('/api/status'), { headers: { 'x-llm-switcher-token': adminToken() } })).json();
|
|
449
|
+
assert.equal(st1.activeProfiles.claude, 'chat');
|
|
450
|
+
|
|
451
|
+
// Toggle Claude profile off
|
|
452
|
+
const toggleOff = await post('/api/toggle', { target: 'claude', enabled: false });
|
|
453
|
+
assert.equal(toggleOff.status, 200);
|
|
454
|
+
|
|
455
|
+
const st2 = await (await fetch(url('/api/status'), { headers: { 'x-llm-switcher-token': adminToken() } })).json();
|
|
456
|
+
assert.equal(st2.activeProfiles.claude, null);
|
|
457
|
+
|
|
458
|
+
// Toggle Claude profile on
|
|
459
|
+
const toggleOn = await post('/api/toggle', { target: 'claude', enabled: true });
|
|
460
|
+
assert.equal(toggleOn.status, 200);
|
|
461
|
+
|
|
462
|
+
const st3 = await (await fetch(url('/api/status'), { headers: { 'x-llm-switcher-token': adminToken() } })).json();
|
|
463
|
+
assert.equal(st3.activeProfiles.claude, 'chat');
|
|
464
|
+
});
|