llm-switcher 1.2.10 → 1.2.12
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 +12 -0
- package/README.md +15 -1
- package/README.vi.md +16 -1
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- package/package.json +1 -1
- package/proxy.mjs +64 -2
- package/service.mjs +12 -6
- package/skills/llm-switcher/SKILL.md +93 -93
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +100 -15
- package/tests/gateway.e2e.test.mjs +11 -0
- package/tests/helpers.mjs +24 -24
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/service.test.mjs +27 -1
- package/tests/update.e2e.test.mjs +197 -0
- package/tests/update.test.mjs +145 -0
- package/ui.html +70 -1
- package/update.mjs +98 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// A real gateway, run from a copy of this checkout that tracks a local bare repository. A release
|
|
2
|
+
// pushed there must reach the running gateway through POST /api/update, and through --autoupdate at
|
|
3
|
+
// start. Nothing here reaches the network or the real home directory.
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { launchBrowser, skipReason } from './cdp.mjs';
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
17
|
+
const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', '-c', 'core.autocrlf=false'];
|
|
18
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
const freePort = () => new Promise(r => {
|
|
21
|
+
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function copySource(dest) {
|
|
25
|
+
const files = git(ROOT, 'ls-files', '--cached', '--others', '--exclude-standard').split(/\r?\n/).filter(Boolean);
|
|
26
|
+
for (const f of files) {
|
|
27
|
+
const from = path.join(ROOT, f);
|
|
28
|
+
if (!fs.existsSync(from)) continue;
|
|
29
|
+
fs.mkdirSync(path.dirname(path.join(dest, f)), { recursive: true });
|
|
30
|
+
fs.copyFileSync(from, path.join(dest, f));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bumpVersion(dir, version) {
|
|
35
|
+
const p = path.join(dir, 'package.json');
|
|
36
|
+
fs.writeFileSync(p, JSON.stringify({ ...JSON.parse(fs.readFileSync(p, 'utf8')), version }, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The npm registry answer only drives the dashboard notice; a git checkout updates from git.
|
|
40
|
+
async function registry(t, version) {
|
|
41
|
+
const srv = http.createServer((req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ version })); });
|
|
42
|
+
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
|
43
|
+
t.after(() => srv.close());
|
|
44
|
+
return `http://127.0.0.1:${srv.address().port}/`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fixture(t, { registryUrl = 'http://127.0.0.1:9/' } = {}) {
|
|
48
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-update-e2e-'));
|
|
49
|
+
const upstream = path.join(base, 'upstream.git');
|
|
50
|
+
const install = path.join(base, 'install');
|
|
51
|
+
const publisher = path.join(base, 'publisher');
|
|
52
|
+
git(base, 'init', '-q', '--bare', '-b', 'main', upstream);
|
|
53
|
+
fs.mkdirSync(install);
|
|
54
|
+
copySource(install);
|
|
55
|
+
git(install, 'init', '-q', '-b', 'main');
|
|
56
|
+
git(install, 'add', '-A');
|
|
57
|
+
git(install, ...ID, 'commit', '-q', '-m', 'installed');
|
|
58
|
+
git(install, 'remote', 'add', 'origin', upstream);
|
|
59
|
+
git(install, 'push', '-q', '-u', 'origin', 'main');
|
|
60
|
+
fs.mkdirSync(publisher);
|
|
61
|
+
git(publisher, 'init', '-q', '-b', 'main');
|
|
62
|
+
git(publisher, 'remote', 'add', 'origin', upstream);
|
|
63
|
+
git(publisher, 'fetch', '-q', 'origin');
|
|
64
|
+
git(publisher, 'checkout', '-q', '-b', 'main', '--track', 'origin/main');
|
|
65
|
+
|
|
66
|
+
const data = path.join(base, 'data');
|
|
67
|
+
const home = path.join(data, 'home');
|
|
68
|
+
fs.mkdirSync(home, { recursive: true });
|
|
69
|
+
const port = await freePort();
|
|
70
|
+
const cfgPath = path.join(data, 'config.json');
|
|
71
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
72
|
+
port, blindfold: { port: await freePort() }, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
|
|
73
|
+
profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
|
|
74
|
+
}, null, 2));
|
|
75
|
+
const env = {
|
|
76
|
+
...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, '.codex'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
|
|
77
|
+
LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: data, LLM_SWITCHER_BLINDFOLD_CERTS: path.join(data, 'certs'),
|
|
78
|
+
LLM_SWITCHER_PORT: '', PORT: '', LLM_SWITCHER_REGISTRY_URL: registryUrl
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let proc = null;
|
|
82
|
+
let log = '';
|
|
83
|
+
const pid = async () => {
|
|
84
|
+
try { return (await (await fetch(`http://127.0.0.1:${port}/health?challenge=t`)).json()).pid ?? null; } catch { return null; }
|
|
85
|
+
};
|
|
86
|
+
const fx = {
|
|
87
|
+
port,
|
|
88
|
+
log: () => log,
|
|
89
|
+
pid,
|
|
90
|
+
release(version) {
|
|
91
|
+
bumpVersion(publisher, version);
|
|
92
|
+
git(publisher, 'add', '-A');
|
|
93
|
+
git(publisher, ...ID, 'commit', '-q', '-m', `v${version}`);
|
|
94
|
+
git(publisher, 'push', '-q', 'origin', 'main');
|
|
95
|
+
},
|
|
96
|
+
async start(...extra) {
|
|
97
|
+
proc = spawn(process.execPath, [path.join(install, 'proxy.mjs'), '--port', String(port), ...extra], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
98
|
+
proc.stdout.on('data', d => { log += d; });
|
|
99
|
+
proc.stderr.on('data', d => { log += d; });
|
|
100
|
+
return proc;
|
|
101
|
+
},
|
|
102
|
+
async waitFor(check, what) {
|
|
103
|
+
for (let i = 0; i < 120; i++) {
|
|
104
|
+
const v = await check();
|
|
105
|
+
if (v) return v;
|
|
106
|
+
await sleep(250);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`timed out waiting for ${what}\n${log}`);
|
|
109
|
+
},
|
|
110
|
+
cli: (...args) => new Promise((resolve) => {
|
|
111
|
+
const c = spawn(process.execPath, [path.join(install, 'switch.mjs'), ...args, '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
112
|
+
let out = '';
|
|
113
|
+
c.stdout.on('data', d => { out += d; });
|
|
114
|
+
c.stderr.on('data', d => { out += d; });
|
|
115
|
+
c.on('close', code => resolve({ code, out }));
|
|
116
|
+
}),
|
|
117
|
+
dashboardUrl: `http://127.0.0.1:${port}/ui`,
|
|
118
|
+
api: (p, opts = {}) => fetch(`http://127.0.0.1:${port}${p}`, {
|
|
119
|
+
...opts,
|
|
120
|
+
headers: { 'x-llm-switcher-token': fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim(), 'Content-Type': 'application/json' }
|
|
121
|
+
})
|
|
122
|
+
};
|
|
123
|
+
t.after(async () => {
|
|
124
|
+
const live = await pid();
|
|
125
|
+
if (live) try { process.kill(live); } catch {}
|
|
126
|
+
proc?.kill();
|
|
127
|
+
await sleep(300);
|
|
128
|
+
fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
129
|
+
});
|
|
130
|
+
return fx;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readSse(res) {
|
|
134
|
+
const events = [];
|
|
135
|
+
for (const block of (await res.text()).split('\n\n')) {
|
|
136
|
+
const event = /^event: (.*)$/m.exec(block)?.[1];
|
|
137
|
+
if (event) events.push({ event, data: JSON.parse(/^data: (.*)$/m.exec(block)[1]) });
|
|
138
|
+
}
|
|
139
|
+
return events;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
test('POST /api/update pulls the release and a new gateway takes over the port', { timeout: 90000 }, async (t) => {
|
|
143
|
+
const fx = await fixture(t);
|
|
144
|
+
const proc = await fx.start();
|
|
145
|
+
const oldPid = await fx.waitFor(fx.pid, 'the gateway');
|
|
146
|
+
assert.equal((await (await fx.api('/api/version')).json()).current, JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version);
|
|
147
|
+
|
|
148
|
+
fx.release('9.9.9');
|
|
149
|
+
const events = await readSse(await fx.api('/api/update', { method: 'POST', body: '{}' }));
|
|
150
|
+
const result = events.find(e => e.event === 'result')?.data;
|
|
151
|
+
assert.equal(result?.updated, true, JSON.stringify(events));
|
|
152
|
+
assert.equal(result.to, '9.9.9');
|
|
153
|
+
|
|
154
|
+
await fx.waitFor(async () => { const p = await fx.pid(); return p && p !== oldPid; }, 'the new gateway');
|
|
155
|
+
assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.9');
|
|
156
|
+
// The first process stays as the parent: a service manager still sees the pid it started.
|
|
157
|
+
assert.equal(proc.exitCode, null);
|
|
158
|
+
|
|
159
|
+
const again = (await readSse(await fx.api('/api/update', { method: 'POST', body: '{}' }))).find(e => e.event === 'result')?.data;
|
|
160
|
+
assert.equal(again?.updated, false);
|
|
161
|
+
assert.match(again.reason, /latest/i);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('--autoupdate starts the newest release', { timeout: 90000 }, async (t) => {
|
|
165
|
+
const fx = await fixture(t);
|
|
166
|
+
fx.release('9.9.10');
|
|
167
|
+
await fx.start('--autoupdate');
|
|
168
|
+
await fx.waitFor(fx.pid, 'the gateway');
|
|
169
|
+
assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.10');
|
|
170
|
+
assert.match(fx.log(), /Pulled 1 commit/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('switch update asks the running gateway and waits for the new one', { timeout: 90000 }, async (t) => {
|
|
174
|
+
const fx = await fixture(t);
|
|
175
|
+
await fx.start();
|
|
176
|
+
await fx.waitFor(fx.pid, 'the gateway');
|
|
177
|
+
fx.release('9.9.11');
|
|
178
|
+
const r = await fx.cli('update');
|
|
179
|
+
assert.equal(r.code, 0, r.out);
|
|
180
|
+
assert.match(r.out, /runs v9\.9\.11/);
|
|
181
|
+
assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.11');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
|
|
185
|
+
const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
|
|
186
|
+
await fx.start();
|
|
187
|
+
await fx.waitFor(fx.pid, 'the gateway');
|
|
188
|
+
fx.release('9.9.12');
|
|
189
|
+
const browser = await launchBrowser();
|
|
190
|
+
t.after(() => browser.close());
|
|
191
|
+
const page = await browser.newPage();
|
|
192
|
+
await page.goto(fx.dashboardUrl);
|
|
193
|
+
await page.waitFor(`!document.getElementById('update-notice').hidden`, 'the update notice');
|
|
194
|
+
await page.click('#update-now');
|
|
195
|
+
await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
|
|
196
|
+
assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
|
|
197
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The update itself: a git checkout pulls fast-forward only, an npm install asks the registry.
|
|
2
|
+
// Every remote here is a local bare repository or a local HTTP server.
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { applyUpdate } from '../update.mjs';
|
|
11
|
+
|
|
12
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
13
|
+
const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false'];
|
|
14
|
+
|
|
15
|
+
function writeVersion(dir, version) {
|
|
16
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'llm-switcher', version }) + '\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function commit(dir, msg) {
|
|
20
|
+
git(dir, 'add', '-A');
|
|
21
|
+
git(dir, ...ID, 'commit', '-q', '-m', msg);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// upstream.git <- publisher (pushes releases), and install (the checkout being updated).
|
|
25
|
+
function fixture(t) {
|
|
26
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-update-'));
|
|
27
|
+
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
|
28
|
+
const upstream = path.join(base, 'upstream.git');
|
|
29
|
+
const publisher = path.join(base, 'publisher');
|
|
30
|
+
const install = path.join(base, 'install');
|
|
31
|
+
git(base, 'init', '-q', '--bare', '-b', 'main', upstream);
|
|
32
|
+
for (const dir of [publisher, install]) {
|
|
33
|
+
fs.mkdirSync(dir);
|
|
34
|
+
git(dir, 'init', '-q', '-b', 'main');
|
|
35
|
+
git(dir, 'remote', 'add', 'origin', upstream);
|
|
36
|
+
}
|
|
37
|
+
writeVersion(publisher, '1.0.0');
|
|
38
|
+
commit(publisher, 'v1.0.0');
|
|
39
|
+
git(publisher, 'push', '-q', '-u', 'origin', 'main');
|
|
40
|
+
git(install, 'fetch', '-q', 'origin');
|
|
41
|
+
git(install, 'checkout', '-q', '-b', 'main', '--track', 'origin/main');
|
|
42
|
+
const release = (version) => {
|
|
43
|
+
writeVersion(publisher, version);
|
|
44
|
+
commit(publisher, `v${version}`);
|
|
45
|
+
git(publisher, 'push', '-q', 'origin', 'main');
|
|
46
|
+
};
|
|
47
|
+
return { install, release, head: () => git(install, 'rev-parse', 'HEAD') };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('a checkout behind its upstream pulls and reports the new version', async (t) => {
|
|
51
|
+
const fx = fixture(t);
|
|
52
|
+
fx.release('1.0.1');
|
|
53
|
+
const r = await applyUpdate({ root: fx.install });
|
|
54
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
55
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(fx.install, 'package.json'), 'utf8')).version, '1.0.1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a checkout level with its upstream does nothing', async (t) => {
|
|
59
|
+
const fx = fixture(t);
|
|
60
|
+
const r = await applyUpdate({ root: fx.install });
|
|
61
|
+
assert.equal(r.updated, false);
|
|
62
|
+
assert.match(r.reason, /latest/i);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a checkout with local edits is left alone, even when upstream moved', async (t) => {
|
|
66
|
+
const fx = fixture(t);
|
|
67
|
+
fx.release('1.0.1');
|
|
68
|
+
const before = fx.head();
|
|
69
|
+
fs.appendFileSync(path.join(fx.install, 'package.json'), ' ');
|
|
70
|
+
const r = await applyUpdate({ root: fx.install });
|
|
71
|
+
assert.equal(r.updated, false);
|
|
72
|
+
assert.match(r.reason, /local changes/i);
|
|
73
|
+
assert.equal(fx.head(), before);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a checkout that is only ahead of upstream does nothing', async (t) => {
|
|
77
|
+
const fx = fixture(t);
|
|
78
|
+
fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
|
|
79
|
+
commit(fx.install, 'local work');
|
|
80
|
+
const r = await applyUpdate({ root: fx.install });
|
|
81
|
+
assert.equal(r.updated, false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a diverged checkout fails and is never merged', async (t) => {
|
|
85
|
+
const fx = fixture(t);
|
|
86
|
+
fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
|
|
87
|
+
commit(fx.install, 'local work');
|
|
88
|
+
fx.release('1.0.1');
|
|
89
|
+
const before = fx.head();
|
|
90
|
+
await assert.rejects(applyUpdate({ root: fx.install }), /fast-forward|diverged/i);
|
|
91
|
+
assert.equal(fx.head(), before);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function registry(t, version) {
|
|
95
|
+
const srv = http.createServer((req, res) => {
|
|
96
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({ version }));
|
|
98
|
+
});
|
|
99
|
+
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
|
100
|
+
t.after(() => srv.close());
|
|
101
|
+
return `http://127.0.0.1:${srv.address().port}/`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function npmInstall(t, version) {
|
|
105
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-npm-'));
|
|
106
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
107
|
+
writeVersion(root, version);
|
|
108
|
+
return root;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('an npm install on the latest version runs no install', async (t) => {
|
|
112
|
+
const root = npmInstall(t, '1.0.0');
|
|
113
|
+
const calls = [];
|
|
114
|
+
const r = await applyUpdate({ root, registryUrl: await registry(t, '1.0.0'), run: async (...a) => { calls.push(a); return ''; } });
|
|
115
|
+
assert.equal(r.updated, false);
|
|
116
|
+
assert.deepEqual(calls, []);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('an npm install behind the registry installs that exact version globally', async (t) => {
|
|
120
|
+
const root = npmInstall(t, '1.0.0');
|
|
121
|
+
const calls = [];
|
|
122
|
+
const run = async (cmd, args) => { calls.push([cmd, ...args]); writeVersion(root, '1.0.1'); return ''; };
|
|
123
|
+
const r = await applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run });
|
|
124
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
125
|
+
assert.equal(calls.length, 1);
|
|
126
|
+
assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// launchd and systemd --user start the gateway with a short PATH that has no Homebrew or nvm npm.
|
|
130
|
+
test('npm runs with the directory of the running node first on PATH', async (t) => {
|
|
131
|
+
const root = npmInstall(t, '1.0.0');
|
|
132
|
+
let env = null;
|
|
133
|
+
const run = async (cmd, args, opts) => { env = opts.env; writeVersion(root, '1.0.1'); return ''; };
|
|
134
|
+
await applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run });
|
|
135
|
+
const keys = Object.keys(env).filter(k => k.toUpperCase() === 'PATH');
|
|
136
|
+
assert.equal(keys.length, 1, `one PATH key, got ${keys.join(', ')}`);
|
|
137
|
+
assert.equal(env[keys[0]].split(path.delimiter)[0], path.dirname(process.execPath));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('an unreachable registry is not an update', async (t) => {
|
|
141
|
+
const root = npmInstall(t, '1.0.0');
|
|
142
|
+
const r = await applyUpdate({ root, registryUrl: 'http://127.0.0.1:9/' });
|
|
143
|
+
assert.equal(r.updated, false);
|
|
144
|
+
assert.match(r.reason, /registry/i);
|
|
145
|
+
});
|
package/ui.html
CHANGED
|
@@ -184,6 +184,8 @@
|
|
|
184
184
|
.update-notice[hidden] { display: none; }
|
|
185
185
|
.update-notice b { color: var(--warn); font-weight: 600; }
|
|
186
186
|
.update-notice code { font-family: var(--font-mono); font-size: 0.72rem; overflow-wrap: anywhere; color: var(--muted); }
|
|
187
|
+
.update-notice .update-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
188
|
+
.update-notice #update-status { color: var(--muted); overflow-wrap: anywhere; }
|
|
187
189
|
|
|
188
190
|
aside nav { display: grid; gap: 0.25rem; }
|
|
189
191
|
aside a.nav-link {
|
|
@@ -886,7 +888,11 @@
|
|
|
886
888
|
<div id="update-notice" class="update-notice" role="status" hidden>
|
|
887
889
|
<b>Update available: v<span id="update-latest"></span></b>
|
|
888
890
|
<code id="update-command"></code>
|
|
889
|
-
<
|
|
891
|
+
<div class="update-actions">
|
|
892
|
+
<button type="button" id="update-now" class="btn btn-primary btn-sm" onclick="runUpdate()">Update now</button>
|
|
893
|
+
<button type="button" class="btn btn-outline btn-sm" onclick="copyUpdateCommand()">Copy command</button>
|
|
894
|
+
</div>
|
|
895
|
+
<span id="update-status" aria-live="polite" hidden></span>
|
|
890
896
|
</div>
|
|
891
897
|
|
|
892
898
|
<nav id="sideNav">
|
|
@@ -2678,6 +2684,69 @@
|
|
|
2678
2684
|
navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd), () => showToast('Copy failed. Select the command and copy it by hand.', true));
|
|
2679
2685
|
}
|
|
2680
2686
|
|
|
2687
|
+
async function gatewayPid() {
|
|
2688
|
+
try {
|
|
2689
|
+
return (await (await fetch('/health?challenge=ui', { cache: 'no-store' })).json()).pid ?? null;
|
|
2690
|
+
} catch {
|
|
2691
|
+
return null;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// The gateway streams the update as SSE, then hands over to the new code. A new pid on /health
|
|
2696
|
+
// means the new gateway answers; the reload then loads the new dashboard too.
|
|
2697
|
+
async function runUpdate() {
|
|
2698
|
+
const btn = document.getElementById('update-now');
|
|
2699
|
+
const status = document.getElementById('update-status');
|
|
2700
|
+
const say = (text) => { status.textContent = text; status.hidden = false; };
|
|
2701
|
+
const reset = () => { btn.disabled = false; btn.textContent = 'Update now'; };
|
|
2702
|
+
btn.disabled = true;
|
|
2703
|
+
btn.textContent = 'Updating...';
|
|
2704
|
+
say('Checking for the new release...');
|
|
2705
|
+
try {
|
|
2706
|
+
const before = await gatewayPid();
|
|
2707
|
+
const res = await api('/api/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
2708
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'HTTP ' + res.status);
|
|
2709
|
+
let result = null, failed = null, buf = '';
|
|
2710
|
+
const reader = res.body.getReader();
|
|
2711
|
+
const decoder = new TextDecoder();
|
|
2712
|
+
for (;;) {
|
|
2713
|
+
const { done, value } = await reader.read();
|
|
2714
|
+
if (done) break;
|
|
2715
|
+
buf += decoder.decode(value, { stream: true });
|
|
2716
|
+
let i;
|
|
2717
|
+
while ((i = buf.indexOf('\n\n')) !== -1) {
|
|
2718
|
+
const block = buf.slice(0, i);
|
|
2719
|
+
buf = buf.slice(i + 2);
|
|
2720
|
+
const event = /^event: (.*)$/m.exec(block)?.[1];
|
|
2721
|
+
const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] || 'null');
|
|
2722
|
+
if (event === 'log') say(data.text);
|
|
2723
|
+
else if (event === 'result') result = data;
|
|
2724
|
+
else if (event === 'error') failed = data.message;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
if (failed || !result) throw new Error(failed || 'The gateway closed the update without a result.');
|
|
2728
|
+
if (!result.updated) {
|
|
2729
|
+
say(result.reason);
|
|
2730
|
+
reset();
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
say('Installed v' + result.to + '. Restarting the gateway...');
|
|
2734
|
+
for (let n = 0; n < 60; n++) {
|
|
2735
|
+
await new Promise(r => setTimeout(r, 500));
|
|
2736
|
+
const pid = await gatewayPid();
|
|
2737
|
+
if (pid && pid !== before) {
|
|
2738
|
+
location.reload();
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
throw new Error('The new gateway did not answer. Read proxy.log.');
|
|
2743
|
+
} catch (err) {
|
|
2744
|
+
say(err.message);
|
|
2745
|
+
showToast('Update failed: ' + err.message, true);
|
|
2746
|
+
reset();
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2681
2750
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2682
2751
|
// Setup initial theme buttons state
|
|
2683
2752
|
const currentTheme = document.documentElement.dataset.theme || 'dark';
|
package/update.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Brings this install to the newest release: a git checkout pulls fast-forward only, an npm install
|
|
2
|
+
// asks the registry and installs that exact version. Restarting the gateway is the caller's job.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { isNewer } from './version.mjs';
|
|
10
|
+
|
|
11
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/llm-switcher/latest';
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
|
|
15
|
+
// Async on purpose: the gateway runs this in-process, and a blocking install would stall every request.
|
|
16
|
+
async function runCommand(cmd, args, opts = {}) {
|
|
17
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
timeout: 120000,
|
|
21
|
+
...opts,
|
|
22
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...opts.env }
|
|
23
|
+
});
|
|
24
|
+
return stdout;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readVersion(root) {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version || '0.0.0'; } catch { return '0.0.0'; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// launchd and systemd --user give the gateway a short PATH without a Homebrew or nvm npm. The npm
|
|
32
|
+
// next to the running node is the right one, and its launcher finds node on the same PATH.
|
|
33
|
+
function envWithNodeFirst(env = process.env) {
|
|
34
|
+
const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') || 'PATH';
|
|
35
|
+
return { ...env, [key]: [path.dirname(process.execPath), env[key]].filter(Boolean).join(path.delimiter) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const failure = (what, err) => new Error(`${what} failed: ${String(err.stderr || err.message).trim()}`);
|
|
39
|
+
const noUpdate = (from, reason) => ({ updated: false, from, to: from, reason });
|
|
40
|
+
|
|
41
|
+
async function updateCheckout(root, run, log) {
|
|
42
|
+
const git = (...args) => run('git', ['-C', root, ...args]);
|
|
43
|
+
const from = readVersion(root);
|
|
44
|
+
// A pull into edited files would mix someone's work in progress with the release.
|
|
45
|
+
if ((await git('status', '--porcelain', '--untracked-files=no')).trim()) {
|
|
46
|
+
return noUpdate(from, 'The checkout has local changes. Commit or stash them, then update.');
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await git('rev-parse', '--abbrev-ref', '@{u}');
|
|
50
|
+
} catch {
|
|
51
|
+
return noUpdate(from, 'The current branch has no upstream branch to update from.');
|
|
52
|
+
}
|
|
53
|
+
log('Fetching the upstream branch...');
|
|
54
|
+
try { await git('fetch', '--quiet'); } catch (err) { throw failure('git fetch', err); }
|
|
55
|
+
const behind = Number((await git('rev-list', '--count', 'HEAD..@{u}')).trim());
|
|
56
|
+
if (!behind) return noUpdate(from, `Already on the latest version (v${from}).`);
|
|
57
|
+
log(`Pulling ${behind} new commit(s)...`);
|
|
58
|
+
// --ff-only: a branch with local commits fails here instead of getting a merge commit.
|
|
59
|
+
try { await git('pull', '--ff-only', '--quiet'); } catch (err) { throw failure('git pull --ff-only', err); }
|
|
60
|
+
return { updated: true, from, to: readVersion(root), reason: `Pulled ${behind} commit(s).` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function updateNpmInstall(root, run, log, registryUrl) {
|
|
64
|
+
const from = readVersion(root);
|
|
65
|
+
let latest = null;
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(registryUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
|
|
68
|
+
if (r.ok) latest = (await r.json())?.version ?? null;
|
|
69
|
+
} catch {}
|
|
70
|
+
if (!latest) return noUpdate(from, 'Could not reach the npm registry.');
|
|
71
|
+
if (!isNewer(latest, from)) return noUpdate(from, `Already on the latest version (v${from}).`);
|
|
72
|
+
log(`Installing llm-switcher@${latest} from npm...`);
|
|
73
|
+
// npm.cmd is a batch file, and Node runs one only through cmd.exe. The cwd is the home directory,
|
|
74
|
+
// because Windows cannot replace a package directory that a process holds as its cwd.
|
|
75
|
+
const npmArgs = ['install', '-g', `llm-switcher@${latest}`];
|
|
76
|
+
const [cmd, args] = process.platform === 'win32' ? ['cmd.exe', ['/d', '/s', '/c', 'npm.cmd', ...npmArgs]] : ['npm', npmArgs];
|
|
77
|
+
try {
|
|
78
|
+
await run(cmd, args, { cwd: os.homedir(), timeout: 300000, env: envWithNodeFirst() });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw failure('npm install', err);
|
|
81
|
+
}
|
|
82
|
+
const to = readVersion(root);
|
|
83
|
+
// npm can install into another prefix than the one this gateway runs from; then nothing changed here.
|
|
84
|
+
if (to === from) return noUpdate(from, `npm installed v${latest}, but not into ${root}.`);
|
|
85
|
+
return { updated: true, from, to, reason: `Installed v${to} from npm.` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Returns { updated, from, to, reason }. Throws only when an update started and failed. */
|
|
89
|
+
export async function applyUpdate({
|
|
90
|
+
root = ROOT,
|
|
91
|
+
run = runCommand,
|
|
92
|
+
logger = () => {},
|
|
93
|
+
registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL
|
|
94
|
+
} = {}) {
|
|
95
|
+
return fs.existsSync(path.join(root, '.git'))
|
|
96
|
+
? updateCheckout(root, run, logger)
|
|
97
|
+
: updateNpmInstall(root, run, logger, registryUrl);
|
|
98
|
+
}
|