llm-switcher 1.6.10 → 1.6.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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/formats.mjs +147 -12
- package/package.json +1 -1
- package/proxy.mjs +55 -19
- package/scripts/run-tests.mjs +12 -0
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/cdp.mjs +8 -1
- package/tests/contract-check.test.mjs +7 -2
- package/tests/dashboard-fixture.mjs +28 -16
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +171 -1
- package/tests/gateway.e2e.test.mjs +209 -7
- package/tests/goalcheck.e2e.test.mjs +46 -2
- package/tests/helpers.mjs +60 -0
- package/tests/shim.test.mjs +8 -5
- package/tests/teardown.test.mjs +84 -0
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +94 -2
- package/windows.mjs +34 -5
|
@@ -27,6 +27,7 @@ async function fixture(t) {
|
|
|
27
27
|
let nativeVerdict = { ok: true, reason: 'looks complete' };
|
|
28
28
|
let jevScores = { complete: 0.14, evidence: 0.84, unfinished: 0.63 };
|
|
29
29
|
let jevStatus = 200;
|
|
30
|
+
const chatRequests = [];
|
|
30
31
|
|
|
31
32
|
// Mock native upstream (Anthropic format)
|
|
32
33
|
const nativeUp = http.createServer((req, res) => {
|
|
@@ -40,6 +41,16 @@ async function fixture(t) {
|
|
|
40
41
|
const body = JSON.parse(raw);
|
|
41
42
|
const isStream = Boolean(body.stream);
|
|
42
43
|
const respText = JSON.stringify(nativeVerdict);
|
|
44
|
+
// A text-only chat model: it refuses any image part.
|
|
45
|
+
if (req.url.endsWith('/chat/completions')) {
|
|
46
|
+
chatRequests.push(body);
|
|
47
|
+
res.setHeader('content-type', 'application/json');
|
|
48
|
+
if (body.messages.some(m => Array.isArray(m.content) && m.content.some(c => c?.type === 'image_url'))) {
|
|
49
|
+
res.statusCode = 400;
|
|
50
|
+
return res.end(JSON.stringify({ error: { message: 'Failed to deserialize the JSON body: messages[1]: unknown variant `image_url`, expected `text`' } }));
|
|
51
|
+
}
|
|
52
|
+
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: body.model, choices: [{ index: 0, message: { role: 'assistant', content: respText }, finish_reason: 'stop' }] }));
|
|
53
|
+
}
|
|
43
54
|
|
|
44
55
|
if (isStream) {
|
|
45
56
|
res.setHeader('content-type', 'text/event-stream');
|
|
@@ -110,6 +121,16 @@ async function fixture(t) {
|
|
|
110
121
|
outFormat: 'anthropic',
|
|
111
122
|
baseURL: `http://127.0.0.1:${nativeUp.address().port}`,
|
|
112
123
|
apiKey: 'sk-native-key'
|
|
124
|
+
},
|
|
125
|
+
chat: {
|
|
126
|
+
name: 'ChatMock',
|
|
127
|
+
mode: 'convert',
|
|
128
|
+
tool: 'claude',
|
|
129
|
+
inFormat: 'auto',
|
|
130
|
+
outFormat: 'openai-chat',
|
|
131
|
+
baseURL: `http://127.0.0.1:${nativeUp.address().port}/v1`,
|
|
132
|
+
apiKey: 'sk-chat-key',
|
|
133
|
+
defaultModels: { opus: 'up-textonly', sonnet: 'up-textonly', haiku: 'up-textonly', fable: 'up-textonly' }
|
|
113
134
|
}
|
|
114
135
|
},
|
|
115
136
|
goalCheck: {
|
|
@@ -166,11 +187,12 @@ async function fixture(t) {
|
|
|
166
187
|
body: body ? JSON.stringify(body) : undefined
|
|
167
188
|
});
|
|
168
189
|
|
|
169
|
-
const sendClaude = (body, stream = false) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
190
|
+
const sendClaude = (body, stream = false, headers = {}) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
170
191
|
method: 'POST',
|
|
171
192
|
headers: {
|
|
172
193
|
'content-type': 'application/json',
|
|
173
|
-
'user-agent': 'claude-cli/2.1.295'
|
|
194
|
+
'user-agent': 'claude-cli/2.1.295',
|
|
195
|
+
...headers
|
|
174
196
|
},
|
|
175
197
|
body: JSON.stringify({ ...body, stream })
|
|
176
198
|
});
|
|
@@ -183,6 +205,7 @@ async function fixture(t) {
|
|
|
183
205
|
setJevScores: s => { jevScores = s; },
|
|
184
206
|
setJevStatus: st => { jevStatus = st; },
|
|
185
207
|
jevRequests,
|
|
208
|
+
chatRequests,
|
|
186
209
|
logs: () => logs
|
|
187
210
|
};
|
|
188
211
|
}
|
|
@@ -314,3 +337,24 @@ test('goalcheck e2e: live interception, veto, and allow behaviors', async t => {
|
|
|
314
337
|
assert.ok(logsData.logs.length >= 4);
|
|
315
338
|
assert.equal(logsData.logs[0].verdictType, 'Unavailable');
|
|
316
339
|
});
|
|
340
|
+
|
|
341
|
+
test('goalcheck e2e: an openai-chat evaluator call that a text-only model refuses for an image is retried as text', async t => {
|
|
342
|
+
const f = await fixture(t);
|
|
343
|
+
await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
|
|
344
|
+
f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
|
|
345
|
+
f.setNativeVerdict({ ok: true, reason: 'screenshot shows green tests' });
|
|
346
|
+
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
347
|
+
const request = {
|
|
348
|
+
...evaluatorRequest,
|
|
349
|
+
messages: [
|
|
350
|
+
{ role: 'user', content: [image, { type: 'text', text: 'Please fix the failing tests in repo.' }] },
|
|
351
|
+
...evaluatorRequest.messages.slice(1)
|
|
352
|
+
]
|
|
353
|
+
};
|
|
354
|
+
const res = await f.sendClaude(request, false, { 'x-llm-profile': 'chat' });
|
|
355
|
+
assert.equal(res.status, 200);
|
|
356
|
+
const verdict = JSON.parse((await res.json()).content[0].text);
|
|
357
|
+
assert.deepEqual(verdict, { ok: true, reason: 'screenshot shows green tests' });
|
|
358
|
+
assert.equal(f.chatRequests.length, 2, 'one refusal, one retry');
|
|
359
|
+
assert.ok(JSON.stringify(f.chatRequests[1]).includes('[image omitted'), 'the retry carries text in place of the image');
|
|
360
|
+
});
|
package/tests/helpers.mjs
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
6
|
+
|
|
7
|
+
// The pids of every process whose command line names `dir`, this one excluded. A gateway records
|
|
8
|
+
// its detached interceptor only once it answers, so a teardown that trusts the record misses one
|
|
9
|
+
// still starting. POSIX only: on Windows it returns [].
|
|
10
|
+
export function pidsNaming(dir) {
|
|
11
|
+
if (process.platform === 'win32' || !dir) return [];
|
|
12
|
+
const r = spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8' });
|
|
13
|
+
if (r.status !== 0) return [];
|
|
14
|
+
return r.stdout.split('\n')
|
|
15
|
+
.map(line => line.trim().match(/^(\d+)\s+(.*)$/))
|
|
16
|
+
.filter(m => m && m[2].includes(dir) && Number(m[1]) !== process.pid)
|
|
17
|
+
.map(m => Number(m[1]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Stops a child and waits for its exit, so nothing it does later lands in a removed directory.
|
|
21
|
+
export async function stopChild(child, ms = 3000) {
|
|
22
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
23
|
+
const exited = new Promise(r => child.once('exit', r));
|
|
24
|
+
child.kill('SIGTERM');
|
|
25
|
+
if (await Promise.race([exited.then(() => true), sleep(ms).then(() => false)])) return;
|
|
26
|
+
child.kill('SIGKILL');
|
|
27
|
+
await Promise.race([exited, sleep(ms)]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Stops every process that still names `dir` (SIGTERM, then SIGKILL), then removes the dir.
|
|
31
|
+
export async function reapDir(dir) {
|
|
32
|
+
for (let i = 0; i < 30; i++) {
|
|
33
|
+
const left = pidsNaming(dir);
|
|
34
|
+
if (!left.length) break;
|
|
35
|
+
for (const pid of left) try { process.kill(pid, i < 20 ? 'SIGTERM' : 'SIGKILL'); } catch { /* gone */ }
|
|
36
|
+
await sleep(100);
|
|
37
|
+
}
|
|
38
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The same, synchronous, for a process that is about to end. A negative pid is a process group.
|
|
42
|
+
export function reapDirSync(dir, pids = []) {
|
|
43
|
+
for (const pid of [...pids.filter(Number.isInteger), ...pidsNaming(dir)]) try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
|
44
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort at exit */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Runs `fn` when this process ends before its teardown did: at exit, or on a signal, which ends a
|
|
48
|
+
// test process with no after() hook. Returns the function that cancels it.
|
|
49
|
+
export function onAbnormalExit(fn) {
|
|
50
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
51
|
+
const run = () => { off(); try { fn(); } catch { /* best effort at exit */ } };
|
|
52
|
+
// The listener replaces the default action, so the signal is raised again once it is gone.
|
|
53
|
+
const onSignal = (sig) => { run(); process.kill(process.pid, sig); };
|
|
54
|
+
function off() {
|
|
55
|
+
process.off('exit', run);
|
|
56
|
+
for (const sig of SIGNALS) process.off(sig, onSignal);
|
|
57
|
+
}
|
|
58
|
+
process.once('exit', run);
|
|
59
|
+
for (const sig of SIGNALS) process.on(sig, onSignal);
|
|
60
|
+
return off;
|
|
61
|
+
}
|
|
2
62
|
|
|
3
63
|
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
64
|
export function assertValidAnthropicEvents(events) {
|
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,
|
|
@@ -583,7 +586,7 @@ for (const [kind, routed] of [['forged', false], ['silent', false], ['real', tru
|
|
|
583
586
|
const server = await agyListener(kind);
|
|
584
587
|
t.after(() => server.close());
|
|
585
588
|
const port = server.address().port;
|
|
586
|
-
const dir = fs.mkdtempSync(path.join(
|
|
589
|
+
const dir = fs.mkdtempSync(path.join(SCRATCH, 'agyposix-'));
|
|
587
590
|
fs.writeFileSync(path.join(dir, 'agy'), `#!/usr/bin/env bash\necho "CCU=[\${CLOUD_CODE_URL:-}]"\nnode "${AGY_REQUEST}"\n`);
|
|
588
591
|
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
589
592
|
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The browser tests start a gateway, a stand-in interceptor and a Chromium, and the gateway can
|
|
2
|
+
// start a real interceptor of its own, detached. Whatever ends the run, none of them and none of
|
|
3
|
+
// their temp dirs may outlive it. Each case runs in a child process with a private TMPDIR, so
|
|
4
|
+
// "nothing left" is a check on that one directory.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
import { skipReason } from './cdp.mjs';
|
|
13
|
+
import { pidsNaming } from './helpers.mjs';
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const url = (f) => JSON.stringify(pathToFileURL(path.join(HERE, f)).href);
|
|
17
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
18
|
+
const skip = process.platform === 'win32' ? 'lists processes with ps' : skipReason();
|
|
19
|
+
|
|
20
|
+
// Starts the fixture and a browser, then moves the interceptor to a new port so the gateway starts
|
|
21
|
+
// a real one. `awaitStart` false: the script does not wait for the gateway to record it.
|
|
22
|
+
const PRELUDE = (awaitStart) => `
|
|
23
|
+
import { startDashboardFixture } from ${url('dashboard-fixture.mjs')};
|
|
24
|
+
import { launchBrowser } from ${url('cdp.mjs')};
|
|
25
|
+
import { pidsNaming } from ${url('helpers.mjs')};
|
|
26
|
+
import net from 'node:net';
|
|
27
|
+
const free = () => new Promise(r => { const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); }); });
|
|
28
|
+
const fx = await startDashboardFixture();
|
|
29
|
+
const browser = await launchBrowser();
|
|
30
|
+
await browser.newPage();
|
|
31
|
+
const cfg = fx.config();
|
|
32
|
+
cfg.blindfold.port = await free();
|
|
33
|
+
fx.writeConfig(cfg);
|
|
34
|
+
const sync = fx.api('/api/blindfold/sync', {});
|
|
35
|
+
${awaitStart ? `const r = await sync; if (r.status !== 200) throw new Error('no interceptor: ' + JSON.stringify(r.json));` : 'sync.catch(() => {});'}
|
|
36
|
+
for (let i = 0; i < 200 && !pidsNaming(fx.dir).length; i++) await new Promise(r => setTimeout(r, 25));
|
|
37
|
+
if (!pidsNaming(fx.dir).length) throw new Error('the gateway started no interceptor');
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
async function inPrivateTmp(t, script, act) {
|
|
41
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-teardown-'));
|
|
42
|
+
t.after(() => {
|
|
43
|
+
// A red run must not leave what it found behind.
|
|
44
|
+
for (const pid of pidsNaming(tmp)) try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
|
45
|
+
fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
46
|
+
});
|
|
47
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
|
48
|
+
env: { ...process.env, TMPDIR: tmp, TMP: tmp, TEMP: tmp }, stdio: ['ignore', 'pipe', 'pipe']
|
|
49
|
+
});
|
|
50
|
+
let out = '';
|
|
51
|
+
child.stdout.on('data', d => { out += d; });
|
|
52
|
+
child.stderr.on('data', d => { out += d; });
|
|
53
|
+
const exited = new Promise(r => child.once('exit', r));
|
|
54
|
+
await act(child, () => out);
|
|
55
|
+
await Promise.race([exited, sleep(30000)]);
|
|
56
|
+
// A detached process may take a moment to die after the signal.
|
|
57
|
+
let left = pidsNaming(tmp);
|
|
58
|
+
for (let i = 0; i < 50 && left.length; i++) { await sleep(100); left = pidsNaming(tmp); }
|
|
59
|
+
return { left, files: fs.readdirSync(tmp), out: () => out };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('stop() and close() remove an interceptor the gateway had not recorded yet, and every temp dir', { skip, timeout: 90000 }, async (t) => {
|
|
63
|
+
const r = await inPrivateTmp(t, `${PRELUDE(false)}
|
|
64
|
+
await fx.stop();
|
|
65
|
+
await browser.close();
|
|
66
|
+
console.log('DONE');
|
|
67
|
+
`, async () => {});
|
|
68
|
+
assert.match(r.out(), /DONE/, r.out());
|
|
69
|
+
assert.deepEqual(r.left, [], 'no process names the temp dir');
|
|
70
|
+
assert.deepEqual(r.files, [], 'no temp dir is left');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a test process stopped by SIGTERM still stops its gateway, interceptor and browser', { skip, timeout: 90000 }, async (t) => {
|
|
74
|
+
const r = await inPrivateTmp(t, `${PRELUDE(true)}
|
|
75
|
+
console.log('READY');
|
|
76
|
+
setInterval(() => {}, 1000);
|
|
77
|
+
`, async (child, out) => {
|
|
78
|
+
for (let i = 0; i < 600 && !/READY/.test(out()) && child.exitCode === null; i++) await sleep(100);
|
|
79
|
+
assert.match(out(), /READY/, out());
|
|
80
|
+
child.kill('SIGTERM');
|
|
81
|
+
});
|
|
82
|
+
assert.deepEqual(r.left, [], 'no process names the temp dir');
|
|
83
|
+
assert.deepEqual(r.files, [], 'no temp dir is left');
|
|
84
|
+
});
|
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
|
@@ -6,7 +6,8 @@ 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
|
|
10
11
|
} from '../windows.mjs';
|
|
11
12
|
|
|
12
13
|
const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-windows-'));
|
|
@@ -20,11 +21,22 @@ test('a list entry gives the whole window and the window to compress at', () =>
|
|
|
20
21
|
assert.deepEqual(parseModelWindows({ id: 'groq/q', context_window: 131072 }), { context: 131072, compact: 0 },
|
|
21
22
|
'one window is the whole window, not a threshold');
|
|
22
23
|
assert.deepEqual(parseModelWindows({ id: 'or/r', context_length: 262144, top_provider: { context_length: 262144, max_completion_tokens: 32768 } }),
|
|
23
|
-
{ context: 262144, compact: 0 });
|
|
24
|
+
{ context: 262144, compact: 0, output: 32768 });
|
|
24
25
|
assert.deepEqual(parseModelWindows({ id: 'bare' }), null, 'a model with no limits says nothing');
|
|
25
26
|
assert.deepEqual(parseModelWindows({ id: 'zero', context_length: 0, compact_window: 0 }), null);
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
// The output limit lets the gateway clamp a max_tokens the model would refuse.
|
|
30
|
+
test('a list entry keeps the output limit it names, and leaves it out when it names none', () => {
|
|
31
|
+
assert.deepEqual(parseModelWindows({ id: 'i/m', context_length: 200000, max_output_tokens: 8192 }),
|
|
32
|
+
{ context: 200000, compact: 0, output: 8192 });
|
|
33
|
+
assert.deepEqual(parseModelWindows({ id: 'only-out', max_output_tokens: 4096 }), { context: 0, compact: 0, output: 4096 },
|
|
34
|
+
'an output limit alone is still worth keeping');
|
|
35
|
+
assert.equal('output' in parseModelWindows({ id: 'g', context_length: 131072 }), false);
|
|
36
|
+
assert.deepEqual(parseModelList({ data: [{ id: 'i/m', context_length: 200000, max_output_tokens: 8192 }] }),
|
|
37
|
+
{ 'i/m': { context: 200000, compact: 0, output: 8192 } });
|
|
38
|
+
});
|
|
39
|
+
|
|
28
40
|
test('parseModelList keys the windows by the id a profile maps, and drops the nameless', () => {
|
|
29
41
|
const out = parseModelList({ data: [{ id: 'models/big', context_length: 1048576 }, { id: 'bare' }, 'plain'] });
|
|
30
42
|
assert.deepEqual(Object.keys(out), ['big'], 'a bare string carries no window, and a models/ prefix is dropped');
|
|
@@ -90,6 +102,25 @@ test('windows are cached for an hour, and a failed refresh keeps the stale answe
|
|
|
90
102
|
}
|
|
91
103
|
});
|
|
92
104
|
|
|
105
|
+
test('the cache persists the output limit, and an old cache file without it still reads', async () => {
|
|
106
|
+
const dir = tmpDir();
|
|
107
|
+
const sink = http.createServer((req, res) => {
|
|
108
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
109
|
+
res.end(JSON.stringify({ data: [{ id: 'm', context_length: 200000, max_output_tokens: 8192 }] }));
|
|
110
|
+
});
|
|
111
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
112
|
+
const base = `http://127.0.0.1:${sink.address().port}/v1`;
|
|
113
|
+
try {
|
|
114
|
+
await resolveProfileWindows(dir, 'p@' + base, base, 'k', { now: 1_000 });
|
|
115
|
+
assert.equal(readWindowsCache(dir)['p@' + base].models.m.output, 8192);
|
|
116
|
+
fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ old: { at: 1_000, baseURL: base, models: { m: { context: 200000, compact: 0 } } } }));
|
|
117
|
+
assert.deepEqual(await resolveProfileWindows(dir, 'old', base, 'k', { now: 2_000 }), { m: { context: 200000, compact: 0 } });
|
|
118
|
+
} finally {
|
|
119
|
+
sink.close();
|
|
120
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
93
124
|
test('an empty cache file reads as no windows instead of throwing', () => {
|
|
94
125
|
const dir = tmpDir();
|
|
95
126
|
try {
|
|
@@ -122,3 +153,64 @@ test('the Codex CLI own cache is a fallback, not a source of truth', () => {
|
|
|
122
153
|
fs.rmSync(home, { recursive: true, force: true });
|
|
123
154
|
}
|
|
124
155
|
});
|
|
156
|
+
|
|
157
|
+
// A profile whose baseURL is spelled with another case or a trailing slash is the same address.
|
|
158
|
+
test('the cache key of a profile ignores the case of scheme and host and a trailing slash', () => {
|
|
159
|
+
assert.equal(windowsCacheKey('p', 'HTTP://Api.Example.COM:8080/v1/'), 'p@http://api.example.com:8080/v1');
|
|
160
|
+
assert.equal(windowsCacheKey('p', 'http://api.example.com:8080/v1'), 'p@http://api.example.com:8080/v1');
|
|
161
|
+
assert.equal(windowsCacheKey('p', 'https://api.example.com/V1//'), 'p@https://api.example.com/V1', 'the path keeps its case');
|
|
162
|
+
assert.equal(windowsCacheKey(null, 'https://h/v1'), 'profile@https://h/v1');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The output clamp reads the cache without a refresh, so it trusts only what this profile read within the hour.
|
|
166
|
+
test('cachedProfileWindows answers only for its own key and only while the entry is fresh', () => {
|
|
167
|
+
const dir = tmpDir();
|
|
168
|
+
try {
|
|
169
|
+
const models = { m: { context: 200000, compact: 0, output: 8192 } };
|
|
170
|
+
fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ 'a@http://h/v1': { at: 1_000, baseURL: 'http://h/v1', models } }));
|
|
171
|
+
assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 1_000 + 60_000 }), models);
|
|
172
|
+
assert.deepEqual(cachedProfileWindows(dir, 'b@http://h/v1', { now: 1_000 + 60_000 }), {}, 'another profile on the same baseURL');
|
|
173
|
+
assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 1_000 + 3_600_000 }), {}, 'an entry an hour old');
|
|
174
|
+
assert.deepEqual(cachedProfileWindows(dir, 'a@http://h/v1', { now: 2_000, ttlMs: 500 }), {});
|
|
175
|
+
} finally {
|
|
176
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A list that names only an output limit must not erase the windows an earlier read found, and an
|
|
181
|
+
// output limit lives only as long as the newest list still states it.
|
|
182
|
+
test('a refresh merges each model into the cached windows, but never carries an output limit forward', async () => {
|
|
183
|
+
const dir = tmpDir();
|
|
184
|
+
const lists = [
|
|
185
|
+
[{ id: 'm', context_length: 200000, compact_window: 150000, max_output_tokens: 4096 }, { id: 'n', context_length: 100000, max_output_tokens: 2048 }],
|
|
186
|
+
[{ id: 'm', context_length: 300000 }, { id: 'o', context_length: 50000 }],
|
|
187
|
+
[{ id: 'm', max_output_tokens: 8192 }]
|
|
188
|
+
];
|
|
189
|
+
let hits = 0;
|
|
190
|
+
const sink = http.createServer((req, res) => {
|
|
191
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
192
|
+
res.end(JSON.stringify({ data: lists[hits++] }));
|
|
193
|
+
});
|
|
194
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
195
|
+
const base = `http://127.0.0.1:${sink.address().port}/v1`;
|
|
196
|
+
try {
|
|
197
|
+
await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 });
|
|
198
|
+
const second = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 + 3_600_000 });
|
|
199
|
+
const expected = {
|
|
200
|
+
m: { context: 300000, compact: 150000 },
|
|
201
|
+
n: { context: 100000, compact: 0 },
|
|
202
|
+
o: { context: 50000, compact: 0 }
|
|
203
|
+
};
|
|
204
|
+
assert.deepEqual(second, expected, 'a changed window replaces the cached one; no output limit the list did not state');
|
|
205
|
+
assert.deepEqual(readWindowsCache(dir).p.models, expected);
|
|
206
|
+
const fresh = cachedProfileWindows(dir, 'p', { now: 1_000 + 3_600_000 + 60_000 });
|
|
207
|
+
assert.equal(fresh.m.output, undefined);
|
|
208
|
+
assert.equal(fresh.m.context, 300000);
|
|
209
|
+
const third = await resolveProfileWindows(dir, 'p', base, 'k', { now: 1_000 + 7_200_000 });
|
|
210
|
+
assert.equal(hits, 3);
|
|
211
|
+
assert.deepEqual(third.m, { context: 300000, compact: 150000, output: 8192 }, 'an output-only list keeps the windows');
|
|
212
|
+
} finally {
|
|
213
|
+
sink.close();
|
|
214
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
package/windows.mjs
CHANGED
|
@@ -21,7 +21,8 @@ const FETCH_TIMEOUT_MS = 15000;
|
|
|
21
21
|
|
|
22
22
|
const n = v => (Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
|
|
23
23
|
|
|
24
|
-
// One entry of a provider's list -> the two windows,
|
|
24
|
+
// One entry of a provider's list -> the two windows, plus `output` (the most the model writes)
|
|
25
|
+
// when the list names it; null when it names none.
|
|
25
26
|
export function parseModelWindows(m) {
|
|
26
27
|
if (!m || typeof m !== 'object') return null;
|
|
27
28
|
const top = m.top_provider && typeof m.top_provider === 'object' ? m.top_provider : {};
|
|
@@ -30,8 +31,9 @@ export function parseModelWindows(m) {
|
|
|
30
31
|
// smaller of the two windows it publishes.
|
|
31
32
|
const compact = n(m.compact_window) || n(m.compaction_threshold)
|
|
32
33
|
|| (context && n(m.context_window) && n(m.context_window) < context ? n(m.context_window) : 0);
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const output = n(m.max_output_tokens) || n(top.max_completion_tokens);
|
|
35
|
+
if (!context && !compact && !output) return null;
|
|
36
|
+
return output ? { context, compact, output } : { context, compact };
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
// The list as it names the models, so a caller can look one up by the id a profile maps.
|
|
@@ -82,8 +84,20 @@ export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey,
|
|
|
82
84
|
const entry = cache[cacheKey] || {};
|
|
83
85
|
const fresh = entry.at && now - entry.at < ttlMs;
|
|
84
86
|
if (fresh) return entry.models || {};
|
|
85
|
-
const
|
|
86
|
-
if (Object.keys(
|
|
87
|
+
const listed = await fetchProfileWindows(baseURL, apiKey);
|
|
88
|
+
if (Object.keys(listed).length) {
|
|
89
|
+
// Merged per model and per number: a list that names only an output limit keeps the cached windows.
|
|
90
|
+
// An output limit is never carried forward: the entry is stamped now, so it must be one this list states.
|
|
91
|
+
const models = {};
|
|
92
|
+
for (const [id, w] of Object.entries(entry.models || {})) {
|
|
93
|
+
const { output: _output, ...windows } = w && typeof w === 'object' ? w : {};
|
|
94
|
+
models[id] = windows;
|
|
95
|
+
}
|
|
96
|
+
for (const [id, w] of Object.entries(listed)) {
|
|
97
|
+
const merged = { ...(models[id] || {}) };
|
|
98
|
+
for (const [k, v] of Object.entries(w)) if (v > 0 || !(merged[k] > 0)) merged[k] = v;
|
|
99
|
+
models[id] = merged;
|
|
100
|
+
}
|
|
87
101
|
cache[cacheKey] = { at: now, baseURL, models };
|
|
88
102
|
writeWindowsCache(stateDir, cache);
|
|
89
103
|
return models;
|
|
@@ -91,6 +105,21 @@ export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey,
|
|
|
91
105
|
return entry.models || {};
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
// One address however it is spelled: scheme and host are case-blind, and a trailing slash adds nothing.
|
|
109
|
+
export function normalizeBaseURL(baseURL) {
|
|
110
|
+
return String(baseURL || '').trim().replace(/\/+$/, '').replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, m => m.toLowerCase());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function windowsCacheKey(profileKey, baseURL) {
|
|
114
|
+
return `${profileKey || 'profile'}@${normalizeBaseURL(baseURL)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The cached windows of one profile while they are fresh, without reading the list again.
|
|
118
|
+
export function cachedProfileWindows(stateDir, cacheKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
|
|
119
|
+
const entry = readWindowsCache(stateDir)[cacheKey];
|
|
120
|
+
return entry?.at && now - entry.at < ttlMs ? entry.models || {} : {};
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
export function windowsCachePath(stateDir) {
|
|
95
124
|
return path.join(stateDir, 'model-windows.json');
|
|
96
125
|
}
|