llm-switcher 1.1.0
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/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
package/switch.mjs
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
6
|
+
import {
|
|
7
|
+
ROOT_DIR, STATE_DIR, TARGETS, configPath, claudeSettingsPath, paths, loadConfig, getConfigLoadError, saveConfig,
|
|
8
|
+
resolvePort, parsePort, findProfileKey, getActiveMap, setTargetProfile, activateProfile, deactivateAll,
|
|
9
|
+
applyLaunchState, clearLaunchState, computeLaunchState,
|
|
10
|
+
modelSlotsForProfile, modelForSlot, model1MForSlot, readAdminToken, adminTokenPath, openLog,
|
|
11
|
+
probeGateway, probeBlindfold, blindfoldPreflight, stopRecordedBlindfold, writeDashboardLauncher,
|
|
12
|
+
contractLabSettings
|
|
13
|
+
} from './state.mjs';
|
|
14
|
+
import { runProbe, runCheck } from './contract.mjs';
|
|
15
|
+
import {
|
|
16
|
+
SHIM_DIR, installShims, uninstallShims, shimStatus, pathExportLine,
|
|
17
|
+
suggestedRcFiles, auditRunningProcesses
|
|
18
|
+
} from './shim.mjs';
|
|
19
|
+
import {
|
|
20
|
+
serviceEnv, systemdUnit, launchdPlist, scheduledTaskXml, decodeConsoleText, portFromServiceText, writeServiceFile
|
|
21
|
+
} from './service.mjs';
|
|
22
|
+
|
|
23
|
+
const proxyScript = path.join(ROOT_DIR, 'proxy.mjs');
|
|
24
|
+
const proxyLogPath = paths.proxyLog;
|
|
25
|
+
const userProfile = os.homedir();
|
|
26
|
+
|
|
27
|
+
const config = loadConfig();
|
|
28
|
+
if (!config) {
|
|
29
|
+
const err = getConfigLoadError();
|
|
30
|
+
console.error(`[Error] Cannot load ${configPath}: ${err ? err.message : 'file not found'}`);
|
|
31
|
+
if (!fs.existsSync(configPath)) {
|
|
32
|
+
console.error(`Create it first: cp "${path.join(ROOT_DIR, 'config.example.json')}" "${configPath}" (then edit baseURL / apiKey)`);
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TARGET_ALIASES = {
|
|
38
|
+
claude: 'anthropic', anthropic: 'anthropic',
|
|
39
|
+
codex: 'responses', responses: 'responses',
|
|
40
|
+
openai: 'openai-chat', chat: 'openai-chat', 'openai-chat': 'openai-chat',
|
|
41
|
+
vertex: 'vertex', gemini: 'vertex'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Strip --port/-p and their value from positional args. -p is the global option everywhere, as in
|
|
45
|
+
// resolvePort; `switch port <n>` is the command that changes the port.
|
|
46
|
+
function positionalArgs() {
|
|
47
|
+
const out = [];
|
|
48
|
+
const argv = process.argv.slice(2);
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
if (argv[i] === '--port' || argv[i] === '-p') { i++; continue; }
|
|
51
|
+
out.push(argv[i]);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// config.json is edited by hand and through the dashboard, so its strings reach the terminal
|
|
57
|
+
// untrusted. A control character could rewrite the screen or the window title.
|
|
58
|
+
const show = (v) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
|
|
59
|
+
|
|
60
|
+
function getTargetPort() {
|
|
61
|
+
return resolvePort(process.argv.slice(2), config);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
65
|
+
|
|
66
|
+
// A port answer is not enough: probeGateway checks HMAC(admin.token, nonce), so a process that
|
|
67
|
+
// replays a /health body counts as foreign, not as this switcher.
|
|
68
|
+
async function checkProxyRunning(port) {
|
|
69
|
+
return (await probeGateway(port)) === 'ours';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function startProxyBackground(port) {
|
|
73
|
+
const log = openLog(proxyLogPath);
|
|
74
|
+
const child = spawn(process.execPath, [proxyScript, '--port', String(port)], {
|
|
75
|
+
detached: true,
|
|
76
|
+
stdio: ['ignore', log, log],
|
|
77
|
+
windowsHide: true
|
|
78
|
+
});
|
|
79
|
+
child.unref();
|
|
80
|
+
fs.closeSync(log);
|
|
81
|
+
return child.pid;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 'silent' is a listener that never answered: a hung gateway of ours, or another program.
|
|
85
|
+
const isHeld = (state) => state === 'foreign' || state === 'silent';
|
|
86
|
+
|
|
87
|
+
function refuseForeignPort(port, owner, state = 'foreign') {
|
|
88
|
+
console.error(state === 'silent'
|
|
89
|
+
? `[Error] Port ${port} accepts connections but does not answer. A hung ${owner} or another program holds it.`
|
|
90
|
+
: `[Error] Port ${port} is held by another process, not by ${owner}.`);
|
|
91
|
+
console.error(' Stop that process, or choose another port with `switch port <n>`. Nothing was changed.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function ensureProxyRunning(port) {
|
|
96
|
+
const state = await probeGateway(port);
|
|
97
|
+
if (state === 'ours') {
|
|
98
|
+
console.log(`Proxy is running on port ${port} (config reloaded dynamically).`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (isHeld(state)) refuseForeignPort(port, 'this switcher', state);
|
|
102
|
+
// An installed service owns the gateway: a detached copy next to it would be a second gateway.
|
|
103
|
+
// A service whose port cannot be read is started too, never doubled.
|
|
104
|
+
const svc = installedService();
|
|
105
|
+
const svcPort = svc ? servicePort(svc) : null;
|
|
106
|
+
if (svc && (svcPort === port || svcPort === null)) {
|
|
107
|
+
console.log(`Starting the ${svc} service${svcPort ? ` on port ${port}` : ''}...`);
|
|
108
|
+
serviceStart(svc);
|
|
109
|
+
} else {
|
|
110
|
+
console.log(`Starting proxy service on port ${port}...`);
|
|
111
|
+
startProxyBackground(port);
|
|
112
|
+
}
|
|
113
|
+
for (let i = 0; i < 20; i++) {
|
|
114
|
+
await sleep(250);
|
|
115
|
+
if ((await probeGateway(port)) === 'ours') return;
|
|
116
|
+
}
|
|
117
|
+
console.error(`[Error] Proxy did not come up on port ${port}. See ${proxyLogPath} for details.`);
|
|
118
|
+
if (svc && svcPort === null) console.error(` The ${svc} service definition could not be read. Run \`switch service install\` again.`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The gateway owns the blindfold interceptor. After every write of launch state the CLI asks it to
|
|
123
|
+
// bring the interceptor in line with config.json.
|
|
124
|
+
async function requestBlindfoldSync(port) {
|
|
125
|
+
try {
|
|
126
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/blindfold/sync`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': readAdminToken() || '' },
|
|
129
|
+
body: '{}',
|
|
130
|
+
signal: AbortSignal.timeout(15000)
|
|
131
|
+
});
|
|
132
|
+
return await r.json();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { ok: false, error: `the gateway on port ${port} did not answer: ${err.message}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// With the gateway up it reconciles; with it down only a stop is safe, and stopping never spawns.
|
|
139
|
+
// When the sync fails and no target wants an interceptor any more, the CLI stops it itself.
|
|
140
|
+
async function syncOrStopBlindfold(port, cfg) {
|
|
141
|
+
if (await checkProxyRunning(port)) {
|
|
142
|
+
const r = await requestBlindfoldSync(port);
|
|
143
|
+
if (r.ok || computeLaunchState(cfg, port).blindfold) return r;
|
|
144
|
+
}
|
|
145
|
+
return stopRecordedBlindfold();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Same atomic, private write as saveConfig, for bytes that must come back unchanged.
|
|
149
|
+
function restoreConfigBytes(bytes) {
|
|
150
|
+
const tmp = `${configPath}.${process.pid}.tmp`;
|
|
151
|
+
fs.writeFileSync(tmp, bytes, { mode: 0o600 });
|
|
152
|
+
fs.chmodSync(tmp, 0o600);
|
|
153
|
+
fs.renameSync(tmp, configPath);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function openBrowser(url) {
|
|
157
|
+
try {
|
|
158
|
+
if (process.platform === 'win32') {
|
|
159
|
+
execFileSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
|
|
160
|
+
} else if (process.platform === 'darwin') {
|
|
161
|
+
execFileSync('open', [url], { stdio: 'ignore' });
|
|
162
|
+
} else {
|
|
163
|
+
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function killPid(pid) {
|
|
169
|
+
try {
|
|
170
|
+
if (process.platform === 'win32') execFileSync('taskkill', ['/F', '/PID', String(pid)], { stdio: 'ignore' });
|
|
171
|
+
else process.kill(pid, 'SIGTERM');
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Find PIDs LISTENing on exactly this port (exact-match the local address column, avoids mismatching :34560).
|
|
176
|
+
function listeningPids(port) {
|
|
177
|
+
const pids = new Set();
|
|
178
|
+
try {
|
|
179
|
+
if (process.platform === 'win32') {
|
|
180
|
+
const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
|
|
181
|
+
for (const line of out.split(/\r?\n/)) {
|
|
182
|
+
const parts = line.trim().split(/\s+/);
|
|
183
|
+
// Proto Local Foreign State PID — foreign "0.0.0.0:0" means listening (independent of OS language)
|
|
184
|
+
if (parts.length >= 5 && /^TCP$/i.test(parts[0]) && parts[1].endsWith(`:${port}`) && /:0$/.test(parts[2])) {
|
|
185
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
186
|
+
if (pid > 0) pids.add(pid);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return [...pids];
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
193
|
+
for (const s of out.split(/\s+/)) {
|
|
194
|
+
const pid = parseInt(s, 10);
|
|
195
|
+
if (pid > 0) pids.add(pid);
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err.code !== 'ENOENT') throw err;
|
|
199
|
+
// Minimal Linux images ship ss (iproute2) but no lsof.
|
|
200
|
+
const out = execFileSync('ss', ['-ltnpH', `sport = :${port}`], { encoding: 'utf8' });
|
|
201
|
+
for (const m of out.matchAll(/pid=(\d+)/g)) pids.add(parseInt(m[1], 10));
|
|
202
|
+
}
|
|
203
|
+
} catch {}
|
|
204
|
+
return [...pids];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Returns 'stopped', 'not-running', 'not-ours', 'silent' or 'still-running'. The kill targets the process that
|
|
208
|
+
// listens on the port, right after the identity probe confirmed that listener is this switcher.
|
|
209
|
+
async function stopProxy(port) {
|
|
210
|
+
const state = await probeGateway(port);
|
|
211
|
+
// Gone means the port is free; a slow answer is not proof that the gateway stopped.
|
|
212
|
+
const waitGone = async () => {
|
|
213
|
+
for (let i = 0; i < 20; i++) {
|
|
214
|
+
await sleep(150);
|
|
215
|
+
if ((await probeGateway(port)) === 'free') return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
};
|
|
219
|
+
// Our own unit needs no identity proof: its manager stops it. That also covers a unit that is
|
|
220
|
+
// still starting (the port is free) and a hung gateway under the unit (the probe is silent).
|
|
221
|
+
const svc = installedService();
|
|
222
|
+
const ownUnit = svc && [port, null].includes(servicePort(svc));
|
|
223
|
+
if (state === 'free') {
|
|
224
|
+
if (!ownUnit) return 'not-running';
|
|
225
|
+
serviceStop(svc);
|
|
226
|
+
return 'stopped';
|
|
227
|
+
}
|
|
228
|
+
if (state === 'foreign') return 'not-ours';
|
|
229
|
+
if (state === 'silent') {
|
|
230
|
+
if (ownUnit) {
|
|
231
|
+
serviceStop(svc);
|
|
232
|
+
if (await waitGone()) return 'stopped';
|
|
233
|
+
}
|
|
234
|
+
return 'silent';
|
|
235
|
+
}
|
|
236
|
+
// A supervisor restarts what we kill, so a service is stopped through its manager first.
|
|
237
|
+
if (svc) {
|
|
238
|
+
serviceStop(svc);
|
|
239
|
+
if (await waitGone()) return 'stopped';
|
|
240
|
+
}
|
|
241
|
+
for (const pid of listeningPids(port)) if (pid !== process.pid) killPid(pid);
|
|
242
|
+
return (await waitGone()) ? 'stopped' : 'still-running';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function changePort(newPortStr) {
|
|
246
|
+
const p = parsePort(newPortStr);
|
|
247
|
+
if (!p) {
|
|
248
|
+
console.error(`[Error] Invalid port: "${newPortStr}". Must be an integer between 1 and 65535.`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
const oldPort = resolvePort([], config);
|
|
252
|
+
// Refuse before anything stops: the old gateway keeps running when the new port is taken.
|
|
253
|
+
const target = await probeGateway(p);
|
|
254
|
+
if (isHeld(target)) refuseForeignPort(p, 'this switcher', target);
|
|
255
|
+
const svc = installedService();
|
|
256
|
+
const wasRunning = await checkProxyRunning(oldPort);
|
|
257
|
+
// Stop the old gateway even when a service is installed: it may be a copy started outside the unit.
|
|
258
|
+
if (wasRunning) {
|
|
259
|
+
console.log(`Stopping gateway on current port ${oldPort}...`);
|
|
260
|
+
if ((await stopProxy(oldPort)) === 'still-running') {
|
|
261
|
+
console.error(`[Error] The gateway on port ${oldPort} did not stop. The port is unchanged.`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Re-read just before the write: a dashboard save made while the gateway stopped must survive.
|
|
266
|
+
const fresh = loadConfig() || config;
|
|
267
|
+
fresh.port = p;
|
|
268
|
+
saveConfig(fresh);
|
|
269
|
+
if (process.env.LLM_SWITCHER_PORT) {
|
|
270
|
+
console.log(`[WARN] LLM_SWITCHER_PORT env var is set and overrides config.json.`);
|
|
271
|
+
}
|
|
272
|
+
// A gateway that does not come up on the new port puts config.json and the gateway back. A detached
|
|
273
|
+
// gateway that was started for the new port is stopped first: it may still come up after the wait.
|
|
274
|
+
let startedPid = null;
|
|
275
|
+
const rollBack = async (why) => {
|
|
276
|
+
console.error(`[Error] ${why}`);
|
|
277
|
+
if (startedPid) killPid(startedPid);
|
|
278
|
+
const back = loadConfig() || fresh;
|
|
279
|
+
back.port = oldPort;
|
|
280
|
+
saveConfig(back);
|
|
281
|
+
if (svc) installService(oldPort);
|
|
282
|
+
else if (wasRunning) startProxyBackground(oldPort);
|
|
283
|
+
let up = !svc && !wasRunning;
|
|
284
|
+
for (let i = 0; i < 20 && !up; i++) { await sleep(250); up = await checkProxyRunning(oldPort); }
|
|
285
|
+
console.error(` config.json is back on port ${oldPort}${up ? '' : `, but no gateway answers there. See ${proxyLogPath}`}.`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
};
|
|
288
|
+
if (svc) {
|
|
289
|
+
// The unit fixes the port on its command line, so it is rewritten and restarted, not fought.
|
|
290
|
+
console.log(`Reinstalling the ${svc} service on port ${p}...`);
|
|
291
|
+
if (!installService(p)) await rollBack(`The ${svc} service could not be installed for port ${p}.`);
|
|
292
|
+
let up = false;
|
|
293
|
+
for (let i = 0; i < 20 && !up; i++) { await sleep(250); up = await checkProxyRunning(p); }
|
|
294
|
+
if (!up) await rollBack(`The ${svc} service did not come up on port ${p}. See ${proxyLogPath}.`);
|
|
295
|
+
} else if (wasRunning) {
|
|
296
|
+
console.log(`Restarting gateway on new port ${p}...`);
|
|
297
|
+
startedPid = startProxyBackground(p);
|
|
298
|
+
let up = false;
|
|
299
|
+
for (let i = 0; i < 20 && !up; i++) { await sleep(250); up = await checkProxyRunning(p); }
|
|
300
|
+
if (!up) await rollBack(`The gateway did not come up on port ${p}. See ${proxyLogPath}.`);
|
|
301
|
+
}
|
|
302
|
+
reportSettings(applyLaunchState(fresh, p).settings);
|
|
303
|
+
if (await checkProxyRunning(p)) {
|
|
304
|
+
const bf = await requestBlindfoldSync(p);
|
|
305
|
+
if (!bf.ok) {
|
|
306
|
+
console.error(`[Error] ${bf.error}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
console.log(`[SUCCESS] Port updated to ${p}.`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function printProfile(profile) {
|
|
314
|
+
console.log(`Input Target: ${show(profile.inFormat || 'auto').toUpperCase()}`);
|
|
315
|
+
console.log(`Routing: ${profile.outFormat ? `out=${show(profile.outFormat)}` : `mode=${show(profile.mode || 'hybrid')}`}`);
|
|
316
|
+
console.log(`Upstream: ${show(profile.baseURL || '(not set)')}`);
|
|
317
|
+
for (const slot of modelSlotsForProfile(profile)) {
|
|
318
|
+
const model = modelForSlot(profile, slot);
|
|
319
|
+
if (model) console.log(`${(slot[0].toUpperCase() + slot.slice(1) + ':').padEnd(14)}${show(model)}${model1MForSlot(profile, slot) ? ' [1M]' : ''}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function printTargets(activeMap) {
|
|
324
|
+
const labels = { anthropic: 'Claude Code', responses: 'Codex', 'openai-chat': 'OpenAI Chat', vertex: 'Vertex' };
|
|
325
|
+
for (const t of TARGETS) {
|
|
326
|
+
console.log(` ${labels[t].padEnd(12)} (${t.padEnd(11)}) -> ${activeMap[t] ? show(activeMap[t]) : 'OFF (official)'}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Applies the switch to a copy of `base`. Exits on an error; nothing is written here.
|
|
331
|
+
function planSwitch(base, key, cliTarget) {
|
|
332
|
+
const planned = structuredClone(base);
|
|
333
|
+
const err = cliTarget ? setTargetProfile(planned, cliTarget, key) : activateProfile(planned, key);
|
|
334
|
+
if (err) {
|
|
335
|
+
console.error(`[Error] ${show(err)}`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
return planned;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function refuseBlindfoldProblem(planned, port) {
|
|
342
|
+
const bf = computeLaunchState(planned, port).blindfold;
|
|
343
|
+
const problem = bf && blindfoldPreflight(bf);
|
|
344
|
+
if (!problem) return bf;
|
|
345
|
+
console.error(`[Error] ${problem}`);
|
|
346
|
+
console.error(' Or set "blindfold": false in the profile. Nothing was changed.');
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function turnOn(profileName, cliTarget) {
|
|
351
|
+
const port = getTargetPort();
|
|
352
|
+
const wanted = profileName || config.activeProfile || Object.keys(config.profiles)[0];
|
|
353
|
+
const key = findProfileKey(config, wanted);
|
|
354
|
+
if (!key) {
|
|
355
|
+
console.error(`[Error] Profile "${show(wanted)}" not found in config.json!`);
|
|
356
|
+
console.error(`Available profiles: ${show(Object.keys(config.profiles).join(', ')) || '(none)'}`);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Plan on a copy. Nothing is written until every check below passes.
|
|
361
|
+
let planned = planSwitch(config, key, cliTarget);
|
|
362
|
+
console.log(`Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
|
|
363
|
+
|
|
364
|
+
const gatewayState = await probeGateway(port);
|
|
365
|
+
if (isHeld(gatewayState)) refuseForeignPort(port, 'this switcher', gatewayState);
|
|
366
|
+
const plannedBlindfold = refuseBlindfoldProblem(planned, port);
|
|
367
|
+
if (plannedBlindfold) {
|
|
368
|
+
const held = (await probeBlindfold(plannedBlindfold.port)).state;
|
|
369
|
+
if (isHeld(held)) refuseForeignPort(plannedBlindfold.port, "this switcher's blindfold interceptor", held);
|
|
370
|
+
}
|
|
371
|
+
await ensureProxyRunning(port);
|
|
372
|
+
|
|
373
|
+
// Plan again on the file as it is now: a dashboard save made while the gateway started must survive.
|
|
374
|
+
const current = loadConfig();
|
|
375
|
+
if (!current || getConfigLoadError()) {
|
|
376
|
+
console.error(`[Error] config.json does not parse any more: ${getConfigLoadError()?.message}. Nothing was changed.`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
planned = planSwitch(current, key, cliTarget);
|
|
380
|
+
refuseBlindfoldProblem(planned, port);
|
|
381
|
+
const profile = planned.profiles[key];
|
|
382
|
+
|
|
383
|
+
const previousBytes = fs.readFileSync(configPath);
|
|
384
|
+
saveConfig(planned);
|
|
385
|
+
const savedBytes = fs.readFileSync(configPath);
|
|
386
|
+
const st = applyLaunchState(planned, port);
|
|
387
|
+
reportSettings(st.settings);
|
|
388
|
+
const bf = await requestBlindfoldSync(port);
|
|
389
|
+
if (!bf.ok) {
|
|
390
|
+
console.error(`[Error] ${bf.error}`);
|
|
391
|
+
// Put back exactly what was there, unless another writer saved in the meantime: then its
|
|
392
|
+
// change wins and nothing is restored. clearLaunchState would also switch off unrelated targets
|
|
393
|
+
// and run the settings.json cleaner, so the previous state is re-applied instead.
|
|
394
|
+
if (!fs.readFileSync(configPath).equals(savedBytes)) {
|
|
395
|
+
console.error(' config.json changed while the interceptor started, so it is not restored. Check it, then run the command again.');
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
restoreConfigBytes(previousBytes);
|
|
399
|
+
applyLaunchState(JSON.parse(previousBytes.toString('utf8')), port, { cleanSettings: false });
|
|
400
|
+
const back = await requestBlindfoldSync(port);
|
|
401
|
+
console.error(' The previous config.json and launcher files are restored.');
|
|
402
|
+
if (!back.ok) console.error(` The previous interceptor did not come back: ${back.error}`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Self-install shims: with them, `claude --resume` sessions launched from a shell that never sourced env.sh
|
|
407
|
+
// still route through the gateway. settings.json stays untouched so Claude Code shows no banner.
|
|
408
|
+
// A shim bakes in the state dir; outside the checkout that is usually a temporary directory, and the shim
|
|
409
|
+
// would outlive it.
|
|
410
|
+
if (process.env.LLM_SWITCHER_STATE_DIR) {
|
|
411
|
+
console.log('\n[Shim] Not installed automatically: LLM_SWITCHER_STATE_DIR is set. Run `switch shim install` to install shims for that directory.');
|
|
412
|
+
} else try {
|
|
413
|
+
const { installed, error } = installShims();
|
|
414
|
+
if (error) console.warn(`[Shim] ${error}`);
|
|
415
|
+
const sh = shimStatus();
|
|
416
|
+
if (installed.length) console.log(`\n[Shim] Installed launcher shims: ${installed.join(', ')}`);
|
|
417
|
+
if (!sh.onPath) {
|
|
418
|
+
console.log(`[Shim] NOT on PATH yet — resumed sessions will still bypass the gateway.`);
|
|
419
|
+
const rc = suggestedRcFiles()[0];
|
|
420
|
+
console.log(rc ? ` Add this line to ${rc} and open a new terminal:` : ' Run this command once, then open a new terminal:');
|
|
421
|
+
console.log(` ${pathExportLine()}`);
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
console.warn(`[Shim] ${err.message}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
console.log(`\n[SUCCESS] Switched to profile "${show(profile.name || key)}".`);
|
|
428
|
+
printProfile(profile);
|
|
429
|
+
console.log('\nActive targets:');
|
|
430
|
+
printTargets(getActiveMap(planned));
|
|
431
|
+
console.log(`\nClaude 1M: ${describeClaude1M(st)}`);
|
|
432
|
+
console.log(`Codex 1M: ${st.codex1M ? 'ACTIVE (1,000,000 tokens)' : 'OFF'}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function turnOff(targetArg) {
|
|
436
|
+
const port = getTargetPort();
|
|
437
|
+
if (targetArg) {
|
|
438
|
+
const target = TARGET_ALIASES[targetArg.toLowerCase()];
|
|
439
|
+
if (!target) {
|
|
440
|
+
console.error(`[Error] Unknown target "${targetArg}". Use one of: claude, codex, openai, vertex`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
setTargetProfile(config, target, null);
|
|
444
|
+
saveConfig(config);
|
|
445
|
+
const st = applyLaunchState(config, port);
|
|
446
|
+
reportSettings(st.settings);
|
|
447
|
+
const bf = await syncOrStopBlindfold(port, config);
|
|
448
|
+
if (!bf.ok) {
|
|
449
|
+
console.error(`[Error] ${target} is switched back, but the blindfold interceptor is not in line: ${bf.error}`);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
console.log(`[SUCCESS] ${target} switched back to official endpoint. Other targets unchanged:`);
|
|
453
|
+
printTargets(getActiveMap(config));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
console.log('Deactivating Proxy and restoring official endpoints...');
|
|
458
|
+
deactivateAll(config);
|
|
459
|
+
saveConfig(config);
|
|
460
|
+
reportSettings(clearLaunchState(port));
|
|
461
|
+
const bf = await syncOrStopBlindfold(port, config);
|
|
462
|
+
if (!bf.ok) console.error(`[Error] The blindfold interceptor did not stop: ${bf.error}`);
|
|
463
|
+
const result = await stopProxy(port);
|
|
464
|
+
if (result === 'still-running') {
|
|
465
|
+
console.error(`[Error] The gateway on port ${port} is still running. Stop it by hand; the launcher files are already cleared.`);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
if (result === 'not-ours' || result === 'silent') {
|
|
469
|
+
console.error(result === 'silent'
|
|
470
|
+
? `[Error] Port ${port} accepts connections but does not answer, so it is not proven to be this switcher. It was not stopped.`
|
|
471
|
+
: `[Error] Port ${port} is held by a process that did not prove it is this switcher. It was not stopped.`);
|
|
472
|
+
process.exit(1);
|
|
473
|
+
}
|
|
474
|
+
console.log(result === 'stopped' ? 'Stopped local proxy service.' : 'Proxy service was not running.');
|
|
475
|
+
if (!bf.ok) process.exit(1);
|
|
476
|
+
console.log('\n[SUCCESS] Switched back to Claude Official Subscription. Run `switch on` to re-enable.');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The flag carries the main session model only; haiku 1M reaches Claude Code through its tier variable.
|
|
480
|
+
function describeClaude1M(st) {
|
|
481
|
+
if (!st.claude1MTiers?.length) return 'OFF';
|
|
482
|
+
return `ACTIVE (${st.claude1MTiers.map(t => `${t}[1m]`).join(', ')})${st.claude1M ? `, main session ${st.claude1M}` : ''}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Only a CLI whose target is active is expected to go through the gateway.
|
|
486
|
+
function auditActiveClis() {
|
|
487
|
+
const map = getActiveMap(config);
|
|
488
|
+
return auditRunningProcesses([map.anthropic && 'claude', map.responses && 'codex'].filter(Boolean));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// settings.json edits are never silent: name every value the switcher removed.
|
|
492
|
+
function reportSettings(result) {
|
|
493
|
+
if (result?.removed?.length) console.log(`[settings.json] Removed switcher-written values: ${result.removed.join(', ')}`);
|
|
494
|
+
if (result?.error) console.warn(`[settings.json] Not cleaned: ${result.error}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function showStatus() {
|
|
498
|
+
const port = getTargetPort();
|
|
499
|
+
const gateway = await probeGateway(port);
|
|
500
|
+
const isRunning = gateway === 'ours';
|
|
501
|
+
const activeMap = getActiveMap(config);
|
|
502
|
+
const flagged = fs.existsSync(paths.activeFlag);
|
|
503
|
+
|
|
504
|
+
console.log('=== LLM Switcher Status ===');
|
|
505
|
+
const held = { foreign: `PORT ${port} HELD BY ANOTHER PROCESS`, silent: `PORT ${port} DOES NOT ANSWER (hung gateway or another program)` };
|
|
506
|
+
console.log(`Proxy Service: ${isRunning ? `RUNNING (port ${port})` : held[gateway] || 'STOPPED'}`);
|
|
507
|
+
console.log(`Web UI: http://127.0.0.1:${port}/ui`);
|
|
508
|
+
console.log(`Launcher Flag: ${flagged ? 'active.flag present' : 'absent (launchers use official endpoints)'}`);
|
|
509
|
+
if (flagged && !isRunning) {
|
|
510
|
+
console.log(`[WARN] active.flag exists but proxy is STOPPED -> launched CLIs will fail to connect. Run 'switch on' or 'switch off'.`);
|
|
511
|
+
}
|
|
512
|
+
console.log(`Claude 1M: ${flagged ? describeClaude1M(computeLaunchState(config, port)) : 'OFF'}`);
|
|
513
|
+
console.log(`Codex 1M Flag: ${fs.existsSync(paths.flagCodex1M) ? 'ACTIVE' : 'OFF'}`);
|
|
514
|
+
console.log('\nActive targets:');
|
|
515
|
+
printTargets(activeMap);
|
|
516
|
+
console.log('\nAvailable profiles:');
|
|
517
|
+
for (const [key, p] of Object.entries(config.profiles)) {
|
|
518
|
+
const mark = Object.values(activeMap).includes(key) ? '* ' : ' ';
|
|
519
|
+
console.log(`${mark}${show(key).padEnd(20)} : [${show(p.inFormat || 'auto')}->${show(p.outFormat || p.mode || 'hybrid')}] ${show(p.name)} (${show(p.baseURL || 'no baseURL')})`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function openUI() {
|
|
524
|
+
const port = getTargetPort();
|
|
525
|
+
await ensureProxyRunning(port);
|
|
526
|
+
// The browser gets a private file path, never the token: a command line is readable by every account.
|
|
527
|
+
const url = `http://127.0.0.1:${port}/ui`;
|
|
528
|
+
console.log(`Opening Web UI: ${url}`);
|
|
529
|
+
openBrowser(writeDashboardLauncher(url));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const SYSTEMD_UNIT = path.join(userProfile, '.config', 'systemd', 'user', 'llm-switcher.service');
|
|
533
|
+
const LAUNCHD_PLIST = path.join(userProfile, 'Library', 'LaunchAgents', 'com.llmswitcher.gateway.plist');
|
|
534
|
+
|
|
535
|
+
// systemctl --user needs the user bus. A shell without a login session (su, cron, ssh -T) has no
|
|
536
|
+
// XDG_RUNTIME_DIR, and then every call fails although the user manager runs.
|
|
537
|
+
function systemctlUser(args, opts = {}) {
|
|
538
|
+
const env = { ...process.env };
|
|
539
|
+
if (!env.XDG_RUNTIME_DIR && process.getuid) env.XDG_RUNTIME_DIR = `/run/user/${process.getuid()}`;
|
|
540
|
+
return execFileSync('systemctl', ['--user', ...args], { env, ...opts });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** 'systemd' | 'launchd' | 'schtasks' | null */
|
|
544
|
+
function installedService() {
|
|
545
|
+
if (process.platform === 'win32') {
|
|
546
|
+
try {
|
|
547
|
+
execFileSync('schtasks', ['/Query', '/TN', 'LLMSwitcher'], { stdio: 'ignore' });
|
|
548
|
+
return 'schtasks';
|
|
549
|
+
} catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (process.platform === 'darwin') return fs.existsSync(LAUNCHD_PLIST) ? 'launchd' : null;
|
|
554
|
+
return fs.existsSync(SYSTEMD_UNIT) ? 'systemd' : null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function serviceStart(kind) {
|
|
558
|
+
try {
|
|
559
|
+
if (kind === 'systemd') systemctlUser(['start', 'llm-switcher'], { stdio: 'ignore' });
|
|
560
|
+
else if (kind === 'launchd') execFileSync('launchctl', ['load', LAUNCHD_PLIST], { stdio: 'ignore' });
|
|
561
|
+
else if (kind === 'schtasks') execFileSync('schtasks', ['/Run', '/TN', 'LLMSwitcher'], { stdio: 'ignore' });
|
|
562
|
+
} catch {}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The port on the service's command line, or null when it cannot be read. */
|
|
566
|
+
function servicePort(kind) {
|
|
567
|
+
try {
|
|
568
|
+
if (kind === 'systemd') return portFromServiceText(fs.readFileSync(SYSTEMD_UNIT, 'utf8'));
|
|
569
|
+
if (kind === 'launchd') return portFromServiceText(fs.readFileSync(LAUNCHD_PLIST, 'utf8'));
|
|
570
|
+
if (kind === 'schtasks') return portFromServiceText(decodeConsoleText(execFileSync('schtasks', ['/Query', '/TN', 'LLMSwitcher', '/XML'])));
|
|
571
|
+
} catch {}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function reportBackup(backup) {
|
|
576
|
+
if (backup) console.log(`[Service] The previous definition differed and is kept as ${backup}.`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// KeepAlive / Restart=always restart a killed gateway, so a service stops through its manager.
|
|
580
|
+
function serviceStop(kind) {
|
|
581
|
+
try {
|
|
582
|
+
if (kind === 'systemd') systemctlUser(['stop', 'llm-switcher'], { stdio: 'ignore' });
|
|
583
|
+
else if (kind === 'launchd') execFileSync('launchctl', ['unload', LAUNCHD_PLIST], { stdio: 'ignore' });
|
|
584
|
+
else if (kind === 'schtasks') execFileSync('schtasks', ['/End', '/TN', 'LLMSwitcher'], { stdio: 'ignore' });
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Writes the service for `port` and (re)starts it, so a running unit picks up the new command line.
|
|
589
|
+
function installService(port) {
|
|
590
|
+
const nodeBin = process.execPath;
|
|
591
|
+
const env = serviceEnv();
|
|
592
|
+
if (process.platform === 'win32') {
|
|
593
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-task-'));
|
|
594
|
+
try {
|
|
595
|
+
const userId = process.env.USERDOMAIN && process.env.USERNAME ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : os.userInfo().username;
|
|
596
|
+
const xmlPath = path.join(dir, 'task.xml');
|
|
597
|
+
// Task Scheduler reads the XML as UTF-16, the encoding it declares.
|
|
598
|
+
fs.writeFileSync(xmlPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(scheduledTaskXml({ nodeBin, script: proxyScript, port, userId }), 'utf16le')]));
|
|
599
|
+
execFileSync('schtasks', ['/Create', '/TN', 'LLMSwitcher', '/XML', xmlPath, '/F'], { stdio: 'inherit' });
|
|
600
|
+
try { execFileSync('schtasks', ['/End', '/TN', 'LLMSwitcher'], { stdio: 'ignore' }); } catch {}
|
|
601
|
+
execFileSync('schtasks', ['/Run', '/TN', 'LLMSwitcher'], { stdio: 'ignore' });
|
|
602
|
+
if (env.length) console.log(`[WARN] The scheduled task does not receive ${env.map(([k]) => k).join(', ')}. Set them as User environment variables.`);
|
|
603
|
+
console.log('[SUCCESS] Installed and started Windows Scheduled Task "LLMSwitcher" (auto-starts on logon).');
|
|
604
|
+
return true;
|
|
605
|
+
} catch (err) {
|
|
606
|
+
console.error('[Error] Failed to register the scheduled task:', err.message);
|
|
607
|
+
return false;
|
|
608
|
+
} finally {
|
|
609
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (process.platform === 'darwin') {
|
|
613
|
+
try {
|
|
614
|
+
reportBackup(writeServiceFile(LAUNCHD_PLIST, launchdPlist({ nodeBin, script: proxyScript, port, logPath: proxyLogPath, env })));
|
|
615
|
+
try { execFileSync('launchctl', ['unload', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch {}
|
|
616
|
+
execFileSync('launchctl', ['load', LAUNCHD_PLIST], { stdio: 'inherit' });
|
|
617
|
+
console.log('[SUCCESS] Installed and started macOS launchd service.');
|
|
618
|
+
return true;
|
|
619
|
+
} catch (e) {
|
|
620
|
+
console.error('[Error] Failed to install the launchd service:', e.message);
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
reportBackup(writeServiceFile(SYSTEMD_UNIT, systemdUnit({ nodeBin, script: proxyScript, port, env })));
|
|
626
|
+
systemctlUser(['daemon-reload'], { stdio: 'inherit' });
|
|
627
|
+
systemctlUser(['enable', 'llm-switcher'], { stdio: 'inherit' });
|
|
628
|
+
systemctlUser(['restart', 'llm-switcher'], { stdio: 'inherit' });
|
|
629
|
+
console.log('[SUCCESS] Installed and started systemd user service.');
|
|
630
|
+
return true;
|
|
631
|
+
} catch (e) {
|
|
632
|
+
console.error('[Error] Failed to install the systemd service:', e.message);
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function manageService(action) {
|
|
638
|
+
const port = getTargetPort();
|
|
639
|
+
|
|
640
|
+
if (action === 'install') {
|
|
641
|
+
if (!installService(port)) process.exit(1);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (action === 'uninstall') {
|
|
646
|
+
const kind = installedService();
|
|
647
|
+
// Running uninstall twice is a successful no-op.
|
|
648
|
+
if (!kind) {
|
|
649
|
+
console.log('[INFO] No LLM Switcher service is installed.');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
serviceStop(kind);
|
|
653
|
+
let removed = true;
|
|
654
|
+
try {
|
|
655
|
+
if (kind === 'schtasks') {
|
|
656
|
+
execFileSync('schtasks', ['/Delete', '/TN', 'LLMSwitcher', '/F'], { stdio: 'inherit' });
|
|
657
|
+
} else if (kind === 'launchd') {
|
|
658
|
+
fs.unlinkSync(LAUNCHD_PLIST);
|
|
659
|
+
} else {
|
|
660
|
+
try { systemctlUser(['disable', '--now', 'llm-switcher'], { stdio: 'ignore' }); } catch {}
|
|
661
|
+
fs.unlinkSync(SYSTEMD_UNIT);
|
|
662
|
+
try { systemctlUser(['daemon-reload'], { stdio: 'ignore' }); } catch {}
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
removed = false;
|
|
666
|
+
console.error(`[Error] Could not remove the ${kind} service: ${err.message}`);
|
|
667
|
+
}
|
|
668
|
+
if (removed) console.log(`[SUCCESS] Removed the ${kind} service.`);
|
|
669
|
+
const stopped = await stopProxy(port);
|
|
670
|
+
if (stopped === 'still-running') console.error(`[Error] The gateway on port ${port} is still running.`);
|
|
671
|
+
if (!removed || stopped === 'still-running') process.exit(1);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
console.log('Usage: switch service [install|uninstall]');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function manageShim(action = 'status') {
|
|
679
|
+
const act = (action || 'status').toLowerCase();
|
|
680
|
+
|
|
681
|
+
if (act === 'install' || act === 'on') {
|
|
682
|
+
const { installed, skipped, error } = installShims();
|
|
683
|
+
if (error) { console.error(`[Error] ${error}`); process.exit(1); }
|
|
684
|
+
if (installed.length) console.log(`[OK] Installed shims: ${installed.join(', ')} → ${SHIM_DIR}`);
|
|
685
|
+
for (const s of skipped) console.log(`[SKIP] ${s.name}: ${s.reason}`);
|
|
686
|
+
|
|
687
|
+
const st = shimStatus();
|
|
688
|
+
if (!st.onPath) {
|
|
689
|
+
console.log(`\n[ACTION REQUIRED] Add the shim dir to PATH so it precedes the real binaries:`);
|
|
690
|
+
console.log(` ${pathExportLine()}`);
|
|
691
|
+
const rcFiles = suggestedRcFiles();
|
|
692
|
+
if (rcFiles.length) {
|
|
693
|
+
console.log(`\nAppend that line to one of:`);
|
|
694
|
+
for (const rc of rcFiles) console.log(` ${rc}`);
|
|
695
|
+
} else {
|
|
696
|
+
console.log(`\nRun that command once; it changes only your User-scope Path.`);
|
|
697
|
+
}
|
|
698
|
+
console.log(`\nThen open a new terminal (or 'exec $SHELL') and verify:`);
|
|
699
|
+
console.log(` switch shim status`);
|
|
700
|
+
} else {
|
|
701
|
+
console.log(`\n[PASS] ${SHIM_DIR} is already on PATH (position ${st.position}).`);
|
|
702
|
+
console.log(`Resumed sessions ('claude --resume') now route through the gateway automatically.`);
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (act === 'uninstall' || act === 'off' || act === 'remove') {
|
|
708
|
+
const { removed, failed } = uninstallShims();
|
|
709
|
+
if (removed.length) console.log(`[OK] Removed shims: ${removed.join(', ')}`);
|
|
710
|
+
for (const f of failed) console.error(`[Error] Could not remove the ${f.name} shim: ${f.reason}`);
|
|
711
|
+
if (!removed.length && !failed.length) console.log('[INFO] No switcher shims found.');
|
|
712
|
+
console.log(`You may also remove the PATH line for ${SHIM_DIR} from your shell rc.`);
|
|
713
|
+
if (failed.length) process.exit(1);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// status
|
|
718
|
+
const st = shimStatus();
|
|
719
|
+
console.log('=== Shim status (auto-inject for resumed sessions) ===\n');
|
|
720
|
+
console.log(`Shim dir: ${st.dir}`);
|
|
721
|
+
console.log(`On PATH : ${st.onPath ? `YES (position ${st.position})` : 'NO'}`);
|
|
722
|
+
if (!st.onPath) console.log(` Add: ${pathExportLine()}`);
|
|
723
|
+
console.log('');
|
|
724
|
+
for (const s of st.shims) {
|
|
725
|
+
if (!s.installed) { console.log(`[MISS] ${s.name}: shim not installed — run 'switch shim install'`); continue; }
|
|
726
|
+
if (s.active) console.log(`[PASS] ${s.name}: shim active → ${s.effective}`);
|
|
727
|
+
else console.log(`[WARN] ${s.name}: shim installed but '${s.name}' resolves to ${s.effective || '(not found)'} — PATH order wrong`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const audit = auditActiveClis();
|
|
731
|
+
if (!audit.supported) console.log('\n[INFO] The running-session check is not supported on Windows.');
|
|
732
|
+
else if (audit.procs.length) {
|
|
733
|
+
console.log('\n--- Running CLI processes ---');
|
|
734
|
+
for (const p of audit.procs) {
|
|
735
|
+
if (p.hasEnv === true) console.log(`[PASS] pid ${p.pid} (${p.name}): routed through the gateway`);
|
|
736
|
+
else if (p.hasEnv === false) console.log(`[ALERT] pid ${p.pid} (${p.name}): NO gateway route — this session bypasses the gateway!\n ${show(p.cmd)}\n Fix: quit it and re-run from a shell where the shim is on PATH.`);
|
|
737
|
+
else console.log(`[INFO] pid ${p.pid} (${p.name}): its environment cannot be read here`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function runDoctor() {
|
|
743
|
+
const port = getTargetPort();
|
|
744
|
+
console.log('=== LLM Switcher System Doctor ===\n');
|
|
745
|
+
const isRunning = await checkProxyRunning(port);
|
|
746
|
+
let allHealthy = true;
|
|
747
|
+
const warn = (msg) => { console.log(msg); allHealthy = false; };
|
|
748
|
+
|
|
749
|
+
// 1. Check proxy liveness
|
|
750
|
+
if (isRunning) {
|
|
751
|
+
console.log(`[PASS] Gateway service is RUNNING on http://127.0.0.1:${port}`);
|
|
752
|
+
} else {
|
|
753
|
+
warn(`[WARN] Gateway service is STOPPED on port ${port}. Run 'switch on' to activate.`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// 2. Check which profiles the targets point to
|
|
757
|
+
const activeMap = getActiveMap(config);
|
|
758
|
+
for (const [t, key] of Object.entries(activeMap)) {
|
|
759
|
+
if (!key) continue;
|
|
760
|
+
const p = config.profiles[key];
|
|
761
|
+
if (!p) warn(`[WARN] Target ${t} points to missing profile "${show(key)}".`);
|
|
762
|
+
else if (!p.baseURL || /YOUR-|REPLACE-ME/i.test(`${p.baseURL} ${p.apiKey}`)) warn(`[WARN] Profile "${show(key)}" (${t}) still has placeholder baseURL/apiKey.`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// 3. Launcher flags match proxy state
|
|
766
|
+
if (fs.existsSync(paths.activeFlag) && !isRunning) {
|
|
767
|
+
warn(`[WARN] active.flag exists but proxy is stopped: launched CLIs will get ECONNREFUSED.`);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// 4. Check ~/.claude/settings.json
|
|
771
|
+
if (fs.existsSync(claudeSettingsPath)) {
|
|
772
|
+
try {
|
|
773
|
+
const s = JSON.parse(fs.readFileSync(claudeSettingsPath, 'utf8'));
|
|
774
|
+
if (s.env?.ANTHROPIC_BASE_URL) {
|
|
775
|
+
warn(`[WARN] ${claudeSettingsPath} contains hardcoded ANTHROPIC_BASE_URL="${s.env.ANTHROPIC_BASE_URL}".`);
|
|
776
|
+
console.log(` This triggers warning banners in Claude Code. Run 'switch on' / 'switch off' to clean.`);
|
|
777
|
+
} else {
|
|
778
|
+
console.log(`[PASS] ${claudeSettingsPath} has no proxy overrides.`);
|
|
779
|
+
}
|
|
780
|
+
} catch {
|
|
781
|
+
warn(`[WARN] ${claudeSettingsPath} exists but is not valid JSON.`);
|
|
782
|
+
}
|
|
783
|
+
} else {
|
|
784
|
+
console.log(`[PASS] ${claudeSettingsPath} does not exist (clean official state).`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 5. Check environment variables
|
|
788
|
+
const anthBase = process.env.ANTHROPIC_BASE_URL;
|
|
789
|
+
if (anthBase) {
|
|
790
|
+
if (!/\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(anthBase)) {
|
|
791
|
+
warn(`[ALERT] Current ANTHROPIC_BASE_URL="${anthBase}" points to an external host!`);
|
|
792
|
+
console.log(` It should point to LLM Switcher (http://127.0.0.1:${port}) or your local optimizer tool.`);
|
|
793
|
+
} else {
|
|
794
|
+
console.log(`[PASS] ANTHROPIC_BASE_URL points to local address: ${anthBase}`);
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
console.log(`[INFO] ANTHROPIC_BASE_URL is not set in current shell (launcher wrapper will inject on demand).`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
console.log(`[INFO] Claude 1M: ${describeClaude1M(computeLaunchState(config, port))}. Codex 1M flag: ${fs.existsSync(paths.flagCodex1M) ? 'YES' : 'NO'}`);
|
|
801
|
+
console.log(`[INFO] Universal environment loader: env.cmd=${fs.existsSync(paths.envCmd) ? 'READY' : 'PENDING'}`);
|
|
802
|
+
if (fs.existsSync(proxyLogPath)) console.log(`[INFO] Background proxy log: ${proxyLogPath}`);
|
|
803
|
+
|
|
804
|
+
// 6. Shims — safety net for resumed sessions / shells that never sourced env.sh
|
|
805
|
+
console.log('\n--- Launcher shims (resumed sessions) ---');
|
|
806
|
+
const sh = shimStatus();
|
|
807
|
+
if (!sh.onPath) {
|
|
808
|
+
warn(`[WARN] ${SHIM_DIR} is not on PATH — 'claude --resume' from a clean shell will BYPASS the gateway.`);
|
|
809
|
+
console.log(` Fix: switch shim install then add: ${pathExportLine()}`);
|
|
810
|
+
} else {
|
|
811
|
+
console.log(`[PASS] Shim dir on PATH (position ${sh.position}).`);
|
|
812
|
+
}
|
|
813
|
+
for (const s of sh.shims) {
|
|
814
|
+
if (!s.installed) warn(`[WARN] No shim for '${s.name}' — run 'switch shim install'.`);
|
|
815
|
+
else if (!s.active) warn(`[WARN] '${s.name}' resolves to ${s.effective || '(not found)'} instead of the shim — PATH order wrong.`);
|
|
816
|
+
else console.log(`[PASS] '${s.name}' routed through shim.`);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// 7. Running processes missing env => those sessions call the provider directly
|
|
820
|
+
const audit = auditActiveClis();
|
|
821
|
+
if (!audit.supported) console.log('[INFO] The running-session check is not supported on Windows.');
|
|
822
|
+
else {
|
|
823
|
+
for (const p of audit.procs) {
|
|
824
|
+
if (p.hasEnv === false) {
|
|
825
|
+
warn(`[ALERT] pid ${p.pid} (${p.name}) has NO gateway route — that session bypasses the gateway.`);
|
|
826
|
+
console.log(` ${show(p.cmd)}`);
|
|
827
|
+
console.log(` Fix: quit it, then re-run from a shell where the shim is on PATH.`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
console.log('\n--- Intermediary Token Optimizers (Headroom / RTK / Ponytail) ---');
|
|
833
|
+
console.log(`If using a token compressor, ensure its upstream target is configured to http://127.0.0.1:${port}.`);
|
|
834
|
+
console.log('LLM Switcher will act as the final edge gatekeeper to heal schemas, unlock 1M, and preserve thinking.');
|
|
835
|
+
|
|
836
|
+
console.log(`\nDoctor summary: ${allHealthy ? 'ALL CHECKS PASSED (HEALTHY)' : 'ATTENTION RECOMMENDED (CHECK WARNINGS ABOVE)'}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ----------------------------------------------------
|
|
840
|
+
// Contract probe
|
|
841
|
+
// ----------------------------------------------------
|
|
842
|
+
function optionValue(name) {
|
|
843
|
+
const argv = process.argv.slice(2);
|
|
844
|
+
const i = argv.indexOf(name);
|
|
845
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// One line per request, so a run can be read while it is still going. A model name comes from
|
|
849
|
+
// config.json, so it is printed through `show` like any other configured string.
|
|
850
|
+
async function runContractProbe() {
|
|
851
|
+
const port = getTargetPort();
|
|
852
|
+
const token = readAdminToken();
|
|
853
|
+
if (!token) {
|
|
854
|
+
console.error(`[Error] No admin token yet (${adminTokenPath}). Start the gateway first: switch on`);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const model = optionValue('--model');
|
|
858
|
+
console.log(`Probing gateway 127.0.0.1:${port} — model, format, variant, trace id, status`);
|
|
859
|
+
const { rows, unreachable } = await runProbe({
|
|
860
|
+
port, token, config, model,
|
|
861
|
+
log: (line) => console.log(show(line))
|
|
862
|
+
});
|
|
863
|
+
if (!rows.length) console.log(model ? `No active profile maps to "${show(model)}".` : 'No active profile maps a model.');
|
|
864
|
+
const ok = rows.filter(r => r.status === 200).length;
|
|
865
|
+
console.log(`${rows.length} requests, ${ok} answered 200.`);
|
|
866
|
+
if (unreachable) {
|
|
867
|
+
console.error(`[Error] The gateway on port ${port} did not answer. Start it with: switch on`);
|
|
868
|
+
process.exit(1);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// The findings and the fixtures are untrusted text, so `runCheck` prints its own sanitized rows
|
|
873
|
+
// and this wrapper only decides the exit code: 2 means intact could not be read.
|
|
874
|
+
async function runContractCheck() {
|
|
875
|
+
const out = await runCheck({ settings: () => contractLabSettings(config) });
|
|
876
|
+
if (!out.ok) {
|
|
877
|
+
console.error(`[Error] ${out.error}`);
|
|
878
|
+
process.exit(2);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const [rawCmd = '', subArg = ''] = positionalArgs();
|
|
883
|
+
const cmd = rawCmd.toLowerCase();
|
|
884
|
+
|
|
885
|
+
if (cmd === 'off' || cmd === 'stop') {
|
|
886
|
+
await turnOff(subArg);
|
|
887
|
+
} else if (cmd === 'port') {
|
|
888
|
+
await changePort(subArg);
|
|
889
|
+
} else if (cmd === 'doctor' || cmd === 'audit') {
|
|
890
|
+
await runDoctor();
|
|
891
|
+
} else if (cmd === 'service' || cmd === 'daemon') {
|
|
892
|
+
await manageService(subArg.toLowerCase() || 'status');
|
|
893
|
+
} else if (cmd === 'shim' || cmd === 'shims') {
|
|
894
|
+
await manageShim(subArg.toLowerCase() || 'status');
|
|
895
|
+
} else if (Object.hasOwn(TARGET_ALIASES, cmd)) {
|
|
896
|
+
await turnOn(subArg, TARGET_ALIASES[cmd]);
|
|
897
|
+
} else if (cmd === 'ui' || cmd === 'web' || cmd === 'gui') {
|
|
898
|
+
await openUI();
|
|
899
|
+
} else if (cmd === 'contract-probe') {
|
|
900
|
+
await runContractProbe();
|
|
901
|
+
} else if (cmd === 'contract-check') {
|
|
902
|
+
await runContractCheck();
|
|
903
|
+
} else if (cmd === 'status' || cmd === 'st') {
|
|
904
|
+
await showStatus();
|
|
905
|
+
} else if (cmd === 'on' || cmd === 'start') {
|
|
906
|
+
await turnOn(subArg);
|
|
907
|
+
} else if (cmd && findProfileKey(config, rawCmd)) {
|
|
908
|
+
await turnOn(rawCmd);
|
|
909
|
+
} else {
|
|
910
|
+
console.log('Usage:');
|
|
911
|
+
console.log(' switch ui # Open Web UI dashboard');
|
|
912
|
+
console.log(' switch status # Show multi-CLI active status');
|
|
913
|
+
console.log(' switch doctor # Audit environment, settings & routing');
|
|
914
|
+
console.log(' switch on [profile] # Start gateway & activate profile for all compatible targets');
|
|
915
|
+
console.log(' switch <profile> # Activate profile for all compatible targets');
|
|
916
|
+
console.log(' switch claude <profile> # Set active profile for Claude Code');
|
|
917
|
+
console.log(' switch codex <profile> # Set active profile for Codex');
|
|
918
|
+
console.log(' switch openai <profile> # Set active profile for OpenAI Chat');
|
|
919
|
+
console.log(' switch vertex <profile> # Set active profile for Vertex');
|
|
920
|
+
console.log(' switch port <number> # Change gateway port');
|
|
921
|
+
console.log(' switch service install # Install OS background autostart service');
|
|
922
|
+
console.log(' switch service uninstall # Uninstall background autostart service');
|
|
923
|
+
console.log(' switch shim install # Auto-inject env into resumed sessions (claude --resume)');
|
|
924
|
+
console.log(' switch shim status # Check shims + detect sessions bypassing the gateway');
|
|
925
|
+
console.log(' switch shim uninstall # Remove launcher shims');
|
|
926
|
+
console.log(' switch off [target] # Restore official endpoints (all, or one target)');
|
|
927
|
+
console.log(' switch contract-probe [--model m] # Drive the contract-lab variants through the gateway');
|
|
928
|
+
console.log(' switch contract-check # Turn the open contract findings into failing tests');
|
|
929
|
+
console.log('\nGlobal option: --port <n> (or env LLM_SWITCHER_PORT)');
|
|
930
|
+
}
|