llm-switcher 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.2.3
4
+
5
+ - **Update notice:** The dashboard sidebar shows the running version. When npm has a newer release, a notice shows the update command with a copy button. The new command `switch version` prints the same information. The switcher asks the npm registry at most once in 12 hours. Without an answer, no notice shows.
6
+ - **Codex daemon follows the route:** The interactive Codex TUI talks to a shared `codex app-server` daemon, and that daemon keeps the environment that it started with. As a result, the TUI bypassed the gateway after a switch. Now each change of the Codex route restarts the running daemon with the new variables. When no daemon runs, nothing starts.
7
+ - **API keys stay with their host:** A catalog refresh sent the key of the active profile to the official Anthropic and OpenAI model lists. Now a key goes only to the host of its own profile.
8
+ - **Real model catalog:** The catalog reads the model list that Codex and Claude Code keep on disk for the signed-in account. A failed refresh no longer marks the tool version as done, so the next request tries again.
9
+ - **Dashboard saves:** Every write action reported a failure after it succeeded, because the page read `ok` and the server sends `success`. Enter in a field no longer saves a half-edited profile. A text selection that ends outside the dialog no longer closes it.
10
+ - **Model slots:** Each slot is a searchable list of the provider models. The list loads when you open the Model Slots tab and sends the saved key. A profile without `outFormat` keeps it empty after a save.
11
+ - **Other fixes:** `make-certs.sh` works when the path contains a dot. `switch doctor` warns again about a Codex profile without `publicModels`. Three dashboard controls have an accessible name.
12
+ - **Tests:** The tests use the 1.2 configuration schema, never read the certificates of the checkout, and never reach the real Codex daemon, npm registry, or `claude` binary.
13
+
3
14
  ## Release 1.2.2
4
15
 
5
16
  - **Structured output reaches every upstream:** A request that asks for JSON that matches a schema now keeps that schema. Before this release, the gateway did not read `output_config.format` (Claude Code) or `text.format` (Codex), so the provider got a free-text request. The schema now goes to the provider as `response_format` (OpenAI Chat), `output_config.format` (Anthropic), or `responseMimeType` with `responseSchema` (Gemini and Vertex). A request for JSON without a schema reaches Anthropic as plain text, because Anthropic has no JSON mode without a schema.
package/README.md CHANGED
@@ -401,6 +401,7 @@ Add the server to your MCP configuration (for example `opencode.jsonc`, `claude_
401
401
  ```bash
402
402
  switch ui # Open the Web UI dashboard in your browser
403
403
  switch status # Display status for all active CLI targets
404
+ switch version # Show the version and tell you when npm has a newer one
404
405
  switch doctor # Audit environment, settings & routing
405
406
  switch on [profile] # Start the gateway and activate a profile
406
407
  switch <profile> # Activate a profile for both tools
@@ -103,8 +103,10 @@ openssl req -x509 -new -key "$WORK/ca.key" -sha256 -days "$CA_DAYS" \
103
103
 
104
104
  openssl ecparam -name prime256v1 -genkey -noout -out "$WORK/leaf.key"
105
105
  openssl req -new -key "$WORK/leaf.key" -config "$WORK/leaf.cnf" -out "$WORK/leaf.csr"
106
+ # An explicit -CAserial: LibreSSL derives the default name by cutting the CA path at its first dot,
107
+ # so a home directory such as /Users/first.last sent the serial file to /Users/first.srl.
106
108
  openssl x509 -req -in "$WORK/leaf.csr" \
107
- -CA "$WORK/ca.pem" -CAkey "$WORK/ca.key" -CAcreateserial \
109
+ -CA "$WORK/ca.pem" -CAkey "$WORK/ca.key" -CAcreateserial -CAserial "$WORK/ca.srl" \
108
110
  -days "$LEAF_DAYS" -sha256 -extfile "$WORK/leaf.ext" \
109
111
  -out "$WORK/leaf.pem"
110
112
 
package/catalog.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // ============================================================
8
8
 
9
9
  import fs from 'node:fs';
10
+ import os from 'node:os';
10
11
  import path from 'node:path';
11
12
 
12
13
  export const OFFICIAL_MODEL_URLS = {
@@ -69,6 +70,70 @@ export function loadCatalogCache(stateDir) {
69
70
  };
70
71
  }
71
72
 
73
+ function defaultSources() {
74
+ return {
75
+ codexHome: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
76
+ claudeDir: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Reads the model list that the tool itself keeps on disk for the signed-in account, so no key and
82
+ * no network call is needed. Codex writes models_cache.json; Claude Code writes cache/model-catalog.
83
+ * Returns null when the tool has no list.
84
+ */
85
+ export function readLocalToolModels(tool, sources = defaultSources()) {
86
+ try {
87
+ if (tool === 'codex') {
88
+ const d = JSON.parse(fs.readFileSync(path.join(sources.codexHome, 'models_cache.json'), 'utf8'));
89
+ const models = (Array.isArray(d?.models) ? d.models : [])
90
+ .filter(m => m && typeof m.slug === 'string' && m.slug)
91
+ .map(m => ({ id: m.slug, role: classifyCodexRole(m.slug), ...(Number.isInteger(m.context_window) ? { contextWindow: m.context_window } : {}) }));
92
+ return models.length ? { models, version: String(d.client_version || '') } : null;
93
+ }
94
+ if (tool === 'claude') {
95
+ // One file per account or surface; the newest fetch wins.
96
+ const dir = path.join(sources.claudeDir, 'cache', 'model-catalog');
97
+ let newest = null;
98
+ for (const f of fs.readdirSync(dir)) {
99
+ if (!f.endsWith('.json')) continue;
100
+ try {
101
+ const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
102
+ const list = d?.catalog?.config?.models;
103
+ if (Array.isArray(list) && list.length && (!newest || (d.fetchedAt || 0) > newest.fetchedAt)) newest = { fetchedAt: d.fetchedAt || 0, list };
104
+ } catch {}
105
+ }
106
+ const models = (newest?.list || [])
107
+ .filter(m => m && typeof m.id === 'string' && m.id)
108
+ .map(m => ({ id: m.id, display_name: m.name || m.id, tier: classifyClaudeTier(m.id) }));
109
+ return models.length ? { models, version: '' } : null;
110
+ }
111
+ } catch {}
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Copies the tools' own lists into the catalog when they changed. A tool can update and rewrite its
117
+ * list without any request through the gateway, so readers of the catalog call this first.
118
+ */
119
+ export function syncLocalCatalog(stateDir, sources = defaultSources()) {
120
+ const cache = loadCatalogCache(stateDir);
121
+ let changed = false;
122
+ for (const tool of ['claude', 'codex']) {
123
+ const local = readLocalToolModels(tool, sources);
124
+ if (!local) continue;
125
+ const ids = (cache[tool]?.models || []).map(m => m.id).join('\n');
126
+ if (ids === local.models.map(m => m.id).join('\n') && cache[tool]?.source === 'local') continue;
127
+ cache[tool] = { ...cache[tool], models: local.models, source: 'local' };
128
+ changed = true;
129
+ }
130
+ if (changed) {
131
+ cache.updatedAt = Date.now();
132
+ saveCatalogCache(stateDir, cache);
133
+ }
134
+ return cache;
135
+ }
136
+
72
137
  /** Atomically writes the model catalog cache to disk */
73
138
  export function saveCatalogCache(stateDir, catalog) {
74
139
  const p = catalogCachePath(stateDir);
@@ -141,19 +206,33 @@ export async function fetchToolModels(tool, { url, apiKey, timeout = 3000 } = {}
141
206
  }
142
207
  }
143
208
 
209
+ // A profile key is a credential for that profile's baseURL. Send it to the official model list only
210
+ // when the profile itself points at the official host, never to a third party.
211
+ function officialKey(profile, tool) {
212
+ try {
213
+ if (profile?.apiKey && new URL(profile.baseURL).host === new URL(OFFICIAL_MODEL_URLS[tool]).host) return profile.apiKey;
214
+ } catch {}
215
+ return undefined;
216
+ }
217
+
144
218
  /**
145
219
  * Refreshes the local catalog cache with models from both official endpoints
146
220
  * and saves to the state directory.
147
221
  */
148
- export async function refreshCatalog(stateDir, { claudeKey, codexKey } = {}) {
222
+ export async function refreshCatalog(stateDir, { claudeProfile, codexProfile, sources = defaultSources() } = {}) {
149
223
  const cache = loadCatalogCache(stateDir);
224
+ const local = { claude: readLocalToolModels('claude', sources), codex: readLocalToolModels('codex', sources) };
150
225
 
226
+ // A local list is the list of the account itself; the built-in names are guesses and are not added.
151
227
  const [claudeRes, codexRes] = await Promise.all([
152
- fetchToolModels('claude', { apiKey: claudeKey }),
153
- fetchToolModels('codex', { apiKey: codexKey })
228
+ local.claude || fetchToolModels('claude', { apiKey: officialKey(claudeProfile, 'claude') }),
229
+ local.codex || fetchToolModels('codex', { apiKey: officialKey(codexProfile, 'codex') })
154
230
  ]);
231
+ for (const tool of ['claude', 'codex']) {
232
+ if (local[tool]) cache[tool] = { ...cache[tool], models: local[tool].models, source: 'local' };
233
+ }
155
234
 
156
- if (claudeRes.ok && claudeRes.models.length > 0) {
235
+ if (!local.claude && claudeRes.ok && claudeRes.models.length > 0) {
157
236
  const existing = new Set(claudeRes.models.map(m => m.id));
158
237
  // Keep any baseline models that might be absent from the API
159
238
  for (const [tier, ids] of Object.entries(BASELINE_MODELS.claude)) {
@@ -161,17 +240,17 @@ export async function refreshCatalog(stateDir, { claudeKey, codexKey } = {}) {
161
240
  if (!existing.has(id)) claudeRes.models.push({ id, tier });
162
241
  }
163
242
  }
164
- cache.claude = { models: claudeRes.models };
243
+ cache.claude = { ...cache.claude, models: claudeRes.models, source: 'official' };
165
244
  }
166
245
 
167
- if (codexRes.ok && codexRes.models.length > 0) {
246
+ if (!local.codex && codexRes.ok && codexRes.models.length > 0) {
168
247
  const existing = new Set(codexRes.models.map(m => m.id));
169
248
  for (const [role, ids] of Object.entries(BASELINE_MODELS.codex)) {
170
249
  for (const id of ids) {
171
250
  if (!existing.has(id)) codexRes.models.push({ id, role });
172
251
  }
173
252
  }
174
- cache.codex = { models: codexRes.models };
253
+ cache.codex = { ...cache.codex, models: codexRes.models, source: 'official' };
175
254
  }
176
255
 
177
256
  cache.updatedAt = Date.now();
@@ -199,48 +278,59 @@ const refreshingTools = new Set();
199
278
 
200
279
  /**
201
280
  * Version-triggered auto-poll:
202
- * When a request arrives with a new tool version not yet seen in cache,
203
- * immediately records the new version and kicks off an asynchronous background refresh.
204
- * Subsequent requests with the same version do zero network calls.
281
+ * When a request arrives with a tool version that has no refreshed catalog yet, the catalog is
282
+ * refreshed from the tool's own list on disk, or else from the official list in the background.
283
+ * The version is recorded only after a refresh succeeds, so a failed refresh is tried again.
205
284
  */
206
- export function checkVersionAndRefresh(tool, headers, stateDir, apiKey) {
285
+ const RETRY_AFTER_MS = 10 * 60 * 1000;
286
+ const failedAttempts = new Map();
287
+
288
+ export function checkVersionAndRefresh(tool, headers, stateDir, apiKey, sources = defaultSources()) {
207
289
  const version = detectToolVersion(headers, tool);
208
290
  if (!version) return;
209
291
 
210
292
  const cache = loadCatalogCache(stateDir);
211
- const toolEntry = cache[tool] || {};
212
- const lastVersion = toolEntry.lastSeenVersion || '';
293
+ if (version === (cache[tool]?.lastSeenVersion || '')) return;
213
294
 
214
- if (version !== lastVersion) {
215
- toolEntry.lastSeenVersion = version;
216
- cache[tool] = toolEntry;
295
+ const local = readLocalToolModels(tool, sources);
296
+ if (local) {
297
+ cache[tool] = { ...cache[tool], lastSeenVersion: version, models: local.models, source: 'local' };
298
+ cache.updatedAt = Date.now();
217
299
  saveCatalogCache(stateDir, cache);
300
+ console.log(`[llm-switcher:catalog] Detected ${tool} ${version} -> model catalog read from the tool (${local.models.length} models)`);
301
+ return;
302
+ }
303
+
304
+ // Without a local list every request would call the network again; wait between failed attempts.
305
+ const failed = failedAttempts.get(tool);
306
+ if (failed && failed.version === version && Date.now() - failed.at < RETRY_AFTER_MS) return;
307
+ if (refreshingTools.has(tool)) return;
218
308
 
219
- if (!refreshingTools.has(tool)) {
220
- refreshingTools.add(tool);
221
- fetchToolModels(tool, { apiKey })
222
- .then(res => {
223
- if (res.ok && res.models.length > 0) {
224
- const fresh = loadCatalogCache(stateDir);
225
- const existing = new Set(res.models.map(m => m.id));
226
- const baseline = BASELINE_MODELS[tool] || {};
227
- for (const [, ids] of Object.entries(baseline)) {
228
- for (const id of ids) {
229
- if (!existing.has(id)) {
230
- res.models.push({ id, ...(tool === 'claude' ? { tier: classifyClaudeTier(id) } : { role: classifyCodexRole(id) }) });
231
- }
232
- }
233
- }
234
- fresh[tool] = { lastSeenVersion: version, models: res.models };
235
- fresh.updatedAt = Date.now();
236
- saveCatalogCache(stateDir, fresh);
237
- console.log(`[llm-switcher:catalog] Detected ${tool} version update to ${version} -> refreshed model catalog (${res.models.length} models)`);
309
+ refreshingTools.add(tool);
310
+ fetchToolModels(tool, { apiKey })
311
+ .then(res => {
312
+ if (!res.ok || res.models.length === 0) {
313
+ failedAttempts.set(tool, { version, at: Date.now() });
314
+ console.log(`[llm-switcher:catalog] ${tool} ${version}: no local model list, official list failed (${res.error || 'empty'})`);
315
+ return;
316
+ }
317
+ const fresh = loadCatalogCache(stateDir);
318
+ const existing = new Set(res.models.map(m => m.id));
319
+ const baseline = BASELINE_MODELS[tool] || {};
320
+ for (const [, ids] of Object.entries(baseline)) {
321
+ for (const id of ids) {
322
+ if (!existing.has(id)) {
323
+ res.models.push({ id, ...(tool === 'claude' ? { tier: classifyClaudeTier(id) } : { role: classifyCodexRole(id) }) });
238
324
  }
239
- })
240
- .catch(() => {})
241
- .finally(() => {
242
- refreshingTools.delete(tool);
243
- });
244
- }
245
- }
325
+ }
326
+ }
327
+ fresh[tool] = { ...fresh[tool], lastSeenVersion: version, models: res.models, source: 'official' };
328
+ fresh.updatedAt = Date.now();
329
+ saveCatalogCache(stateDir, fresh);
330
+ console.log(`[llm-switcher:catalog] Detected ${tool} version update to ${version} -> refreshed model catalog (${res.models.length} models)`);
331
+ })
332
+ .catch(() => {})
333
+ .finally(() => {
334
+ refreshingTools.delete(tool);
335
+ });
246
336
  }
package/mcp.mjs CHANGED
@@ -143,11 +143,11 @@ async function handleToolCall(name, args) {
143
143
  const port = getMcpPort();
144
144
 
145
145
  if (name === 'switcher_models') {
146
- const { loadCatalogCache, refreshCatalog } = await import('./catalog.mjs');
146
+ const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
147
147
  if (args?.refresh) {
148
148
  await refreshCatalog(STATE_DIR);
149
149
  }
150
- const cache = loadCatalogCache(STATE_DIR);
150
+ const cache = syncLocalCatalog(STATE_DIR);
151
151
  const t = args?.tool?.toLowerCase();
152
152
  const res = {};
153
153
  if (!t || t === 'claude') res.claude = cache.claude;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code, Codex, OpenAI and Gemini clients",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  codexModelEntry, smallestWindows, publicModelWindows, model1MForSlot,
24
24
  contractLabSettings, STATE_DIR
25
25
  } from './state.mjs';
26
- import { classifyCodexRole, classifyClaudeTier, loadCatalogCache, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
26
+ import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
27
+ import { checkForUpdate } from './version.mjs';
27
28
  import { createContractLab, createHalfTap, tapClientWrites, capText, capJson, toolVersionFromUA, finishHalf, PROBE_HEADER, TRACE_ID_RE } from './contract.mjs';
28
29
 
29
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -1287,9 +1288,13 @@ async function routeApi(req, res, method, pathname) {
1287
1288
  return sendJson(res, 200, { logs: requestLogs.slice().reverse() });
1288
1289
  }
1289
1290
 
1291
+ if (method === 'GET' && pathname === '/api/version') {
1292
+ return sendJson(res, 200, await checkForUpdate({ stateDir: STATE_DIR }));
1293
+ }
1294
+
1290
1295
  // GET /api/catalog (Dynamic Model Discovery)
1291
1296
  if (method === 'GET' && pathname === '/api/catalog') {
1292
- return sendJson(res, 200, loadCatalogCache(STATE_DIR));
1297
+ return sendJson(res, 200, syncLocalCatalog(STATE_DIR));
1293
1298
  }
1294
1299
 
1295
1300
  if (method !== 'POST') {
@@ -1310,10 +1315,7 @@ async function routeApi(req, res, method, pathname) {
1310
1315
  const cfg = requireConfig(res) || {};
1311
1316
  const claudeProfile = active.claude ? cfg.profiles?.[active.claude] : null;
1312
1317
  const codexProfile = active.codex ? cfg.profiles?.[active.codex] : null;
1313
- const updated = await refreshCatalog(STATE_DIR, {
1314
- claudeKey: claudeProfile?.apiKey,
1315
- codexKey: codexProfile?.apiKey
1316
- });
1318
+ const updated = await refreshCatalog(STATE_DIR, { claudeProfile, codexProfile });
1317
1319
  return sendJson(res, 200, { success: true, catalog: updated });
1318
1320
  }
1319
1321
 
@@ -15,5 +15,7 @@ const files = [];
15
15
  }
16
16
  })(path.join(root, 'tests'));
17
17
 
18
- const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit' });
18
+ // A version check must never reach the real npm registry.
19
+ const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
20
+ const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
19
21
  process.exit(r.status ?? 1);
package/state.mjs CHANGED
@@ -1441,26 +1441,78 @@ function writeToolFiles(pairs, shPath, cmdPath) {
1441
1441
  // launcher files are never opened (A13: emptied, never deleted).
1442
1442
  export function emptyToolEnvFiles(tool) {
1443
1443
  const codex = tool === 'codex';
1444
- withLaunchLock(() => {
1444
+ const codexRouteChanged = withLaunchLock(() => {
1445
+ const before = codex ? readOrEmpty(paths.envCodexSh) : '';
1445
1446
  writeAtomic(codex ? paths.envCodexCmd : paths.envClaudeCmd, '');
1446
1447
  writeAtomic(codex ? paths.envCodexSh : paths.envClaudeSh, '');
1448
+ return before !== '';
1447
1449
  });
1450
+ return { codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
1448
1451
  }
1449
1452
 
1450
1453
  // Write the per-tool env files from activeProfiles. settings.json is never touched here: it
1451
1454
  // belongs to the coding tool (R1), and model-catalog.json is no longer written (R9).
1452
1455
  export function applyLaunchState(cfg, port, opts = {}) {
1453
- return withLaunchLock(() => writeLaunchState(cfg, port, opts));
1456
+ const st = withLaunchLock(() => writeLaunchState(cfg, port, opts));
1457
+ if (st.codexRouteChanged) st.codexDaemon = restartCodexDaemon(st.envCodex);
1458
+ return st;
1459
+ }
1460
+
1461
+ const LOOPBACK_URL = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\/?$/i;
1462
+
1463
+ /**
1464
+ * The interactive Codex TUI talks to a shared `codex app-server` daemon, and the daemon keeps the
1465
+ * environment it started with. A new route reaches it only through a restart. Only the launch files
1466
+ * that the installed codex shim reads can route that daemon, so any other state dir (tests) is a no-op.
1467
+ */
1468
+ export function restartCodexDaemon(pairs, {
1469
+ home = os.homedir(),
1470
+ codexHome = process.env.CODEX_HOME || path.join(home, '.codex'),
1471
+ stateDir = STATE_DIR,
1472
+ baseEnv = process.env,
1473
+ ca = paths.blindfoldCA,
1474
+ spawnFn = spawn
1475
+ } = {}) {
1476
+ let shim = '';
1477
+ try { shim = fs.readFileSync(path.join(home, '.llm-switcher', 'bin', 'codex'), 'utf8'); } catch {}
1478
+ if (!shim.includes(`SWITCHER_DIR="${stateDir}"`)) return { restarted: false, reason: 'the codex shim does not read this state dir' };
1479
+ const bin = path.join(codexHome, 'packages', 'app-server-daemon', 'current', 'bin', 'codex');
1480
+ // The socket is a link to the running daemon; it no longer resolves once the daemon stopped.
1481
+ if (!fs.existsSync(bin) || !fs.existsSync(path.join(codexHome, 'app-server-control', 'app-server-control.sock'))) {
1482
+ return { restarted: false, reason: 'no Codex daemon runs' };
1483
+ }
1484
+
1485
+ // Remove only the values this switcher writes (R8); a proxy or CA the user set stays.
1486
+ const env = { ...baseEnv };
1487
+ for (const k of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) if (LOOPBACK_URL.test(env[k] || '')) delete env[k];
1488
+ for (const k of ['NO_PROXY', 'no_proxy']) if (env[k] === '127.0.0.1,localhost') delete env[k];
1489
+ if (env.CODEX_CA_CERTIFICATE === ca) delete env.CODEX_CA_CERTIFICATE;
1490
+ for (const [k, v] of pairs) env[k] = v;
1491
+
1492
+ try {
1493
+ const child = spawnFn(bin, ['app-server', 'daemon', 'restart'], { env, stdio: 'ignore', detached: true });
1494
+ child.on?.('error', () => {});
1495
+ child.unref?.();
1496
+ return { restarted: true };
1497
+ } catch (err) {
1498
+ return { restarted: false, reason: err.message };
1499
+ }
1500
+ }
1501
+
1502
+ function readOrEmpty(file) {
1503
+ try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
1454
1504
  }
1455
1505
 
1456
1506
  function writeLaunchState(cfg, port) {
1457
1507
  const st = computeLaunchState(cfg, port);
1508
+ const codexBefore = readOrEmpty(paths.envCodexSh);
1458
1509
 
1459
1510
  // Env files first, the flag last: a flag that says "active" while a file is missing or stale
1460
1511
  // makes the launcher route with the wrong variables. A failed write leaves the flag as it was.
1461
1512
  try {
1462
1513
  writeToolFiles(st.envClaude, paths.envClaudeSh, paths.envClaudeCmd);
1463
1514
  writeToolFiles(st.envCodex, paths.envCodexSh, paths.envCodexCmd);
1515
+ st.codexRouteChanged = readOrEmpty(paths.envCodexSh) !== codexBefore;
1464
1516
  // Recorded on switch on so a shell opened while the gateway ran can still recognize its own
1465
1517
  // stale loopback URL later, after the port changed (R8).
1466
1518
  if (st.active) writeAtomic(paths.gatewayPort, `${port}\n`);
@@ -1476,7 +1528,8 @@ function writeLaunchState(cfg, port) {
1476
1528
  }
1477
1529
 
1478
1530
  export function clearLaunchState(port) {
1479
- withLaunchLock(() => {
1531
+ const codexRouteChanged = withLaunchLock(() => {
1532
+ const codexBefore = readOrEmpty(paths.envCodexSh);
1480
1533
  writeOrRemove(paths.activeFlag, null);
1481
1534
  // Stubs rather than deletions, and the tool files emptied rather than removed (R8, A13):
1482
1535
  // `switch off claude` must leave an empty claude file behind, and bare `switch off` must
@@ -1486,9 +1539,10 @@ export function clearLaunchState(port) {
1486
1539
  for (const f of [paths.envClaudeCmd, paths.envClaudeSh, paths.envCodexCmd, paths.envCodexSh]) {
1487
1540
  writeAtomic(f, '');
1488
1541
  }
1542
+ return codexBefore !== '';
1489
1543
  });
1490
1544
  // settings.json belongs to the coding tool: the switcher neither reads nor writes it (R1).
1491
- return { changed: false, removed: [] };
1545
+ return { changed: false, removed: [], codexRouteChanged, codexDaemon: codexRouteChanged ? restartCodexDaemon([]) : undefined };
1492
1546
  }
1493
1547
 
1494
1548
  export function readLaunchFlags() {
package/switch.mjs CHANGED
@@ -26,6 +26,12 @@ const proxyScript = path.join(ROOT_DIR, 'proxy.mjs');
26
26
  const proxyLogPath = paths.proxyLog;
27
27
  const userProfile = os.homedir();
28
28
 
29
+ // The version needs no config.json: it must also answer before the first setup.
30
+ if (['version', '--version', '-v'].includes(String(process.argv[2] || '').toLowerCase())) {
31
+ await showVersion();
32
+ process.exit(0);
33
+ }
34
+
29
35
  const config = loadConfig();
30
36
  if (!config) {
31
37
  const err = getConfigLoadError();
@@ -415,6 +421,7 @@ async function turnOn(profileName, cliTarget) {
415
421
  const savedBytes = fs.readFileSync(configPath);
416
422
  const st = applyLaunchState(planned, port);
417
423
  reportSettings(st.settings);
424
+ reportCodexDaemon(st);
418
425
  const bf = await requestBlindfoldSync(port);
419
426
  if (!bf.ok) {
420
427
  console.error(`[Error] ${bf.error}`);
@@ -520,7 +527,7 @@ async function turnOff(targetArg) {
520
527
  // The order is fixed by R7b: the CAS write, then this tool's env files, then the active-tools
521
528
  // update. If the CAS write cannot land, the env file and the interceptor stay untouched.
522
529
  const saved = casOff([target]);
523
- emptyToolEnvFiles(target);
530
+ reportCodexDaemon(emptyToolEnvFiles(target));
524
531
  const bf = await syncOrStopBlindfold(port, config);
525
532
  if (!bf.ok) {
526
533
  console.error(`[Error] ${target} is switched back, but the interceptor is not in line: ${bf.error}`);
@@ -534,6 +541,7 @@ async function turnOff(targetArg) {
534
541
  saveConfig(config);
535
542
  const st = applyLaunchState(config, port);
536
543
  reportSettings(st.settings);
544
+ reportCodexDaemon(st);
537
545
  const bf = await syncOrStopBlindfold(port, config);
538
546
  if (!bf.ok) {
539
547
  console.error(`[Error] ${target} is switched back, but the blindfold interceptor is not in line: ${bf.error}`);
@@ -581,6 +589,12 @@ function auditActiveClis() {
581
589
  function reportSettings(result) {
582
590
  if (result?.removed?.length) console.log(`[settings.json] Removed switcher-written values: ${result.removed.join(', ')}`);
583
591
  if (result?.error) console.warn(`[settings.json] Not cleaned: ${result.error}`);
592
+ reportCodexDaemon(result);
593
+ }
594
+
595
+ // The interactive Codex TUI keeps using its shared daemon, so a new route needs that daemon restarted.
596
+ function reportCodexDaemon(st) {
597
+ if (st?.codexDaemon?.restarted) console.log('[Codex] Restarted the codex app-server daemon: open Codex sessions reconnect with the new route.');
584
598
  }
585
599
 
586
600
  async function showStatus() {
@@ -848,7 +862,7 @@ async function runDoctor() {
848
862
  const p = config.profiles[key];
849
863
  if (!p) warn(`[WARN] Target ${t} points to missing profile "${show(key)}".`);
850
864
  else if (!p.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey}`)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
851
- if (p && t === 'responses') {
865
+ if (p && t === 'codex') {
852
866
  const codexWarning = codexPublicModelsWarning(show(key), p);
853
867
  if (codexWarning) warn(`[WARN] ${codexWarning}`);
854
868
  }
@@ -1036,13 +1050,22 @@ async function runContractCheck() {
1036
1050
  }
1037
1051
  }
1038
1052
 
1053
+ async function showVersion() {
1054
+ const { checkForUpdate } = await import('./version.mjs');
1055
+ const v = await checkForUpdate({ stateDir: STATE_DIR });
1056
+ console.log(`llm-switcher ${v.current}`);
1057
+ if (v.updateAvailable) console.log(`A new version is available: ${v.latest}. Update with: ${v.updateCommand}`);
1058
+ else if (!v.latest) console.log('Could not check the npm registry for a newer version.');
1059
+ else console.log('This is the latest version.');
1060
+ }
1061
+
1039
1062
  async function showModels(target) {
1040
- const { loadCatalogCache, refreshCatalog } = await import('./catalog.mjs');
1063
+ const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
1041
1064
  if (optionValue('--refresh') || process.argv.includes('--refresh')) {
1042
1065
  console.log('Refreshing models catalog from official endpoints...');
1043
1066
  await refreshCatalog(STATE_DIR);
1044
1067
  }
1045
- const cache = loadCatalogCache(STATE_DIR);
1068
+ const cache = syncLocalCatalog(STATE_DIR);
1046
1069
  console.log('=== LLM Switcher Discovered Models Catalog ===\n');
1047
1070
  const t = target ? target.toLowerCase() : '';
1048
1071
  if (!t || t === 'claude') {
@@ -1105,6 +1128,7 @@ if (cmd === 'off' || cmd === 'stop') {
1105
1128
  console.log('Usage:');
1106
1129
  console.log(' switch ui # Open Web UI dashboard');
1107
1130
  console.log(' switch status # Show multi-CLI active status');
1131
+ console.log(' switch version # Show the version and check for a newer one');
1108
1132
  console.log(' switch doctor # Audit environment, settings & routing');
1109
1133
  console.log(' switch on [profile] # Start gateway & activate profile for all compatible targets');
1110
1134
  console.log(' switch <profile> # Activate profile for all compatible targets');
@@ -150,7 +150,7 @@ async function startInterceptor({ port, gatewayPort, config, upstreamPort }) {
150
150
  child.stdout.on('data', (d) => output.push(String(d)));
151
151
  child.stderr.on('data', (d) => output.push(String(d)));
152
152
  const stop = async () => {
153
- if (child.exitCode !== null) return;
153
+ if (child.exitCode !== null || child.signalCode !== null) return;
154
154
  child.kill();
155
155
  await once(child, 'exit').catch(() => {});
156
156
  };
@@ -181,7 +181,7 @@ async function startGateway(t, port, cfgPath) {
181
181
  child.stdout.on('data', (d) => { log += d; });
182
182
  child.stderr.on('data', (d) => { log += d; });
183
183
  const stop = async () => {
184
- if (child.exitCode !== null) return;
184
+ if (child.exitCode !== null || child.signalCode !== null) return;
185
185
  child.kill();
186
186
  await once(child, 'exit').catch(() => {});
187
187
  };
@@ -113,7 +113,7 @@ async function startInterceptor({ port, gatewayPort, config, activeTools = 'clau
113
113
  child.stderr.on('data', (d) => output.push(String(d)));
114
114
 
115
115
  const stop = async () => {
116
- if (child.exitCode !== null) return;
116
+ if (child.exitCode !== null || child.signalCode !== null) return;
117
117
  child.kill();
118
118
  await once(child, 'exit').catch(() => {});
119
119
  };
@@ -126,7 +126,7 @@ async function startInterceptor({ port, gatewayPort, config, activeTools = 'clau
126
126
  child.stderr.on('data', (d) => output.push(String(d)));
127
127
 
128
128
  const stop = async () => {
129
- if (child.exitCode !== null) return; // already gone: waiting for 'exit' would never resolve
129
+ if (child.exitCode !== null || child.signalCode !== null) return; // already gone: waiting for 'exit' would never resolve
130
130
  child.kill();
131
131
  await once(child, 'exit').catch(() => {});
132
132
  };
@@ -650,7 +650,7 @@ test('R3b: an interceptor from 1.1.11 proves the old way and is replaced, not tr
650
650
  `;
651
651
  const legacy = spawn(process.execPath, ['--input-type=module', '-e', legacySrc], { stdio: ['ignore', 'pipe', 'pipe'] });
652
652
  t.after(async () => {
653
- if (legacy.exitCode !== null) return;
653
+ if (legacy.exitCode !== null || legacy.signalCode !== null) return;
654
654
  legacy.kill();
655
655
  await once(legacy, 'exit').catch(() => {});
656
656
  });
@@ -53,7 +53,9 @@ async function startInterceptor() {
53
53
  const port = await freePort();
54
54
  const child = spawn(process.execPath, [path.join(ROOT, 'blindfold', 'blindfold.mjs'),
55
55
  '--port', String(port), '--gateway-port', String(gatewayPort), '--certs', certDir,
56
- '--capture', captureDir, '--token-file', path.join(dir, 'admin.token')], { stdio: ['ignore', 'pipe', 'pipe'] });
56
+ '--capture', captureDir, '--token-file', path.join(dir, 'admin.token'),
57
+ // Since R3 only the paths of an active tool reach the gateway; everything else passes through.
58
+ '--active-tools', 'codex'], { stdio: ['ignore', 'pipe', 'pipe'] });
57
59
  let out = '';
58
60
  child.stdout.on('data', d => { out += d; });
59
61
  child.stderr.on('data', d => { out += d; });