llm-switcher 1.2.7 → 1.2.8

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/state.mjs CHANGED
@@ -134,6 +134,10 @@ export const paths = {
134
134
  envClaudeSh: path.join(STATE_DIR, 'env-claude.sh'),
135
135
  envCodexCmd: path.join(STATE_DIR, 'env-codex.cmd'),
136
136
  envCodexSh: path.join(STATE_DIR, 'env-codex.sh'),
137
+ // One line per tool, written with that tool's env file: what the launcher shows the person at
138
+ // launch. Empty exactly when the tool is not routed, so the launcher needs no second condition.
139
+ routeClaude: path.join(STATE_DIR, 'route-claude.txt'),
140
+ routeCodex: path.join(STATE_DIR, 'route-codex.txt'),
137
141
  // The gateway port of the last switch on. The shim scrubs a stale loopback base URL
138
142
  // against it, so a shell opened before a port change still reaches the right gateway.
139
143
  gatewayPort: path.join(STATE_DIR, 'gateway.port'),
@@ -454,6 +458,11 @@ export function validateProfileInput(p) {
454
458
  return null;
455
459
  }
456
460
 
461
+ /** True while a profile still carries the example config's placeholder baseURL or apiKey. */
462
+ export function hasPlaceholder(p) {
463
+ return !p?.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey ?? ''}`);
464
+ }
465
+
457
466
  export function needsMigration(cfg) {
458
467
  if (!cfg || typeof cfg !== 'object') return false;
459
468
  if (Object.hasOwn(cfg, 'activeProfile')) return true;
@@ -1227,8 +1236,9 @@ export function setTargetProfile(cfg, target, profileKey) {
1227
1236
  if (!profileAcceptsTarget(p, tool)) {
1228
1237
  return `Profile "${profileKey}" only accepts "${p.tool || p.inFormat}" input and cannot serve target "${tool}"`;
1229
1238
  }
1239
+ // No legacy `activeProfile` here: needsMigration flags it, so every switch would re-run the
1240
+ // migration on the next load and leave another config.json.bak-* holding the API keys.
1230
1241
  map[tool] = profileKey;
1231
- cfg.activeProfile = profileKey;
1232
1242
  return null;
1233
1243
  }
1234
1244
 
@@ -1240,7 +1250,6 @@ export function activateProfile(cfg, profileKey) {
1240
1250
  for (const t of TOOLS) {
1241
1251
  if (profileAcceptsTarget(p, t)) map[t] = profileKey;
1242
1252
  }
1243
- cfg.activeProfile = profileKey;
1244
1253
  return null;
1245
1254
  }
1246
1255
 
@@ -1300,6 +1309,16 @@ export function primaryModel(profile) {
1300
1309
  return '';
1301
1310
  }
1302
1311
 
1312
+ // What a person needs at launch: which profile took the traffic, the host it goes to, the model it
1313
+ // maps to, and whether the 1M window is on, because that changes what the session costs. Never the
1314
+ // API key: a desktop notification reads this line, and so can any shell.
1315
+ function routeLine(tool, key, profile) {
1316
+ let host = '';
1317
+ try { host = new URL(profile.baseURL).host; } catch { /* a profile may carry no address yet */ }
1318
+ return [`${tool} -> ${key}`, host, primaryModel(profile), anyTier1M(profile) ? '1M' : '']
1319
+ .filter(Boolean).join(' | ');
1320
+ }
1321
+
1303
1322
  // Derive launcher state from activeProfiles (single source of truth).
1304
1323
  export function computeLaunchState(cfg, port) {
1305
1324
  const map = getActiveMap(cfg);
@@ -1310,7 +1329,15 @@ export function computeLaunchState(cfg, port) {
1310
1329
 
1311
1330
  // One interceptor serves both tools (R3) and its port lives at the top level of config.json
1312
1331
  // (R3b). The per-profile port went with the per-profile host and prefix.
1313
- const bfPort = parsePort(cfg?.blindfold?.port) || DEFAULT_BLINDFOLD_PORT;
1332
+ // Precedence mirrors resolvePort for the gateway: the environment, then the file, then the
1333
+ // default. Without the variable a second instance could not move the interceptor, and a gateway
1334
+ // sent to 3457 by --port or by LLM_SWITCHER_PORT landed on the interceptor's own port.
1335
+ let bfPort = parsePort(process.env.LLM_SWITCHER_BLINDFOLD_PORT)
1336
+ || parsePort(cfg?.blindfold?.port)
1337
+ || DEFAULT_BLINDFOLD_PORT;
1338
+ if (bfPort === port) {
1339
+ bfPort = port === DEFAULT_BLINDFOLD_PORT ? DEFAULT_BLINDFOLD_PORT + 1 : DEFAULT_BLINDFOLD_PORT;
1340
+ }
1314
1341
  const interceptor = `http://127.0.0.1:${bfPort}`;
1315
1342
  const loopbackOnly = '127.0.0.1,localhost';
1316
1343
  const proxyPairs = () => [
@@ -1339,7 +1366,10 @@ export function computeLaunchState(cfg, port) {
1339
1366
  // no --config (F1, F3, F4). The shim of the tool may therefore never capture the traffic
1340
1367
  // of the other tool, and a restart cannot leave a stale override behind.
1341
1368
  envClaude: [],
1342
- envCodex: []
1369
+ envCodex: [],
1370
+ // The launcher notice, one line per tool. Empty for a tool that is not routed (A1).
1371
+ routeClaude: claude ? routeLine('claude', map.claude, claude) : '',
1372
+ routeCodex: codex ? routeLine('codex', map.codex, codex) : ''
1343
1373
  };
1344
1374
 
1345
1375
  // NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
@@ -1446,6 +1476,7 @@ export function emptyToolEnvFiles(tool) {
1446
1476
  const before = codex ? readOrEmpty(paths.envCodexSh) : '';
1447
1477
  writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
1448
1478
  writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
1479
+ writeAtomic(codex ? paths.routeCodex : paths.routeClaude, '');
1449
1480
  return before !== '';
1450
1481
  });
1451
1482
  return { codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
@@ -1513,6 +1544,10 @@ function writeLaunchState(cfg, port) {
1513
1544
  try {
1514
1545
  writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
1515
1546
  writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
1547
+ writeAtomic(paths.routeClaude, st.routeClaude ? `${st.routeClaude}
1548
+ ` : '');
1549
+ writeAtomic(paths.routeCodex, st.routeCodex ? `${st.routeCodex}
1550
+ ` : '');
1516
1551
  st.codexRouteChanged = readOrEmpty(paths.envCodexSh) !== codexBefore;
1517
1552
  // Recorded on switch on so a shell opened while the gateway ran can still recognize its own
1518
1553
  // stale loopback URL later, after the port changed (R8).
@@ -1537,7 +1572,8 @@ export function clearLaunchState(port) {
1537
1572
  // leave env.sh present and containing no export and no unset.
1538
1573
  writeAtomic(paths.envCmd, STUB_CMD);
1539
1574
  writeAtomic(paths.envSh, STUB_SH);
1540
- for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh]) {
1575
+ for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh,
1576
+ paths.routeClaude, paths.routeCodex]) {
1541
1577
  writeAtomic(f, '');
1542
1578
  }
1543
1579
  return codexBefore !== '';
package/switch CHANGED
File without changes
package/switch.cmd CHANGED
@@ -1,2 +1,2 @@
1
- @echo off
2
- node "%~dp0switch.mjs" %*
1
+ @echo off
2
+ node "%~dp0switch.mjs" %*
package/switch.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { spawn, execFileSync } from 'node:child_process';
6
6
  import {
7
- ROOT_DIR, STATE_DIR, TOOLS, configPath, claudeSettingsPath, paths, certCoversHost, loadConfig, getConfigLoadError, saveConfig,
7
+ ROOT_DIR, STATE_DIR, TOOLS, configPath, hasPlaceholder, claudeSettingsPath, paths, certCoversHost, loadConfig, getConfigLoadError, saveConfig,
8
8
  resolvePort, parsePort, findProfileKey, getActiveMap, setTargetProfile, activateProfile, deactivateAll,
9
9
  applyLaunchState, clearLaunchState, computeLaunchState,
10
10
  modelSlotsForProfile, modelForSlot, model1MForSlot, readAdminToken, adminTokenPath, openLog,
@@ -14,6 +14,7 @@ import {
14
14
  casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
15
15
  } from './state.mjs';
16
16
  import { runProbe, runCheck } from './contract.mjs';
17
+ import { installPlugin, uninstallPlugin, pluginStatus, PLUGIN_NAME } from './plugin.mjs';
17
18
  import {
18
19
  SHIM_DIR, installShims, uninstallShims, shimStatus, pathExportLine, pathOrderHint,
19
20
  suggestedRcFiles, auditRunningProcesses
@@ -391,6 +392,10 @@ async function turnOn(profileName, cliTarget) {
391
392
 
392
393
  // Plan on a copy. Nothing is written until every check below passes.
393
394
  let planned = planSwitch(config, key, cliTarget);
395
+ // The example config routes to YOUR-ROUTER-HOST; activating it silently makes every request fail.
396
+ if (hasPlaceholder(planned.profiles[key])) {
397
+ console.warn(`[WARN] Profile "${show(key)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
398
+ }
394
399
  console.log(`Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
395
400
 
396
401
  const gatewayState = await probeGateway(port);
@@ -777,6 +782,47 @@ async function manageService(action) {
777
782
  console.log('Usage: switch service [install|uninstall]');
778
783
  }
779
784
 
785
+ // The launch notice, as a hook inside each tool. Opt-in: the routing does not depend on it, and a
786
+ // person who installs nothing still gets the toast from the shim.
787
+ async function managePlugin(action = 'status') {
788
+ const act = (action || 'status').toLowerCase();
789
+
790
+ if (act === 'install' || act === 'on') {
791
+ const { CURRENT_VERSION } = await import('./version.mjs');
792
+ const { installed, failed, paths } = installPlugin({}, CURRENT_VERSION);
793
+ for (const tool of installed) console.log(`[OK] ${tool}: ${paths[tool]}`);
794
+ for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
795
+ if (installed.length) {
796
+ console.log('\nNeither settings.json nor config.toml was opened. Each tool reads a file of its own.');
797
+ console.log(`Claude Code loads it on the next session as ${PLUGIN_NAME}@skills-dir.`);
798
+ console.log('Codex asks you once to trust a new hook. Accept it, or the hook stays off.');
799
+ console.log('\nTo remove it: switch plugin uninstall');
800
+ }
801
+ if (failed.length) process.exit(1);
802
+ return;
803
+ }
804
+
805
+ if (act === 'uninstall' || act === 'off' || act === 'remove') {
806
+ const { installed, failed, paths } = uninstallPlugin();
807
+ for (const tool of installed) console.log(`[OK] ${tool}: removed from ${paths[tool]}`);
808
+ for (const f of failed) console.error(`[Error] ${f.tool}: ${f.reason}`);
809
+ if (failed.length) process.exit(1);
810
+ return;
811
+ }
812
+
813
+ const st = pluginStatus();
814
+ console.log('=== Launch notice (hook inside each coding tool) ===\n');
815
+ console.log(`Claude Code: ${st.claude.installed ? `INSTALLED as ${st.claude.id}` : 'not installed'}`);
816
+ console.log(` ${st.claude.path}`);
817
+ console.log(`Codex : ${st.codex.installed ? 'INSTALLED' : 'not installed'}`);
818
+ console.log(` ${st.codex.path}`);
819
+ if (!st.claude.installed || !st.codex.installed) {
820
+ console.log(`\nThe notice is optional. Without it the gateway still routes every request,`);
821
+ console.log(`and the shim still raises its own notice at launch.`);
822
+ console.log(`To add it: switch plugin install`);
823
+ }
824
+ }
825
+
780
826
  async function manageShim(action = 'status') {
781
827
  const act = (action || 'status').toLowerCase();
782
828
 
@@ -862,7 +908,7 @@ async function runDoctor() {
862
908
  if (!key) continue;
863
909
  const p = config.profiles[key];
864
910
  if (!p) warn(`[WARN] Target ${t} points to missing profile "${show(key)}".`);
865
- else if (!p.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey}`)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
911
+ else if (hasPlaceholder(p)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
866
912
  if (p && t === 'codex') {
867
913
  const codexWarning = codexPublicModelsWarning(show(key), p);
868
914
  if (codexWarning) warn(`[WARN] ${codexWarning}`);
@@ -1109,6 +1155,8 @@ if (cmd === 'off' || cmd === 'stop') {
1109
1155
  await manageService(subArg.toLowerCase() || 'status');
1110
1156
  } else if (cmd === 'shim' || cmd === 'shims') {
1111
1157
  await manageShim(subArg.toLowerCase() || 'status');
1158
+ } else if (cmd === 'plugin' || cmd === 'plugins') {
1159
+ await managePlugin(subArg.toLowerCase() || 'status');
1112
1160
  } else if (Object.hasOwn(TARGET_ALIASES, cmd)) {
1113
1161
  await turnOn(subArg, TARGET_ALIASES[cmd]);
1114
1162
  } else if (cmd === 'ui' || cmd === 'web' || cmd === 'gui') {
@@ -1141,6 +1189,9 @@ if (cmd === 'off' || cmd === 'stop') {
1141
1189
  console.log(' switch shim install # Auto-inject env into resumed sessions (claude --resume)');
1142
1190
  console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
1143
1191
  console.log(' switch shim uninstall # Remove launcher shims');
1192
+ console.log(' switch plugin install # Optional launch notice inside Claude Code & Codex');
1193
+ console.log(' switch plugin status # Is that notice installed');
1194
+ console.log(' switch plugin uninstall # Remove that notice');
1144
1195
  console.log(' switch off [target] # Restore official endpoints (all, or one target)');
1145
1196
  console.log(' switch models [--refresh] # Show discovered models catalog for Claude Code & Codex');
1146
1197
  console.log(' switch contract-probe [--model m] # Drive the contract-lab variants through the gateway');
@@ -137,7 +137,7 @@ test('a compressed response body is decoded for the capture', () => {
137
137
  assert.equal(decodeBody(zlib.gzipSync(Buffer.from(sse)), 'GZIP'), sse, 'must be case-insensitive');
138
138
  assert.equal(decodeBody(zlib.brotliCompressSync(Buffer.from(sse)), 'br'), sse);
139
139
  assert.equal(decodeBody(zlib.deflateSync(Buffer.from(sse)), 'deflate'), sse);
140
- assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
140
+ if (typeof zlib.zstdCompressSync === 'function') assert.equal(decodeBody(zlib.zstdCompressSync(Buffer.from(sse)), 'zstd'), sse);
141
141
  });
142
142
 
143
143
  test('an unencoded body is passed through untouched', () => {
package/tests/cdp.mjs CHANGED
@@ -82,6 +82,7 @@ export class Page {
82
82
  this.dialogs = []; // { type, message } for every alert, confirm and prompt
83
83
  this.dialogPolicy = 'accept';
84
84
  this.inflight = new Map(); // requestId -> url, so a stuck request can be named
85
+ this.requests = []; // { method, url } for every request, so a double submit is countable
85
86
  this.ws.addEventListener('message', (ev) => this.#onMessage(JSON.parse(ev.data)));
86
87
  }
87
88
 
@@ -93,7 +94,10 @@ export class Page {
93
94
  });
94
95
  this.on('Runtime.exceptionThrown', (e) => this.problems.push(`exception: ${e.exceptionDetails.exception?.description || e.exceptionDetails.text}`));
95
96
  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
+ this.on('Network.requestWillBeSent', (e) => {
98
+ this.inflight.set(e.requestId, e.request.url);
99
+ this.requests.push({ method: e.request.method, url: e.request.url });
100
+ });
97
101
  // A new document ends every request of the old one, and no event says so for some of them.
98
102
  this.on('Page.frameNavigated', (e) => { if (!e.frame.parentId) this.inflight.clear(); });
99
103
  this.on('Network.loadingFinished', (e) => this.inflight.delete(e.requestId));
@@ -132,7 +136,22 @@ export class Page {
132
136
  close() { try { this.ws.close(); } catch { /* already closed */ } }
133
137
 
134
138
  // 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; }
139
+ clearProblems() {
140
+ this.problems.length = 0;
141
+ this.badResponses.length = 0;
142
+ this.dialogs.length = 0;
143
+ this.requests.length = 0;
144
+ }
145
+
146
+ // Every request of one method whose URL holds `part`. A double submit is two entries here.
147
+ sent(method, part) { return this.requests.filter(r => r.method === method && r.url.includes(part)); }
148
+
149
+ // A real viewport change, the way a narrow window is, so a media query takes effect.
150
+ async resize(width, height) {
151
+ await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
152
+ // A layout pass runs on the next frame, so a measurement taken at once reads the old box.
153
+ await this.evaluate('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))');
154
+ }
136
155
 
137
156
  // Wait until no request is in flight for `quiet` ms. A click starts a request, and the page then
138
157
  // starts a second one to read the state back, so one quiet moment is not enough.
@@ -667,4 +667,120 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
667
667
  await expectScreenMatchesDisk('after outside edit');
668
668
  });
669
669
  });
670
+ // Found by a human-behaviour pass over the running dashboard on 2026-09-29: a real browser, real
671
+ // clicks and real keys, never a value set through the page's own JavaScript. Each test here is the
672
+ // reproduction of one finding from that pass.
673
+ describe('what a person notices when pressing the dashboard hard', () => {
674
+ const editButtons = () => page.evaluate(
675
+ `[...document.querySelectorAll('#profiles-grid .pcard button')].map(b => b.textContent.trim()).filter(t => /Edit/.test(t))`
676
+ );
677
+
678
+ it('gives a card back its normal Edit button after a close that saved nothing', async () => {
679
+ await editProfile('intact-claude');
680
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
681
+ await page.key('Escape');
682
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "none"', 'the dialog to close');
683
+ assert.deepEqual((await editButtons()).filter(t => /Editing/.test(t)), [],
684
+ 'a dialog closed without saving left the card in the editing state');
685
+ });
686
+
687
+ it('moves the keyboard into the dialog when it opens', async () => {
688
+ await editProfile('intact-claude');
689
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
690
+ const inside = await page.evaluate(
691
+ `document.getElementById('profile-modal-overlay').contains(document.activeElement)`
692
+ );
693
+ assert.equal(inside, true, 'the focus stayed behind the overlay, so the first Tab leaves the dialog');
694
+ });
695
+
696
+ it('never shows the connection result of the profile opened before', async () => {
697
+ await editProfile('intact-claude');
698
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
699
+ await act(() => page.clickText('#profile-modal-overlay button', 'Test Connection'));
700
+ await page.waitFor(`document.getElementById('test-result').textContent.trim().length > 0`, 'a result');
701
+ await page.key('Escape');
702
+ await act(() => page.clickText('button', '+ Add Profile'));
703
+ assert.equal(await page.text('#test-result'), '',
704
+ 'an untested new profile showed the connection result of the profile opened before it');
705
+ });
706
+
707
+ it('sends one save for one double-click on Save Profile', async () => {
708
+ await act(() => page.clickText('button', '+ Add Profile'));
709
+ await page.type('#p-key', 'dbl');
710
+ await page.type('#p-name', 'Double click');
711
+ await page.type('#p-url', 'https://dbl.example.io/v1');
712
+ await page.type('#p-key-val', 'sk-dbl');
713
+ page.clearProblems();
714
+ const save = '#profile-modal-overlay button[type="submit"]';
715
+ await page.click(save);
716
+ await page.click(save);
717
+ await page.idle();
718
+ assert.equal(page.sent('POST', '/api/save-profile').length, 1,
719
+ 'a double-click sent the save twice, because the button is not disabled while it runs');
720
+ });
721
+
722
+ it('says so when a model search matches nothing', async () => {
723
+ await open('#/models');
724
+ await page.waitFor(`document.querySelectorAll('#catalog-claude-items .row').length > 0`, 'the catalog rows');
725
+ await page.type('#catalog-search', 'zzz-nothing-matches-this-zzz');
726
+ await page.waitFor(
727
+ `[...document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row')].every(r => r.offsetParent === null)`,
728
+ 'every row hidden'
729
+ );
730
+ const shown = await page.evaluate(`(() => {
731
+ const visible = (e) => e.offsetParent !== null;
732
+ const message = [...document.querySelectorAll('#view-models *')]
733
+ .some(e => visible(e) && /no match|nothing matches|no model/i.test(e.textContent));
734
+ const badge = document.getElementById('badge-claude-count');
735
+ return { message, badge: badge && visible(badge) ? badge.textContent.trim() : '' };
736
+ })()`);
737
+ assert.equal(shown.message, true, 'an empty result looked like a broken page: no message at all');
738
+ assert.doesNotMatch(shown.badge, /^[1-9]/, `the header kept the unfiltered count: "${shown.badge}"`);
739
+ });
740
+
741
+ it('keeps the navigation reachable on a narrow window', async () => {
742
+ await page.resize(400, 900);
743
+ try {
744
+ const box = await page.evaluate(`(() => {
745
+ const aside = document.querySelector('aside'), nav = document.querySelector('aside nav');
746
+ const a = aside.getBoundingClientRect(), n = nav.getBoundingClientRect();
747
+ return { navHeight: n.height, navBottom: n.bottom, asideBottom: a.bottom, asideHeight: a.height };
748
+ })()`);
749
+ assert.ok(box.navHeight > 0, 'the navigation has no height at 400 px');
750
+ assert.ok(box.navBottom <= box.asideBottom + 1,
751
+ `the navigation is drawn outside the sidebar (nav bottom ${box.navBottom}, sidebar bottom ${box.asideBottom}, sidebar height ${box.asideHeight})`);
752
+ assert.equal(await page.visible('aside nav a'), true, 'no navigation link is visible at 400 px');
753
+ } finally {
754
+ await page.resize(1280, 900);
755
+ }
756
+ });
757
+
758
+ it('does not let a long profile name break the dialog header', async () => {
759
+ const cfg = fx.config();
760
+ cfg.profiles['intact-claude'].name = 'N'.repeat(300);
761
+ fx.writeConfig(cfg);
762
+ await open('#/routes');
763
+ await editProfile('intact-claude');
764
+ await page.waitFor('document.getElementById("profile-modal-overlay").style.display === "grid"', 'the dialog');
765
+ const t = await page.evaluate(`(() => {
766
+ const e = document.getElementById('form-title');
767
+ return { scroll: e.scrollWidth, client: e.clientWidth };
768
+ })()`);
769
+ assert.ok(t.scroll <= t.client + 1,
770
+ `the header text is wider than its box (${t.scroll} > ${t.client}), so it clips mid-character`);
771
+ });
772
+
773
+ it('opens the payload preview while auto-refresh is on', async () => {
774
+ const sent = await fx.messages();
775
+ assert.equal(sent.status, 200, sent.text);
776
+ await open('#/logs');
777
+ await page.waitFor(`document.querySelectorAll('#inspector-logs-container details').length >= 1`, 'at least one log row with a preview');
778
+ assert.equal(await page.evaluate(`document.getElementById('chk-auto-refresh-logs').checked`), true,
779
+ 'this test needs the default, which is auto-refresh on');
780
+ // One click, the way a person clicks. A control that the poll detaches fails right here.
781
+ await page.click('#inspector-logs-container details summary');
782
+ const opened = await page.evaluate(`Boolean(document.querySelector('#inspector-logs-container details[open]'))`);
783
+ assert.equal(opened, true, 'the poll replaces the row, so the preview cannot be opened while it runs');
784
+ });
785
+ });
670
786
  });
@@ -470,7 +470,7 @@ test('save-profile rejects a model name that could act as a command', async () =
470
470
  assert.equal(ok.status, 200);
471
471
  });
472
472
 
473
- test('Codex WS transport: upstream 429 becomes response.failed with rate_limit_exceeded', async () => { const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
473
+ test('Codex WS transport: upstream 429 becomes response.failed with rate_limit_exceeded', { skip: typeof WebSocket === 'undefined' && 'global WebSocket needs Node 22+' }, async () => { const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}/v1/responses`);
474
474
  await new Promise((resolve, reject) => {
475
475
  const t = setTimeout(() => reject(new Error('ws open timeout')), 5000);
476
476
  ws.addEventListener('open', () => { clearTimeout(t); resolve(); }, { once: true });
package/tests/helpers.mjs CHANGED
@@ -1,24 +1,24 @@
1
- import assert from 'node:assert/strict';
2
-
3
- // Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
4
- export function assertValidAnthropicEvents(events) {
5
- const open = new Set();
6
- const seen = new Set();
7
- let expectedNext = 0;
8
- for (const { event, data } of events) {
9
- if (event === 'content_block_start') {
10
- assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
11
- assert.ok(!seen.has(data.index), `index ${data.index} reused`);
12
- seen.add(data.index);
13
- open.add(data.index);
14
- expectedNext++;
15
- } else if (event === 'content_block_delta') {
16
- assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
17
- } else if (event === 'content_block_stop') {
18
- assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
19
- open.delete(data.index);
20
- } else if (event === 'message_stop') {
21
- assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
22
- }
23
- }
24
- }
1
+ import assert from 'node:assert/strict';
2
+
3
+ // Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
4
+ export function assertValidAnthropicEvents(events) {
5
+ const open = new Set();
6
+ const seen = new Set();
7
+ let expectedNext = 0;
8
+ for (const { event, data } of events) {
9
+ if (event === 'content_block_start') {
10
+ assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
11
+ assert.ok(!seen.has(data.index), `index ${data.index} reused`);
12
+ seen.add(data.index);
13
+ open.add(data.index);
14
+ expectedNext++;
15
+ } else if (event === 'content_block_delta') {
16
+ assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
17
+ } else if (event === 'content_block_stop') {
18
+ assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
19
+ open.delete(data.index);
20
+ } else if (event === 'message_stop') {
21
+ assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,119 @@
1
+ // The launch hook: what a coding tool shows the person about the switcher at the start of a session.
2
+ //
3
+ // The shim toast fires only when the shim runs, and it needs the shim directory first on PATH. A
4
+ // hook runs inside the tool itself, so it reports even when the launcher is the person's own script.
5
+ // Both tools read one JSON object from stdout and show `systemMessage` to the person.
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import http from 'node:http';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const HOOK = path.join(ROOT, 'hook-status.mjs');
17
+ const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
18
+
19
+ function tmpState(t) {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-hook-'));
21
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
22
+ // The proof is an HMAC over the admin token, so the fake gateway needs the same file.
23
+ fs.writeFileSync(path.join(dir, 'admin.token'), 'tok-for-the-hook-test', { mode: 0o600 });
24
+ return dir;
25
+ }
26
+
27
+ function writeConfig(dir, port) {
28
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({
29
+ port,
30
+ activeProfiles: { claude: 'cl', codex: null },
31
+ profiles: {
32
+ cl: {
33
+ name: 'Intact Claude', baseURL: 'https://intact.example.io/v1', apiKey: 'sk-secret',
34
+ defaultModels: { opus: 'gemini-3.8-flash' }, model1M: { opus: true }
35
+ }
36
+ }
37
+ }), { mode: 0o600 });
38
+ }
39
+
40
+ // A gateway of this build: it answers /health with a proof over the challenge.
41
+ function fakeGateway(t, port) {
42
+ const srv = http.createServer((req, res) => {
43
+ const nonce = new URL(req.url, 'http://127.0.0.1').searchParams.get('challenge') || '';
44
+ const body = { proxy: 'llm-switcher', port, pid: process.pid };
45
+ body.proof = identityProof(nonce, { role: 'gateway', port, pid: body.pid }, 'tok-for-the-hook-test');
46
+ res.writeHead(200, { 'content-type': 'application/json' });
47
+ res.end(JSON.stringify(body));
48
+ });
49
+ return new Promise((resolve) => srv.listen(port, '127.0.0.1', () => {
50
+ t.after(() => new Promise(r => srv.close(r)));
51
+ resolve(srv);
52
+ }));
53
+ }
54
+
55
+ function freePort() {
56
+ return new Promise((resolve) => {
57
+ const s = http.createServer();
58
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
59
+ });
60
+ }
61
+
62
+ function runHook(dir, tool) {
63
+ const out = execFileSync(process.execPath, [HOOK, tool], {
64
+ encoding: 'utf8', timeout: 20000,
65
+ env: {
66
+ ...process.env,
67
+ LLM_SWITCHER_STATE_DIR: dir,
68
+ LLM_SWITCHER_CONFIG: path.join(dir, 'config.json'),
69
+ LLM_SWITCHER_PORT: ''
70
+ }
71
+ });
72
+ return { raw: out, json: JSON.parse(out.trim() || '{}') };
73
+ }
74
+
75
+ test('a tool that is not routed gets no message at all', async (t) => {
76
+ const dir = tmpState(t);
77
+ writeConfig(dir, await freePort());
78
+ // codex has no profile, so its route file is empty. This is the state after `switch off codex`.
79
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
80
+ fs.writeFileSync(path.join(dir, 'route-codex.txt'), '');
81
+ const r = runHook(dir, 'codex');
82
+ assert.equal(r.json.systemMessage, undefined, 'a tool on its official endpoint says nothing');
83
+ });
84
+
85
+ test('a routed tool with a live gateway reports where its traffic goes', async (t) => {
86
+ const dir = tmpState(t);
87
+ const port = await freePort();
88
+ writeConfig(dir, port);
89
+ await fakeGateway(t, port);
90
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
91
+ fs.writeFileSync(path.join(dir, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini-3.8-flash | 1M\n');
92
+ const r = runHook(dir, 'claude');
93
+ assert.match(r.json.systemMessage, /LLM Switcher/, 'the message names the switcher');
94
+ assert.match(r.json.systemMessage, /intact\.example\.io/, 'and the host the traffic goes to');
95
+ assert.match(r.json.systemMessage, /gemini-3\.8-flash/, 'and the model');
96
+ assert.doesNotMatch(r.json.systemMessage, /sk-secret/, 'and never the API key');
97
+ });
98
+
99
+ test('a routed tool with a dead gateway is warned that it cannot reach the provider', async (t) => {
100
+ const dir = tmpState(t);
101
+ const port = await freePort(); // nothing listens on it
102
+ writeConfig(dir, port);
103
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
104
+ fs.writeFileSync(path.join(dir, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini-3.8-flash | 1M\n');
105
+ const r = runHook(dir, 'claude');
106
+ assert.match(r.json.systemMessage, /does not answer|not running/i, 'the message says the gateway is down');
107
+ assert.match(r.json.systemMessage, new RegExp(String(port)), 'and names the port');
108
+ assert.match(r.json.systemMessage, /switch on/, 'and names the command that fixes it');
109
+ });
110
+
111
+ test('a broken state never fails the session: exit 0 and parseable output', async (t) => {
112
+ const dir = tmpState(t);
113
+ fs.writeFileSync(path.join(dir, 'config.json'), '{ this is not json', { mode: 0o600 });
114
+ fs.writeFileSync(path.join(dir, 'active.flag'), 'active');
115
+ const r = runHook(dir, 'claude');
116
+ assert.equal(typeof r.json, 'object', 'the tool still gets one JSON object');
117
+ const bad = runHook(dir, 'not-a-tool');
118
+ assert.equal(bad.json.systemMessage, undefined, 'an unknown tool name says nothing');
119
+ });