llm-switcher 1.6.12 → 1.6.13

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.
@@ -21,9 +21,14 @@ const freePort = () => new Promise(r => {
21
21
  const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
22
22
  });
23
23
 
24
+ // Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
25
+ const made = [];
26
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
27
+
24
28
  // A free interceptor port too: the default 3457 belongs to a switcher that may run on this machine.
25
29
  async function workspace(t, port, profiles) {
26
30
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cli-'));
31
+ made.push(dir);
27
32
  const cfgPath = path.join(dir, 'config.json');
28
33
  fs.writeFileSync(cfgPath, JSON.stringify({
29
34
  port, blindfold: { port: await freePort() }, activeProfiles: { anthropic: null, responses: null, 'openai-chat': null, vertex: null },
@@ -661,3 +666,44 @@ test('switch port leaves the installed service of another port unchanged', { ski
661
666
  const log = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';
662
667
  assert.doesNotMatch(log, /\b(stop|restart|start|enable)\b/, `systemctl was told to act on the other instance:\n${log}`);
663
668
  });
669
+
670
+ // Bare `switch on` brings back what `switch off` turned off; it never turns on a target the user had
671
+ // switched off, and never moves a target to another profile.
672
+ const twoProfiles = () => ({
673
+ c1: { name: 'One', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } },
674
+ c2: { name: 'Two', mode: 'convert', tool: 'claude', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } },
675
+ x1: { name: 'Codex', mode: 'convert', tool: 'codex', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { default: 'o' } }
676
+ });
677
+ const setActive = (ws, map) => {
678
+ const cfg = readCfg(ws);
679
+ cfg.activeProfiles = map;
680
+ fs.writeFileSync(ws.cfgPath, JSON.stringify(cfg, null, 2), { mode: 0o600 });
681
+ };
682
+ const pointers = (ws) => { const a = readCfg(ws).activeProfiles; return { claude: a.claude ?? null, codex: a.codex ?? null, agy: a.agy ?? null }; };
683
+
684
+ test('switch on after switch off brings back each target as it was', { skip: !POSIX && 'posix' }, async (t) => {
685
+ const ws = await workspace(t, await freePort(), twoProfiles());
686
+ setActive(ws, { claude: null, codex: 'x1', agy: null });
687
+ assert.equal((await run(ws, ['off'])).status, 0);
688
+ const r = await run(ws, ['on']);
689
+ assert.equal(r.status, 0, r.stdout + r.stderr);
690
+ assert.deepEqual(pointers(ws), { claude: null, codex: 'x1', agy: null });
691
+ });
692
+
693
+ test('switch on keeps every target off when all were off before switch off', { skip: !POSIX && 'posix' }, async (t) => {
694
+ const ws = await workspace(t, await freePort(), twoProfiles());
695
+ setActive(ws, { claude: null, codex: null, agy: null });
696
+ assert.equal((await run(ws, ['off'])).status, 0);
697
+ const r = await run(ws, ['on']);
698
+ assert.equal(r.status, 0, r.stdout + r.stderr);
699
+ assert.deepEqual(pointers(ws), { claude: null, codex: null, agy: null });
700
+ assert.match(r.stdout, /switch <profile>/);
701
+ });
702
+
703
+ test('switch on keeps a target on the profile it already uses', { skip: !POSIX && 'posix' }, async (t) => {
704
+ const ws = await workspace(t, await freePort(), twoProfiles());
705
+ setActive(ws, { claude: 'c2', codex: null, agy: null });
706
+ const r = await run(ws, ['on']);
707
+ assert.equal(r.status, 0, r.stdout + r.stderr);
708
+ assert.deepEqual(pointers(ws), { claude: 'c2', codex: null, agy: null });
709
+ });
@@ -1,7 +1,7 @@
1
1
  // A real gateway, run from a copy of this checkout that tracks a local bare repository. A release
2
2
  // pushed there must reach the running gateway through POST /api/update, and through --autoupdate at
3
3
  // start. Nothing here reaches the network or the real home directory.
4
- import { test } from 'node:test';
4
+ import { test, after } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import fs from 'node:fs';
7
7
  import http from 'node:http';
@@ -44,8 +44,13 @@ async function registry(t, version) {
44
44
  return `http://127.0.0.1:${srv.address().port}/`;
45
45
  }
46
46
 
47
+ // Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
48
+ const made = [];
49
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
50
+
47
51
  async function fixture(t, { registryUrl = 'http://127.0.0.1:9/' } = {}) {
48
52
  const base = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-update-e2e-'));
53
+ made.push(base);
49
54
  const upstream = path.join(base, 'upstream.git');
50
55
  const install = path.join(base, 'install');
51
56
  const publisher = path.join(base, 'publisher');
@@ -1,4 +1,4 @@
1
- import test from 'node:test';
1
+ import test, { after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
@@ -7,10 +7,17 @@ import http from 'node:http';
7
7
  import {
8
8
  parseModelWindows, parseModelList, fetchProfileWindows,
9
9
  resolveProfileWindows, readWindowsCache, readLocalCodexWindows,
10
- windowsCacheKey, cachedProfileWindows
10
+ windowsCacheKey, cachedProfileWindows, createWindowsRefresher
11
11
  } from '../windows.mjs';
12
12
 
13
- const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-windows-'));
13
+ // Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
14
+ const made = [];
15
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
16
+ const tmpDir = () => {
17
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-windows-'));
18
+ made.push(d);
19
+ return d;
20
+ };
14
21
 
15
22
  // A Codex model publishes both windows; intact names the threshold itself. Neither is guessed.
16
23
  test('a list entry gives the whole window and the window to compress at', () => {
@@ -214,3 +221,78 @@ test('a refresh merges each model into the cached windows, but never carries an
214
221
  fs.rmSync(dir, { recursive: true, force: true });
215
222
  }
216
223
  });
224
+
225
+ // A clock that moved back, or a hand-edited file, must not make an entry fresh forever.
226
+ test('a cache entry stamped in the future is stale', async () => {
227
+ const dir = tmpDir();
228
+ let hits = 0;
229
+ const sink = http.createServer((req, res) => {
230
+ hits++;
231
+ res.writeHead(200, { 'content-type': 'application/json' });
232
+ res.end(JSON.stringify({ data: [{ id: 'm', context_length: 200000, max_output_tokens: 2048 }] }));
233
+ });
234
+ await new Promise(r => sink.listen(0, '127.0.0.1', r));
235
+ const base = `http://127.0.0.1:${sink.address().port}/v1`;
236
+ try {
237
+ const models = { m: { context: 200000, compact: 0, output: 8192 } };
238
+ fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ p: { at: 10_000_000, baseURL: base, models } }));
239
+ assert.deepEqual(cachedProfileWindows(dir, 'p', { now: 1_000 }), {});
240
+ const listed = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 });
241
+ assert.equal(hits, 1, 'the list is read again');
242
+ assert.equal(listed.m.output, 2048);
243
+ assert.equal(readWindowsCache(dir).p.at, 1_000);
244
+ } finally {
245
+ sink.close();
246
+ fs.rmSync(dir, { recursive: true, force: true });
247
+ }
248
+ });
249
+
250
+ // A converted request with no fresh entry asks for one read in the background; it never waits for it.
251
+ test('the background refresh reads a list once per profile, keeps each profile apart, and logs a failure', async () => {
252
+ const dir = tmpDir();
253
+ const reads = [];
254
+ const sink = http.createServer((req, res) => {
255
+ reads.push(req.headers.authorization);
256
+ const output = req.headers.authorization === 'Bearer kb' ? 2048 : 8192;
257
+ setTimeout(() => {
258
+ res.writeHead(200, { 'content-type': 'application/json' });
259
+ res.end(JSON.stringify({ data: [{ id: 'm', context_length: 200000, max_output_tokens: output }] }));
260
+ }, req.headers.authorization === 'Bearer ka' ? 150 : 20);
261
+ });
262
+ await new Promise(r => sink.listen(0, '127.0.0.1', r));
263
+ const base = `http://127.0.0.1:${sink.address().port}/v1`;
264
+ const logged = [];
265
+ const refresh = createWindowsRefresher(dir, { debug: (...a) => logged.push(a.join(' ')) });
266
+ try {
267
+ const a = windowsCacheKey('a', base);
268
+ const b = windowsCacheKey('b', base);
269
+ const first = refresh(a, base, 'ka');
270
+ assert.equal(refresh(a, base, 'ka'), first, 'a second request joins the read in flight');
271
+ await Promise.all([first, refresh(b, base, 'kb')]);
272
+ assert.deepEqual(reads, ['Bearer ka', 'Bearer kb']);
273
+ assert.equal(cachedProfileWindows(dir, a).m.output, 8192);
274
+ assert.equal(cachedProfileWindows(dir, b).m.output, 2048, 'two profiles on one baseURL keep their own limits');
275
+ assert.equal(refresh(a, base, 'ka'), null, 'no second read soon after the first');
276
+
277
+ const dead = windowsCacheKey('dead', 'http://127.0.0.1:9/v1');
278
+ await refresh(dead, 'http://127.0.0.1:9/v1', 'k');
279
+ assert.equal(logged.length, 1);
280
+ assert.match(logged[0], /dead@http:\/\/127\.0\.0\.1:9\/v1/);
281
+ assert.equal(refresh(dead, 'http://127.0.0.1:9/v1', 'k'), null, 'a failed read is not retried at once');
282
+ } finally {
283
+ sink.close();
284
+ fs.rmSync(dir, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ // The gateway calls refresh() without awaiting it, so a rejection would be an unhandled rejection there.
289
+ test('the background refresh never rejects, even when its logger throws', async () => {
290
+ const dir = tmpDir();
291
+ const dead = 'http://127.0.0.1:9/v1';
292
+ const refresh = createWindowsRefresher(dir, { debug: () => { throw new Error('logger down'); } });
293
+ try {
294
+ await assert.doesNotReject(refresh(windowsCacheKey('dead', dead), dead, 'k'));
295
+ } finally {
296
+ fs.rmSync(dir, { recursive: true, force: true });
297
+ }
298
+ });
package/windows.mjs CHANGED
@@ -18,9 +18,13 @@ import os from 'node:os';
18
18
  const ONE_HOUR_MS = 60 * 60 * 1000;
19
19
  const DEFAULT_TTL_MS = ONE_HOUR_MS;
20
20
  const FETCH_TIMEOUT_MS = 15000;
21
+ const REFRESH_RETRY_MS = 5 * 60 * 1000;
21
22
 
22
23
  const n = v => (Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
23
24
 
25
+ // An entry stamped after `now` came from a clock that moved back or a hand edit: stale, not fresh forever.
26
+ const isFresh = (entry, now, ttlMs) => Number.isFinite(entry?.at) && entry.at > 0 && entry.at <= now && now - entry.at < ttlMs;
27
+
24
28
  // One entry of a provider's list -> the two windows, plus `output` (the most the model writes)
25
29
  // when the list names it; null when it names none.
26
30
  export function parseModelWindows(m) {
@@ -82,8 +86,7 @@ export async function fetchProfileWindows(baseURL, apiKey, { timeoutMs = FETCH_T
82
86
  export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
83
87
  const cache = readWindowsCache(stateDir);
84
88
  const entry = cache[cacheKey] || {};
85
- const fresh = entry.at && now - entry.at < ttlMs;
86
- if (fresh) return entry.models || {};
89
+ if (isFresh(entry, now, ttlMs)) return entry.models || {};
87
90
  const listed = await fetchProfileWindows(baseURL, apiKey);
88
91
  if (Object.keys(listed).length) {
89
92
  // Merged per model and per number: a list that names only an output limit keeps the cached windows.
@@ -98,13 +101,40 @@ export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey,
98
101
  for (const [k, v] of Object.entries(w)) if (v > 0 || !(merged[k] > 0)) merged[k] = v;
99
102
  models[id] = merged;
100
103
  }
101
- cache[cacheKey] = { at: now, baseURL, models };
102
- writeWindowsCache(stateDir, cache);
104
+ // Read again just before the write: another profile's refresh may have written while this list loaded.
105
+ const latest = readWindowsCache(stateDir);
106
+ latest[cacheKey] = { at: now, baseURL, models };
107
+ writeWindowsCache(stateDir, latest);
103
108
  return models;
104
109
  }
105
110
  return entry.models || {};
106
111
  }
107
112
 
113
+ /**
114
+ * Reads a profile's list in the background, so a request that found no fresh entry is clamped from
115
+ * the next request on. One read per cache key at a time, and none again within retryMs of the last,
116
+ * so a provider with no usable list costs one read per interval. Returns the read, which never rejects, or null.
117
+ */
118
+ export function createWindowsRefresher(stateDir, { retryMs = REFRESH_RETRY_MS, debug = () => {} } = {}) {
119
+ const running = new Map();
120
+ const lastTry = new Map();
121
+ // The caller does not await the read, so a logger that throws must not turn it into a rejection.
122
+ const log = (msg) => { try { debug(msg); } catch {} };
123
+ return function refresh(cacheKey, baseURL, apiKey) {
124
+ if (running.has(cacheKey)) return running.get(cacheKey);
125
+ if (Date.now() - (lastTry.get(cacheKey) ?? -Infinity) < retryMs) return null;
126
+ lastTry.set(cacheKey, Date.now());
127
+ const read = resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey)
128
+ .then(() => {
129
+ if (!Object.keys(cachedProfileWindows(stateDir, cacheKey)).length) log(`model list refresh for ${cacheKey}: no limits read`);
130
+ })
131
+ .catch(err => log(`model list refresh for ${cacheKey} failed: ${err?.message}`))
132
+ .finally(() => running.delete(cacheKey));
133
+ running.set(cacheKey, read);
134
+ return read;
135
+ };
136
+ }
137
+
108
138
  // One address however it is spelled: scheme and host are case-blind, and a trailing slash adds nothing.
109
139
  export function normalizeBaseURL(baseURL) {
110
140
  return String(baseURL || '').trim().replace(/\/+$/, '').replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, m => m.toLowerCase());
@@ -117,7 +147,7 @@ export function windowsCacheKey(profileKey, baseURL) {
117
147
  // The cached windows of one profile while they are fresh, without reading the list again.
118
148
  export function cachedProfileWindows(stateDir, cacheKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
119
149
  const entry = readWindowsCache(stateDir)[cacheKey];
120
- return entry?.at && now - entry.at < ttlMs ? entry.models || {} : {};
150
+ return isFresh(entry, now, ttlMs) ? entry.models || {} : {};
121
151
  }
122
152
 
123
153
  export function windowsCachePath(stateDir) {