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
|
@@ -8,9 +8,9 @@ import fs from 'node:fs';
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
11
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
import zlib from 'node:zlib';
|
|
13
|
-
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
14
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
15
15
|
import { createFrameReader } from '../blindfold/wsframe.mjs';
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@ const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
|
|
|
18
18
|
|
|
19
19
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
20
|
|
|
21
|
-
let upstream, upstreamPort, proxy, proxyPort, tmpDir;
|
|
21
|
+
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir;
|
|
22
22
|
const received = []; // { url, headers, body }
|
|
23
23
|
const hangState = { closed: false, slowAborted: false };
|
|
24
24
|
const bigState = { finishedAt: 0 };
|
|
@@ -34,6 +34,27 @@ function freePort() {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// The blindfold check only passes with a certificate in hand, and minting one needs bash and
|
|
38
|
+
// openssl. Where they are missing this fixture says so for the two tests that need it instead of
|
|
39
|
+
// failing them with a confusing 502.
|
|
40
|
+
function findBash() {
|
|
41
|
+
for (const candidate of ['bash', 'C:\\Program Files\\Git\\bin\\bash.exe', '/usr/bin/bash', '/bin/bash']) {
|
|
42
|
+
try {
|
|
43
|
+
const r = spawnSync(candidate, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
|
44
|
+
if (!r.error && r.status === 0) return candidate;
|
|
45
|
+
} catch { /* try the next candidate */ }
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const BASH = findBash();
|
|
50
|
+
const noCerts = BASH ? false : 'needs bash + openssl to mint the interceptor certificate';
|
|
51
|
+
|
|
52
|
+
function buildCerts(dir) {
|
|
53
|
+
const script = path.join(ROOT, 'blindfold', 'make-certs.sh');
|
|
54
|
+
const r = spawnSync(BASH, [script, 'chatgpt.com', dir.replace(/\\/g, '/')], { encoding: 'utf8' });
|
|
55
|
+
if (r.status !== 0) throw new Error(`make-certs.sh failed: ${r.stderr || r.stdout}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
const sse = (res, objs, { raw = [] } = {}) => {
|
|
38
59
|
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
39
60
|
for (const o of objs) res.write(`data: ${JSON.stringify(o)}\n\n`);
|
|
@@ -159,14 +180,23 @@ before(async () => {
|
|
|
159
180
|
await startUpstream();
|
|
160
181
|
proxyPort = await freePort();
|
|
161
182
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-test-'));
|
|
183
|
+
|
|
184
|
+
// R3b: both tools are active in this fixture, so every admin write runs through
|
|
185
|
+
// checkBlindfoldTarget. Give the interceptor a port of its own and a certificate minted for
|
|
186
|
+
// this run — otherwise the check probes whatever interceptor this machine happens to run, finds
|
|
187
|
+
// a proof it cannot match, and turns a successful save into a 502.
|
|
188
|
+
blindfoldPort = await freePort();
|
|
189
|
+
certsDir = path.join(tmpDir, 'certs');
|
|
190
|
+
if (!noCerts) buildCerts(certsDir);
|
|
191
|
+
|
|
162
192
|
const base = `http://127.0.0.1:${upstreamPort}`;
|
|
163
193
|
const models = { opus: 'up-opus', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' };
|
|
164
194
|
const cfg = {
|
|
165
195
|
port: proxyPort,
|
|
166
|
-
|
|
167
|
-
activeProfiles: {
|
|
196
|
+
blindfold: { port: blindfoldPort },
|
|
197
|
+
activeProfiles: { claude: 'chat', codex: 'chat' },
|
|
168
198
|
profiles: {
|
|
169
|
-
chat: { name: 'Mock Chat', mode: 'convert',
|
|
199
|
+
chat: { name: 'Mock Chat', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-chat', defaultModels: models },
|
|
170
200
|
vtx: { name: 'Mock Vertex', mode: 'convert', inFormat: 'auto', outFormat: 'vertex', baseURL: `${base}/vtx`, apiKey: 'sk-secret-vtx', defaultModels: models },
|
|
171
201
|
agmock: { name: 'Mock AG via chat', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-ag', defaultModels: { main: 'ag/mock-flash', review: 'ag/mock-review', subagent: 'ag/mock-low' } },
|
|
172
202
|
pub: { name: 'Mock Public', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-pub', publicModels: ['gpt-5.6-sol', 'gpt-5.2'], defaultModels: { main: 'ag/mock-flash' }, model1M: { main: true } },
|
|
@@ -177,23 +207,62 @@ before(async () => {
|
|
|
177
207
|
};
|
|
178
208
|
fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify(cfg, null, 2));
|
|
179
209
|
proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(proxyPort)], {
|
|
180
|
-
env: {
|
|
210
|
+
env: {
|
|
211
|
+
...process.env,
|
|
212
|
+
LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'),
|
|
213
|
+
LLM_SWITCHER_STATE_DIR: tmpDir,
|
|
214
|
+
CLAUDE_CONFIG_DIR: path.join(tmpDir, 'claude'),
|
|
215
|
+
LLM_SWITCHER_PORT: '',
|
|
216
|
+
...(noCerts ? {} : { LLM_SWITCHER_BLINDFOLD_CERTS: certsDir })
|
|
217
|
+
},
|
|
181
218
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
182
219
|
});
|
|
183
220
|
let log = '';
|
|
184
221
|
proxy.stdout.on('data', d => { log += d; });
|
|
185
222
|
proxy.stderr.on('data', d => { log += d; });
|
|
186
|
-
|
|
223
|
+
let started = false;
|
|
224
|
+
for (let i = 0; i < 50 && !started; i++) {
|
|
187
225
|
try {
|
|
188
226
|
const r = await fetch(`http://127.0.0.1:${proxyPort}/health`);
|
|
189
|
-
if (r.ok)
|
|
227
|
+
if (r.ok) started = true;
|
|
190
228
|
} catch {}
|
|
229
|
+
if (!started) await new Promise(r => setTimeout(r, 100));
|
|
230
|
+
}
|
|
231
|
+
if (!started) throw new Error(`proxy did not start:\n${log}`);
|
|
232
|
+
|
|
233
|
+
// A stand-in interceptor on the fixture's own port. It only has to answer the identity proof
|
|
234
|
+
// with THIS gateway's port and tool set, so a commit() sees its own interceptor and leaves the
|
|
235
|
+
// port alone — nothing is spawned, and nothing is left running after the suite.
|
|
236
|
+
fakeInterceptor = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
237
|
+
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
238
|
+
const http = await import('node:http');
|
|
239
|
+
const fs = await import('node:fs');
|
|
240
|
+
s.ensureAdminToken();
|
|
241
|
+
const port = ${blindfoldPort};
|
|
242
|
+
const gatewayPort = ${proxyPort};
|
|
243
|
+
http.createServer((req, res) => {
|
|
244
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
245
|
+
const activeTools = s.deriveActiveTools(s.loadConfig() || {}).join(',');
|
|
246
|
+
const f = { role: 'blindfold', port, pid: process.pid, gatewayPort, activeTools };
|
|
247
|
+
res.setHeader('content-type', 'application/json');
|
|
248
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort, activeTools, proof: s.identityProof(nonce, f) }));
|
|
249
|
+
void fs;
|
|
250
|
+
}).listen(port, '127.0.0.1');
|
|
251
|
+
`], {
|
|
252
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'), LLM_SWITCHER_STATE_DIR: tmpDir },
|
|
253
|
+
stdio: 'ignore'
|
|
254
|
+
});
|
|
255
|
+
const deadline = Date.now() + 8000;
|
|
256
|
+
for (;;) {
|
|
257
|
+
const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
|
|
258
|
+
if (probe?.ok) break;
|
|
259
|
+
if (Date.now() > deadline) throw new Error('the stand-in interceptor never came up');
|
|
191
260
|
await new Promise(r => setTimeout(r, 100));
|
|
192
261
|
}
|
|
193
|
-
throw new Error(`proxy did not start:\n${log}`);
|
|
194
262
|
});
|
|
195
263
|
|
|
196
264
|
after(() => {
|
|
265
|
+
fakeInterceptor?.kill();
|
|
197
266
|
proxy?.kill();
|
|
198
267
|
upstream?.close();
|
|
199
268
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -384,13 +453,12 @@ test('save-profile rejects a model name that could act as a command', async () =
|
|
|
384
453
|
assert.equal(badHost.status, 400);
|
|
385
454
|
assert.match((await badHost.json()).error, /blindfoldHost/);
|
|
386
455
|
|
|
387
|
-
//
|
|
456
|
+
// A new-format body stays valid: exactly one `tool` and no legacy field (R7c).
|
|
388
457
|
const ok = await post('/api/save-profile', {
|
|
389
458
|
key: 'probe', profile: {
|
|
390
|
-
name: 'p', baseURL: 'http://127.0.0.1:1/v1',
|
|
459
|
+
name: 'p', baseURL: 'http://127.0.0.1:1/v1', tool: 'codex',
|
|
391
460
|
publicModels: ['gpt-5.6-sol', 'ag/mock-flash'],
|
|
392
|
-
codexRoles: { main: 'gpt-5.6-sol', review: '', subagent: '' }
|
|
393
|
-
blindfold: true, blindfoldHost: 'chatgpt.com', blindfoldPort: 3457, blindfoldPrefix: '/backend-api/codex'
|
|
461
|
+
codexRoles: { main: 'gpt-5.6-sol', review: '', subagent: '' }
|
|
394
462
|
}
|
|
395
463
|
});
|
|
396
464
|
assert.equal(ok.status, 200);
|
|
@@ -462,12 +530,17 @@ test('Vertex upstream: multiple functionCall chunks become separate tool_use blo
|
|
|
462
530
|
assert.match(received.at(-1).url, /:streamGenerateContent\?alt=sse$/);
|
|
463
531
|
});
|
|
464
532
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
533
|
+
// A8 / R5: the openai-chat INPUT route is gone. Two paths used to reach the same converter as a
|
|
534
|
+
// second protocol; a client that still speaks one is refused, not silently converted into a
|
|
535
|
+
// request under a profile its tool never chose.
|
|
536
|
+
test('A8: /v1/chat/completions and /chat/completions answer 404 on the gateway', { skip: noCerts }, async () => {
|
|
537
|
+
const before = received.length;
|
|
538
|
+
for (const p of ['/v1/chat/completions', '/chat/completions']) {
|
|
539
|
+
const res = await post(p, { model: 'default', messages: [{ role: 'user', content: 'hi' }] });
|
|
540
|
+
assert.equal(res.status, 404, p);
|
|
541
|
+
assert.match((await res.json()).error.message, /Not found/, p);
|
|
542
|
+
}
|
|
543
|
+
assert.equal(received.length, before, 'a removed route never reaches an upstream');
|
|
471
544
|
});
|
|
472
545
|
|
|
473
546
|
test('Upstream 429 is returned in Anthropic error shape with retry-after', async () => {
|
|
@@ -580,7 +653,7 @@ test('Security: the admin API refuses a caller without the token and changes not
|
|
|
580
653
|
assert.ok(!JSON.stringify(hits[0]).includes('sk-secret-chat'), 'the stored key is not sent to a foreign baseURL');
|
|
581
654
|
|
|
582
655
|
assert.equal((await fetch(url('/health'))).status, 200, '/health needs no token');
|
|
583
|
-
assert.equal((fs.statSync(path.join(tmpDir, 'admin.token')).mode & 0o777).toString(8), '600');
|
|
656
|
+
if (process.platform !== 'win32') assert.equal((fs.statSync(path.join(tmpDir, 'admin.token')).mode & 0o777).toString(8), '600');
|
|
584
657
|
} finally {
|
|
585
658
|
sink.close();
|
|
586
659
|
}
|
|
@@ -713,7 +786,26 @@ test('an auto profile maps Codex names by client protocol and leaves Claude mapp
|
|
|
713
786
|
assert.equal(await upstreamModel('/v1/responses', { model: 'gpt-5.5', stream: false, input: 'hi' }), 'up-opus');
|
|
714
787
|
assert.equal(await upstreamModel('/v1/responses', { model: 'main', stream: false, input: 'hi' }), 'up-opus');
|
|
715
788
|
assert.equal(await upstreamModel('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] }), 'up-opus');
|
|
716
|
-
assert.equal(await upstreamModel('/v1/
|
|
789
|
+
assert.equal(await upstreamModel('/v1/messages', { model: 'fable', max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] }), 'up-fable');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// R4: an alias is a name for a human. What leaves for the provider is always a real model ID —
|
|
793
|
+
// the tier suffix and the role words stay on this side of the wire.
|
|
794
|
+
test('R4: official model names map correctly and no alias is emitted to tool', async () => {
|
|
795
|
+
const upstreamModel = async (p, body) => {
|
|
796
|
+
const before = received.length;
|
|
797
|
+
const r = await post(p, body, { 'x-llm-profile': 'chat' });
|
|
798
|
+
await r.text();
|
|
799
|
+
return received.slice(before).at(-1)?.body?.model;
|
|
800
|
+
};
|
|
801
|
+
// Claude Code asks by tier, and the [1m] marker never travels.
|
|
802
|
+
assert.equal(await upstreamModel('/v1/messages', { model: 'opus[1m]', max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] }), 'up-opus');
|
|
803
|
+
assert.equal(await upstreamModel('/v1/messages', { model: 'sonnet', max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] }), 'up-sonnet');
|
|
804
|
+
assert.equal(await upstreamModel('/v1/messages', { model: 'haiku', max_tokens: 16, messages: [{ role: 'user', content: 'hi' }] }), 'up-haiku');
|
|
805
|
+
// Codex asks by role alias, which resolves to that role's slot.
|
|
806
|
+
assert.equal(await upstreamModel('/v1/responses', { model: 'main', stream: false, input: 'hi' }), 'up-opus');
|
|
807
|
+
// A bare OpenAI id has no slot of its own here; it fails closed instead of reaching upstream.
|
|
808
|
+
assert.equal(await upstreamModel('/v1/responses', { model: 'gpt-5.5', stream: false, input: 'hi' }), 'up-opus');
|
|
717
809
|
});
|
|
718
810
|
|
|
719
811
|
// ---- Request input limits and the Codex WS transport (audit F04, F05, F20, F37, F38, F51) ----
|
|
@@ -899,7 +991,9 @@ test('Codex WS: a turn that throws before its own error handling still ends in r
|
|
|
899
991
|
});
|
|
900
992
|
|
|
901
993
|
test('An in-band upstream error closes the upstream connection instead of leaving it open', async () => {
|
|
902
|
-
|
|
994
|
+
// R5: this travels on /v1/messages now. The upstream still speaks openai-chat, so the stream is
|
|
995
|
+
// still a converted one and the in-band error still has to tear the upstream down.
|
|
996
|
+
const r = await post('/v1/messages', { model: 'claude-sonnet-4-6', max_tokens: 16, stream: true, messages: [{ role: 'user', content: 'ERROR_THEN_HANG' }] }, { 'x-llm-profile': 'chat' });
|
|
903
997
|
await r.text();
|
|
904
998
|
assert.ok(await until(() => hangState.closed, 3000), 'the upstream response is still open');
|
|
905
999
|
});
|
|
@@ -979,7 +1073,7 @@ test('/v1/models windows follow model1M and the entry comes from codex-catalog-t
|
|
|
979
1073
|
test('a dashboard change based on a stale revision is refused with 409 and changes nothing', async () => {
|
|
980
1074
|
const { revision } = await (await fetch(url('/api/status'), { headers: withToken('/api/status', {}) })).json();
|
|
981
1075
|
assert.match(revision, /^[0-9a-f]{16}$/);
|
|
982
|
-
const first = await post('/api/save-profile', { key: 'rev1', revision, profile: { name: 'Rev', mode: 'convert',
|
|
1076
|
+
const first = await post('/api/save-profile', { key: 'rev1', revision, profile: { name: 'Rev', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k' } });
|
|
983
1077
|
assert.equal(first.status, 200);
|
|
984
1078
|
const next = (await first.json()).revision;
|
|
985
1079
|
assert.notEqual(next, revision);
|
|
@@ -997,7 +1091,7 @@ test('save-profile refuses control characters in the name', async () => {
|
|
|
997
1091
|
});
|
|
998
1092
|
|
|
999
1093
|
test('a kept API key is never pointed at a new baseURL or new endpoints', async () => {
|
|
1000
|
-
const create = await post('/api/save-profile', { key: 'keydest', profile: { name: 'K', mode: 'convert',
|
|
1094
|
+
const create = await post('/api/save-profile', { key: 'keydest', profile: { name: 'K', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'sk-keydest' } });
|
|
1001
1095
|
assert.equal(create.status, 200);
|
|
1002
1096
|
try {
|
|
1003
1097
|
for (const change of [{ endpoints: { 'openai-chat': 'http://evil.test/v1/chat/completions' } }, { baseURL: 'http://evil.test/v1' }, { baseURL: 'http://evil.test/v1', apiKey: MASKED }]) {
|
|
@@ -1036,17 +1130,23 @@ test('/ui serves the dashboard with anti-framing headers', async () => {
|
|
|
1036
1130
|
assert.match(await r.text(), /LLM Switcher/);
|
|
1037
1131
|
});
|
|
1038
1132
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
const
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1133
|
+
// A8 / R5: every vertex INPUT route, in each of the three shapes it used to accept. The vertex
|
|
1134
|
+
// OUTPUT format is untouched — the fixtures that convert Anthropic requests to a vertex upstream
|
|
1135
|
+
// still run above — but a Gemini-shaped request is no longer an input this gateway takes.
|
|
1136
|
+
test('A8: vertex routes answer 404 on the gateway', { skip: noCerts }, async () => {
|
|
1137
|
+
const routes = [
|
|
1138
|
+
'/v1beta/models/claude-opus-like:streamGenerateContent?alt=sse',
|
|
1139
|
+
'/v1beta/models/claude-opus-like:generateContent',
|
|
1140
|
+
'/v1/projects/p1/locations/us-central1/publishers/google/models/claude-opus-like:generateContent',
|
|
1141
|
+
'/v1beta/models/x:explode'
|
|
1142
|
+
];
|
|
1143
|
+
const before = received.length;
|
|
1144
|
+
for (const p of routes) {
|
|
1145
|
+
const res = await post(p, { contents: [{ role: 'user', parts: [{ text: 'hi' }] }] });
|
|
1146
|
+
assert.equal(res.status, 404, p);
|
|
1147
|
+
assert.match((await res.json()).error.message, /Not found/, p);
|
|
1148
|
+
}
|
|
1149
|
+
assert.equal(received.length, before, 'a removed route never reaches an upstream');
|
|
1050
1150
|
});
|
|
1051
1151
|
|
|
1052
1152
|
test('Codex WS: conversation.item.create is echoed and response.cancel stops the running turn', async () => {
|
package/tests/lifecycle.test.mjs
CHANGED
|
@@ -13,14 +13,16 @@ import http from 'node:http';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import crypto from 'node:crypto';
|
|
15
15
|
import { spawn, execFile, execFileSync } from 'node:child_process';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
|
|
18
18
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
19
|
const POSIX = process.platform !== 'win32';
|
|
20
20
|
const HAS_OPENSSL = POSIX && fs.existsSync('/usr/bin/openssl');
|
|
21
21
|
|
|
22
22
|
// switch.mjs writes the launcher files into the repository root; keep whatever was there.
|
|
23
|
-
|
|
23
|
+
// One file per tool now (env-claude.*, env-codex.*), plus the stub env.sh/env.cmd and the
|
|
24
|
+
// recorded gateway port. The 1M flags and model-catalog.json are gone (R2, R9).
|
|
25
|
+
const LAUNCH_FILES = ['active.flag', 'gateway.port', 'env.sh', 'env.cmd', 'env-claude.sh', 'env-claude.cmd', 'env-codex.sh', 'env-codex.cmd', 'model-catalog.json'];
|
|
24
26
|
// Every child runs with LLM_SWITCHER_STATE_DIR = ws.dir, so the launch files of the checkout stay untouched.
|
|
25
27
|
function snapshotLaunchFiles(dir) {
|
|
26
28
|
return Object.fromEntries(LAUNCH_FILES.map(f => {
|
|
@@ -71,7 +73,7 @@ function writeConfig(ws, gwPort, bfPort, activeResponses = null) {
|
|
|
71
73
|
function probe(ws, expr) {
|
|
72
74
|
return new Promise((resolve, reject) => {
|
|
73
75
|
execFile(process.execPath, ['--input-type=module', '-e',
|
|
74
|
-
`const s = await import(${JSON.stringify(path.join(ROOT, 'state.mjs'))}); console.log(JSON.stringify(await (${expr})));`
|
|
76
|
+
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)}); console.log(JSON.stringify(await (${expr})));`
|
|
75
77
|
], { env: envFor(ws), encoding: 'utf8' }, (err, stdout) => (err ? reject(err) : resolve(JSON.parse(stdout.trim()))));
|
|
76
78
|
});
|
|
77
79
|
}
|
|
@@ -323,20 +325,22 @@ test('reconcile reports an interceptor that does not stop instead of calling it
|
|
|
323
325
|
const bfPort = await freePort();
|
|
324
326
|
writeConfig(ws, gwPort, bfPort);
|
|
325
327
|
const fake = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
326
|
-
const s = await import(${JSON.stringify(path.join(ROOT, 'state.mjs'))});
|
|
328
|
+
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
327
329
|
const http = await import('node:http');
|
|
328
330
|
s.ensureAdminToken();
|
|
329
331
|
process.on('SIGTERM', () => {});
|
|
330
332
|
const port = ${bfPort};
|
|
331
333
|
http.createServer((req, res) => {
|
|
332
334
|
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
333
|
-
|
|
334
|
-
|
|
335
|
+
// R3: the proof signs the active tool set, not a host and a prefix.
|
|
336
|
+
const activeTools = s.deriveActiveTools(s.loadConfig()).join(',');
|
|
337
|
+
const f = { role: 'blindfold', port, pid: process.pid, gatewayPort: ${gwPort}, activeTools };
|
|
338
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort: f.gatewayPort, activeTools: f.activeTools, proof: s.identityProof(nonce, f) }));
|
|
335
339
|
}).listen(port, '127.0.0.1');
|
|
336
340
|
`], { env: envFor(ws), stdio: 'ignore' });
|
|
337
341
|
try {
|
|
338
342
|
await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
|
|
339
|
-
fs.writeFileSync(path.join(ws.dir, 'blindfold.json'), JSON.stringify({ pid: fake.pid, port: bfPort, gatewayPort: gwPort,
|
|
343
|
+
fs.writeFileSync(path.join(ws.dir, 'blindfold.json'), JSON.stringify({ pid: fake.pid, port: bfPort, gatewayPort: gwPort, activeTools: '' }));
|
|
340
344
|
const r = await probe(ws, `s.reconcileBlindfold(s.loadConfig(), ${gwPort})`);
|
|
341
345
|
assert.equal(r.ok, false, JSON.stringify(r));
|
|
342
346
|
assert.match(r.error, /did not stop/);
|
|
@@ -356,14 +360,16 @@ test('a dashboard change never overwrites a config.json that another process sav
|
|
|
356
360
|
writeConfig(ws, gwPort, bfPort, 'bf');
|
|
357
361
|
// A genuine-looking interceptor that answers each probe after 1 s.
|
|
358
362
|
const fake = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
359
|
-
const s = await import(${JSON.stringify(path.join(ROOT, 'state.mjs'))});
|
|
363
|
+
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
360
364
|
const http = await import('node:http');
|
|
361
365
|
s.ensureAdminToken();
|
|
362
366
|
const port = ${bfPort};
|
|
363
367
|
http.createServer((req, res) => setTimeout(() => {
|
|
364
368
|
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
365
|
-
|
|
366
|
-
|
|
369
|
+
// R3: the proof signs the active tool set, not a host and a prefix.
|
|
370
|
+
const activeTools = s.deriveActiveTools(s.loadConfig()).join(',');
|
|
371
|
+
const f = { role: 'blindfold', port, pid: process.pid, gatewayPort: ${gwPort}, activeTools };
|
|
372
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort: f.gatewayPort, activeTools: f.activeTools, proof: s.identityProof(nonce, f) }));
|
|
367
373
|
}, 1000)).listen(port, '127.0.0.1');
|
|
368
374
|
`], { env: envFor(ws), stdio: 'ignore' });
|
|
369
375
|
let gw;
|
package/tests/mcp.test.mjs
CHANGED
|
@@ -19,12 +19,34 @@ async function mcpCall(t, env, calls) {
|
|
|
19
19
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-mcp-'));
|
|
20
20
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
21
21
|
const cfgPath = path.join(dir, 'config.json');
|
|
22
|
-
|
|
22
|
+
// CFG_JSON hands the server a config of its own - a colliding one, for example - and
|
|
23
|
+
// START_GATEWAY runs a gateway against that very config and state dir, so the MCP child and
|
|
24
|
+
// the gateway share one admin token. Neither option changes what the other tests see.
|
|
25
|
+
const cfgJson = env.CFG_JSON !== undefined
|
|
26
|
+
? env.CFG_JSON
|
|
27
|
+
: JSON.stringify({ port: await freePort(), activeProfiles: {}, profiles: {} });
|
|
28
|
+
fs.writeFileSync(cfgPath, cfgJson, { mode: 0o600 });
|
|
23
29
|
const claudeDir = path.join(dir, 'claude');
|
|
24
30
|
fs.mkdirSync(claudeDir);
|
|
25
31
|
if (env.SETTINGS !== undefined) fs.writeFileSync(path.join(claudeDir, 'settings.json'), env.SETTINGS);
|
|
32
|
+
const childEnv = { ...process.env, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: claudeDir, LLM_SWITCHER_PORT: '', ANTHROPIC_BASE_URL: '' };
|
|
33
|
+
let gateway = null;
|
|
34
|
+
if (env.START_GATEWAY) {
|
|
35
|
+
gateway = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(JSON.parse(cfgJson).port)], {
|
|
36
|
+
env: { ...childEnv, PORT: '' }, stdio: ['ignore', 'pipe', 'pipe']
|
|
37
|
+
});
|
|
38
|
+
let glog = '';
|
|
39
|
+
gateway.stderr.on('data', d => { glog += d; });
|
|
40
|
+
const port = JSON.parse(cfgJson).port;
|
|
41
|
+
let up = false;
|
|
42
|
+
for (let i = 0; i < 160 && !up; i++) {
|
|
43
|
+
try { up = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* not listening yet */ }
|
|
44
|
+
if (!up) await new Promise(r => setTimeout(r, 50));
|
|
45
|
+
}
|
|
46
|
+
if (!up) throw new Error(`gateway did not come up on ${port}: ${glog}`);
|
|
47
|
+
}
|
|
26
48
|
const child = spawn(process.execPath, [path.join(ROOT, 'mcp.mjs')], {
|
|
27
|
-
env: { ...
|
|
49
|
+
env: { ...childEnv, ...env.vars }
|
|
28
50
|
});
|
|
29
51
|
let out = '';
|
|
30
52
|
child.stdout.on('data', d => { out += d; });
|
|
@@ -32,6 +54,7 @@ async function mcpCall(t, env, calls) {
|
|
|
32
54
|
child.stdin.write(requests);
|
|
33
55
|
for (let i = 0; i < 100 && out.split('\n').filter(Boolean).length < calls.length; i++) await new Promise(r => setTimeout(r, 50));
|
|
34
56
|
child.kill();
|
|
57
|
+
if (gateway && gateway.exitCode === null) gateway.kill('SIGKILL');
|
|
35
58
|
return out.split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
36
59
|
}
|
|
37
60
|
|
|
@@ -89,3 +112,56 @@ test('routeEvidence does not take the shim --config arguments for an environment
|
|
|
89
112
|
// The override on the command line is proof enough, with or without a readable environment.
|
|
90
113
|
assert.equal(routeEvidence('codex', '/usr/local/bin/codex --config openai_base_url=http://127.0.0.1:3456/v1 exec'), true);
|
|
91
114
|
});
|
|
115
|
+
|
|
116
|
+
// ==================== Task 5 ====================
|
|
117
|
+
|
|
118
|
+
// The gateway stands between two tools and nothing else. An agent that is offered the retired
|
|
119
|
+
// names would be told they worked, then fail at the gateway.
|
|
120
|
+
test('R6: switcher_switch_profile accepts claude and codex targets and rejects legacy targets', async (t) => {
|
|
121
|
+
const [list] = await mcpCall(t, {}, [{ method: 'tools/list' }]);
|
|
122
|
+
const def = list.result.tools.find(x => x.name === 'switcher_switch_profile');
|
|
123
|
+
assert.ok(def, 'the tool is advertised');
|
|
124
|
+
assert.deepEqual(def.inputSchema.properties.target.enum, ['claude', 'codex']);
|
|
125
|
+
assert.match(def.inputSchema.properties.target.description, /"claude" \(Claude Code\) or "codex" \(Codex\)/);
|
|
126
|
+
assert.match(def.description, /Claude Code \(claude\) or Codex \(codex\)/);
|
|
127
|
+
// openai and vertex are not tools a caller may name, so they are not in the schema at all.
|
|
128
|
+
assert.ok(!JSON.stringify(def).includes('openai'), 'no retired target in the schema');
|
|
129
|
+
assert.ok(!JSON.stringify(def).includes('vertex'), 'no retired target in the schema');
|
|
130
|
+
|
|
131
|
+
const [status] = await mcpCall(t, {}, [tool('switcher_status')]);
|
|
132
|
+
const s = text(status);
|
|
133
|
+
assert.match(s, /Active Profiles by tool:/);
|
|
134
|
+
assert.match(s, /Claude Code \(\/v1\/messages\)/);
|
|
135
|
+
assert.match(s, /Codex CLI\s+\(\/v1\/responses\)/);
|
|
136
|
+
assert.doesNotMatch(s, /openai-chat|Vertex|active multi-CLI targets/i);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A colliding config.json is one this build refuses to rewrite. The agent must be told which two
|
|
140
|
+
// pointers disagree, or its only next move is to edit the file by hand.
|
|
141
|
+
test('MCP switcher_switch_profile returns HTTP 409 with clashing keys during collision', async (t) => {
|
|
142
|
+
const port = await freePort();
|
|
143
|
+
const colliding = {
|
|
144
|
+
port,
|
|
145
|
+
activeProfile: 'p',
|
|
146
|
+
profiles: {
|
|
147
|
+
p: { name: 'P', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'x', main: 'y' } },
|
|
148
|
+
'p-codex': { name: 'P codex', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { main: 'y' } }
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const [r] = await mcpCall(t, { CFG_JSON: JSON.stringify(colliding, null, 2), START_GATEWAY: true },
|
|
152
|
+
[tool('switcher_switch_profile', { target: 'claude', profile: 'p' })]);
|
|
153
|
+
assert.equal(r.result.isError, true, text(r));
|
|
154
|
+
assert.match(text(r), /Switch failed: Migration collision/);
|
|
155
|
+
assert.match(text(r), /Clashing keys:.*p-codex/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('MCP switcher_models returns discovered model catalog', async (t) => {
|
|
159
|
+
const [list] = await mcpCall(t, {}, [{ method: 'tools/list' }]);
|
|
160
|
+
const def = list.result.tools.find(x => x.name === 'switcher_models');
|
|
161
|
+
assert.ok(def, 'switcher_models tool is advertised');
|
|
162
|
+
|
|
163
|
+
const [models] = await mcpCall(t, {}, [tool('switcher_models', { tool: 'claude' })]);
|
|
164
|
+
const json = JSON.parse(text(models));
|
|
165
|
+
assert.ok(json.claude, 'returns claude models catalog');
|
|
166
|
+
assert.ok(json.claude.models.some(m => m.id === 'claude-opus-5-5'));
|
|
167
|
+
});
|