llm-switcher 1.6.11 → 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.
@@ -34,7 +34,10 @@ if (process.platform !== 'win32') {
34
34
  // Only node, never the directory it lives in: an npm install puts the real claude and codex next to it.
35
35
  const NODE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'shimnode-'));
36
36
  if (process.platform !== 'win32') fs.symlinkSync(process.execPath, path.join(NODE_DIR, 'node'));
37
+ // Per-test fake binaries live here, so one teardown removes them even when a test fails.
38
+ const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), 'shimscratch-'));
37
39
  test.after(() => {
40
+ fs.rmSync(SCRATCH, { recursive: true, force: true });
38
41
  fs.rmSync(NODE_DIR, { recursive: true, force: true });
39
42
  fs.rmSync(RENDER_DIR, { recursive: true, force: true });
40
43
  fs.rmSync(STATE, { recursive: true, force: true });
@@ -61,7 +64,7 @@ function runShim(name, args, { active, fakeDir, extraPath = '', env = {}, files
61
64
  // Prints every value the switcher could have touched, so a test can see both what was scrubbed
62
65
  // and what must have been kept.
63
66
  function makeEnvBin(name) {
64
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimenv-'));
67
+ const dir = fs.mkdtempSync(path.join(SCRATCH, 'shimenv-'));
65
68
  fs.writeFileSync(path.join(dir, name),
66
69
  '#!/usr/bin/env bash\n'
67
70
  + 'echo "HTTPS_PROXY=${HTTPS_PROXY:-NONE} BASE=${ANTHROPIC_BASE_URL:-NONE} OPENAI=${OPENAI_BASE_URL:-NONE} MODEL=${ANTHROPIC_MODEL:-NONE} DEFOPUS=${ANTHROPIC_DEFAULT_OPUS_MODEL:-NONE} CA=${NODE_EXTRA_CA_CERTS:-NONE}"\n'
@@ -73,14 +76,14 @@ function makeEnvBin(name) {
73
76
 
74
77
  // A fake binary that prints every argument on its own line, so the test sees argument boundaries.
75
78
  function makeArgvBin(name) {
76
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimargv-'));
79
+ const dir = fs.mkdtempSync(path.join(SCRATCH, 'shimargv-'));
77
80
  fs.writeFileSync(path.join(dir, name), '#!/usr/bin/env bash\necho "HTTPS_PROXY=${HTTPS_PROXY:-NONE}"\nprintf \'%s\\n\' "$@"\n');
78
81
  fs.chmodSync(path.join(dir, name), 0o755);
79
82
  return dir;
80
83
  }
81
84
 
82
85
  function makeFakeBin(name) {
83
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimtest-'));
86
+ const dir = fs.mkdtempSync(path.join(SCRATCH, 'shimtest-'));
84
87
  fs.writeFileSync(path.join(dir, name),
85
88
  '#!/usr/bin/env bash\necho "BASE=${ANTHROPIC_BASE_URL:-NONE} ARGS=$*"\n');
86
89
  fs.chmodSync(path.join(dir, name), 0o755);
@@ -118,7 +121,7 @@ test('shim never recurses into itself', (t) => {
118
121
 
119
122
  test('shim fails loudly (127) when the real binary is missing', (t) => {
120
123
  if (process.platform === 'win32') return t.skip('posix only');
121
- const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'shimempty-'));
124
+ const empty = fs.mkdtempSync(path.join(SCRATCH, 'shimempty-'));
122
125
  assert.throws(
123
126
  () => runShim('claude', [], { active: true, fakeDir: empty }),
124
127
  (err) => err.status === 127,
@@ -538,19 +541,22 @@ async function agyListener(kind) {
538
541
  async function runAgyWindowsShim(port) {
539
542
  const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
540
543
  const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
541
- fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), `@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\nnode "${AGY_REQUEST}"\r\n`);
542
- for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
543
- fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
544
- fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
545
- const shim = path.join(shimDir, 'agy.cmd');
546
- fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
547
- const started = Date.now();
548
- const child = await runAsync('cmd.exe', ['/d', '/c', shim], {
549
- env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
550
- });
551
- fs.rmSync(shimDir, { recursive: true, force: true });
552
- fs.rmSync(fakeDir, { recursive: true, force: true });
553
- return { out: child.stdout || '', err: child.stderr || '', ms: Date.now() - started };
544
+ try {
545
+ fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), `@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\nnode "${AGY_REQUEST}"\r\n`);
546
+ for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
547
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
548
+ fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
549
+ const shim = path.join(shimDir, 'agy.cmd');
550
+ fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
551
+ const started = Date.now();
552
+ const child = await runAsync('cmd.exe', ['/d', '/c', shim], {
553
+ env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
554
+ });
555
+ return { out: child.stdout || '', err: child.stderr || '', ms: Date.now() - started };
556
+ } finally {
557
+ fs.rmSync(shimDir, { recursive: true, force: true });
558
+ fs.rmSync(fakeDir, { recursive: true, force: true });
559
+ }
554
560
  }
555
561
 
556
562
  for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
@@ -583,7 +589,7 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
583
589
  const server = await agyListener(kind);
584
590
  t.after(() => server.close());
585
591
  const port = server.address().port;
586
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
592
+ const dir = fs.mkdtempSync(path.join(SCRATCH, 'agyposix-'));
587
593
  fs.writeFileSync(path.join(dir, 'agy'), `#!/usr/bin/env bash\necho "CCU=[\${CLOUD_CODE_URL:-}]"\nnode "${AGY_REQUEST}"\n`);
588
594
  fs.chmodSync(path.join(dir, 'agy'), 0o755);
589
595
  for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
@@ -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,5 +1,5 @@
1
1
  // The running version and the update notice. The registry is a local server, never npm.
2
- import { test } from 'node:test';
2
+ import { test, after } from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
@@ -15,7 +15,10 @@ async function registry(handler) {
15
15
  await new Promise(r => server.listen(0, '127.0.0.1', r));
16
16
  return { url: `http://127.0.0.1:${server.address().port}/llm-switcher/latest`, hits: () => hits, close: () => server.close() };
17
17
  }
18
- const tmpState = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-version-'));
18
+ // Every state dir is removed after the file, also the ones a failed test did not reach.
19
+ const made = [];
20
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
21
+ const tmpState = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-version-')); made.push(d); return d; };
19
22
 
20
23
  test('the current version is the one in package.json', () => {
21
24
  assert.equal(CURRENT_VERSION, pkg.version);
@@ -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';
@@ -6,10 +6,18 @@ import path from 'node:path';
6
6
  import http from 'node:http';
7
7
  import {
8
8
  parseModelWindows, parseModelList, fetchProfileWindows,
9
- resolveProfileWindows, readWindowsCache, readLocalCodexWindows
9
+ resolveProfileWindows, readWindowsCache, readLocalCodexWindows,
10
+ windowsCacheKey, cachedProfileWindows, createWindowsRefresher
10
11
  } from '../windows.mjs';
11
12
 
12
- 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
+ };
13
21
 
14
22
  // A Codex model publishes both windows; intact names the threshold itself. Neither is guessed.
15
23
  test('a list entry gives the whole window and the window to compress at', () => {
@@ -152,3 +160,139 @@ test('the Codex CLI own cache is a fallback, not a source of truth', () => {
152
160
  fs.rmSync(home, { recursive: true, force: true });
153
161
  }
154
162
  });
163
+
164
+ // A profile whose baseURL is spelled with another case or a trailing slash is the same address.
165
+ test('the cache key of a profile ignores the case of scheme and host and a trailing slash', () => {
166
+ assert.equal(windowsCacheKey('p', 'HTTP://Api.Example.COM:8080/v1/'), 'p@http://api.example.com:8080/v1');
167
+ assert.equal(windowsCacheKey('p', 'http://api.example.com:8080/v1'), 'p@http://api.example.com:8080/v1');
168
+ assert.equal(windowsCacheKey('p', 'https://api.example.com/V1//'), 'p@https://api.example.com/V1', 'the path keeps its case');
169
+ assert.equal(windowsCacheKey(null, 'https://h/v1'), 'profile@https://h/v1');
170
+ });
171
+
172
+ // The output clamp reads the cache without a refresh, so it trusts only what this profile read within the hour.
173
+ test('cachedProfileWindows answers only for its own key and only while the entry is fresh', () => {
174
+ const dir = tmpDir();
175
+ try {
176
+ const models = { m: { context: 200000, compact: 0, output: 8192 } };
177
+ fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ 'a@http://h/v1': { at: 1_000, baseURL: 'http://h/v1', models } }));
178
+ assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 1_000 + 60_000 }), models);
179
+ assert.deepEqual(cachedProfileWindows(dir, 'b@http://h/v1', { now: 1_000 + 60_000 }), {}, 'another profile on the same baseURL');
180
+ assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 1_000 + 3_600_000 }), {}, 'an entry an hour old');
181
+ assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 2_000, ttlMs: 500 }), {});
182
+ } finally {
183
+ fs.rmSync(dir, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ // A list that names only an output limit must not erase the windows an earlier read found, and an
188
+ // output limit lives only as long as the newest list still states it.
189
+ test('a refresh merges each model into the cached windows, but never carries an output limit forward', async () => {
190
+ const dir = tmpDir();
191
+ const lists = [
192
+ [{ id: 'm', context_length: 200000, compact_window: 150000, max_output_tokens: 4096 }, { id: 'n', context_length: 100000, max_output_tokens: 2048 }],
193
+ [{ id: 'm', context_length: 300000 }, { id: 'o', context_length: 50000 }],
194
+ [{ id: 'm', max_output_tokens: 8192 }]
195
+ ];
196
+ let hits = 0;
197
+ const sink = http.createServer((req, res) => {
198
+ res.writeHead(200, { 'content-type': 'application/json' });
199
+ res.end(JSON.stringify({ data: lists[hits++] }));
200
+ });
201
+ await new Promise(r => sink.listen(0, '127.0.0.1', r));
202
+ const base = `http://127.0.0.1:${sink.address().port}/v1`;
203
+ try {
204
+ await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 });
205
+ const second = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 + 3_600_000 });
206
+ const expected = {
207
+ m: { context: 300000, compact: 150000 },
208
+ n: { context: 100000, compact: 0 },
209
+ o: { context: 50000, compact: 0 }
210
+ };
211
+ assert.deepEqual(second, expected, 'a changed window replaces the cached one; no output limit the list did not state');
212
+ assert.deepEqual(readWindowsCache(dir).p.models, expected);
213
+ const fresh = cachedProfileWindows(dir, 'p', { now: 1_000 + 3_600_000 + 60_000 });
214
+ assert.equal(fresh.m.output, undefined);
215
+ assert.equal(fresh.m.context, 300000);
216
+ const third = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 + 7_200_000 });
217
+ assert.equal(hits, 3);
218
+ assert.deepEqual(third.m, { context: 300000, compact: 150000, output: 8192 }, 'an output-only list keeps the windows');
219
+ } finally {
220
+ sink.close();
221
+ fs.rmSync(dir, { recursive: true, force: true });
222
+ }
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,17 +86,70 @@ 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 || {};
87
- const models = await fetchProfileWindows(baseURL, apiKey);
88
- if (Object.keys(models).length) {
89
- cache[cacheKey] = { at: now, baseURL, models };
90
- writeWindowsCache(stateDir, cache);
89
+ if (isFresh(entry, now, ttlMs)) return entry.models || {};
90
+ const listed = await fetchProfileWindows(baseURL, apiKey);
91
+ if (Object.keys(listed).length) {
92
+ // Merged per model and per number: a list that names only an output limit keeps the cached windows.
93
+ // An output limit is never carried forward: the entry is stamped now, so it must be one this list states.
94
+ const models = {};
95
+ for (const [id, w] of Object.entries(entry.models || {})) {
96
+ const { output: _output, ...windows } = w && typeof w === 'object' ? w : {};
97
+ models[id] = windows;
98
+ }
99
+ for (const [id, w] of Object.entries(listed)) {
100
+ const merged = { ...(models[id] || {}) };
101
+ for (const [k, v] of Object.entries(w)) if (v > 0 || !(merged[k] > 0)) merged[k] = v;
102
+ models[id] = merged;
103
+ }
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);
91
108
  return models;
92
109
  }
93
110
  return entry.models || {};
94
111
  }
95
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
+
138
+ // One address however it is spelled: scheme and host are case-blind, and a trailing slash adds nothing.
139
+ export function normalizeBaseURL(baseURL) {
140
+ return String(baseURL || '').trim().replace(/\/+$/, '').replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, m => m.toLowerCase());
141
+ }
142
+
143
+ export function windowsCacheKey(profileKey, baseURL) {
144
+ return `${profileKey || 'profile'}@${normalizeBaseURL(baseURL)}`;
145
+ }
146
+
147
+ // The cached windows of one profile while they are fresh, without reading the list again.
148
+ export function cachedProfileWindows(stateDir, cacheKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
149
+ const entry = readWindowsCache(stateDir)[cacheKey];
150
+ return isFresh(entry, now, ttlMs) ? entry.models || {} : {};
151
+ }
152
+
96
153
  export function windowsCachePath(stateDir) {
97
154
  return path.join(stateDir, 'model-windows.json');
98
155
  }