llm-switcher 1.2.11 → 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 +8 -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 +56 -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/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,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
|
+
}
|