llm-switcher 1.3.7 → 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 CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## Release 1.3.7
4
9
 
5
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
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",
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
- export function scheduledTaskXml({ nodeBin, script, port, userId, autoupdate = false }) {
70
- const args = autoupdate ? `"${script}" --port ${port} --autoupdate` : `"${script}" --port ${port}`;
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(nodeBin)}</Command>
101
+ <Command>${xmlEscape(conhost)}</Command>
99
102
  <Arguments>${xmlEscape(args)}</Arguments>
100
103
  </Exec>
101
104
  </Actions>
@@ -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
- assert.match(xml, /<Command>C:\\Program Files\\nodejs\\node.exe<\/Command>/);
47
- assert.match(xml, /<Arguments>&quot;C:\\Users\\A &amp; B\\llm-switcher\\proxy.mjs&quot; --port 4002<\/Arguments>/);
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 &quot;C:\\Program Files\\nodejs\\node.exe&quot; &quot;C:\\Users\\A &amp; B\\llm-switcher\\proxy.mjs&quot; --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&amp;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>&quot;proxy\.mjs&quot; --port 3456 --autoupdate<\/Arguments>/);
65
+ assert.match(xml, /<Arguments>--headless &quot;node\.exe&quot; &quot;proxy\.mjs&quot; --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 });
@@ -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/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
- return fs.existsSync(path.join(root, '.git'))
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
  }