llm-switcher 1.2.7 → 1.2.9
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 +49 -0
- package/README.md +87 -4
- package/README.vi.md +88 -4
- package/blindfold/blindfold.mjs +2 -1
- package/docs/cross-platform.md +3 -1
- package/hook-status.mjs +51 -0
- package/notify-route.ps1 +46 -0
- package/package.json +2 -2
- package/plugin.mjs +204 -0
- package/proxy.mjs +76 -7
- package/service.mjs +1 -1
- package/shim.mjs +28 -2
- package/state.mjs +41 -5
- package/switch.mjs +53 -2
- package/tests/blindfold.test.mjs +1 -1
- package/tests/cdp.mjs +21 -2
- package/tests/contract-lab.test.mjs +5 -0
- package/tests/dashboard.e2e.test.mjs +116 -0
- package/tests/gateway.e2e.test.mjs +51 -2
- package/tests/hook-status.test.mjs +119 -0
- package/tests/plugin.test.mjs +132 -0
- package/tests/real-user-sim.test.mjs +2 -2
- package/tests/service.test.mjs +2 -0
- package/tests/shim.test.mjs +112 -0
- package/tests/state.test.mjs +130 -5
- package/ui.html +86 -7
package/tests/shim.test.mjs
CHANGED
|
@@ -282,6 +282,22 @@ test('the POSIX shims source launch files only when this account owns them', ()
|
|
|
282
282
|
|
|
283
283
|
// ISS-CC-LS-002: on zsh a later line in .zshrc or .zprofile can prepend npm-global or Homebrew
|
|
284
284
|
// ahead of the shim. The hint names both files and says the line must come last.
|
|
285
|
+
test('pathOrderHint gives a fish user the fish file and fish syntax', () => {
|
|
286
|
+
// `export PATH=...` is not fish syntax. A fish user who pasted the old hint got an error, and
|
|
287
|
+
// the hint named ~/.profile, which fish does not read.
|
|
288
|
+
const shell = process.env.SHELL;
|
|
289
|
+
process.env.SHELL = '/usr/bin/fish';
|
|
290
|
+
try {
|
|
291
|
+
assert.deepEqual(suggestedRcFiles('linux'), [path.join(os.homedir(), '.config', 'fish', 'config.fish')]);
|
|
292
|
+
const text = pathOrderHint('linux').join(' ');
|
|
293
|
+
assert.ok(text.includes('config.fish'), text);
|
|
294
|
+
assert.ok(text.includes('fish_add_path'), text);
|
|
295
|
+
assert.ok(!text.includes('export PATH='), text);
|
|
296
|
+
} finally {
|
|
297
|
+
if (shell === undefined) delete process.env.SHELL; else process.env.SHELL = shell;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
285
301
|
test('pathOrderHint tells a zsh user to put the export last in .zshrc and .zprofile', () => {
|
|
286
302
|
const shell = process.env.SHELL;
|
|
287
303
|
process.env.SHELL = '/bin/zsh';
|
|
@@ -340,3 +356,99 @@ test('A8: the Windows shim scrubs a loopback base URL and resolves every subrout
|
|
|
340
356
|
assert.match(child.stdout || '', /HTTPS=\[http:\/\/127\.0\.0\.1:3457\]/,
|
|
341
357
|
'R2: the proxy comes from the env file of this tool');
|
|
342
358
|
});
|
|
359
|
+
|
|
360
|
+
// ---- The route notice: the launcher tells the person the switcher took the traffic -------------
|
|
361
|
+
|
|
362
|
+
// A person opens `claude`, the switcher routes it to another provider, and nothing on screen says
|
|
363
|
+
// so. The shim therefore raises a notice at launch. It is guarded by TOOL_ACTIVE, so a tool whose
|
|
364
|
+
// profile is off stays silent: the guard is the same one that decides whether to source the env.
|
|
365
|
+
test('the route notice is guarded by TOOL_ACTIVE and names only this tool route file', () => {
|
|
366
|
+
for (const platform of ['win32', 'linux']) {
|
|
367
|
+
const claude = renderShim('claude', platform);
|
|
368
|
+
const codex = renderShim('codex', platform);
|
|
369
|
+
assert.match(claude, /route-claude\.txt/, `${platform}: the claude shim reads its own route file`);
|
|
370
|
+
assert.doesNotMatch(claude, /route-codex/, `${platform}: and never the other tool file`);
|
|
371
|
+
assert.match(codex, /route-codex\.txt/);
|
|
372
|
+
assert.doesNotMatch(codex, /route-claude/);
|
|
373
|
+
// Everything that raises the notice sits after the TOOL_ACTIVE decision, never before it.
|
|
374
|
+
const guard = platform === 'win32' ? claude.indexOf('set "TOOL_ACTIVE=1"') : claude.indexOf('TOOL_ACTIVE=1');
|
|
375
|
+
assert.ok(guard > 0, `${platform}: the guard exists`);
|
|
376
|
+
assert.ok(claude.indexOf('route-claude.txt') > guard, `${platform}: the notice comes after the guard`);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('the POSIX shim prints the route line for an active tool and nothing for one that is off', (t) => {
|
|
381
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
382
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfake-'));
|
|
383
|
+
t.after(() => fs.rmSync(fakeDir, { recursive: true, force: true }));
|
|
384
|
+
fs.writeFileSync(path.join(fakeDir, 'claude'), '#!/bin/sh\necho ran\n');
|
|
385
|
+
fs.chmodSync(path.join(fakeDir, 'claude'), 0o755);
|
|
386
|
+
|
|
387
|
+
const run = ({ active, route }) => {
|
|
388
|
+
for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
|
|
389
|
+
if (active) {
|
|
390
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
391
|
+
fs.writeFileSync(path.join(STATE, 'env-claude.sh'), "export HTTPS_PROXY='http://127.0.0.1:3457'\n");
|
|
392
|
+
}
|
|
393
|
+
fs.writeFileSync(path.join(STATE, 'route-claude.txt'), route);
|
|
394
|
+
return spawnSync(path.join(RENDER_DIR, 'claude'), [], {
|
|
395
|
+
encoding: 'utf8', timeout: 15000,
|
|
396
|
+
env: { ...process.env, PATH: [RENDER_DIR, fakeDir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const on = run({ active: true, route: 'claude -> cl | intact.example.io | gemini | 1M\n' });
|
|
401
|
+
assert.match(on.stderr, /intact\.example\.io/, 'an active tool shows where its traffic goes');
|
|
402
|
+
assert.match(on.stdout, /ran/, 'and the real binary still runs');
|
|
403
|
+
|
|
404
|
+
const off = run({ active: false, route: 'claude -> cl | intact.example.io | gemini | 1M\n' });
|
|
405
|
+
assert.doesNotMatch(off.stderr, /intact\.example\.io/, 'a tool that is off says nothing, even with a stale route file');
|
|
406
|
+
assert.match(off.stdout, /ran/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// The Windows notice is a detached toast, so the only way to prove it fires is to put a fake
|
|
410
|
+
// `powershell` on PATH and read what the shim asked it to do.
|
|
411
|
+
test('the Windows shim raises the toast only while this tool is routed', (t) => {
|
|
412
|
+
if (process.platform !== 'win32') return t.skip('cmd.exe only');
|
|
413
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimwintoast-'));
|
|
414
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfaketoast-'));
|
|
415
|
+
t.after(() => {
|
|
416
|
+
fs.rmSync(shimDir, { recursive: true, force: true });
|
|
417
|
+
fs.rmSync(fakeDir, { recursive: true, force: true });
|
|
418
|
+
});
|
|
419
|
+
const log = path.join(fakeDir, 'toast.log');
|
|
420
|
+
fs.writeFileSync(path.join(fakeDir, 'claude.cmd'), '@echo off\r\necho ran\r\n');
|
|
421
|
+
fs.writeFileSync(path.join(fakeDir, 'powershell.cmd'), `@echo off\r\necho %*>>"${log}"\r\n`);
|
|
422
|
+
const shim = path.join(shimDir, 'shim.cmd');
|
|
423
|
+
fs.writeFileSync(shim, renderShim('claude', 'win32'), 'utf8');
|
|
424
|
+
|
|
425
|
+
const run = ({ active }) => {
|
|
426
|
+
for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
|
|
427
|
+
fs.rmSync(log, { force: true });
|
|
428
|
+
if (active) {
|
|
429
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
430
|
+
fs.writeFileSync(path.join(STATE, 'env-claude.cmd'), 'SET "HTTPS_PROXY=http://127.0.0.1:3457"\r\n');
|
|
431
|
+
}
|
|
432
|
+
fs.writeFileSync(path.join(STATE, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini | 1M\r\n');
|
|
433
|
+
const child = spawnSync('cmd.exe', ['/d', '/c', shim, '--version'], {
|
|
434
|
+
encoding: 'utf8', timeout: 30000,
|
|
435
|
+
env: {
|
|
436
|
+
...process.env,
|
|
437
|
+
PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)]
|
|
438
|
+
.join(path.delimiter)
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
// `start /b` detaches, so the fake may still be writing when the shim has already exited.
|
|
442
|
+
for (let i = 0; i < 60 && !fs.existsSync(log); i++) execFileSync(process.execPath, ['-e', 'setTimeout(()=>{},50)']);
|
|
443
|
+
return { out: child.stdout || '', asked: fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '' };
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const on = run({ active: true });
|
|
447
|
+
assert.match(on.out, /ran/, 'the real binary still runs');
|
|
448
|
+
assert.match(on.asked, /route-claude\.txt/, 'the toast is asked for, with this tool route file');
|
|
449
|
+
assert.match(on.asked, /notify-route\.ps1/, 'through the notifier that ships with the switcher');
|
|
450
|
+
|
|
451
|
+
const off = run({ active: false });
|
|
452
|
+
assert.match(off.out, /ran/);
|
|
453
|
+
assert.equal(off.asked, '', 'a tool that is off raises no toast');
|
|
454
|
+
});
|
package/tests/state.test.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
modelSlotsForProfile, modelForSlot, primaryModel, codexPublicModel,
|
|
8
8
|
certCoversHost, blindfoldPreflight, ROOT_DIR, openLog, probeGateway, isSafeModelName,
|
|
9
9
|
getMigrationCollision, getMigrationError, getConfigLoadError, saveConfig, loadConfig,
|
|
10
|
-
migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration
|
|
10
|
+
migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration, hasPlaceholder
|
|
11
11
|
} from '../state.mjs';
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
@@ -52,6 +52,76 @@ test('activateProfile only assigns compatible tools; deactivateProfile leaves th
|
|
|
52
52
|
assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null });
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('switching a migrated config does not make it need migration again', () => {
|
|
56
|
+
// A legacy pointer written on every switch re-ran the migration on the next load and left one
|
|
57
|
+
// more config.json.bak-* (with the API keys) in the data folder each time.
|
|
58
|
+
const cfg = {
|
|
59
|
+
port: 4000,
|
|
60
|
+
activeProfiles: { claude: null, codex: null },
|
|
61
|
+
profiles: {
|
|
62
|
+
router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } },
|
|
63
|
+
codexOnly: { tool: 'codex', apiKey: 'sk-2', baseURL: 'https://c/v1', defaultModels: { main: 'gpt-main' } }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
assert.equal(needsMigration(cfg), false, 'fixture is already migrated');
|
|
67
|
+
assert.equal(activateProfile(cfg, 'router'), null);
|
|
68
|
+
assert.equal(setTargetProfile(cfg, 'codex', 'codexOnly'), null);
|
|
69
|
+
assert.equal(Object.hasOwn(cfg, 'activeProfile'), false);
|
|
70
|
+
assert.equal(needsMigration(cfg), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('hasPlaceholder spots the example config values', () => {
|
|
74
|
+
assert.equal(hasPlaceholder({ baseURL: 'https://YOUR-ROUTER-HOST/v1', apiKey: 'sk-real' }), true);
|
|
75
|
+
assert.equal(hasPlaceholder({ baseURL: 'https://r/v1', apiKey: 'REPLACE-ME' }), true);
|
|
76
|
+
assert.equal(hasPlaceholder({ apiKey: 'sk-real' }), true, 'no baseURL');
|
|
77
|
+
assert.equal(hasPlaceholder({ baseURL: 'https://r/v1', apiKey: 'sk-real' }), false);
|
|
78
|
+
assert.equal(hasPlaceholder({ baseURL: 'http://127.0.0.1:11434/v1' }), false, 'a keyless local backend is fine');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('the interceptor port follows LLM_SWITCHER_BLINDFOLD_PORT and never lands on the gateway port', () => {
|
|
82
|
+
// The gateway reads --port and LLM_SWITCHER_PORT through resolvePort. The interceptor read only
|
|
83
|
+
// config.json, so a second instance collided and HTTPS_PROXY named a port nothing listened on.
|
|
84
|
+
const saved = process.env.LLM_SWITCHER_BLINDFOLD_PORT;
|
|
85
|
+
delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
|
|
86
|
+
const cfg = {
|
|
87
|
+
port: 4000,
|
|
88
|
+
activeProfiles: { claude: 'router', codex: null },
|
|
89
|
+
profiles: { router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } } }
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
assert.equal(computeLaunchState(cfg, 4000).blindfold.port, 3457, 'the default');
|
|
93
|
+
assert.equal(computeLaunchState({ ...cfg, blindfold: { port: 4444 } }, 4000).blindfold.port, 4444, 'the file');
|
|
94
|
+
process.env.LLM_SWITCHER_BLINDFOLD_PORT = '5555';
|
|
95
|
+
assert.equal(computeLaunchState({ ...cfg, blindfold: { port: 4444 } }, 4000).blindfold.port, 5555, 'the environment wins');
|
|
96
|
+
delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
|
|
97
|
+
// A gateway moved onto 3457 by --port or by the environment must push the interceptor off it.
|
|
98
|
+
const st = computeLaunchState(cfg, 3457);
|
|
99
|
+
assert.notEqual(st.blindfold.port, 3457, 'the interceptor moved off the gateway port');
|
|
100
|
+
const proxy = st.envClaude.find(([k]) => k === 'HTTPS_PROXY');
|
|
101
|
+
assert.equal(proxy[1], `http://127.0.0.1:${st.blindfold.port}`, 'HTTPS_PROXY names the real port');
|
|
102
|
+
} finally {
|
|
103
|
+
if (saved === undefined) delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
|
|
104
|
+
else process.env.LLM_SWITCHER_BLINDFOLD_PORT = saved;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('deleteProfile does not make a migrated config need migration again', () => {
|
|
109
|
+
// Reachable from the dashboard Delete Profile button via POST /api/delete-profile, which
|
|
110
|
+
// persists. A legacy pointer written here revives the backup leak the switch path just lost.
|
|
111
|
+
const cfg = {
|
|
112
|
+
port: 4000,
|
|
113
|
+
activeProfiles: { claude: null, codex: 'codexOnly' },
|
|
114
|
+
profiles: {
|
|
115
|
+
router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } },
|
|
116
|
+
codexOnly: { tool: 'codex', apiKey: 'sk-2', baseURL: 'https://c/v1', defaultModels: { main: 'gpt-main' } }
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
assert.equal(needsMigration(cfg), false, 'fixture is already migrated');
|
|
120
|
+
assert.equal(deleteProfile(cfg, 'codexOnly'), null);
|
|
121
|
+
assert.equal(Object.hasOwn(cfg, 'activeProfile'), false);
|
|
122
|
+
assert.equal(needsMigration(cfg), false);
|
|
123
|
+
});
|
|
124
|
+
|
|
55
125
|
test('deleteProfile unassigns targets that pointed at it', () => {
|
|
56
126
|
const cfg = makeCfg();
|
|
57
127
|
activateProfile(cfg, 'codexOnly');
|
|
@@ -602,15 +672,21 @@ test('isSafeModelName refuses names that TOML reads as something other than a st
|
|
|
602
672
|
test('launch-file writers take turns through a lock, and a dead holder does not block them', async (t) => {
|
|
603
673
|
const { dir, env } = tmpDirs(t);
|
|
604
674
|
const lock = path.join(dir, '.launch.lock');
|
|
675
|
+
const startedFile = path.join(dir, 'writer.started');
|
|
605
676
|
// A live holder: this test process. The child must wait until the lock is gone.
|
|
606
677
|
fs.writeFileSync(lock, String(process.pid));
|
|
607
|
-
|
|
608
|
-
const started = Date.now();
|
|
609
|
-
const r = await new Promise((resolve, reject) => {
|
|
678
|
+
const done = new Promise((resolve, reject) => {
|
|
610
679
|
execFileCb(process.execPath, ['--input-type=module', '-e',
|
|
611
|
-
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
680
|
+
`const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); (await import('node:fs')).writeFileSync(${JSON.stringify(startedFile)}, ''); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
|
|
612
681
|
{ env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
|
|
613
682
|
});
|
|
683
|
+
// The 400 ms count from the writer's start, the same way the next test does it. A spawn plus an
|
|
684
|
+
// import measured 163 to 541 ms on Node 18, so a fixed timer left under 100 ms of wait and the
|
|
685
|
+
// assertion below failed on a loaded machine.
|
|
686
|
+
for (let i = 0; i < 1000 && !fs.existsSync(startedFile); i++) await new Promise(r => setTimeout(r, 10));
|
|
687
|
+
const started = Date.now();
|
|
688
|
+
setTimeout(() => fs.rmSync(lock, { force: true }), 400);
|
|
689
|
+
const r = await done;
|
|
614
690
|
// Without the lock the write takes a few ms; with it the child waits for the release at 400 ms.
|
|
615
691
|
assert.ok(r >= 100, `the writer waited ${r} ms for the live holder`);
|
|
616
692
|
assert.ok(Date.now() - started >= 380);
|
|
@@ -1496,3 +1572,52 @@ test('A5: config.example.json loads and passes validation without requiring migr
|
|
|
1496
1572
|
}
|
|
1497
1573
|
}
|
|
1498
1574
|
});
|
|
1575
|
+
|
|
1576
|
+
// ---- Route lines: what the launcher shows when it routes a tool -------------------------------
|
|
1577
|
+
|
|
1578
|
+
// A person opens `claude` and cannot see that the switcher took over the traffic. The launcher
|
|
1579
|
+
// therefore reads one line per tool, written beside that tool's env file. The line exists exactly
|
|
1580
|
+
// when that tool is routed, so a tool whose profile is off stays silent with no extra condition.
|
|
1581
|
+
test('each routed tool gets a route line; a tool that is off gets an empty one', (t) => {
|
|
1582
|
+
const { dir, env } = tmpDirs(t);
|
|
1583
|
+
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({
|
|
1584
|
+
port: 4000,
|
|
1585
|
+
activeProfiles: { claude: 'cl', codex: 'cx' },
|
|
1586
|
+
profiles: {
|
|
1587
|
+
cl: {
|
|
1588
|
+
name: 'Intact Claude', baseURL: 'https://intact.example.io/v1', apiKey: 'sk-secret-claude',
|
|
1589
|
+
defaultModels: { opus: 'gemini-3.8-flash' }, model1M: { opus: true }
|
|
1590
|
+
},
|
|
1591
|
+
cx: {
|
|
1592
|
+
inFormat: 'responses', baseURL: 'https://other.example.io/v1', apiKey: 'sk-secret-codex',
|
|
1593
|
+
defaultModels: { main: 'gpt-5.6-sol' }
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}), { mode: 0o600 });
|
|
1597
|
+
|
|
1598
|
+
const r = runState(env, `
|
|
1599
|
+
const read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
|
|
1600
|
+
s.applyLaunchState(s.loadConfig(), 4000);
|
|
1601
|
+
const on = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
|
|
1602
|
+
s.emptyToolEnvFiles('claude');
|
|
1603
|
+
const oneOff = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
|
|
1604
|
+
s.applyLaunchState(s.loadConfig(), 4000);
|
|
1605
|
+
s.clearLaunchState(4000);
|
|
1606
|
+
const allOff = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
|
|
1607
|
+
return { on, oneOff, allOff };
|
|
1608
|
+
`);
|
|
1609
|
+
|
|
1610
|
+
assert.match(r.on.claude, /\bcl\b/, 'the line names the profile that serves claude');
|
|
1611
|
+
assert.match(r.on.claude, /intact\.example\.io/, 'and the host the traffic goes to');
|
|
1612
|
+
assert.match(r.on.claude, /gemini-3\.8-flash/, 'and the model it maps to');
|
|
1613
|
+
assert.match(r.on.claude, /1M/, 'and the 1M window, because that changes what the session costs');
|
|
1614
|
+
assert.match(r.on.codex, /\bcx\b/);
|
|
1615
|
+
assert.match(r.on.codex, /other\.example\.io/);
|
|
1616
|
+
assert.doesNotMatch(r.on.codex, /intact\.example\.io/, 'one tool never shows the other tool route');
|
|
1617
|
+
assert.doesNotMatch(`${r.on.claude}${r.on.codex}`, /sk-secret/, 'a route line never carries the API key');
|
|
1618
|
+
|
|
1619
|
+
assert.equal(r.oneOff.claude.trim(), '', 'the tool switched off has no route line');
|
|
1620
|
+
assert.notEqual(r.oneOff.codex.trim(), '', 'the other tool keeps its own');
|
|
1621
|
+
assert.equal(r.allOff.claude.trim(), '', 'switch off leaves no route line for either tool');
|
|
1622
|
+
assert.equal(r.allOff.codex.trim(), '');
|
|
1623
|
+
});
|
package/ui.html
CHANGED
|
@@ -811,6 +811,10 @@
|
|
|
811
811
|
position: sticky;
|
|
812
812
|
top: 0;
|
|
813
813
|
height: auto;
|
|
814
|
+
/* The base rule makes this a scroll container, and a grid item that scrolls has an automatic
|
|
815
|
+
minimum size of zero. The row then collapsed to the padding (16 px measured) and the nav
|
|
816
|
+
was drawn outside it, so no link could be pressed. Reset it, or the height is ignored. */
|
|
817
|
+
overflow: visible;
|
|
814
818
|
flex-direction: row;
|
|
815
819
|
flex-wrap: wrap;
|
|
816
820
|
align-items: center;
|
|
@@ -926,7 +930,7 @@
|
|
|
926
930
|
<div class="ph">
|
|
927
931
|
<div class="title-box">
|
|
928
932
|
<h1 id="page-title">Routes & Profiles</h1>
|
|
929
|
-
<p id="status-sub">Route Claude Code
|
|
933
|
+
<p id="status-sub">Route Claude Code and Codex independently.</p>
|
|
930
934
|
</div>
|
|
931
935
|
<div class="ph-actions" id="ph-actions">
|
|
932
936
|
<!-- Rendered dynamically per route -->
|
|
@@ -1323,18 +1327,44 @@
|
|
|
1323
1327
|
for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
|
|
1324
1328
|
|
|
1325
1329
|
// Modal Control
|
|
1330
|
+
|
|
1331
|
+
// A result belongs to the profile it was measured on. Cleared on every open, because a dialog
|
|
1332
|
+
// that opens showing "HTTP 200 OK" from the profile before it says the wrong thing about this one.
|
|
1333
|
+
// Empties the three fields, never the box: they are static markup, and innerHTML would delete
|
|
1334
|
+
// them for the rest of the session.
|
|
1335
|
+
function clearTestResult() {
|
|
1336
|
+
const box = document.getElementById('test-result');
|
|
1337
|
+
if (!box) return;
|
|
1338
|
+
for (const id of ['test-status-badge', 'test-latency', 'test-sample']) {
|
|
1339
|
+
const el = document.getElementById(id);
|
|
1340
|
+
if (el) el.textContent = '';
|
|
1341
|
+
}
|
|
1342
|
+
document.getElementById('test-status-badge')?.setAttribute('class', 'badge');
|
|
1343
|
+
box.className = 'test-result-box';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1326
1346
|
function openProfileModal(isNew = false) {
|
|
1327
1347
|
const modal = document.getElementById('profile-modal-overlay');
|
|
1328
1348
|
if (modal) modal.style.display = 'grid';
|
|
1349
|
+
clearTestResult();
|
|
1329
1350
|
if (isNew) {
|
|
1330
1351
|
initNewProfile();
|
|
1331
1352
|
}
|
|
1353
|
+
// Without this the focus stays on the button behind the overlay, so the first Tab walks into
|
|
1354
|
+
// the page under the dialog instead of through the dialog.
|
|
1355
|
+
const first = modal?.querySelector('input:not([readonly]):not([disabled]), select, textarea, button');
|
|
1356
|
+
first?.focus();
|
|
1332
1357
|
}
|
|
1333
1358
|
|
|
1334
1359
|
function closeProfileModal() {
|
|
1335
1360
|
const modal = document.getElementById('profile-modal-overlay');
|
|
1336
1361
|
if (modal) modal.style.display = 'none';
|
|
1337
1362
|
formDirty = false;
|
|
1363
|
+
// render() reads this to draw the ring and the "Editing Profile" label. Left set, a card stayed
|
|
1364
|
+
// marked as being edited after Escape, Cancel or a press on the dark area.
|
|
1365
|
+
currentlyEditingKey = '';
|
|
1366
|
+
formLoadedKey = null;
|
|
1367
|
+
render();
|
|
1338
1368
|
}
|
|
1339
1369
|
|
|
1340
1370
|
// The close button, Cancel, the dark area and Escape. A save or a delete calls closeProfileModal
|
|
@@ -2086,6 +2116,15 @@
|
|
|
2086
2116
|
if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
|
|
2087
2117
|
|
|
2088
2118
|
showToast('Saving profile "' + key + '"...');
|
|
2119
|
+
// A double-click sent the save twice. The same pattern as the catalog sync button: disabled
|
|
2120
|
+
// while the request runs, re-enabled in `finally` so a failed save can be tried again.
|
|
2121
|
+
const submit = e.target?.querySelector?.('button[type="submit"]')
|
|
2122
|
+
|| document.querySelector('#profile-modal-overlay button[type="submit"]');
|
|
2123
|
+
if (submit) {
|
|
2124
|
+
if (submit.disabled) return;
|
|
2125
|
+
submit.disabled = true;
|
|
2126
|
+
submit.setAttribute('aria-busy', 'true');
|
|
2127
|
+
}
|
|
2089
2128
|
try {
|
|
2090
2129
|
const res = await api('/api/save-profile', {
|
|
2091
2130
|
method: 'POST',
|
|
@@ -2102,6 +2141,11 @@
|
|
|
2102
2141
|
}
|
|
2103
2142
|
} catch (err) {
|
|
2104
2143
|
showToast('Network error saving profile', true);
|
|
2144
|
+
} finally {
|
|
2145
|
+
if (submit) {
|
|
2146
|
+
submit.disabled = false;
|
|
2147
|
+
submit.removeAttribute('aria-busy');
|
|
2148
|
+
}
|
|
2105
2149
|
}
|
|
2106
2150
|
}
|
|
2107
2151
|
|
|
@@ -2434,8 +2478,15 @@
|
|
|
2434
2478
|
const ctModels = document.getElementById('ct-models');
|
|
2435
2479
|
if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
|
|
2436
2480
|
|
|
2437
|
-
|
|
2438
|
-
|
|
2481
|
+
// `data-full` is what the search box restores when it is emptied again.
|
|
2482
|
+
for (const [id, text] of [
|
|
2483
|
+
['badge-claude-count', claudeModels.length + ' models · ' + claudeTiers.size + ' tiers'],
|
|
2484
|
+
['badge-codex-count', codexModels.length + ' models · ' + codexRoles.size + ' roles']
|
|
2485
|
+
]) {
|
|
2486
|
+
const el = document.getElementById(id);
|
|
2487
|
+
el.textContent = text;
|
|
2488
|
+
el.dataset.full = text;
|
|
2489
|
+
}
|
|
2439
2490
|
|
|
2440
2491
|
claudeContainer.innerHTML = claudeModels.map(m => `
|
|
2441
2492
|
<div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
|
|
@@ -2465,12 +2516,40 @@
|
|
|
2465
2516
|
}
|
|
2466
2517
|
}
|
|
2467
2518
|
|
|
2519
|
+
// A filter that hides every row but keeps the header count reads as a broken page: the header
|
|
2520
|
+
// still promised 11 models over an empty list. So the count follows the filter, and an empty
|
|
2521
|
+
// result says so in words.
|
|
2468
2522
|
function filterCatalogView(q) {
|
|
2469
2523
|
const term = (q || '').toLowerCase();
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2524
|
+
for (const [list, badge, unit] of [
|
|
2525
|
+
['#catalog-claude-items', 'badge-claude-count', 'models'],
|
|
2526
|
+
['#catalog-codex-items', 'badge-codex-count', 'models']
|
|
2527
|
+
]) {
|
|
2528
|
+
const container = document.querySelector(list);
|
|
2529
|
+
if (!container) continue;
|
|
2530
|
+
let shown = 0;
|
|
2531
|
+
container.querySelectorAll('.row').forEach(row => {
|
|
2532
|
+
const hit = row.textContent.toLowerCase().includes(term);
|
|
2533
|
+
row.style.display = hit ? 'flex' : 'none';
|
|
2534
|
+
if (hit) shown++;
|
|
2535
|
+
});
|
|
2536
|
+
let empty = container.querySelector('.catalog-empty');
|
|
2537
|
+
if (!shown) {
|
|
2538
|
+
if (!empty) {
|
|
2539
|
+
empty = document.createElement('div');
|
|
2540
|
+
empty.className = 'catalog-empty hint';
|
|
2541
|
+
empty.style.padding = '0.8rem';
|
|
2542
|
+
container.appendChild(empty);
|
|
2543
|
+
}
|
|
2544
|
+
empty.textContent = term ? 'No model matches "' + term + '".' : 'No model in this list.';
|
|
2545
|
+
empty.style.display = 'block';
|
|
2546
|
+
} else if (empty) {
|
|
2547
|
+
empty.style.display = 'none';
|
|
2548
|
+
}
|
|
2549
|
+
const el = document.getElementById(badge);
|
|
2550
|
+
// `data-full` is the unfiltered text that the render wrote. An empty box restores it.
|
|
2551
|
+
if (el) el.textContent = term ? shown + ' ' + unit + ' shown' : (el.dataset.full || el.textContent);
|
|
2552
|
+
}
|
|
2474
2553
|
}
|
|
2475
2554
|
|
|
2476
2555
|
async function refreshCatalogUpstream() {
|