llm-switcher 1.4.0 → 1.4.1
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 +2 -3
- package/README.vi.md +2 -3
- package/package.json +1 -1
- package/tests/update.test.mjs +92 -9
- package/update.mjs +29 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.4.1 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **The update comes from npm, because that is where a release is published.** The gateway asked npm what the newest version is and ran `npm install -g llm-switcher@<version>` for exactly that version. Before, the install decided: a directory with a `.git` pulled and never asked npm, and the dashboard's Update now button read as a git operation on a project that ships to npm.
|
|
6
|
+
- **A git checkout is the one install npm cannot serve, and it says so.** npm writes into the global prefix while the running code is the checkout itself, so the install lands somewhere nothing runs. The checkout is then asked, and it pulls fast-forward only. When npm held a release the checkout could not take, the notice names that version and what the checkout has, because "already the latest version" on its own would hide the one version the reader does not have.
|
|
7
|
+
- **A failed npm install no longer fails the update of a checkout.** A checkout used to update without npm in the way. Asking npm first put an install between the reader and a pull, and any failure of that install -- a version the registry does not serve, a network, a read-only prefix -- ended the update even though the checkout could still have moved. Now a checkout reports what npm did and goes on to pull; an npm install with no checkout still fails loudly, because there is nothing else to try.
|
|
8
|
+
|
|
9
|
+
Tests: 17 in `tests/update.test.mjs`, plus the rest of the suite.
|
|
10
|
+
|
|
3
11
|
## Release 1.4.0 — 2026-10-07
|
|
4
12
|
|
|
5
13
|
- **A model answers with the tool names it was trained on, and the caller only runs its own.** A Codex model says `shell` where Claude Code declared `Bash`, and the turn ends with `Error: No such tool available: shell`. The gateway now writes the call back in the vocabulary the caller declared, and says the names in the prompt so most calls never need rewriting.
|
package/README.md
CHANGED
|
@@ -554,10 +554,9 @@ On Linux the unit is a systemd *user* service: it starts when you log in. To sta
|
|
|
554
554
|
|
|
555
555
|
When npm has a newer release, the dashboard shows a notice. Click **Update now** in this notice. The gateway installs the release and starts the new code. Then the dashboard reloads. `switch update` does the same from a terminal.
|
|
556
556
|
|
|
557
|
-
The gateway installs a release
|
|
557
|
+
The gateway installs a release from npm, because that is where a release is published. It asks the registry for the newest version and runs `npm install -g llm-switcher@<version>`, then restarts into it.
|
|
558
558
|
|
|
559
|
-
|
|
560
|
-
- An npm install runs `npm install -g llm-switcher@<latest>`.
|
|
559
|
+
A git checkout is the one install npm cannot serve: npm writes into the global prefix while the running code is the checkout itself. So when npm did not move the directory the gateway runs from, and that directory is a checkout, the checkout is asked next, and it runs `git pull --ff-only`. If the checkout has local changes, or commits that are not on the upstream branch, the update stops and changes nothing. When npm held a release the checkout could not take, the notice says both: which version npm has, and that the checkout has nothing.
|
|
561
560
|
|
|
562
561
|
Requests that are open on the gateway when the update starts finish on the old code. New requests go to the new code. The Codex interceptor stops, and the new gateway starts it again. Thus a Codex request that is open at that moment fails one time. The old process stays as the parent of the new process until the gateway stops. Thus a service manager keeps the process that it started.
|
|
563
562
|
|
package/README.vi.md
CHANGED
|
@@ -552,10 +552,9 @@ Trên Linux, unit là service systemd *của user*: nó khởi động khi bạn
|
|
|
552
552
|
|
|
553
553
|
Khi npm có bản mới, dashboard hiện một thông báo. Bấm **Update now** trong thông báo này. Gateway cài bản mới và chạy code mới. Sau đó dashboard tự tải lại. `switch update` làm việc tương tự từ terminal.
|
|
554
554
|
|
|
555
|
-
Gateway cài bản mới
|
|
555
|
+
Gateway cài bản mới từ npm, vì đó là nơi bản phát hành được đăng. Nó hỏi registry lấy số phiên bản mới nhất rồi chạy `npm install -g llm-switcher@<phiên bản>`, sau đó khởi động lại vào bản đó.
|
|
556
556
|
|
|
557
|
-
|
|
558
|
-
- Bản cài bằng npm chạy `npm install -g llm-switcher@<latest>`.
|
|
557
|
+
Bản git checkout là thứ duy nhất npm không phục vụ được: npm ghi vào global prefix, trong khi code đang chạy chính là thư mục checkout. Nên khi npm không dịch chuyển được thư mục mà gateway đang chạy, và thư mục đó là một checkout, gateway hỏi tiếp checkout, và checkout chạy `git pull --ff-only`. Nếu checkout có thay đổi cục bộ, hoặc có commit không nằm trên nhánh upstream, việc cập nhật dừng lại và không đổi gì. Khi npm có bản mới mà checkout không lấy được, thông báo nói cả hai: npm đang có số phiên bản nào, và checkout không có gì để cập nhật.
|
|
559
558
|
|
|
560
559
|
Các request đang chạy trên gateway lúc cập nhật sẽ chạy xong trên code cũ. Request mới đi vào code mới. Interceptor của Codex dừng lại, và gateway mới khởi động nó lại. Vì vậy một request Codex đang chạy đúng lúc đó sẽ lỗi một lần. Tiến trình cũ ở lại làm tiến trình cha của tiến trình mới cho tới khi gateway dừng. Nhờ vậy service manager vẫn giữ đúng tiến trình mà nó đã khởi động.
|
|
561
560
|
|
package/package.json
CHANGED
package/tests/update.test.mjs
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
// The update itself:
|
|
2
|
-
// Every remote here is a local bare
|
|
1
|
+
// The update itself: npm is asked first and installs the version the registry names; a git
|
|
2
|
+
// checkout is the fallback, and pulls fast-forward only. Every remote here is a local bare
|
|
3
|
+
// repository or a local HTTP server.
|
|
3
4
|
import { test } from 'node:test';
|
|
4
5
|
import assert from 'node:assert/strict';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import http from 'node:http';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
|
-
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
10
12
|
import { applyUpdate } from '../update.mjs';
|
|
11
13
|
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
// Records every command in the order it is asked. git runs for real, because the checkout has to
|
|
17
|
+
// move before the fallback can be observed. npm's install is recorded and not run: the version a
|
|
18
|
+
// fixture registry names is a number from the test, not one the real registry serves, and a real
|
|
19
|
+
// install would put a package on the machine running the suite.
|
|
20
|
+
const recording = (calls) => async (cmd, args, opts) => {
|
|
21
|
+
calls.push([cmd, ...args]);
|
|
22
|
+
if (cmd === 'npm' || cmd === 'cmd.exe') return '';
|
|
23
|
+
const { stdout } = await execFileAsync(cmd, args, { encoding: 'utf8', ...opts });
|
|
24
|
+
return stdout;
|
|
25
|
+
};
|
|
26
|
+
|
|
12
27
|
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
13
28
|
const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false'];
|
|
14
29
|
|
|
@@ -50,14 +65,14 @@ function fixture(t) {
|
|
|
50
65
|
test('a checkout behind its upstream pulls and reports the new version', async (t) => {
|
|
51
66
|
const fx = fixture(t);
|
|
52
67
|
fx.release('1.0.1');
|
|
53
|
-
const r = await applyUpdate({ root: fx.install });
|
|
68
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
54
69
|
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
55
70
|
assert.equal(JSON.parse(fs.readFileSync(path.join(fx.install, 'package.json'), 'utf8')).version, '1.0.1');
|
|
56
71
|
});
|
|
57
72
|
|
|
58
73
|
test('a checkout level with its upstream does nothing', async (t) => {
|
|
59
74
|
const fx = fixture(t);
|
|
60
|
-
const r = await applyUpdate({ root: fx.install });
|
|
75
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
61
76
|
assert.equal(r.updated, false);
|
|
62
77
|
assert.match(r.reason, /latest/i);
|
|
63
78
|
});
|
|
@@ -66,7 +81,7 @@ test('a gateway that runs older code than its checkout restarts into it, with no
|
|
|
66
81
|
const fx = fixture(t);
|
|
67
82
|
fx.release('1.0.1');
|
|
68
83
|
git(fx.install, 'pull', '-q', '--ff-only');
|
|
69
|
-
const r = await applyUpdate({ root: fx.install, running: '1.0.0' });
|
|
84
|
+
const r = await applyUpdate({ root: fx.install, running: '1.0.0', registryUrl: await registry(t, '1.0.1') });
|
|
70
85
|
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
71
86
|
});
|
|
72
87
|
|
|
@@ -75,7 +90,7 @@ test('a checkout with local edits is left alone, even when upstream moved', asyn
|
|
|
75
90
|
fx.release('1.0.1');
|
|
76
91
|
const before = fx.head();
|
|
77
92
|
fs.appendFileSync(path.join(fx.install, 'package.json'), ' ');
|
|
78
|
-
const r = await applyUpdate({ root: fx.install });
|
|
93
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
79
94
|
assert.equal(r.updated, false);
|
|
80
95
|
assert.match(r.reason, /local changes/i);
|
|
81
96
|
assert.equal(fx.head(), before);
|
|
@@ -85,7 +100,7 @@ test('a checkout that is only ahead of upstream does nothing', async (t) => {
|
|
|
85
100
|
const fx = fixture(t);
|
|
86
101
|
fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
|
|
87
102
|
commit(fx.install, 'local work');
|
|
88
|
-
const r = await applyUpdate({ root: fx.install });
|
|
103
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
89
104
|
assert.equal(r.updated, false);
|
|
90
105
|
});
|
|
91
106
|
|
|
@@ -95,7 +110,7 @@ test('a diverged checkout fails and is never merged', async (t) => {
|
|
|
95
110
|
commit(fx.install, 'local work');
|
|
96
111
|
fx.release('1.0.1');
|
|
97
112
|
const before = fx.head();
|
|
98
|
-
await assert.rejects(applyUpdate({ root: fx.install }), /fast-forward|diverged/i);
|
|
113
|
+
await assert.rejects(applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') }), /fast-forward|diverged/i);
|
|
99
114
|
assert.equal(fx.head(), before);
|
|
100
115
|
});
|
|
101
116
|
|
|
@@ -159,3 +174,71 @@ test('an unreachable registry is not an update', async (t) => {
|
|
|
159
174
|
assert.equal(r.updated, false);
|
|
160
175
|
assert.match(r.reason, /registry/i);
|
|
161
176
|
});
|
|
177
|
+
|
|
178
|
+
// ---- npm first, and the checkout only when npm cannot serve it ----
|
|
179
|
+
|
|
180
|
+
test('a release on npm is installed before the checkout is asked at all', async (t) => {
|
|
181
|
+
const fx = fixture(t);
|
|
182
|
+
fx.release('1.0.1');
|
|
183
|
+
const calls = [];
|
|
184
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
|
|
185
|
+
|
|
186
|
+
const order = calls.map(c => c[0]);
|
|
187
|
+
assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
|
|
188
|
+
assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
|
|
189
|
+
// npm put the release in the global prefix, which is not this directory, so the checkout is what
|
|
190
|
+
// moved -- and that is the version this gateway runs, which is the only reason the pull happened.
|
|
191
|
+
assert.match(r.reason, /Pulled 1 commit/);
|
|
192
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a checkout level with its upstream runs no git pull when npm had nothing newer', async (t) => {
|
|
196
|
+
const fx = fixture(t);
|
|
197
|
+
const calls = [];
|
|
198
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0'), run: recording(calls) });
|
|
199
|
+
|
|
200
|
+
assert.equal(calls.some(c => c[0] === 'npm' && c.includes('install')), false, 'no install when npm is not newer');
|
|
201
|
+
assert.equal(calls.some(c => c.includes('pull')), false, 'nothing to pull');
|
|
202
|
+
assert.match(r.reason, /latest/i);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('npm holding a release the checkout cannot take is said plainly, not hidden by "latest"', async (t) => {
|
|
206
|
+
const fx = fixture(t);
|
|
207
|
+
// The checkout is level, and the registry is a version ahead of it: the two answers disagree.
|
|
208
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.4.0'), run: recording([]) });
|
|
209
|
+
|
|
210
|
+
assert.equal(r.updated, false);
|
|
211
|
+
assert.match(r.reason, /npm installed v1\.4\.0, but not into/);
|
|
212
|
+
assert.match(r.reason, /latest/i, 'the checkout answer is kept as well');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a checkout is still updated by git when the registry cannot be reached', async (t) => {
|
|
216
|
+
const fx = fixture(t);
|
|
217
|
+
fx.release('1.0.1');
|
|
218
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: 'http://127.0.0.1:9/' });
|
|
219
|
+
|
|
220
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an npm install that fails does not stop a checkout from updating', async (t) => {
|
|
224
|
+
const fx = fixture(t);
|
|
225
|
+
fx.release('1.0.1');
|
|
226
|
+
const logs = [];
|
|
227
|
+
// npm refuses the version a fixture registry names, the way a real registry would.
|
|
228
|
+
const run = async (cmd, args, opts) => {
|
|
229
|
+
if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
|
|
230
|
+
return recording([])(cmd, args, opts);
|
|
231
|
+
};
|
|
232
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
|
|
233
|
+
|
|
234
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
235
|
+
assert.match(r.reason, /Pulled 1 commit/);
|
|
236
|
+
// The npm failure is said out loud, not swallowed into a success nobody can explain.
|
|
237
|
+
assert.match(logs.join('\n'), /npm could not install it here/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('an npm install behind the registry still fails loudly when there is no checkout', async (t) => {
|
|
241
|
+
const root = npmInstall(t, '1.0.0');
|
|
242
|
+
const run = async () => { throw new Error('npm install failed: EACCES'); };
|
|
243
|
+
await assert.rejects(applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run }), /npm install failed/);
|
|
244
|
+
});
|
package/update.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
// Brings this install to the newest release
|
|
2
|
-
//
|
|
1
|
+
// Brings this install to the newest release. A release is published to npm, so npm is what is asked
|
|
2
|
+
// first: `npm install -g llm-switcher@<version>` for the exact version the registry names. A git
|
|
3
|
+
// checkout is the one install npm cannot serve, because npm writes into the global prefix while the
|
|
4
|
+
// running code is the checkout itself, so the checkout is asked second and only then. Restarting
|
|
5
|
+
// the gateway is the caller's job.
|
|
3
6
|
import fs from 'node:fs';
|
|
4
7
|
import os from 'node:os';
|
|
5
8
|
import path from 'node:path';
|
|
@@ -80,8 +83,9 @@ async function updateNpmInstall(root, run, log, registryUrl) {
|
|
|
80
83
|
throw failure('npm install', err);
|
|
81
84
|
}
|
|
82
85
|
const to = readVersion(root);
|
|
83
|
-
// npm
|
|
84
|
-
|
|
86
|
+
// npm installs into the global prefix. When this gateway runs from a checkout that is a different
|
|
87
|
+
// directory, so the registry can name a release this code never receives; `elsewhere` says so.
|
|
88
|
+
if (to === from) return { ...noUpdate(from, `npm installed v${latest}, but not into ${root}.`), elsewhere: latest };
|
|
85
89
|
return { updated: true, from, to, reason: `Installed v${to} from npm.` };
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -93,10 +97,27 @@ export async function applyUpdate({
|
|
|
93
97
|
registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL,
|
|
94
98
|
running = CURRENT_VERSION
|
|
95
99
|
} = {}) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
+
// npm writes into the global prefix, so a checkout is the one install it cannot serve: the running
|
|
101
|
+
// code is this directory, not the prefix. The registry is still asked, because the release on npm
|
|
102
|
+
// is the release every other install receives, but for a checkout a failed install is not the end
|
|
103
|
+
// of the update -- the checkout is the thing that can move, so it is asked next.
|
|
104
|
+
const checkout = fs.existsSync(path.join(root, '.git'));
|
|
105
|
+
let registry;
|
|
106
|
+
try {
|
|
107
|
+
registry = await updateNpmInstall(root, run, logger, registryUrl);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (!checkout) throw err;
|
|
110
|
+
logger(`npm could not install it here: ${err.message}`);
|
|
111
|
+
registry = { ...noUpdate(readVersion(root), `npm could not install it here: ${err.message}`), elsewhere: true };
|
|
112
|
+
}
|
|
113
|
+
let r = registry;
|
|
114
|
+
if (!r.updated && checkout) {
|
|
115
|
+
r = await updateCheckout(root, run, logger);
|
|
116
|
+
// Neither could deliver it, and npm held a release this directory cannot take: both facts, because
|
|
117
|
+
// "already the latest version" on its own would hide the one version the reader does not have.
|
|
118
|
+
if (!r.updated && registry.elsewhere) r = { ...r, reason: `${registry.reason} ${r.reason}` };
|
|
119
|
+
}
|
|
120
|
+
// Newer code already on disk (an install outside the gateway) is an update for the
|
|
100
121
|
// running process: without the restart it keeps the old code and the old version forever.
|
|
101
122
|
const onDisk = readVersion(root);
|
|
102
123
|
if (!r.updated && isNewer(onDisk, running)) {
|