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 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 in one of two ways:
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
- - A git checkout 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.
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 theo một trong hai cách:
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
- - Bản git 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ì.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
@@ -1,14 +1,29 @@
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.
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: 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.
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 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}.`);
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
- 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
+ // 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)) {