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.
@@ -1,205 +1,205 @@
1
- // LIVE test: requires a running gateway (switch on) and a real upstream (costs tokens).
2
- // Offline test, no network needed: npm test
3
- const GATEWAY_PORT = Number(process.env.LLM_SWITCHER_PORT) || 3456;
4
- const BASE_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
5
-
6
- console.log('================================================================');
7
- console.log(' LLM SWITCHER — TOKEN OPTIMIZER INTEROPERABILITY TEST SUITE ');
8
- console.log(' Simulating failure modes from Headroom, RTK, and Ponytail ');
9
- console.log('================================================================\n');
10
-
11
- let passed = 0;
12
- let failed = 0;
13
-
14
- async function runTest(name, fn) {
15
- process.stdout.write(`[TEST] ${name}... `);
16
- const t0 = Date.now();
17
- try {
18
- const detail = await fn();
19
- console.log(`PASS (${Date.now() - t0}ms)`);
20
- if (detail) console.log(` ↳ ${detail}`);
21
- passed++;
22
- } catch (err) {
23
- console.log(`FAIL (${Date.now() - t0}ms)`);
24
- console.log(` ↳ ERROR: ${err.message}`);
25
- failed++;
26
- }
27
- }
28
-
29
- // -----------------------------------------------------------------------------
30
- // TEST 1: Headroom Failure Mode — Orphaned tool_result
31
- // When Headroom prunes history to save tokens, it drops the assistant tool_use turn.
32
- // Anthropic strictly throws: 400 invalid_request_error: 'tool_use_id does not correspond to any tool_use'
33
- // LLM Switcher Healer Engine: Repairs orphaned result into context text -> 200 OK
34
- // -----------------------------------------------------------------------------
35
- await runTest('Headroom Simulation: Orphaned tool_result turn', async () => {
36
- const payload = {
37
- model: 'claude-opus-4-6',
38
- max_tokens: 40,
39
- messages: [
40
- { role: 'user', content: 'Context turn before pruning' },
41
- {
42
- role: 'user',
43
- content: [
44
- { type: 'tool_result', tool_use_id: 'headroom_pruned_call_99', content: 'Database query result: 42 rows found' },
45
- { type: 'text', text: 'Reply: healed successfully' }
46
- ]
47
- }
48
- ],
49
- stream: false
50
- };
51
-
52
- const res = await fetch(`${BASE_URL}/v1/messages`, {
53
- method: 'POST',
54
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
55
- body: JSON.stringify(payload)
56
- });
57
-
58
- if (res.status !== 200) {
59
- const err = await res.text();
60
- throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
61
- }
62
- const json = await res.json();
63
- const text = json.content?.map(c => c.text).join('') || '';
64
- return `Healed orphaned tool_result. Response HTTP 200: "${text.slice(0, 50).replace(/\n/g, ' ')}..."`;
65
- });
66
-
67
- // -----------------------------------------------------------------------------
68
- // TEST 2: Headroom Failure Mode — Consecutive User turns (Roles must alternate)
69
- // When Headroom collapses history, multiple user turns occur back-to-back.
70
- // Anthropic strictly throws: 400 invalid_request_error: 'roles must alternate'
71
- // LLM Switcher Healer Engine: Merges consecutive same-role turns -> 200 OK
72
- // -----------------------------------------------------------------------------
73
- await runTest('Headroom Simulation: Consecutive User turns (Role alternation violation)', async () => {
74
- const payload = {
75
- model: 'claude-opus-4-6',
76
- max_tokens: 30,
77
- messages: [
78
- { role: 'user', content: 'User message turn 1' },
79
- { role: 'user', content: 'User message turn 2 (no assistant in between)' },
80
- { role: 'user', content: 'User message turn 3: reply pong' }
81
- ],
82
- stream: false
83
- };
84
-
85
- const res = await fetch(`${BASE_URL}/v1/messages`, {
86
- method: 'POST',
87
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
88
- body: JSON.stringify(payload)
89
- });
90
-
91
- if (res.status !== 200) {
92
- const err = await res.text();
93
- throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
94
- }
95
- const json = await res.json();
96
- return `Merged consecutive turns seamlessly. Stop reason: ${json.stop_reason}`;
97
- });
98
-
99
- // -----------------------------------------------------------------------------
100
- // TEST 3: Headroom / RTK Failure Mode — Stripped Thinking Parameter
101
- // Optimizer stripped the 'thinking' object to reduce tokens.
102
- // LLM Switcher Thinking Guard: Detects reasoning model (ag/claude-opus-4-6-thinking)
103
- // and restores thinking.budget_tokens automatically -> thinking_delta emitted!
104
- // -----------------------------------------------------------------------------
105
- await runTest('Thinking Guard: Restoring stripped thinking parameter on reasoning models', async () => {
106
- const payload = {
107
- model: 'claude-opus-4-6',
108
- max_tokens: 200,
109
- // Note: NO thinking parameter included (simulating optimizer stripping it)
110
- messages: [
111
- { role: 'user', content: 'Solve step by step: what is 17 * 23? Show reasoning.' }
112
- ],
113
- stream: true
114
- };
115
-
116
- const res = await fetch(`${BASE_URL}/v1/messages`, {
117
- method: 'POST',
118
- headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
119
- body: JSON.stringify(payload)
120
- });
121
-
122
- if (res.status !== 200) {
123
- throw new Error(`Expected HTTP 200, got ${res.status}`);
124
- }
125
-
126
- const text = await res.text();
127
- const hasThinkingDelta = text.includes('thinking_delta');
128
- const hasSignatureDelta = text.includes('signature_delta');
129
- const hasTextDelta = text.includes('text_delta');
130
-
131
- if (!hasThinkingDelta) {
132
- throw new Error('Thinking Guard failed: thinking_delta was NOT emitted in stream!');
133
- }
134
-
135
- return `Automatically restored thinking: thinking_delta=${hasThinkingDelta}, signature_delta=${hasSignatureDelta}, text_delta=${hasTextDelta}`;
136
- });
137
-
138
- // -----------------------------------------------------------------------------
139
- // TEST 4: RTK & Intermediary Headers Passthrough
140
- // RTK / tracing tools inject headers: x-rtk-version, traceparent, x-request-id.
141
- // LLM Switcher: Transparently preserves all tracking headers without rejection.
142
- // -----------------------------------------------------------------------------
143
- await runTest('RTK Intermediary: Custom headers and traceparent passthrough', async () => {
144
- const payload = {
145
- model: 'claude-opus-4-6',
146
- max_tokens: 20,
147
- messages: [{ role: 'user', content: 'ping' }],
148
- stream: false
149
- };
150
-
151
- const res = await fetch(`${BASE_URL}/v1/messages`, {
152
- method: 'POST',
153
- headers: {
154
- 'Content-Type': 'application/json',
155
- 'anthropic-version': '2023-06-01',
156
- 'x-rtk-version': '0.37.2',
157
- 'x-optimizer-id': 'rtk-cli-hook',
158
- 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
159
- },
160
- body: JSON.stringify(payload)
161
- });
162
-
163
- if (res.status !== 200) {
164
- throw new Error(`Expected HTTP 200, got ${res.status}`);
165
- }
166
- return `Headers accepted cleanly with HTTP 200 OK`;
167
- });
168
-
169
- // -----------------------------------------------------------------------------
170
- // TEST 5: OpenAI Chat Healer Mode — Orphaned Tool Result in Chat Completions
171
- // Same test but over /v1/chat/completions: tool role message without prior tool_calls assistant message.
172
- // OpenAI API strictly throws: 400 'messages with role tool must be a response to a preceding message with tool_calls'
173
- // LLM Switcher: Converts orphaned tool to user text message -> 200 OK
174
- // -----------------------------------------------------------------------------
175
- await runTest('OpenAI Chat Healer: Orphaned role tool without preceding assistant tool_calls', async () => {
176
- const payload = {
177
- model: 'ag/gpt-oss-120b-medium',
178
- max_tokens: 30,
179
- messages: [
180
- { role: 'user', content: 'Turn 1 context' },
181
- { role: 'tool', tool_call_id: 'orphaned_chat_call_77', content: 'Tool execution logs: OK' },
182
- { role: 'user', content: 'reply pong' }
183
- ],
184
- stream: false
185
- };
186
-
187
- const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
188
- method: 'POST',
189
- headers: { 'Content-Type': 'application/json' },
190
- body: JSON.stringify(payload)
191
- });
192
-
193
- if (res.status !== 200) {
194
- const err = await res.text();
195
- throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
196
- }
197
- const json = await res.json();
198
- return `Chat Healer rescued orphaned tool role. Stop reason: ${json.choices?.[0]?.finish_reason}`;
199
- });
200
-
201
- console.log('\n================================================================');
202
- console.log(` TEST RESULTS: ${passed} PASSED / ${failed} FAILED `);
203
- console.log('================================================================\n');
204
-
205
- process.exit(failed ? 1 : 0);
1
+ // LIVE test: requires a running gateway (switch on) and a real upstream (costs tokens).
2
+ // Offline test, no network needed: npm test
3
+ const GATEWAY_PORT = Number(process.env.LLM_SWITCHER_PORT) || 3456;
4
+ const BASE_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
5
+
6
+ console.log('================================================================');
7
+ console.log(' LLM SWITCHER — TOKEN OPTIMIZER INTEROPERABILITY TEST SUITE ');
8
+ console.log(' Simulating failure modes from Headroom, RTK, and Ponytail ');
9
+ console.log('================================================================\n');
10
+
11
+ let passed = 0;
12
+ let failed = 0;
13
+
14
+ async function runTest(name, fn) {
15
+ process.stdout.write(`[TEST] ${name}... `);
16
+ const t0 = Date.now();
17
+ try {
18
+ const detail = await fn();
19
+ console.log(`PASS (${Date.now() - t0}ms)`);
20
+ if (detail) console.log(` ↳ ${detail}`);
21
+ passed++;
22
+ } catch (err) {
23
+ console.log(`FAIL (${Date.now() - t0}ms)`);
24
+ console.log(` ↳ ERROR: ${err.message}`);
25
+ failed++;
26
+ }
27
+ }
28
+
29
+ // -----------------------------------------------------------------------------
30
+ // TEST 1: Headroom Failure Mode — Orphaned tool_result
31
+ // When Headroom prunes history to save tokens, it drops the assistant tool_use turn.
32
+ // Anthropic strictly throws: 400 invalid_request_error: 'tool_use_id does not correspond to any tool_use'
33
+ // LLM Switcher Healer Engine: Repairs orphaned result into context text -> 200 OK
34
+ // -----------------------------------------------------------------------------
35
+ await runTest('Headroom Simulation: Orphaned tool_result turn', async () => {
36
+ const payload = {
37
+ model: 'claude-opus-4-6',
38
+ max_tokens: 40,
39
+ messages: [
40
+ { role: 'user', content: 'Context turn before pruning' },
41
+ {
42
+ role: 'user',
43
+ content: [
44
+ { type: 'tool_result', tool_use_id: 'headroom_pruned_call_99', content: 'Database query result: 42 rows found' },
45
+ { type: 'text', text: 'Reply: healed successfully' }
46
+ ]
47
+ }
48
+ ],
49
+ stream: false
50
+ };
51
+
52
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
55
+ body: JSON.stringify(payload)
56
+ });
57
+
58
+ if (res.status !== 200) {
59
+ const err = await res.text();
60
+ throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
61
+ }
62
+ const json = await res.json();
63
+ const text = json.content?.map(c => c.text).join('') || '';
64
+ return `Healed orphaned tool_result. Response HTTP 200: "${text.slice(0, 50).replace(/\n/g, ' ')}..."`;
65
+ });
66
+
67
+ // -----------------------------------------------------------------------------
68
+ // TEST 2: Headroom Failure Mode — Consecutive User turns (Roles must alternate)
69
+ // When Headroom collapses history, multiple user turns occur back-to-back.
70
+ // Anthropic strictly throws: 400 invalid_request_error: 'roles must alternate'
71
+ // LLM Switcher Healer Engine: Merges consecutive same-role turns -> 200 OK
72
+ // -----------------------------------------------------------------------------
73
+ await runTest('Headroom Simulation: Consecutive User turns (Role alternation violation)', async () => {
74
+ const payload = {
75
+ model: 'claude-opus-4-6',
76
+ max_tokens: 30,
77
+ messages: [
78
+ { role: 'user', content: 'User message turn 1' },
79
+ { role: 'user', content: 'User message turn 2 (no assistant in between)' },
80
+ { role: 'user', content: 'User message turn 3: reply pong' }
81
+ ],
82
+ stream: false
83
+ };
84
+
85
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
88
+ body: JSON.stringify(payload)
89
+ });
90
+
91
+ if (res.status !== 200) {
92
+ const err = await res.text();
93
+ throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
94
+ }
95
+ const json = await res.json();
96
+ return `Merged consecutive turns seamlessly. Stop reason: ${json.stop_reason}`;
97
+ });
98
+
99
+ // -----------------------------------------------------------------------------
100
+ // TEST 3: Headroom / RTK Failure Mode — Stripped Thinking Parameter
101
+ // Optimizer stripped the 'thinking' object to reduce tokens.
102
+ // LLM Switcher Thinking Guard: Detects reasoning model (ag/claude-opus-4-6-thinking)
103
+ // and restores thinking.budget_tokens automatically -> thinking_delta emitted!
104
+ // -----------------------------------------------------------------------------
105
+ await runTest('Thinking Guard: Restoring stripped thinking parameter on reasoning models', async () => {
106
+ const payload = {
107
+ model: 'claude-opus-4-6',
108
+ max_tokens: 200,
109
+ // Note: NO thinking parameter included (simulating optimizer stripping it)
110
+ messages: [
111
+ { role: 'user', content: 'Solve step by step: what is 17 * 23? Show reasoning.' }
112
+ ],
113
+ stream: true
114
+ };
115
+
116
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
119
+ body: JSON.stringify(payload)
120
+ });
121
+
122
+ if (res.status !== 200) {
123
+ throw new Error(`Expected HTTP 200, got ${res.status}`);
124
+ }
125
+
126
+ const text = await res.text();
127
+ const hasThinkingDelta = text.includes('thinking_delta');
128
+ const hasSignatureDelta = text.includes('signature_delta');
129
+ const hasTextDelta = text.includes('text_delta');
130
+
131
+ if (!hasThinkingDelta) {
132
+ throw new Error('Thinking Guard failed: thinking_delta was NOT emitted in stream!');
133
+ }
134
+
135
+ return `Automatically restored thinking: thinking_delta=${hasThinkingDelta}, signature_delta=${hasSignatureDelta}, text_delta=${hasTextDelta}`;
136
+ });
137
+
138
+ // -----------------------------------------------------------------------------
139
+ // TEST 4: RTK & Intermediary Headers Passthrough
140
+ // RTK / tracing tools inject headers: x-rtk-version, traceparent, x-request-id.
141
+ // LLM Switcher: Transparently preserves all tracking headers without rejection.
142
+ // -----------------------------------------------------------------------------
143
+ await runTest('RTK Intermediary: Custom headers and traceparent passthrough', async () => {
144
+ const payload = {
145
+ model: 'claude-opus-4-6',
146
+ max_tokens: 20,
147
+ messages: [{ role: 'user', content: 'ping' }],
148
+ stream: false
149
+ };
150
+
151
+ const res = await fetch(`${BASE_URL}/v1/messages`, {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ 'anthropic-version': '2023-06-01',
156
+ 'x-rtk-version': '0.37.2',
157
+ 'x-optimizer-id': 'rtk-cli-hook',
158
+ 'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
159
+ },
160
+ body: JSON.stringify(payload)
161
+ });
162
+
163
+ if (res.status !== 200) {
164
+ throw new Error(`Expected HTTP 200, got ${res.status}`);
165
+ }
166
+ return `Headers accepted cleanly with HTTP 200 OK`;
167
+ });
168
+
169
+ // -----------------------------------------------------------------------------
170
+ // TEST 5: OpenAI Chat Healer Mode — Orphaned Tool Result in Chat Completions
171
+ // Same test but over /v1/chat/completions: tool role message without prior tool_calls assistant message.
172
+ // OpenAI API strictly throws: 400 'messages with role tool must be a response to a preceding message with tool_calls'
173
+ // LLM Switcher: Converts orphaned tool to user text message -> 200 OK
174
+ // -----------------------------------------------------------------------------
175
+ await runTest('OpenAI Chat Healer: Orphaned role tool without preceding assistant tool_calls', async () => {
176
+ const payload = {
177
+ model: 'ag/gpt-oss-120b-medium',
178
+ max_tokens: 30,
179
+ messages: [
180
+ { role: 'user', content: 'Turn 1 context' },
181
+ { role: 'tool', tool_call_id: 'orphaned_chat_call_77', content: 'Tool execution logs: OK' },
182
+ { role: 'user', content: 'reply pong' }
183
+ ],
184
+ stream: false
185
+ };
186
+
187
+ const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/json' },
190
+ body: JSON.stringify(payload)
191
+ });
192
+
193
+ if (res.status !== 200) {
194
+ const err = await res.text();
195
+ throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
196
+ }
197
+ const json = await res.json();
198
+ return `Chat Healer rescued orphaned tool role. Stop reason: ${json.choices?.[0]?.finish_reason}`;
199
+ });
200
+
201
+ console.log('\n================================================================');
202
+ console.log(` TEST RESULTS: ${passed} PASSED / ${failed} FAILED `);
203
+ console.log('================================================================\n');
204
+
205
+ process.exit(failed ? 1 : 0);
@@ -8,7 +8,7 @@ import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { execFileSync } from 'node:child_process';
10
10
  import {
11
- systemdUnit, launchdPlist, scheduledTaskXml, portFromServiceText, decodeConsoleText, serviceEnv, writeServiceFile
11
+ systemdUnit, launchdPlist, scheduledTaskXml, portFromServiceText, autoupdateFromServiceText, decodeConsoleText, serviceEnv, writeServiceFile
12
12
  } from '../service.mjs';
13
13
 
14
14
  const HAS_XMLLINT = (() => { try { execFileSync('xmllint', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } })();
@@ -58,6 +58,32 @@ test('the port is read back from UTF-16 console output, as schtasks /Query /XML
58
58
  assert.equal(portFromServiceText('no port here'), null);
59
59
  });
60
60
 
61
+ test('autoupdate flag is placed into task XML, systemd unit, and launchd plist', () => {
62
+ 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>/);
64
+ assert.equal(portFromServiceText(xml), 3456);
65
+
66
+ const unit = systemdUnit({ nodeBin: '/usr/bin/node', script: '/opt/proxy.mjs', port: 3456, autoupdate: true });
67
+ assert.match(unit, /ExecStart="\/usr\/bin\/node" "\/opt\/proxy\.mjs" --port 3456 --autoupdate/);
68
+ assert.equal(portFromServiceText(unit), 3456);
69
+
70
+ const plistText = launchdPlist({ nodeBin: '/node', script: '/proxy.mjs', port: 3456, logPath: '/log', autoupdate: true });
71
+ assert.match(plistText, /<string>--autoupdate<\/string>/);
72
+ assert.equal(portFromServiceText(plistText), 3456);
73
+ });
74
+
75
+ // `switch port` rewrites the service. It must keep the choice that `service install --no-autoupdate` made.
76
+ test('the autoupdate flag is read back from each service definition', () => {
77
+ for (const autoupdate of [true, false]) {
78
+ const texts = [
79
+ scheduledTaskXml({ nodeBin: 'node.exe', script: 'proxy.mjs', port: 3456, userId: 'u', autoupdate }),
80
+ systemdUnit({ nodeBin: '/usr/bin/node', script: '/opt/proxy.mjs', port: 3456, autoupdate }),
81
+ launchdPlist({ nodeBin: '/node', script: '/proxy.mjs', port: 3456, logPath: '/log', autoupdate })
82
+ ];
83
+ for (const text of texts) assert.equal(autoupdateFromServiceText(text), autoupdate);
84
+ }
85
+ });
86
+
61
87
  test('writeServiceFile keeps a hand-edited definition as .bak before it replaces it', (t) => {
62
88
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-svc-'));
63
89
  t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
@@ -0,0 +1,197 @@
1
+ // A real gateway, run from a copy of this checkout that tracks a local bare repository. A release
2
+ // pushed there must reach the running gateway through POST /api/update, and through --autoupdate at
3
+ // start. Nothing here reaches the network or the real home directory.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import http from 'node:http';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { spawn, execFileSync } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { launchBrowser, skipReason } from './cdp.mjs';
14
+
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
17
+ const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', '-c', 'core.autocrlf=false'];
18
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
19
+
20
+ const freePort = () => new Promise(r => {
21
+ const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
22
+ });
23
+
24
+ function copySource(dest) {
25
+ const files = git(ROOT, 'ls-files', '--cached', '--others', '--exclude-standard').split(/\r?\n/).filter(Boolean);
26
+ for (const f of files) {
27
+ const from = path.join(ROOT, f);
28
+ if (!fs.existsSync(from)) continue;
29
+ fs.mkdirSync(path.dirname(path.join(dest, f)), { recursive: true });
30
+ fs.copyFileSync(from, path.join(dest, f));
31
+ }
32
+ }
33
+
34
+ function bumpVersion(dir, version) {
35
+ const p = path.join(dir, 'package.json');
36
+ fs.writeFileSync(p, JSON.stringify({ ...JSON.parse(fs.readFileSync(p, 'utf8')), version }, null, 2) + '\n');
37
+ }
38
+
39
+ // The npm registry answer only drives the dashboard notice; a git checkout updates from git.
40
+ async function registry(t, version) {
41
+ const srv = http.createServer((req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ version })); });
42
+ await new Promise(r => srv.listen(0, '127.0.0.1', r));
43
+ t.after(() => srv.close());
44
+ return `http://127.0.0.1:${srv.address().port}/`;
45
+ }
46
+
47
+ async function fixture(t, { registryUrl = 'http://127.0.0.1:9/' } = {}) {
48
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-update-e2e-'));
49
+ const upstream = path.join(base, 'upstream.git');
50
+ const install = path.join(base, 'install');
51
+ const publisher = path.join(base, 'publisher');
52
+ git(base, 'init', '-q', '--bare', '-b', 'main', upstream);
53
+ fs.mkdirSync(install);
54
+ copySource(install);
55
+ git(install, 'init', '-q', '-b', 'main');
56
+ git(install, 'add', '-A');
57
+ git(install, ...ID, 'commit', '-q', '-m', 'installed');
58
+ git(install, 'remote', 'add', 'origin', upstream);
59
+ git(install, 'push', '-q', '-u', 'origin', 'main');
60
+ fs.mkdirSync(publisher);
61
+ git(publisher, 'init', '-q', '-b', 'main');
62
+ git(publisher, 'remote', 'add', 'origin', upstream);
63
+ git(publisher, 'fetch', '-q', 'origin');
64
+ git(publisher, 'checkout', '-q', '-b', 'main', '--track', 'origin/main');
65
+
66
+ const data = path.join(base, 'data');
67
+ const home = path.join(data, 'home');
68
+ fs.mkdirSync(home, { recursive: true });
69
+ const port = await freePort();
70
+ const cfgPath = path.join(data, 'config.json');
71
+ fs.writeFileSync(cfgPath, JSON.stringify({
72
+ port, blindfold: { port: await freePort() }, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
73
+ profiles: { plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } } }
74
+ }, null, 2));
75
+ const env = {
76
+ ...process.env, HOME: home, USERPROFILE: home, CODEX_HOME: path.join(home, '.codex'), CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
77
+ LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: data, LLM_SWITCHER_BLINDFOLD_CERTS: path.join(data, 'certs'),
78
+ LLM_SWITCHER_PORT: '', PORT: '', LLM_SWITCHER_REGISTRY_URL: registryUrl
79
+ };
80
+
81
+ let proc = null;
82
+ let log = '';
83
+ const pid = async () => {
84
+ try { return (await (await fetch(`http://127.0.0.1:${port}/health?challenge=t`)).json()).pid ?? null; } catch { return null; }
85
+ };
86
+ const fx = {
87
+ port,
88
+ log: () => log,
89
+ pid,
90
+ release(version) {
91
+ bumpVersion(publisher, version);
92
+ git(publisher, 'add', '-A');
93
+ git(publisher, ...ID, 'commit', '-q', '-m', `v${version}`);
94
+ git(publisher, 'push', '-q', 'origin', 'main');
95
+ },
96
+ async start(...extra) {
97
+ proc = spawn(process.execPath, [path.join(install, 'proxy.mjs'), '--port', String(port), ...extra], { env, stdio: ['ignore', 'pipe', 'pipe'] });
98
+ proc.stdout.on('data', d => { log += d; });
99
+ proc.stderr.on('data', d => { log += d; });
100
+ return proc;
101
+ },
102
+ async waitFor(check, what) {
103
+ for (let i = 0; i < 120; i++) {
104
+ const v = await check();
105
+ if (v) return v;
106
+ await sleep(250);
107
+ }
108
+ throw new Error(`timed out waiting for ${what}\n${log}`);
109
+ },
110
+ cli: (...args) => new Promise((resolve) => {
111
+ const c = spawn(process.execPath, [path.join(install, 'switch.mjs'), ...args, '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
112
+ let out = '';
113
+ c.stdout.on('data', d => { out += d; });
114
+ c.stderr.on('data', d => { out += d; });
115
+ c.on('close', code => resolve({ code, out }));
116
+ }),
117
+ dashboardUrl: `http://127.0.0.1:${port}/ui`,
118
+ api: (p, opts = {}) => fetch(`http://127.0.0.1:${port}${p}`, {
119
+ ...opts,
120
+ headers: { 'x-llm-switcher-token': fs.readFileSync(path.join(data, 'admin.token'), 'utf8').trim(), 'Content-Type': 'application/json' }
121
+ })
122
+ };
123
+ t.after(async () => {
124
+ const live = await pid();
125
+ if (live) try { process.kill(live); } catch {}
126
+ proc?.kill();
127
+ await sleep(300);
128
+ fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
129
+ });
130
+ return fx;
131
+ }
132
+
133
+ async function readSse(res) {
134
+ const events = [];
135
+ for (const block of (await res.text()).split('\n\n')) {
136
+ const event = /^event: (.*)$/m.exec(block)?.[1];
137
+ if (event) events.push({ event, data: JSON.parse(/^data: (.*)$/m.exec(block)[1]) });
138
+ }
139
+ return events;
140
+ }
141
+
142
+ test('POST /api/update pulls the release and a new gateway takes over the port', { timeout: 90000 }, async (t) => {
143
+ const fx = await fixture(t);
144
+ const proc = await fx.start();
145
+ const oldPid = await fx.waitFor(fx.pid, 'the gateway');
146
+ assert.equal((await (await fx.api('/api/version')).json()).current, JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version);
147
+
148
+ fx.release('9.9.9');
149
+ const events = await readSse(await fx.api('/api/update', { method: 'POST', body: '{}' }));
150
+ const result = events.find(e => e.event === 'result')?.data;
151
+ assert.equal(result?.updated, true, JSON.stringify(events));
152
+ assert.equal(result.to, '9.9.9');
153
+
154
+ await fx.waitFor(async () => { const p = await fx.pid(); return p && p !== oldPid; }, 'the new gateway');
155
+ assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.9');
156
+ // The first process stays as the parent: a service manager still sees the pid it started.
157
+ assert.equal(proc.exitCode, null);
158
+
159
+ const again = (await readSse(await fx.api('/api/update', { method: 'POST', body: '{}' }))).find(e => e.event === 'result')?.data;
160
+ assert.equal(again?.updated, false);
161
+ assert.match(again.reason, /latest/i);
162
+ });
163
+
164
+ test('--autoupdate starts the newest release', { timeout: 90000 }, async (t) => {
165
+ const fx = await fixture(t);
166
+ fx.release('9.9.10');
167
+ await fx.start('--autoupdate');
168
+ await fx.waitFor(fx.pid, 'the gateway');
169
+ assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.10');
170
+ assert.match(fx.log(), /Pulled 1 commit/);
171
+ });
172
+
173
+ test('switch update asks the running gateway and waits for the new one', { timeout: 90000 }, async (t) => {
174
+ const fx = await fixture(t);
175
+ await fx.start();
176
+ await fx.waitFor(fx.pid, 'the gateway');
177
+ fx.release('9.9.11');
178
+ const r = await fx.cli('update');
179
+ assert.equal(r.code, 0, r.out);
180
+ assert.match(r.out, /runs v9\.9\.11/);
181
+ assert.equal((await (await fx.api('/api/version')).json()).current, '9.9.11');
182
+ });
183
+
184
+ test('the dashboard Update now button installs the release and reloads on the new gateway', { timeout: 90000, skip: skipReason() }, async (t) => {
185
+ const fx = await fixture(t, { registryUrl: await registry(t, '9.9.12') });
186
+ await fx.start();
187
+ await fx.waitFor(fx.pid, 'the gateway');
188
+ fx.release('9.9.12');
189
+ const browser = await launchBrowser();
190
+ t.after(() => browser.close());
191
+ const page = await browser.newPage();
192
+ await page.goto(fx.dashboardUrl);
193
+ await page.waitFor(`!document.getElementById('update-notice').hidden`, 'the update notice');
194
+ await page.click('#update-now');
195
+ await page.waitFor(`document.getElementById('app-version')?.textContent === 'v9.9.12'`, 'the reloaded dashboard on the new version', 30000);
196
+ assert.equal(await page.evaluate(`document.getElementById('update-notice').hidden`), true);
197
+ });