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 +11 -0
- package/README.md +1 -0
- package/blindfold/make-certs.sh +3 -1
- package/catalog.mjs +132 -42
- package/mcp.mjs +2 -2
- package/package.json +1 -1
- package/proxy.mjs +8 -6
- package/scripts/run-tests.mjs +3 -1
- package/state.mjs +58 -4
- package/switch.mjs +28 -4
- package/tests/blindfold-e2e.test.mjs +2 -2
- package/tests/blindfold-task5.test.mjs +1 -1
- package/tests/blindfold.task3.test.mjs +2 -2
- package/tests/blindfold.wire.test.mjs +3 -1
- package/tests/catalog.test.mjs +117 -19
- package/tests/codex-daemon.test.mjs +92 -0
- package/tests/lifecycle.test.mjs +49 -37
- package/tests/make-certs.test.mjs +22 -0
- package/tests/real-user-sim.test.mjs +3 -0
- package/tests/shim.test.mjs +8 -3
- package/tests/switch.test.mjs +30 -24
- package/tests/ui.test.mjs +90 -0
- package/tests/version.test.mjs +78 -0
- package/ui.html +285 -83
- package/version.mjs +53 -0
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
|
package/blindfold/make-certs.sh
CHANGED
|
@@ -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, {
|
|
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:
|
|
153
|
-
fetchToolModels('codex', { apiKey:
|
|
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
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
const lastVersion = toolEntry.lastSeenVersion || '';
|
|
293
|
+
if (version === (cache[tool]?.lastSeenVersion || '')) return;
|
|
213
294
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
cache[tool] =
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 {
|
|
146
|
+
const { syncLocalCatalog, refreshCatalog } = await import('./catalog.mjs');
|
|
147
147
|
if (args?.refresh) {
|
|
148
148
|
await refreshCatalog(STATE_DIR);
|
|
149
149
|
}
|
|
150
|
-
const cache =
|
|
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
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,
|
|
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,
|
|
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
|
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -15,5 +15,7 @@ const files = [];
|
|
|
15
15
|
}
|
|
16
16
|
})(path.join(root, 'tests'));
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 {
|
|
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 =
|
|
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')
|
|
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; });
|