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.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +202 -257
  3. package/README.vi.md +200 -256
  4. package/blindfold/blindfold.mjs +200 -53
  5. package/blindfold/make-certs.sh +26 -7
  6. package/catalog.mjs +246 -0
  7. package/classifier.mjs +238 -0
  8. package/config.example.json +12 -34
  9. package/docs/codex-blindfold.md +28 -17
  10. package/docs/cross-platform.md +16 -7
  11. package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
  12. package/docs/diagrams/ir-healer-pipeline.png +0 -0
  13. package/docs/diagrams/ir-healer-pipeline.svg +90 -0
  14. package/docs/diagrams/ir-translation-pipeline.html +14925 -0
  15. package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
  16. package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
  17. package/docs/diagrams/system-architecture.architecture.json +76 -0
  18. package/docs/diagrams/system-architecture.html +14978 -0
  19. package/docs/diagrams/system-architecture.svg +5147 -0
  20. package/docs/diagrams/system-topology.mmd +30 -0
  21. package/docs/diagrams/system-topology.png +0 -0
  22. package/docs/diagrams/system-topology.svg +125 -0
  23. package/ensure-ca-bundle.mjs +28 -0
  24. package/formats.mjs +43 -156
  25. package/icons/antigravity.png +0 -0
  26. package/icons/claude.png +0 -0
  27. package/icons/codex.png +0 -0
  28. package/icons/deepseek.png +0 -0
  29. package/icons/gemini.png +0 -0
  30. package/icons/github.png +0 -0
  31. package/icons/groq.png +0 -0
  32. package/icons/intact.svg +1 -0
  33. package/icons/ollama.png +0 -0
  34. package/icons/openai.png +0 -0
  35. package/icons/openrouter.png +0 -0
  36. package/icons/qwen.png +0 -0
  37. package/icons/vertex.png +0 -0
  38. package/mcp.mjs +39 -11
  39. package/package.json +1 -1
  40. package/proxy.mjs +114 -37
  41. package/shim.mjs +200 -57
  42. package/skills/llm-switcher/SKILL.md +15 -10
  43. package/state.mjs +1100 -191
  44. package/switch.cmd +2 -2
  45. package/switch.mjs +228 -53
  46. package/tests/blindfold-e2e.test.mjs +380 -0
  47. package/tests/blindfold-task5.test.mjs +429 -0
  48. package/tests/blindfold.task3.test.mjs +700 -0
  49. package/tests/blindfold.test.mjs +10 -5
  50. package/tests/catalog.test.mjs +147 -0
  51. package/tests/classifier.test.mjs +210 -0
  52. package/tests/contract-lab.test.mjs +22 -7
  53. package/tests/formats.test.mjs +63 -46
  54. package/tests/gateway.e2e.test.mjs +136 -36
  55. package/tests/lifecycle.test.mjs +16 -10
  56. package/tests/mcp.test.mjs +78 -2
  57. package/tests/real-user-sim.test.mjs +464 -0
  58. package/tests/shim.test.mjs +159 -66
  59. package/tests/state.test.mjs +975 -193
  60. package/tests/switch.test.mjs +446 -2
  61. 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
- activeProfile: 'chat',
167
- activeProfiles: { anthropic: 'chat', responses: 'chat', 'openai-chat': 'chat', vertex: 'chat' },
196
+ blindfold: { port: blindfoldPort },
197
+ activeProfiles: { claude: 'chat', codex: 'chat' },
168
198
  profiles: {
169
- chat: { name: 'Mock Chat', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-chat', defaultModels: models },
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: { ...process.env, LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'), LLM_SWITCHER_STATE_DIR: tmpDir, CLAUDE_CONFIG_DIR: path.join(tmpDir, 'claude'), LLM_SWITCHER_PORT: '' },
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
- for (let i = 0; i < 50; i++) {
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) return;
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
- // The shapes the dashboard actually sends stay valid, empty strings included.
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', inFormat: 'responses',
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
- test('Gemini SDK route: model comes from the URL path', async () => {
466
- const res = await post('/v1beta/models/claude-opus-like:generateContent', { contents: [{ role: 'user', parts: [{ text: 'hi' }] }] });
467
- assert.equal(res.status, 200);
468
- const json = await res.json();
469
- assert.equal(received.at(-1).body.model, 'up-opus');
470
- assert.ok(json.candidates[0].content.parts.some(p => p.text === 'Final answer'));
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/chat/completions', { model: 'default', messages: [{ role: 'user', content: 'hi' }] }), 'up-sonnet');
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
- const r = await post('/v1/chat/completions', { model: 'main', stream: true, messages: [{ role: 'user', content: 'ERROR_THEN_HANG' }] });
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', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k' } });
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', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'sk-keydest' } });
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
- test('Vertex routes: streaming action, full resource path, and an unknown action', async () => {
1040
- const stream = await post('/v1beta/models/claude-opus-like:streamGenerateContent?alt=sse', { contents: [{ role: 'user', parts: [{ text: 'hi' }] }] });
1041
- assert.equal(stream.status, 200);
1042
- assert.match(stream.headers.get('content-type'), /text\/event-stream/);
1043
- const frames = parseSSE(await stream.text()).filter(f => f.data);
1044
- assert.ok(frames.some(f => f.data.candidates?.[0]?.content?.parts?.some(p => p.text)), 'a text part is streamed');
1045
- const full = await post('/v1/projects/p1/locations/us-central1/publishers/google/models/claude-opus-like:generateContent', { contents: [{ role: 'user', parts: [{ text: 'hi' }] }] });
1046
- assert.equal(full.status, 200);
1047
- assert.equal(received.at(-1).body.model, 'up-opus');
1048
- const bad = await post('/v1beta/models/x:explode', { contents: [] });
1049
- assert.equal(bad.status, 404);
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 () => {
@@ -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
- const LAUNCH_FILES = ['active.flag', '1m.flag', 'codex-1m.flag', 'openai-1m.flag', 'env.sh', 'env.cmd', 'env-codex.sh', 'env-codex.cmd', 'model-catalog.json'];
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
- const f = { role: 'blindfold', port, pid: process.pid, gatewayPort: ${gwPort}, host: 'chatgpt.com', prefix: '/backend-api/codex' };
334
- res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort: f.gatewayPort, host: f.host, prefix: f.prefix, proof: s.identityProof(nonce, f) }));
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, host: 'chatgpt.com', prefix: '/backend-api/codex' }));
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
- const f = { role: 'blindfold', port, pid: process.pid, gatewayPort: ${gwPort}, host: 'chatgpt.com', prefix: '/backend-api/codex' };
366
- res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort: f.gatewayPort, host: f.host, prefix: f.prefix, proof: s.identityProof(nonce, f) }));
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;
@@ -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
- fs.writeFileSync(cfgPath, JSON.stringify({ port: await freePort(), activeProfiles: {}, profiles: {} }), { mode: 0o600 });
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: { ...process.env, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: dir, CLAUDE_CONFIG_DIR: claudeDir, LLM_SWITCHER_PORT: '', ANTHROPIC_BASE_URL: '', ...env.vars }
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
+ });