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
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,12 +4,14 @@ 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, TARGETS, configPath, claudeSettingsPath, paths, loadConfig, getConfigLoadError, saveConfig,
7
+ ROOT_DIR, STATE_DIR, TOOLS, configPath, 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,
11
11
  probeGateway, probeBlindfold, blindfoldPreflight, stopRecordedBlindfold, writeDashboardLauncher,
12
- contractLabSettings, codexPublicModelsWarning
12
+ contractLabSettings, codexPublicModelsWarning,
13
+ getMigrationError, getMigrationCollision, getLastLoadError,
14
+ casClearToolPointers, emptyToolEnvFiles, syncInterceptorTools
13
15
  } from './state.mjs';
14
16
  import { runProbe, runCheck } from './contract.mjs';
15
17
  import {
@@ -34,12 +36,8 @@ if (!config) {
34
36
  process.exit(1);
35
37
  }
36
38
 
37
- const TARGET_ALIASES = {
38
- claude: 'anthropic', anthropic: 'anthropic',
39
- codex: 'responses', responses: 'responses',
40
- openai: 'openai-chat', chat: 'openai-chat', 'openai-chat': 'openai-chat',
41
- vertex: 'vertex', gemini: 'vertex'
42
- };
39
+ // R5: two commands, one per tool. The openai and vertex commands went with their input routes.
40
+ const TARGET_ALIASES = { claude: 'claude', codex: 'codex' };
43
41
 
44
42
  // Strip --port/-p and their value from positional args. -p is the global option everywhere, as in
45
43
  // resolvePort; `switch port <n>` is the command that changes the port.
@@ -140,13 +138,17 @@ async function requestBlindfoldSync(port) {
140
138
  }
141
139
  }
142
140
 
143
- // With the gateway up it reconciles; with it down only a stop is safe, and stopping never spawns.
144
- // When the sync fails and no target wants an interceptor any more, the CLI stops it itself.
141
+ // With the gateway up it reconciles. With the gateway down there are two cases: a target that still
142
+ // wants an interceptor gets the new tool set pushed to it directly (Finding 4) — stopping it here
143
+ // would take the other tool's traffic down with it — and only when no target wants one is a stop
144
+ // safe. Neither branch ever starts an interceptor.
145
145
  async function syncOrStopBlindfold(port, cfg) {
146
+ const wants = computeLaunchState(cfg, port).blindfold;
146
147
  if (await checkProxyRunning(port)) {
147
148
  const r = await requestBlindfoldSync(port);
148
- if (r.ok || computeLaunchState(cfg, port).blindfold) return r;
149
+ if (r.ok || wants) return r;
149
150
  }
151
+ if (wants) return syncInterceptorTools(cfg, port);
150
152
  return stopRecordedBlindfold();
151
153
  }
152
154
 
@@ -254,6 +256,10 @@ async function changePort(newPortStr) {
254
256
  console.error(`[Error] Invalid port: "${newPortStr}". Must be an integer between 1 and 65535.`);
255
257
  process.exit(1);
256
258
  }
259
+ if (getMigrationError()) {
260
+ console.error(`[Error] Migration error: ${getMigrationError().message}`);
261
+ process.exit(1);
262
+ }
257
263
  const oldPort = resolvePort([], config);
258
264
  // Refuse before anything stops: the old gateway keeps running when the new port is taken.
259
265
  const target = await probeGateway(p);
@@ -283,7 +289,11 @@ async function changePort(newPortStr) {
283
289
  if (startedPid) killPid(startedPid);
284
290
  const back = loadConfig() || fresh;
285
291
  back.port = oldPort;
286
- saveConfig(back);
292
+ try {
293
+ saveConfig(back);
294
+ } catch (err) {
295
+ console.error(`[Error] Failed to restore config.json during rollback: ${err.message}`);
296
+ }
287
297
  if (svc) installService(oldPort);
288
298
  else if (wasRunning) startProxyBackground(oldPort);
289
299
  let up = !svc && !wasRunning;
@@ -327,8 +337,8 @@ function printProfile(profile) {
327
337
  }
328
338
 
329
339
  function printTargets(activeMap) {
330
- const labels = { anthropic: 'Claude Code', responses: 'Codex', 'openai-chat': 'OpenAI Chat', vertex: 'Vertex' };
331
- for (const t of TARGETS) {
340
+ const labels = { claude: 'Claude Code', codex: 'Codex' };
341
+ for (const t of TOOLS) {
332
342
  console.log(` ${labels[t].padEnd(12)} (${t.padEnd(11)}) -> ${activeMap[t] ? show(activeMap[t]) : 'OFF (official)'}`);
333
343
  }
334
344
  }
@@ -349,11 +359,20 @@ function refuseBlindfoldProblem(planned, port) {
349
359
  const problem = bf && blindfoldPreflight(bf);
350
360
  if (!problem) return bf;
351
361
  console.error(`[Error] ${problem}`);
352
- console.error(' Or set "blindfold": false in the profile. Nothing was changed.');
362
+ console.error(' Fix the certificate with: bash blindfold/make-certs.sh - it names all three hosts this switcher routes. Nothing was changed.');
353
363
  process.exit(1);
354
364
  }
355
365
 
356
366
  async function turnOn(profileName, cliTarget) {
367
+ if (getMigrationError()) {
368
+ console.error(`[Error] Migration error: ${getMigrationError().message}`);
369
+ process.exit(1);
370
+ }
371
+ if (getMigrationCollision()) {
372
+ console.error(`[Error] Refusing to switch: configuration migration collision: ${JSON.stringify(getMigrationCollision().clashingKeys)}. Nothing was changed.`);
373
+ process.exit(1);
374
+ }
375
+
357
376
  const port = getTargetPort();
358
377
  const wanted = profileName || config.activeProfile || Object.keys(config.profiles)[0];
359
378
  const key = findProfileKey(config, wanted);
@@ -378,8 +397,8 @@ async function turnOn(profileName, cliTarget) {
378
397
 
379
398
  // Plan again on the file as it is now: a dashboard save made while the gateway started must survive.
380
399
  const current = loadConfig();
381
- if (!current || getConfigLoadError()) {
382
- console.error(`[Error] config.json does not parse any more: ${getConfigLoadError()?.message}. Nothing was changed.`);
400
+ if (!current || getLastLoadError()) {
401
+ console.error(`[Error] config.json does not parse any more: ${getLastLoadError()?.message}. Nothing was changed.`);
383
402
  process.exit(1);
384
403
  }
385
404
  planned = planSwitch(current, key, cliTarget);
@@ -387,7 +406,12 @@ async function turnOn(profileName, cliTarget) {
387
406
  const profile = planned.profiles[key];
388
407
 
389
408
  const previousBytes = fs.readFileSync(configPath);
390
- saveConfig(planned);
409
+ try {
410
+ saveConfig(planned);
411
+ } catch (err) {
412
+ console.error(`[Error] ${err.message}`);
413
+ process.exit(1);
414
+ }
391
415
  const savedBytes = fs.readFileSync(configPath);
392
416
  const st = applyLaunchState(planned, port);
393
417
  reportSettings(st.settings);
@@ -395,14 +419,14 @@ async function turnOn(profileName, cliTarget) {
395
419
  if (!bf.ok) {
396
420
  console.error(`[Error] ${bf.error}`);
397
421
  // Put back exactly what was there, unless another writer saved in the meantime: then its
398
- // change wins and nothing is restored. clearLaunchState would also switch off unrelated targets
399
- // and run the settings.json cleaner, so the previous state is re-applied instead.
422
+ // change wins and nothing is restored. clearLaunchState would also switch off unrelated
423
+ // targets, so the previous state is re-applied instead.
400
424
  if (!fs.readFileSync(configPath).equals(savedBytes)) {
401
425
  console.error(' config.json changed while the interceptor started, so it is not restored. Check it, then run the command again.');
402
426
  process.exit(1);
403
427
  }
404
428
  restoreConfigBytes(previousBytes);
405
- applyLaunchState(JSON.parse(previousBytes.toString('utf8')), port, { cleanSettings: false });
429
+ applyLaunchState(JSON.parse(previousBytes.toString('utf8')), port);
406
430
  const back = await requestBlindfoldSync(port);
407
431
  console.error(' The previous config.json and launcher files are restored.');
408
432
  if (!back.ok) console.error(` The previous interceptor did not come back: ${back.error}`);
@@ -434,21 +458,78 @@ async function turnOn(profileName, cliTarget) {
434
458
  printProfile(profile);
435
459
  console.log('\nActive targets:');
436
460
  printTargets(getActiveMap(planned));
437
- console.log(`\nClaude 1M: ${describeClaude1M(st)}`);
438
- console.log(`Codex 1M: ${st.codex1M ? 'ACTIVE (1,000,000 tokens)' : 'OFF'}`);
439
- const codexKey = getActiveMap(planned).responses;
461
+ const codexKey = getActiveMap(planned).codex;
440
462
  const codexWarning = codexPublicModelsWarning(show(codexKey), planned.profiles[codexKey]);
441
463
  if (codexWarning) console.warn(`\n[WARN] ${codexWarning}`);
442
464
  }
443
465
 
466
+ // The one writer that runs while saveConfig refuses (R7b): re-read config.json, change only the tool
467
+ // pointers named here, and let the CAS writer confirm the bytes it started from are still the ones on
468
+ // disk. Every refusal exits before a launcher file or the interceptor has been touched.
469
+ function casOff(tools) {
470
+ let res;
471
+ try {
472
+ res = casClearToolPointers(tools);
473
+ } catch (err) {
474
+ console.error(`[Error] Could not read config.json: ${err.message}. Nothing was changed.`);
475
+ process.exit(1);
476
+ }
477
+ if (res.error) {
478
+ console.error(`[Error] Could not write config.json: ${res.error.message}. Nothing was changed.`);
479
+ process.exit(1);
480
+ }
481
+ if (res.collision) {
482
+ console.error(`[Error] Refusing to switch off: configuration migration collision: ${JSON.stringify(res.collision.clashingKeys)}. Nothing was changed.`);
483
+ process.exit(1);
484
+ }
485
+ return res.config;
486
+ }
487
+
488
+ // stopProxy's answer, turned into an exit. Every answer but a clean stop is a port this switcher
489
+ // could not prove it owns, and the user has to act; the launcher files are already cleared.
490
+ function reportStopped(result, port) {
491
+ if (result === 'still-running') {
492
+ console.error(`[Error] The gateway on port ${port} is still running. Stop it by hand; the launcher files are already cleared.`);
493
+ process.exit(1);
494
+ }
495
+ if (result === 'legacy') {
496
+ console.error(`[Error] An llm-switcher gateway older than 1.1.1 runs on port ${port}. Stop it, then run \`switch on\` again.`);
497
+ process.exit(1);
498
+ }
499
+ if (result === 'not-ours' || result === 'silent') {
500
+ console.error(result === 'silent'
501
+ ? `[Error] Port ${port} accepts connections but does not answer, so it is not proven to be this switcher. It was not stopped.`
502
+ : `[Error] Port ${port} is held by a process that did not prove it is this switcher. It was not stopped.`);
503
+ process.exit(1);
504
+ }
505
+ console.log(result === 'stopped' ? 'Stopped local proxy service.' : 'Proxy service was not running.');
506
+ }
507
+
444
508
  async function turnOff(targetArg) {
445
509
  const port = getTargetPort();
510
+ // saveConfig refuses while a collision or a failed migration stands (R7). Turning a tool off is
511
+ // still a write the user asked for, so it takes the CAS path instead of throwing (R7b).
512
+ const refused = Boolean(getMigrationCollision() || getMigrationError());
446
513
  if (targetArg) {
447
514
  const target = TARGET_ALIASES[targetArg.toLowerCase()];
448
515
  if (!target) {
449
- console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex, openai, vertex`);
516
+ console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex`);
450
517
  process.exit(1);
451
518
  }
519
+ if (refused) {
520
+ // The order is fixed by R7b: the CAS write, then this tool's env files, then the active-tools
521
+ // update. If the CAS write cannot land, the env file and the interceptor stay untouched.
522
+ const saved = casOff([target]);
523
+ emptyToolEnvFiles(target);
524
+ const bf = await syncOrStopBlindfold(port, config);
525
+ if (!bf.ok) {
526
+ console.error(`[Error] ${target} is switched back, but the interceptor is not in line: ${bf.error}`);
527
+ process.exit(1);
528
+ }
529
+ console.log(`[SUCCESS] ${target} switched back to official endpoint. Other targets unchanged:`);
530
+ printTargets(getActiveMap(saved));
531
+ return;
532
+ }
452
533
  setTargetProfile(config, target, null);
453
534
  saveConfig(config);
454
535
  const st = applyLaunchState(config, port);
@@ -464,41 +545,36 @@ async function turnOff(targetArg) {
464
545
  }
465
546
 
466
547
  console.log('Deactivating Proxy and restoring official endpoints...');
548
+ if (refused) {
549
+ // Both pointers through the same writer. Nothing below runs until those bytes are on disk (R7b).
550
+ casOff(['claude', 'codex']);
551
+ reportSettings(clearLaunchState(port));
552
+ const result = await stopProxy(port);
553
+ reportStopped(result, port);
554
+ // Direct, not through syncOrStopBlindfold: that asks the gateway this command just stopped.
555
+ const bfDirect = await stopRecordedBlindfold();
556
+ if (!bfDirect.ok) {
557
+ console.error(`[Error] The blindfold interceptor did not stop: ${bfDirect.error}`);
558
+ process.exit(1);
559
+ }
560
+ console.log('\n[SUCCESS] Switched back to Claude Official Subscription. Run `switch on` to re-enable.');
561
+ return;
562
+ }
467
563
  deactivateAll(config);
468
564
  saveConfig(config);
469
565
  reportSettings(clearLaunchState(port));
470
566
  const bf = await syncOrStopBlindfold(port, config);
471
567
  if (!bf.ok) console.error(`[Error] The blindfold interceptor did not stop: ${bf.error}`);
472
568
  const result = await stopProxy(port);
473
- if (result === 'still-running') {
474
- console.error(`[Error] The gateway on port ${port} is still running. Stop it by hand; the launcher files are already cleared.`);
475
- process.exit(1);
476
- }
477
- if (result === 'legacy') {
478
- console.error(`[Error] An llm-switcher gateway older than 1.1.1 runs on port ${port}. Stop it, then run \`switch on\` again.`);
479
- process.exit(1);
480
- }
481
- if (result === 'not-ours' || result === 'silent') {
482
- console.error(result === 'silent'
483
- ? `[Error] Port ${port} accepts connections but does not answer, so it is not proven to be this switcher. It was not stopped.`
484
- : `[Error] Port ${port} is held by a process that did not prove it is this switcher. It was not stopped.`);
485
- process.exit(1);
486
- }
487
- console.log(result === 'stopped' ? 'Stopped local proxy service.' : 'Proxy service was not running.');
569
+ reportStopped(result, port);
488
570
  if (!bf.ok) process.exit(1);
489
571
  console.log('\n[SUCCESS] Switched back to Claude Official Subscription. Run `switch on` to re-enable.');
490
572
  }
491
573
 
492
- // The flag carries the main session model only; haiku 1M reaches Claude Code through its tier variable.
493
- function describeClaude1M(st) {
494
- if (!st.claude1MTiers?.length) return 'OFF';
495
- return `ACTIVE (${st.claude1MTiers.map(t => `${t}[1m]`).join(', ')})${st.claude1M ? `, main session ${st.claude1M}` : ''}`;
496
- }
497
-
498
574
  // Only a CLI whose target is active is expected to go through the gateway.
499
575
  function auditActiveClis() {
500
576
  const map = getActiveMap(config);
501
- return auditRunningProcesses([map.anthropic && 'claude', map.responses && 'codex'].filter(Boolean));
577
+ return auditRunningProcesses([map.claude && 'claude', map.codex && 'codex'].filter(Boolean));
502
578
  }
503
579
 
504
580
  // settings.json edits are never silent: name every value the switcher removed.
@@ -522,8 +598,6 @@ async function showStatus() {
522
598
  if (flagged && !isRunning) {
523
599
  console.log(`[WARN] active.flag exists but proxy is STOPPED -> launched CLIs will fail to connect. Run 'switch on' or 'switch off'.`);
524
600
  }
525
- console.log(`Claude 1M: ${flagged ? describeClaude1M(computeLaunchState(config, port)) : 'OFF'}`);
526
- console.log(`Codex 1M Flag: ${fs.existsSync(paths.flagCodex1M) ? 'ACTIVE' : 'OFF'}`);
527
601
  console.log('\nActive targets:');
528
602
  printTargets(activeMap);
529
603
  console.log('\nAvailable profiles:');
@@ -815,7 +889,6 @@ async function runDoctor() {
815
889
  console.log(`[INFO] ANTHROPIC_BASE_URL is not set in current shell (launcher wrapper will inject on demand).`);
816
890
  }
817
891
 
818
- console.log(`[INFO] Claude 1M: ${describeClaude1M(computeLaunchState(config, port))}. Codex 1M flag: ${fs.existsSync(paths.flagCodex1M) ? 'YES' : 'NO'}`);
819
892
  console.log(`[INFO] Universal environment loader: env.cmd=${fs.existsSync(paths.envCmd) ? 'READY' : 'PENDING'}`);
820
893
  if (fs.existsSync(proxyLogPath)) console.log(`[INFO] Background proxy log: ${proxyLogPath}`);
821
894
 
@@ -850,8 +923,73 @@ async function runDoctor() {
850
923
 
851
924
  console.log('\n--- Intermediary Token Optimizers (Headroom / RTK / Ponytail) ---');
852
925
  console.log(`If using a token compressor, ensure its upstream target is configured to http://127.0.0.1:${port}.`);
853
- console.log('LLM Switcher will act as the final edge gatekeeper to heal schemas, unlock 1M, and preserve thinking.');
926
+ console.log('LLM Switcher will act as the final edge gatekeeper: it heals schemas, keeps the context window of the model you picked, and preserves thinking.');
927
+
928
+
929
+ // R10: read-only. The doctor reports what it finds in the tool's own file and never rewrites
930
+ // it - a diagnosis must not change the thing being diagnosed.
931
+ if (fs.existsSync(claudeSettingsPath)) {
932
+ try {
933
+ const raw = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
934
+ for (const [k, v] of Object.entries(raw.env || {})) {
935
+ if (/^ANTHROPIC_DEFAULT_[A-Z0-9_]+_MODEL$/.test(k) && /\[1m\]/i.test(String(v))) {
936
+ warn(`[WARN] ${claudeSettingsPath} sets ${k}=${v}.`);
937
+ console.log(' That alias is what reaches the provider. Delete the entry and let the gateway map the model.');
938
+ }
939
+ }
940
+ } catch { /* an unreadable settings.json is already reported above */ }
941
+ }
854
942
 
943
+ // R3a: one certificate serves every host this switcher routes. A certificate missing a host
944
+ // leaves that tool talking to the provider with no interceptor in between.
945
+ const leafPath = path.join(path.dirname(paths.blindfoldCA), 'leaf.pem');
946
+ if (!fs.existsSync(leafPath)) {
947
+ warn(`[WARN] No interceptor certificate at ${leafPath}: neither tool can be intercepted.`);
948
+ console.log(' Fix: bash blindfold/make-certs.sh');
949
+ } else {
950
+ try {
951
+ const pem = fs.readFileSync(leafPath, 'utf8');
952
+ const missing = ['api.anthropic.com', 'api.openai.com', 'chatgpt.com'].filter(h => !certCoversHost(pem, h));
953
+ if (missing.length) {
954
+ warn(`[WARN] The interceptor certificate does not cover ${missing.join(', ')}.`);
955
+ console.log(' Fix: bash blindfold/make-certs.sh (it names all three hosts this switcher routes)');
956
+ } else {
957
+ console.log('[PASS] Interceptor certificate covers api.anthropic.com, api.openai.com and chatgpt.com.');
958
+ }
959
+ } catch (e) {
960
+ warn(`[WARN] The interceptor certificate could not be read: ${e.message}`);
961
+ }
962
+ }
963
+
964
+ // R8: the shims inject the proxy variables themselves, so an rc file that still sources env.sh
965
+ // puts a second, stale copy of the routing into every shell.
966
+ let rcFiles = [];
967
+ try { rcFiles = suggestedRcFiles() || []; } catch { rcFiles = []; }
968
+ for (const rc of rcFiles) {
969
+ try {
970
+ const rcPath = typeof rc === 'string' ? rc : (rc && rc.path) || '';
971
+ if (!rcPath || !fs.existsSync(rcPath)) continue;
972
+ const txt = fs.readFileSync(rcPath, 'utf8');
973
+ if (/^\s*(\.|source)\s+\S*env\.(sh|cmd)\b/m.test(txt)) {
974
+ warn(`[WARN] ${rcPath} sources env.sh/env.cmd.`);
975
+ console.log(' The shims inject the route on their own; remove that line to avoid two conflicting copies.');
976
+ }
977
+ } catch { /* not readable is not a finding */ }
978
+ }
979
+
980
+ // R2: a pre-existing proxy variable chains this gateway behind another one, which is out of
981
+ // scope - the interceptor owns HTTPS_PROXY for the tools it launches.
982
+ const inherited = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
983
+ if (inherited && !/\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(inherited)) {
984
+ warn(`[WARN] HTTPS_PROXY/HTTP_PROXY is already set to ${inherited}.`);
985
+ console.log(' Chaining this gateway behind another proxy is out of scope; unset it before using the shims.');
986
+ }
987
+
988
+ // R7: a collision is a report, not a failure - the doctor still exits 0.
989
+ if (getMigrationCollision()) {
990
+ console.log(`[INFO] Configuration migration collision: ${JSON.stringify(getMigrationCollision().clashingKeys)}.`);
991
+ console.log(' config.json is left exactly as it is. Fix the two pointers by hand, then run switch on.');
992
+ }
855
993
  console.log(`\nDoctor summary: ${allHealthy ? 'ALL CHECKS PASSED (HEALTHY)' : 'ATTENTION RECOMMENDED (CHECK WARNINGS ABOVE)'}`);
856
994
  }
857
995
 
@@ -898,6 +1036,42 @@ async function runContractCheck() {
898
1036
  }
899
1037
  }
900
1038
 
1039
+ async function showModels(target) {
1040
+ const { loadCatalogCache, refreshCatalog } = await import('./catalog.mjs');
1041
+ if (optionValue('--refresh') || process.argv.includes('--refresh')) {
1042
+ console.log('Refreshing models catalog from official endpoints...');
1043
+ await refreshCatalog(STATE_DIR);
1044
+ }
1045
+ const cache = loadCatalogCache(STATE_DIR);
1046
+ console.log('=== LLM Switcher Discovered Models Catalog ===\n');
1047
+ const t = target ? target.toLowerCase() : '';
1048
+ if (!t || t === 'claude') {
1049
+ console.log('Claude Code Models:');
1050
+ const grouped = { opus: [], sonnet: [], haiku: [], fable: [] };
1051
+ for (const m of (cache.claude?.models || [])) {
1052
+ if (grouped[m.tier]) grouped[m.tier].push(m.id);
1053
+ }
1054
+ for (const [tier, ids] of Object.entries(grouped)) {
1055
+ console.log(` [${tier.padEnd(6)}] : ${ids.join(', ')}`);
1056
+ }
1057
+ console.log('');
1058
+ }
1059
+ if (!t || t === 'codex') {
1060
+ console.log('Codex CLI Models:');
1061
+ const grouped = { main: [], review: [], subagent: [] };
1062
+ for (const m of (cache.codex?.models || [])) {
1063
+ if (grouped[m.role]) grouped[m.role].push(m.id);
1064
+ }
1065
+ for (const [role, ids] of Object.entries(grouped)) {
1066
+ console.log(` [${role.padEnd(8)}] : ${ids.join(', ')}`);
1067
+ }
1068
+ console.log('');
1069
+ }
1070
+ const updatedStr = cache.updatedAt ? new Date(cache.updatedAt).toLocaleString() : 'built-in catalog baseline';
1071
+ console.log(`Catalog timestamp: ${updatedStr}`);
1072
+ console.log('Run `switch models --refresh` to query latest upstream models.');
1073
+ }
1074
+
901
1075
  const [rawCmd = '', subArg = ''] = positionalArgs();
902
1076
  const cmd = rawCmd.toLowerCase();
903
1077
 
@@ -919,6 +1093,8 @@ if (cmd === 'off' || cmd === 'stop') {
919
1093
  await runContractProbe();
920
1094
  } else if (cmd === 'contract-check') {
921
1095
  await runContractCheck();
1096
+ } else if (cmd === 'models' || cmd === 'catalog') {
1097
+ await showModels(subArg);
922
1098
  } else if (cmd === 'status' || cmd === 'st') {
923
1099
  await showStatus();
924
1100
  } else if (cmd === 'on' || cmd === 'start') {
@@ -934,8 +1110,6 @@ if (cmd === 'off' || cmd === 'stop') {
934
1110
  console.log(' switch <profile> # Activate profile for all compatible targets');
935
1111
  console.log(' switch claude <profile> # Set active profile for Claude Code');
936
1112
  console.log(' switch codex <profile> # Set active profile for Codex');
937
- console.log(' switch openai <profile> # Set active profile for OpenAI Chat');
938
- console.log(' switch vertex <profile> # Set active profile for Vertex');
939
1113
  console.log(' switch port <number> # Change gateway port');
940
1114
  console.log(' switch service install # Install OS background autostart service');
941
1115
  console.log(' switch service uninstall # Uninstall background autostart service');
@@ -943,6 +1117,7 @@ if (cmd === 'off' || cmd === 'stop') {
943
1117
  console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
944
1118
  console.log(' switch shim uninstall # Remove launcher shims');
945
1119
  console.log(' switch off [target] # Restore official endpoints (all, or one target)');
1120
+ console.log(' switch models [--refresh] # Show discovered models catalog for Claude Code & Codex');
946
1121
  console.log(' switch contract-probe [--model m] # Drive the contract-lab variants through the gateway');
947
1122
  console.log(' switch contract-check # Turn the open contract findings into failing tests');
948
1123
  console.log('\nGlobal option: --port <n> (or env LLM_SWITCHER_PORT)');