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.
- package/CHANGELOG.md +15 -0
- package/README.md +2 -1
- package/README.vi.md +2 -1
- package/formats.mjs +63 -21
- package/package.json +1 -1
- package/proxy.mjs +34 -28
- package/scripts/run-tests.mjs +60 -24
- package/switch.mjs +38 -13
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/formats.test.mjs +180 -5
- package/tests/gateway.e2e.test.mjs +102 -1
- package/tests/goalcheck.e2e.test.mjs +44 -6
- 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 +16 -13
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/windows.test.mjs +85 -3
- package/windows.mjs +35 -5
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/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';
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
150
|
+
return isFresh(entry, now, ttlMs) ? entry.models || {} : {};
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
export function windowsCachePath(stateDir) {
|