llm-switcher 1.2.5 → 1.2.7

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.2.7
4
+
5
+ - **Save from the dashboard:** The dashboard sent `tool: "auto"` for a profile that serves both tools. The gateway refuses that value. As a result, a new profile with the default tool, four of the five templates, and a save of any both-tools profile all failed. Now the dashboard saves `tool: null` for such a profile. The Vertex AI template uses `claude`.
6
+ - **New profile with a used key:** A new profile with the key of an existing profile replaced that profile and its API key without a warning. Now the dashboard refuses the save and names the key.
7
+ - **Turn all routes on:** After you turned every route off, "Activate compatible routes" gave only one profile a route. Codex stayed on the official endpoint. Now each tool gets its own profile, the same way as the `target` form of `/api/toggle`.
8
+ - **Failed switch:** After a failed switch, the dashboard kept the state that the person had pressed. Now it reads the state from the gateway again, so the switch, the badges, and the file agree.
9
+ - **Element ids:** The sidebar and the page header both used the id `btn-toggle`. The header button is now `btn-toggle-routes`.
10
+ - **Unsaved changes:** The dashboard never asked before it threw away typed text. Now the close button, Cancel, a press on the dark area, and Escape ask "Discard unsaved changes?" when you typed something. A refresh of the state no longer wipes the text you are typing.
11
+ - **Save with no change:** Every save added `thinkingMode: "auto"` and an empty `model1M` to the profile. Now a save with no edit leaves the profile as it was.
12
+ - **Emptied slots:** A model slot that you emptied, and a 1M box that you cleared, came back after the save. Now the save keeps what the form shows.
13
+ - **Tests:** New browser tests press the real dashboard: routes, profile forms, model slots, connection test, delete, catalog, and request inspector. After each step they check that the page, the gateway, and `config.json` say the same. They need Chromium and a global `WebSocket` (Node 22 or newer). Without them, the tests skip. Set `LLM_SWITCHER_CHROMIUM` to use another browser binary.
14
+
15
+ ## Release 1.2.6
16
+
17
+ - **Deactivate in the dashboard:** The Deactivate button did not change the configuration, but the dashboard showed "deactivated". The dashboard sends `deactivate: true` and the profile key in `profile`. The gateway read `deactivate` as the profile key, so no target matched. Now `POST /api/switch` accepts both forms: `{"profile": "x", "deactivate": true}` and `{"deactivate": "x"}`.
18
+ - **No silent success:** If the profile is not active, `POST /api/switch` now answers `404` with the error `Profile "x" is not active`. If `deactivate` is `true` and no profile is named, it answers `400`. Before, both requests answered `200` with `success: true`.
19
+
3
20
  ## Release 1.2.5
4
21
 
5
22
  - **Dashboard controls:** The dashboard controls use MIT elements from Uiverse.io galaxy as a base. The CSS gives the name of each author.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.2.5",
3
+ "version": "1.2.7",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -1367,7 +1367,12 @@ async function routeConfigApi(res, method, pathname, body) {
1367
1367
  if (pathname === '/api/switch') {
1368
1368
  let err = null;
1369
1369
  if (body.deactivate) {
1370
- deactivateProfile(cfg, body.deactivate);
1370
+ // The dashboard sends `deactivate: true` with the key in `profile`; the CLI sends the key itself.
1371
+ const key = body.deactivate === true ? body.profile : body.deactivate;
1372
+ if (typeof key !== 'string' || !key) return sendJson(res, 400, { error: 'deactivate needs a profile key' });
1373
+ // A no-op must not answer success: the dashboard trusts `success` alone.
1374
+ if (!isProfileActive(cfg, key)) return sendJson(res, 404, { error: `Profile "${key}" is not active` });
1375
+ deactivateProfile(cfg, key);
1371
1376
  } else if (body.target) {
1372
1377
  err = setTargetProfile(cfg, body.target, body.profile || null);
1373
1378
  } else if (body.profile) {
@@ -1394,9 +1399,14 @@ async function routeConfigApi(res, method, pathname, body) {
1394
1399
  }
1395
1400
  err = setTargetProfile(cfg, body.target, key);
1396
1401
  } else if (body.enabled) {
1397
- const key = hasProfile(cfg, cfg.activeProfile) ? cfg.activeProfile : Object.keys(cfg.profiles)[0];
1398
- if (!key) return sendJson(res, 400, { error: 'No profiles configured' });
1399
- err = activateProfile(cfg, key);
1402
+ if (!Object.keys(cfg.profiles).length) return sendJson(res, 400, { error: 'No profiles configured' });
1403
+ // Each tool gets the profile that the `target` form would give it. Turning everything off and on
1404
+ // again must not leave a tool on the official endpoint when a profile can serve it.
1405
+ const preferred = cfg.activeProfile;
1406
+ for (const tool of TOOLS) {
1407
+ const key = [map[tool], preferred, ...Object.keys(cfg.profiles)].find(k => hasProfile(cfg, k) && profileAcceptsTarget(cfg.profiles[k], tool));
1408
+ if (key) err = err || setTargetProfile(cfg, tool, key);
1409
+ }
1400
1410
  } else {
1401
1411
  deactivateAll(cfg);
1402
1412
  }
package/switch.cmd CHANGED
@@ -1,2 +1,2 @@
1
- @echo off
2
- node "%~dp0switch.mjs" %*
1
+ @echo off
2
+ node "%~dp0switch.mjs" %*
package/tests/cdp.mjs ADDED
@@ -0,0 +1,278 @@
1
+ // A headless Chromium and one page, driven over the Chrome DevTools Protocol with no dependency.
2
+ // The tests use it to press the real dashboard the way a person does: a click is a mouse event at
3
+ // the pixel where the element is drawn, so a control that is hidden or covered fails the test.
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const CANDIDATES = [
10
+ process.env.LLM_SWITCHER_CHROMIUM,
11
+ '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable',
12
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
13
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
14
+ ].filter(Boolean);
15
+
16
+ export function findChromium() {
17
+ return CANDIDATES.find(p => fs.existsSync(p)) || null;
18
+ }
19
+
20
+ // undefined when this machine can run the browser tests, otherwise the reason to skip them.
21
+ export function skipReason() {
22
+ if (typeof WebSocket === 'undefined') return 'needs a global WebSocket (Node 22 or newer)';
23
+ if (!findChromium()) return 'needs a Chromium or Chrome binary (set LLM_SWITCHER_CHROMIUM)';
24
+ return undefined;
25
+ }
26
+
27
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
28
+
29
+ const KEYS = {
30
+ Enter: { code: 'Enter', vk: 13, text: '\r' },
31
+ Escape: { code: 'Escape', vk: 27 },
32
+ Tab: { code: 'Tab', vk: 9 },
33
+ ArrowDown: { code: 'ArrowDown', vk: 40 },
34
+ ArrowUp: { code: 'ArrowUp', vk: 38 },
35
+ Backspace: { code: 'Backspace', vk: 8 }
36
+ };
37
+
38
+ export async function launchBrowser() {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-chromium-'));
40
+ const args = [
41
+ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${dir}`,
42
+ '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-gpu',
43
+ '--disable-background-networking', '--window-size=1280,900',
44
+ ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
45
+ 'about:blank'
46
+ ];
47
+ const child = spawn(findChromium(), args, { stdio: 'ignore', env: { ...process.env, HOME: dir } });
48
+ let port = 0;
49
+ for (let i = 0; i < 150 && !port; i++) {
50
+ try { port = Number(fs.readFileSync(path.join(dir, 'DevToolsActivePort'), 'utf8').split('\n')[0]) || 0; } catch { /* not written yet */ }
51
+ if (!port) await sleep(100);
52
+ }
53
+ if (!port) { child.kill(); throw new Error('Chromium did not open a debugging port'); }
54
+
55
+ const pages = [];
56
+ return {
57
+ async newPage() {
58
+ const list = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
59
+ const page = new Page(list.webSocketDebuggerUrl);
60
+ await page.ready();
61
+ pages.push(page);
62
+ return page;
63
+ },
64
+ async close() {
65
+ for (const p of pages) p.close();
66
+ child.kill();
67
+ await new Promise(r => { child.once('exit', r); setTimeout(r, 2000); });
68
+ fs.rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ };
71
+ }
72
+
73
+ export class Page {
74
+ constructor(wsUrl) {
75
+ this.ws = new WebSocket(wsUrl);
76
+ this.nextId = 1;
77
+ this.pending = new Map();
78
+ this.listeners = new Map();
79
+ // What a person would notice or what the code would report: kept so a test can assert on it.
80
+ this.problems = []; // console errors, uncaught exceptions, failed loads
81
+ this.badResponses = []; // { url, status } for every HTTP 4xx and 5xx
82
+ this.dialogs = []; // { type, message } for every alert, confirm and prompt
83
+ this.dialogPolicy = 'accept';
84
+ this.inflight = new Map(); // requestId -> url, so a stuck request can be named
85
+ this.ws.addEventListener('message', (ev) => this.#onMessage(JSON.parse(ev.data)));
86
+ }
87
+
88
+ async ready() {
89
+ if (this.ws.readyState !== 1) await new Promise((res, rej) => { this.ws.addEventListener('open', res, { once: true }); this.ws.addEventListener('error', rej, { once: true }); });
90
+ await Promise.all(['Page', 'Runtime', 'Network', 'Log'].map(d => this.send(`${d}.enable`)));
91
+ this.on('Runtime.consoleAPICalled', (e) => {
92
+ if (e.type === 'error' || e.type === 'assert') this.problems.push(`console.${e.type}: ${e.args.map(a => a.value ?? a.description ?? '').join(' ')}`);
93
+ });
94
+ this.on('Runtime.exceptionThrown', (e) => this.problems.push(`exception: ${e.exceptionDetails.exception?.description || e.exceptionDetails.text}`));
95
+ this.on('Log.entryAdded', (e) => { if (e.entry.level === 'error') this.problems.push(`log: ${e.entry.text} ${e.entry.url || ''}`.trim()); });
96
+ this.on('Network.requestWillBeSent', (e) => this.inflight.set(e.requestId, e.request.url));
97
+ // A new document ends every request of the old one, and no event says so for some of them.
98
+ this.on('Page.frameNavigated', (e) => { if (!e.frame.parentId) this.inflight.clear(); });
99
+ this.on('Network.loadingFinished', (e) => this.inflight.delete(e.requestId));
100
+ this.on('Network.responseReceived', (e) => { if (e.response.status >= 400) this.badResponses.push({ url: e.response.url, status: e.response.status }); });
101
+ this.on('Network.loadingFailed', (e) => {
102
+ this.inflight.delete(e.requestId);
103
+ if (!e.canceled) this.problems.push(`load failed: ${e.errorText} ${e.requestId}`);
104
+ });
105
+ this.on('Page.javascriptDialogOpening', (e) => {
106
+ this.dialogs.push({ type: e.type, message: e.message });
107
+ this.send('Page.handleJavaScriptDialog', { accept: this.dialogPolicy === 'accept' }).catch(() => {});
108
+ });
109
+ }
110
+
111
+ #onMessage(msg) {
112
+ if (msg.id && this.pending.has(msg.id)) {
113
+ const { resolve, reject } = this.pending.get(msg.id);
114
+ this.pending.delete(msg.id);
115
+ if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`)); else resolve(msg.result);
116
+ } else if (msg.method) {
117
+ for (const cb of this.listeners.get(msg.method) || []) cb(msg.params);
118
+ }
119
+ }
120
+
121
+ on(method, cb) {
122
+ if (!this.listeners.has(method)) this.listeners.set(method, []);
123
+ this.listeners.get(method).push(cb);
124
+ }
125
+
126
+ send(method, params = {}) {
127
+ const id = this.nextId++;
128
+ this.ws.send(JSON.stringify({ id, method, params }));
129
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
130
+ }
131
+
132
+ close() { try { this.ws.close(); } catch { /* already closed */ } }
133
+
134
+ // Forget what happened so far, so a test asserts only on the action it just made.
135
+ clearProblems() { this.problems.length = 0; this.badResponses.length = 0; this.dialogs.length = 0; }
136
+
137
+ // Wait until no request is in flight for `quiet` ms. A click starts a request, and the page then
138
+ // starts a second one to read the state back, so one quiet moment is not enough.
139
+ async idle(quiet = 250, timeout = 8000) {
140
+ const end = Date.now() + timeout;
141
+ let quietSince = Date.now();
142
+ while (Date.now() < end) {
143
+ if (this.inflight.size > 0) quietSince = Date.now();
144
+ else if (Date.now() - quietSince >= quiet) return;
145
+ await sleep(25);
146
+ }
147
+ throw new Error(`the page still has ${this.inflight.size} request(s) in flight after ${timeout} ms: ${[...this.inflight.values()].join(', ')}`);
148
+ }
149
+
150
+ #loaded(timeout = 10000) {
151
+ return new Promise((resolve, reject) => {
152
+ const timer = setTimeout(() => reject(new Error(`the page did not finish loading in ${timeout} ms`)), timeout);
153
+ const done = () => {
154
+ clearTimeout(timer);
155
+ const handlers = this.listeners.get('Page.loadEventFired');
156
+ handlers.splice(handlers.indexOf(done), 1);
157
+ resolve();
158
+ };
159
+ this.on('Page.loadEventFired', done);
160
+ });
161
+ }
162
+
163
+ async goto(url) {
164
+ const loaded = this.#loaded();
165
+ const { errorText, loaderId } = await this.send('Page.navigate', { url });
166
+ if (errorText) throw new Error(`could not open ${url}: ${errorText}`);
167
+ // A change of the #hash alone stays in the same document: no load event will follow.
168
+ if (loaderId) await loaded; else loaded.catch(() => {});
169
+ }
170
+
171
+ async reload() {
172
+ const loaded = this.#loaded();
173
+ await this.send('Page.reload');
174
+ await loaded;
175
+ }
176
+
177
+ async evaluate(expression) {
178
+ const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
179
+ if (r.exceptionDetails) throw new Error(`evaluate failed: ${r.exceptionDetails.exception?.description || r.exceptionDetails.text}\n${expression}`);
180
+ return r.result.value;
181
+ }
182
+
183
+ // Poll until the page expression is truthy. The message says what the test was waiting for.
184
+ async waitFor(expression, message, timeout = 5000) {
185
+ const end = Date.now() + timeout;
186
+ let last;
187
+ while (Date.now() < end) {
188
+ try { last = await this.evaluate(expression); if (last) return last; } catch (e) { last = e.message; }
189
+ await sleep(50);
190
+ }
191
+ throw new Error(`timed out after ${timeout} ms waiting for: ${message}\nlast value: ${JSON.stringify(last)}\nexpression: ${expression}`);
192
+ }
193
+
194
+ q(selector) { return `document.querySelector(${JSON.stringify(selector)})`; }
195
+ text(selector) { return this.evaluate(`${this.q(selector)}?.textContent.trim() ?? null`); }
196
+ value(selector) { return this.evaluate(`${this.q(selector)}?.value ?? null`); }
197
+ count(selector) { return this.evaluate(`document.querySelectorAll(${JSON.stringify(selector)}).length`); }
198
+ exists(selector) { return this.evaluate(`Boolean(${this.q(selector)})`); }
199
+ // Visible means drawn with a size and not hidden by display, visibility or an ancestor.
200
+ visible(selector) {
201
+ return this.evaluate(`(() => { const e = ${this.q(selector)}; if (!e) return false; const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && e.getClientRects().length > 0; })()`);
202
+ }
203
+
204
+ // The centre of the element after it scrolled into view, and what the browser draws there.
205
+ async #target(selector) {
206
+ return this.evaluate(`(() => {
207
+ const e = ${this.q(selector)};
208
+ if (!e) return { missing: true };
209
+ e.scrollIntoView({ block: 'center', inline: 'center' });
210
+ const r = e.getBoundingClientRect();
211
+ if (r.width === 0 || r.height === 0) return { hidden: true };
212
+ const x = r.left + r.width / 2, y = r.top + r.height / 2;
213
+ const hit = document.elementFromPoint(x, y);
214
+ const label = e.closest('label');
215
+ const reachable = hit && (e.contains(hit) || hit.contains(e) || (label && label.contains(hit)));
216
+ return { x, y, reachable, hit: hit ? hit.tagName + (hit.id ? '#' + hit.id : '') + (hit.className && typeof hit.className === 'string' ? '.' + hit.className.split(' ').join('.') : '') : null };
217
+ })()`);
218
+ }
219
+
220
+ async click(selector) {
221
+ const t = await this.#target(selector);
222
+ if (t.missing) throw new Error(`click: no element matches ${selector}`);
223
+ if (t.hidden) throw new Error(`click: ${selector} has no size, a person cannot press it`);
224
+ if (!t.reachable) throw new Error(`click: ${selector} is covered by ${t.hit}, a person cannot press it`);
225
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: t.x, y: t.y });
226
+ await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: t.x, y: t.y, button: 'left', clickCount: 1 });
227
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: t.x, y: t.y, button: 'left', clickCount: 1 });
228
+ }
229
+
230
+ // A press at a pixel, for a target that has no element of its own: the dark area outside a dialog.
231
+ async clickAt(x, y) {
232
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
233
+ await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
234
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
235
+ }
236
+
237
+ // Press the visible button or link whose label is `text`, the way a person finds it by reading.
238
+ async clickText(scope, text) {
239
+ const marked = await this.evaluate(`(() => {
240
+ document.querySelectorAll('[data-test-target]').forEach(e => e.removeAttribute('data-test-target'));
241
+ const hit = [...document.querySelectorAll(${JSON.stringify(scope)})].filter(e => e.textContent.trim() === ${JSON.stringify(text)} && e.getClientRects().length > 0);
242
+ if (hit.length !== 1) return hit.length;
243
+ hit[0].setAttribute('data-test-target', '1');
244
+ return 1;
245
+ })()`);
246
+ if (marked !== 1) throw new Error(`clickText: ${marked} visible match(es) for "${text}" in ${scope}, expected exactly 1`);
247
+ await this.click('[data-test-target="1"]');
248
+ await this.evaluate(`document.querySelector('[data-test-target]')?.removeAttribute('data-test-target')`);
249
+ }
250
+
251
+ // Focus the field with a click, drop the old text, then type like a person: one input event.
252
+ async type(selector, text) {
253
+ await this.click(selector);
254
+ await this.evaluate(`${this.q(selector)}.select()`);
255
+ if (text === '') await this.key('Backspace'); else await this.send('Input.insertText', { text });
256
+ }
257
+
258
+ async key(name) {
259
+ const k = KEYS[name];
260
+ if (!k) throw new Error(`key: unknown key ${name}`);
261
+ const base = { key: name, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk };
262
+ await this.send('Input.dispatchKeyEvent', { type: k.text ? 'keyDown' : 'rawKeyDown', ...base, ...(k.text ? { text: k.text } : {}) });
263
+ await this.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
264
+ }
265
+
266
+ // A native <select> opens an operating system popup that no protocol call can press, so this sets
267
+ // the value the way the popup would and fires the same events.
268
+ async select(selector, value) {
269
+ await this.click(selector);
270
+ await this.evaluate(`(() => { const s = ${this.q(selector)}; s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw new Error('select: no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
271
+ }
272
+
273
+ async screenshot(file) {
274
+ const { data } = await this.send('Page.captureScreenshot', { format: 'png' });
275
+ fs.writeFileSync(file, Buffer.from(data, 'base64'));
276
+ return file;
277
+ }
278
+ }
@@ -0,0 +1,167 @@
1
+ // A real gateway on its own data directory, a stand-in upstream, and a stand-in interceptor.
2
+ // The dashboard tests open this gateway in a browser, so nothing here may touch the real home
3
+ // directory, the real Codex daemon, the real npm registry or a real interceptor.
4
+ import http from 'node:http';
5
+ import net from 'node:net';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { fileURLToPath, pathToFileURL } from 'node:url';
11
+
12
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+
14
+ function freePort() {
15
+ return new Promise((resolve, reject) => {
16
+ const s = net.createServer();
17
+ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
18
+ s.on('error', reject);
19
+ });
20
+ }
21
+
22
+ const MODELS = { opus: 'up-opus', sonnet: 'up-sonnet', haiku: 'up-haiku' };
23
+
24
+ // The starting point of every test. Two profiles that each serve one tool and one that serves
25
+ // both, with Claude and Codex each on their own profile: the state in the Deactivate bug report.
26
+ export function baselineConfig(port, blindfoldPort, upstreamBase) {
27
+ return {
28
+ port,
29
+ blindfold: { port: blindfoldPort },
30
+ activeProfiles: { claude: 'intact-claude', codex: 'intact-codex' },
31
+ profiles: {
32
+ 'intact-claude': { name: 'Intact Claude', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', baseURL: `${upstreamBase}/chat/v1`, apiKey: 'sk-secret-claude', defaultModels: { ...MODELS } },
33
+ 'intact-codex': { name: 'Intact Codex', mode: 'convert', tool: 'codex', inFormat: 'responses', outFormat: 'openai-chat', baseURL: `${upstreamBase}/chat/v1`, apiKey: 'sk-secret-codex', defaultModels: { main: 'up-main', review: 'up-review' } },
34
+ shared: { name: 'Shared Router', mode: 'convert', tool: null, outFormat: 'openai-chat', baseURL: `${upstreamBase}/chat/v1`, apiKey: 'sk-secret-shared', defaultModels: { ...MODELS } }
35
+ }
36
+ };
37
+ }
38
+
39
+ export async function startDashboardFixture() {
40
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-dashboard-'));
41
+ const cfgPath = path.join(dir, 'config.json');
42
+ const received = [];
43
+ // 'ok' answers like a healthy provider; 'deny' answers 401; 'down' answers 503.
44
+ const upstream = { mode: 'ok', received, models: ['up-alpha', 'up-beta', 'up-gamma'], latest: '99.0.0' };
45
+
46
+ const upstreamServer = http.createServer((req, res) => {
47
+ let body = '';
48
+ req.on('data', c => { body += c; });
49
+ req.on('end', () => {
50
+ received.push({ method: req.method, url: req.url, headers: req.headers, body });
51
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
52
+ if (req.url.startsWith('/registry')) return json(200, { version: upstream.latest });
53
+ if (upstream.mode === 'deny') return json(401, { error: { message: 'bad key' } });
54
+ if (upstream.mode === 'down') return json(503, { error: { message: 'upstream is down' } });
55
+ if (req.url.endsWith('/models')) {
56
+ return json(200, { data: upstream.models.map(id => ({ id, context_length: id === 'up-alpha' ? 200000 : 1000000 })) });
57
+ }
58
+ if (req.url.includes('/chat/completions')) {
59
+ return json(200, { id: 'c1', object: 'chat.completion', model: 'up', choices: [{ index: 0, message: { role: 'assistant', content: 'Final answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } });
60
+ }
61
+ return json(404, { error: 'not found' });
62
+ });
63
+ });
64
+ await new Promise(r => upstreamServer.listen(0, '127.0.0.1', r));
65
+ const upstreamBase = `http://127.0.0.1:${upstreamServer.address().port}`;
66
+
67
+ const port = await freePort();
68
+ const blindfoldPort = await freePort();
69
+ fs.writeFileSync(cfgPath, JSON.stringify(baselineConfig(port, blindfoldPort, upstreamBase), null, 2));
70
+
71
+ // The model lists that Claude Code and Codex keep for the signed-in account. With both present the
72
+ // catalog reads them from disk and never asks the official endpoints.
73
+ const home = path.join(dir, 'home');
74
+ fs.mkdirSync(path.join(home, '.claude', 'cache', 'model-catalog'), { recursive: true });
75
+ fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
76
+ fs.writeFileSync(path.join(home, '.claude', 'cache', 'model-catalog', 'account.json'), JSON.stringify({
77
+ fetchedAt: 1,
78
+ catalog: { config: { models: [{ id: 'claude-opus-4-6', name: 'Opus' }, { id: 'claude-sonnet-4-6', name: 'Sonnet' }, { id: 'claude-haiku-4-5', name: 'Haiku' }] } }
79
+ }));
80
+ fs.writeFileSync(path.join(home, '.codex', 'models_cache.json'), JSON.stringify({
81
+ client_version: '1.0.0',
82
+ models: [{ slug: 'gpt-5.6-sol', context_window: 400000 }, { slug: 'gpt-5.6-terra', context_window: 400000 }]
83
+ }));
84
+ const env = {
85
+ ...process.env,
86
+ HOME: home,
87
+ CODEX_HOME: path.join(home, '.codex'),
88
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
89
+ LLM_SWITCHER_CONFIG: cfgPath,
90
+ LLM_SWITCHER_STATE_DIR: dir,
91
+ LLM_SWITCHER_PORT: '',
92
+ // Without this the gateway reads and writes the certificates of the checkout.
93
+ LLM_SWITCHER_BLINDFOLD_CERTS: path.join(dir, 'certs'),
94
+ LLM_SWITCHER_REGISTRY_URL: `${upstreamBase}/registry/latest`
95
+ };
96
+ // The gateway starts an interceptor of its own when it finds none on the port. This one is up
97
+ // first and answers the identity proof with this gateway's port and tool set, so nothing real
98
+ // is spawned.
99
+ const interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
100
+ const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
101
+ const http = await import('node:http');
102
+ s.ensureAdminToken();
103
+ http.createServer((req, res) => {
104
+ const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
105
+ const activeTools = s.deriveActiveTools(s.loadConfig() || {}).join(',');
106
+ const f = { role: 'blindfold', port: ${blindfoldPort}, pid: process.pid, gatewayPort: ${port}, activeTools };
107
+ res.setHeader('content-type', 'application/json');
108
+ res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port: ${blindfoldPort}, pid: process.pid, gatewayPort: ${port}, activeTools, proof: s.identityProof(nonce, f) }));
109
+ }).listen(${blindfoldPort}, '127.0.0.1');
110
+ `], { env, stdio: 'ignore' });
111
+ const deadline = Date.now() + 8000;
112
+ for (;;) {
113
+ const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
114
+ if (probe?.ok) break;
115
+ if (Date.now() > deadline) { interceptor.kill(); throw new Error('the stand-in interceptor never came up'); }
116
+ await new Promise(r => setTimeout(r, 100));
117
+ }
118
+
119
+ const proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
120
+ let log = '';
121
+ proxy.stdout.on('data', d => { log += d; });
122
+ proxy.stderr.on('data', d => { log += d; });
123
+ let started = false;
124
+ for (let i = 0; i < 80 && !started; i++) {
125
+ try { started = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* not listening yet */ }
126
+ if (!started) await new Promise(r => setTimeout(r, 100));
127
+ }
128
+ if (!started) { proxy.kill(); interceptor.kill(); throw new Error(`gateway did not start:\n${log}`); }
129
+
130
+ const adminToken = () => fs.readFileSync(path.join(dir, 'admin.token'), 'utf8').trim();
131
+ return {
132
+ port,
133
+ dir,
134
+ upstream,
135
+ upstreamBase,
136
+ dashboardUrl: `http://127.0.0.1:${port}/ui`,
137
+ // What is on disk, which the dashboard has to agree with after every action.
138
+ config: () => JSON.parse(fs.readFileSync(cfgPath, 'utf8')),
139
+ // A change made outside the dashboard, as `switch` in a terminal or an editor would make it.
140
+ writeConfig: (cfg) => fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)),
141
+ baseline: () => baselineConfig(port, blindfoldPort, upstreamBase),
142
+ // The same call the dashboard makes, for a test that needs the server's own answer.
143
+ api: (p, body) => fetch(`http://127.0.0.1:${port}${p}`, {
144
+ method: body ? 'POST' : 'GET',
145
+ headers: { 'x-llm-switcher-token': adminToken(), 'Content-Type': 'application/json' },
146
+ body: body ? JSON.stringify(body) : undefined
147
+ }).then(async r => ({ status: r.status, json: await r.json().catch(() => null) })),
148
+ // One request from Claude Code through the route that is on, so the request inspector has a row.
149
+ messages: () => fetch(`http://127.0.0.1:${port}/v1/messages`, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
152
+ body: JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 64, messages: [{ role: 'user', content: 'hello from the test' }] })
153
+ }).then(async r => ({ status: r.status, text: await r.text() })),
154
+ async stop() {
155
+ interceptor.kill();
156
+ proxy.kill();
157
+ upstreamServer.close();
158
+ upstreamServer.closeAllConnections?.();
159
+ // A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
160
+ try {
161
+ const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
162
+ if (Number.isInteger(pid) && pid !== interceptor.pid) process.kill(pid);
163
+ } catch { /* none was started */ }
164
+ fs.rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ };
167
+ }