llm-switcher 1.1.11 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
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,
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
144
|
-
//
|
|
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 ||
|
|
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
|
-
|
|
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 = {
|
|
331
|
-
for (const t of
|
|
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('
|
|
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 ||
|
|
382
|
-
console.error(`[Error] config.json does not parse any more: ${
|
|
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
|
-
|
|
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
|
|
399
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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)');
|