llm-switcher 1.3.6 → 1.3.8
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 +9 -0
- package/package.json +1 -1
- package/service.mjs +6 -3
- package/tests/service.test.mjs +6 -4
- package/tests/update.test.mjs +16 -0
- package/tests/version.test.mjs +15 -0
- package/update.mjs +13 -5
- package/version.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.3.8
|
|
4
|
+
|
|
5
|
+
- **Update now with the new code already on disk:** The update compared the version on disk, not the version of the running gateway. When the checkout or the npm install already had the new release, the update said "Already on the latest version" and did not restart. The gateway then ran the old code and showed the update notice again and again. The gateway now restarts into the newer code on disk.
|
|
6
|
+
- **Windows service without a window:** The logon task started `node.exe` directly. `node.exe` is a console program, so Windows opened a terminal window for the gateway, and when you closed that window the gateway stopped. The task now starts the gateway through `conhost.exe --headless`, and no window opens. Run `switch service install` again to update an installed task.
|
|
7
|
+
|
|
8
|
+
## Release 1.3.7
|
|
9
|
+
|
|
10
|
+
- **Update notice in time:** The gateway kept the npm answer for 12 hours. A release published after the last check did not show an update button until the next day. It now asks npm again after 5 minutes.
|
|
11
|
+
|
|
3
12
|
## Release 1.3.6
|
|
4
13
|
|
|
5
14
|
- **Dashboard from a bookmark:** In 1.3.4 the tab kept the token in `sessionStorage`. A bookmark, a typed URL or a new tab then had no token: every call got HTTP 401, and the dashboard showed no profiles and every route as OFF. The token is now in `localStorage`, so after one `switch ui` every tab of that browser works. The page still does not carry the token.
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -66,8 +66,11 @@ ${envBlock} <key>StandardOutPath</key>
|
|
|
66
66
|
// Task Scheduler reads the command and its arguments from two elements, so no path goes through
|
|
67
67
|
// the quoting rules of a /TR command line. A task made with /TR also stops after 72 hours by
|
|
68
68
|
// default; PT0S removes that limit.
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// node.exe is a console program: started directly at logon it opens a terminal window, and closing that
|
|
70
|
+
// window stops the gateway. conhost --headless gives it a console that nobody sees.
|
|
71
|
+
export function scheduledTaskXml({ nodeBin, script, port, userId, autoupdate = false, systemRoot = process.env.SystemRoot || 'C:\\Windows' }) {
|
|
72
|
+
const conhost = path.win32.join(systemRoot, 'System32', 'conhost.exe');
|
|
73
|
+
const args = `--headless "${nodeBin}" "${script}" --port ${port}${autoupdate ? ' --autoupdate' : ''}`;
|
|
71
74
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
72
75
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
73
76
|
<RegistrationInfo>
|
|
@@ -95,7 +98,7 @@ export function scheduledTaskXml({ nodeBin, script, port, userId, autoupdate = f
|
|
|
95
98
|
</Settings>
|
|
96
99
|
<Actions Context="Author">
|
|
97
100
|
<Exec>
|
|
98
|
-
<Command>${xmlEscape(
|
|
101
|
+
<Command>${xmlEscape(conhost)}</Command>
|
|
99
102
|
<Arguments>${xmlEscape(args)}</Arguments>
|
|
100
103
|
</Exec>
|
|
101
104
|
</Actions>
|
package/tests/service.test.mjs
CHANGED
|
@@ -28,7 +28,7 @@ test('systemd unit quotes the command and carries the environment', () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const plist = () => launchdPlist({ nodeBin: '/n', script: '/a&b/proxy.mjs', port: 4001, logPath: '/l<og>', env: [['CLAUDE_CONFIG_DIR', '/x&y']] });
|
|
31
|
-
const taskXml = () => scheduledTaskXml({ nodeBin: 'C:\\Program Files\\nodejs\\node.exe', script: 'C:\\Users\\A & B\\llm-switcher\\proxy.mjs', port: 4002, userId: 'PC\\a&b' });
|
|
31
|
+
const taskXml = () => scheduledTaskXml({ nodeBin: 'C:\\Program Files\\nodejs\\node.exe', script: 'C:\\Users\\A & B\\llm-switcher\\proxy.mjs', port: 4002, userId: 'PC\\a&b', systemRoot: 'C:\\Windows' });
|
|
32
32
|
|
|
33
33
|
test('the plist and the task XML are well-formed', { skip: !HAS_XMLLINT && 'needs xmllint' }, () => {
|
|
34
34
|
execFileSync('xmllint', ['--noout', '-'], { input: plist() });
|
|
@@ -43,8 +43,10 @@ test('launchd plist escapes values and carries the environment', () => {
|
|
|
43
43
|
|
|
44
44
|
test('Windows task XML keeps the command and its arguments apart and has no run-time limit', () => {
|
|
45
45
|
const xml = taskXml();
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// node.exe is a console program: started directly at logon it opens a terminal window, and closing
|
|
47
|
+
// that window stops the gateway. conhost --headless gives it a console that nobody sees.
|
|
48
|
+
assert.match(xml, /<Command>C:\\Windows\\System32\\conhost.exe<\/Command>/);
|
|
49
|
+
assert.match(xml, /<Arguments>--headless "C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\A & B\\llm-switcher\\proxy.mjs" --port 4002<\/Arguments>/);
|
|
48
50
|
assert.match(xml, /<ExecutionTimeLimit>PT0S<\/ExecutionTimeLimit>/);
|
|
49
51
|
assert.match(xml, /<LogonTrigger>\s*<Enabled>true<\/Enabled>\s*<UserId>PC\\a&b<\/UserId>/);
|
|
50
52
|
assert.equal(portFromServiceText(xml), 4002);
|
|
@@ -60,7 +62,7 @@ test('the port is read back from UTF-16 console output, as schtasks /Query /XML
|
|
|
60
62
|
|
|
61
63
|
test('autoupdate flag is placed into task XML, systemd unit, and launchd plist', () => {
|
|
62
64
|
const xml = scheduledTaskXml({ nodeBin: 'node.exe', script: 'proxy.mjs', port: 3456, userId: 'u', autoupdate: true });
|
|
63
|
-
assert.match(xml, /<Arguments
|
|
65
|
+
assert.match(xml, /<Arguments>--headless "node\.exe" "proxy\.mjs" --port 3456 --autoupdate<\/Arguments>/);
|
|
64
66
|
assert.equal(portFromServiceText(xml), 3456);
|
|
65
67
|
|
|
66
68
|
const unit = systemdUnit({ nodeBin: '/usr/bin/node', script: '/opt/proxy.mjs', port: 3456, autoupdate: true });
|
package/tests/update.test.mjs
CHANGED
|
@@ -62,6 +62,14 @@ test('a checkout level with its upstream does nothing', async (t) => {
|
|
|
62
62
|
assert.match(r.reason, /latest/i);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test('a gateway that runs older code than its checkout restarts into it, with nothing to pull', async (t) => {
|
|
66
|
+
const fx = fixture(t);
|
|
67
|
+
fx.release('1.0.1');
|
|
68
|
+
git(fx.install, 'pull', '-q', '--ff-only');
|
|
69
|
+
const r = await applyUpdate({ root: fx.install, running: '1.0.0' });
|
|
70
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
71
|
+
});
|
|
72
|
+
|
|
65
73
|
test('a checkout with local edits is left alone, even when upstream moved', async (t) => {
|
|
66
74
|
const fx = fixture(t);
|
|
67
75
|
fx.release('1.0.1');
|
|
@@ -116,6 +124,14 @@ test('an npm install on the latest version runs no install', async (t) => {
|
|
|
116
124
|
assert.deepEqual(calls, []);
|
|
117
125
|
});
|
|
118
126
|
|
|
127
|
+
test('an npm install that a running gateway has not loaded yet restarts into it, with no install', async (t) => {
|
|
128
|
+
const root = npmInstall(t, '1.0.1');
|
|
129
|
+
const calls = [];
|
|
130
|
+
const r = await applyUpdate({ root, running: '1.0.0', registryUrl: await registry(t, '1.0.1'), run: async (...a) => { calls.push(a); return ''; } });
|
|
131
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
132
|
+
assert.deepEqual(calls, []);
|
|
133
|
+
});
|
|
134
|
+
|
|
119
135
|
test('an npm install behind the registry installs that exact version globally', async (t) => {
|
|
120
136
|
const root = npmInstall(t, '1.0.0');
|
|
121
137
|
const calls = [];
|
package/tests/version.test.mjs
CHANGED
|
@@ -63,6 +63,21 @@ test('the same version is no update; a stale cache asks the registry again', asy
|
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
test('a release published after the last check shows within 5 minutes, not after 12 hours', async () => {
|
|
67
|
+
const reg = await registry((req, res) => res.end(JSON.stringify({ version: '99.0.0' })));
|
|
68
|
+
const stateDir = tmpState();
|
|
69
|
+
try {
|
|
70
|
+
const answeredBeforeTheRelease = { checkedAt: Date.now() - 6 * 60 * 1000, latest: CURRENT_VERSION };
|
|
71
|
+
fs.writeFileSync(path.join(stateDir, 'version-check.json'), JSON.stringify(answeredBeforeTheRelease));
|
|
72
|
+
const r = await checkForUpdate({ stateDir, url: reg.url });
|
|
73
|
+
assert.equal(reg.hits(), 1, 'a 6-minute-old answer is asked again');
|
|
74
|
+
assert.equal(r.updateAvailable, true);
|
|
75
|
+
} finally {
|
|
76
|
+
reg.close();
|
|
77
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
66
81
|
test('an unreachable or broken registry gives no notice and never throws', async () => {
|
|
67
82
|
const reg = await registry((req, res) => { res.statusCode = 500; res.end('down'); });
|
|
68
83
|
const stateDir = tmpState();
|
package/update.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
-
import { isNewer } from './version.mjs';
|
|
9
|
+
import { isNewer, CURRENT_VERSION } from './version.mjs';
|
|
10
10
|
|
|
11
11
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const REGISTRY_URL = 'https://registry.npmjs.org/llm-switcher/latest';
|
|
@@ -90,9 +90,17 @@ export async function applyUpdate({
|
|
|
90
90
|
root = ROOT,
|
|
91
91
|
run = runCommand,
|
|
92
92
|
logger = () => {},
|
|
93
|
-
registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL
|
|
93
|
+
registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL,
|
|
94
|
+
running = CURRENT_VERSION
|
|
94
95
|
} = {}) {
|
|
95
|
-
|
|
96
|
-
? updateCheckout(root, run, logger)
|
|
97
|
-
: updateNpmInstall(root, run, logger, registryUrl);
|
|
96
|
+
const r = fs.existsSync(path.join(root, '.git'))
|
|
97
|
+
? await updateCheckout(root, run, logger)
|
|
98
|
+
: await updateNpmInstall(root, run, logger, registryUrl);
|
|
99
|
+
// Newer code already on disk (a pull or an install outside the gateway) is an update for the
|
|
100
|
+
// running process: without the restart it keeps the old code and the old version forever.
|
|
101
|
+
const onDisk = readVersion(root);
|
|
102
|
+
if (!r.updated && isNewer(onDisk, running)) {
|
|
103
|
+
return { updated: true, from: running, to: onDisk, reason: `v${onDisk} is on disk, but the gateway ran v${running}.` };
|
|
104
|
+
}
|
|
105
|
+
return r;
|
|
98
106
|
}
|
package/version.mjs
CHANGED
|
@@ -11,7 +11,8 @@ export const CURRENT_VERSION = (() => {
|
|
|
11
11
|
|
|
12
12
|
const REGISTRY_URL = 'https://registry.npmjs.org/llm-switcher/latest';
|
|
13
13
|
const CACHE_FILE = 'version-check.json';
|
|
14
|
-
|
|
14
|
+
// A release shows on the dashboard within minutes; one small GET per 5 minutes.
|
|
15
|
+
const MAX_AGE_MS = 5 * 60 * 1000;
|
|
15
16
|
|
|
16
17
|
const parse = (v) => {
|
|
17
18
|
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? ''));
|
|
@@ -30,7 +31,7 @@ export function isNewer(latest, current) {
|
|
|
30
31
|
const updateCommand = () => (fs.existsSync(path.join(ROOT, '.git')) ? `git -C "${ROOT}" pull` : 'npm install -g llm-switcher@latest');
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Asks the registry at most once per
|
|
34
|
+
* Asks the registry at most once per 5 minutes; a failed ask also waits, so an offline machine is not
|
|
34
35
|
* slowed on every call. Never throws: without an answer there is simply no notice.
|
|
35
36
|
*/
|
|
36
37
|
export async function checkForUpdate({ stateDir, url = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL } = {}) {
|