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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -2
- package/README.vi.md +3 -2
- package/formats.mjs +158 -13
- package/package.json +1 -1
- package/proxy.mjs +54 -38
- package/scripts/run-tests.mjs +60 -12
- package/switch.mjs +38 -13
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-check.test.mjs +7 -2
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +301 -1
- package/tests/gateway.e2e.test.mjs +231 -1
- package/tests/goalcheck.e2e.test.mjs +90 -8
- package/tests/idlecompact.e2e.test.mjs +6 -1
- package/tests/lifecycle.test.mjs +5 -1
- package/tests/run-tests.test.mjs +50 -0
- package/tests/shim.test.mjs +24 -18
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +147 -3
- package/windows.mjs +63 -6
package/tests/shim.test.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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(
|
|
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 });
|
package/tests/switch.test.mjs
CHANGED
|
@@ -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');
|
package/tests/version.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/tests/windows.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
}
|