llm-switcher 1.6.10 → 1.6.11
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 +6 -0
- package/formats.mjs +32 -0
- package/package.json +1 -1
- package/proxy.mjs +30 -4
- package/tests/cdp.mjs +8 -1
- package/tests/dashboard-fixture.mjs +28 -16
- package/tests/formats.test.mjs +46 -1
- package/tests/gateway.e2e.test.mjs +80 -7
- package/tests/helpers.mjs +60 -0
- package/tests/teardown.test.mjs +84 -0
- package/tests/windows.test.mjs +31 -1
- package/windows.mjs +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.11 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- **Output limit.** The proxy keeps the output limit of each model from the `/models` list of the profile (`max_output_tokens` or `top_provider.max_completion_tokens`) in `model-windows.json`. If the client asks for more tokens than this limit, the proxy sends the limit. This applies to `max_tokens`, `max_completion_tokens` and `maxOutputTokens`. If the limit is not known, the value does not change. The limit does not apply to the anthropic-to-anthropic passthrough (direct mode, outFormat `anthropic`, and the native goal-check request). These requests go to the upstream unchanged.
|
|
6
|
+
- **Images to a text-only chat model.** If a chat upstream refuses an image with HTTP 400, the proxy sends the request one more time. In that request, each image is the text `[image omitted: this model does not accept images]`. The retry happens in each request. The proxy remembers nothing between requests.
|
|
7
|
+
- **Test cleanup.** The browser tests and the gateway tests stop the gateway, the stand-in interceptor, an interceptor that the gateway started, and Chromium. They also delete their temp directories. This occurs also when a test fails or the test process gets `SIGTERM`, `SIGINT` or `SIGHUP`.
|
|
8
|
+
|
|
3
9
|
## Release 1.6.10 — 2026-10-11
|
|
4
10
|
|
|
5
11
|
- **`reasoning_content` replay.** On assistant tool-call turns, `auto` mode sends the earlier thinking as `reasoning_content`. `native` mode sends it only when the profile sets `replayReasoning: true`, because a strict OpenAI-compatible server can refuse the field. `off` mode never sends it.
|
package/formats.mjs
CHANGED
|
@@ -1654,6 +1654,13 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
|
|
|
1654
1654
|
// thinkingMode 'off' applies to all upstreams: drop the client's thinking entirely.
|
|
1655
1655
|
let src = normThinkingMode(opts.thinkingMode) === 'off' ? { ...ir, thinking: { type: 'disabled' } } : ir;
|
|
1656
1656
|
if (isAntigravityModel(model) && src.system) src = { ...src, system: src.system.replace(BILLING_HEADER_RE, '') };
|
|
1657
|
+
// An upstream refuses a max tokens above the model's output limit. The Anthropic body always
|
|
1658
|
+
// carries one (4096 when the client sent none), so that default is clamped too.
|
|
1659
|
+
const limit = opts.maxOutputTokens;
|
|
1660
|
+
if (Number.isFinite(limit) && limit > 0) {
|
|
1661
|
+
const asked = typeof src.params?.maxTokens === 'number' ? src.params.maxTokens : (outFormat === 'anthropic' ? 4096 : null);
|
|
1662
|
+
if (asked != null && asked > limit) src = { ...src, params: { ...src.params, maxTokens: limit } };
|
|
1663
|
+
}
|
|
1657
1664
|
switch (outFormat) {
|
|
1658
1665
|
case 'anthropic': return irToAnthropicBody(src, model, opts);
|
|
1659
1666
|
case 'vertex': return irToVertexBody(src, model, opts);
|
|
@@ -1662,6 +1669,31 @@ function emitUpstreamBody(outFormat, ir, model, opts = {}) {
|
|
|
1662
1669
|
}
|
|
1663
1670
|
}
|
|
1664
1671
|
|
|
1672
|
+
// A text-only chat model refuses an image part with a 400. These are the refusals seen so far;
|
|
1673
|
+
// a looser match would turn an unrelated 400 into a silent retry without the image.
|
|
1674
|
+
export const IMAGE_OMITTED_TEXT = '[image omitted: this model does not accept images]';
|
|
1675
|
+
|
|
1676
|
+
export function refusesImageInput(text) {
|
|
1677
|
+
const t = String(text || '').toLowerCase();
|
|
1678
|
+
return t.includes('unknown variant `image_url`') || t.includes('image_url is only supported by certain models')
|
|
1679
|
+
|| t.includes('does not support image') || t.includes('image input is not supported')
|
|
1680
|
+
|| (t.includes('vision') && t.includes('not supported'));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Replaces every image part of a chat body with IMAGE_OMITTED_TEXT, in place. Returns how many it replaced.
|
|
1684
|
+
export function omitChatImages(body) {
|
|
1685
|
+
let replaced = 0;
|
|
1686
|
+
for (const m of Array.isArray(body?.messages) ? body.messages : []) {
|
|
1687
|
+
if (!Array.isArray(m?.content)) continue;
|
|
1688
|
+
m.content = m.content.map(p => {
|
|
1689
|
+
if (p?.type !== 'image_url') return p;
|
|
1690
|
+
replaced++;
|
|
1691
|
+
return { type: 'text', text: IMAGE_OMITTED_TEXT };
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
return replaced;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1665
1697
|
// ---------------- native Anthropic healer (direct passthrough) ----------------
|
|
1666
1698
|
// The anthropic -> anthropic branch skips IR (to preserve thinking signatures, cache_control, documents...),
|
|
1667
1699
|
// so it patches the Anthropic payload directly, touching only what the API will definitely reject:
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
|
|
14
|
-
toGeminiSchema, isAntigravityModel,
|
|
14
|
+
toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
|
|
15
15
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
16
16
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
17
17
|
createCodeAssistStream, buildCodeAssistMessage
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
codexModelEntry,
|
|
27
27
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
28
28
|
} from './state.mjs';
|
|
29
|
-
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
|
|
29
|
+
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, readWindowsCache } from './windows.mjs';
|
|
30
30
|
import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
|
|
31
31
|
import { readUpstreamPayloads } from './upstream-stream.mjs';
|
|
32
32
|
import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
|
|
@@ -828,12 +828,24 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
|
828
828
|
return profile?.thinkingEffort || 'auto';
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
// The output limit the profile's own model list names for this model, or 0 when no cached list
|
|
832
|
+
// names one. The cache is filled by /v1/models; this only reads it.
|
|
833
|
+
function modelOutputLimit(profile, mappedModel) {
|
|
834
|
+
if (!profile?.baseURL || !mappedModel) return 0;
|
|
835
|
+
for (const entry of Object.values(readWindowsCache(STATE_DIR))) {
|
|
836
|
+
const output = entry?.baseURL === profile.baseURL ? Number(entry.models?.[mappedModel]?.output) : 0;
|
|
837
|
+
if (output > 0) return output;
|
|
838
|
+
}
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
831
842
|
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
|
|
832
843
|
const effort = resolveThinkingEffort(profile, slot, mappedModel);
|
|
833
844
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
834
845
|
thinkingMode: profile.thinkingMode,
|
|
835
846
|
thinkingEffort: effort,
|
|
836
847
|
replayReasoning: profile.replayReasoning === true,
|
|
848
|
+
maxOutputTokens: modelOutputLimit(profile, mappedModel),
|
|
837
849
|
});
|
|
838
850
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
839
851
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
@@ -845,6 +857,20 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
|
|
|
845
857
|
return { url, headers, upBody };
|
|
846
858
|
}
|
|
847
859
|
|
|
860
|
+
// Sends the upstream request. A chat 400 that says the model takes no images is retried once with
|
|
861
|
+
// every image replaced by text. The status arrives before any byte goes to the client, so the client
|
|
862
|
+
// sees only the retry's answer. Nothing is remembered: a false refusal costs one request, not the model.
|
|
863
|
+
async function fetchUpstream(url, headers, upBody, signal, outFormat, profileKey, mappedModel) {
|
|
864
|
+
const send = () => fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal });
|
|
865
|
+
const res = await send();
|
|
866
|
+
const hasImage = outFormat === 'openai-chat' && upBody.messages?.some(m => Array.isArray(m?.content) && m.content.some(p => p?.type === 'image_url'));
|
|
867
|
+
if (res.status !== 400 || !hasImage) return res;
|
|
868
|
+
const errText = await res.text().catch(() => '');
|
|
869
|
+
if (!refusesImageInput(errText)) return new Response(errText, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
870
|
+
console.warn(`[${profileKey}] ${mappedModel} refused ${omitChatImages(upBody)} image(s); retrying this request as text`);
|
|
871
|
+
return send();
|
|
872
|
+
}
|
|
873
|
+
|
|
848
874
|
// Feeds upstream stream events to a client renderer until the stream ends, fails or is aborted.
|
|
849
875
|
// Returns the stream error, or null. `sink` is the client stream whose backpressure it waits for.
|
|
850
876
|
async function pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal, sink, tag }) {
|
|
@@ -1282,7 +1308,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1282
1308
|
|
|
1283
1309
|
let upstreamRes;
|
|
1284
1310
|
try {
|
|
1285
|
-
upstreamRes = await
|
|
1311
|
+
upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
|
|
1286
1312
|
} catch (fetchErr) {
|
|
1287
1313
|
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
1288
1314
|
console.error(`[${profileKey}] Network error:`, fetchErr.message);
|
|
@@ -2553,7 +2579,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2553
2579
|
|
|
2554
2580
|
let upstreamRes;
|
|
2555
2581
|
try {
|
|
2556
|
-
upstreamRes = await
|
|
2582
|
+
upstreamRes = await fetchUpstream(url, headers, upBody, ac.signal, outFormat, profileKey, mappedModel);
|
|
2557
2583
|
} catch (fetchErr) {
|
|
2558
2584
|
if (ac.signal.aborted) return log({ status: 499, error: 'client disconnected' });
|
|
2559
2585
|
console.error(`[${profileKey}:ws] Network error:`, fetchErr.message);
|
package/tests/cdp.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { reapDirSync, onAbnormalExit } from './helpers.mjs';
|
|
8
9
|
|
|
9
10
|
const CANDIDATES = [
|
|
10
11
|
process.env.LLM_SWITCHER_CHROMIUM,
|
|
@@ -48,7 +49,10 @@ export async function launchBrowser() {
|
|
|
48
49
|
'about:blank'
|
|
49
50
|
];
|
|
50
51
|
// POSIX: a process group of its own, so close() can stop the renderers and helpers too.
|
|
51
|
-
|
|
52
|
+
// TMPDIR too: Chromium makes its own org.chromium.* dirs there, and they go with the profile.
|
|
53
|
+
const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir, TMPDIR: dir }, detached: process.platform !== 'win32' });
|
|
54
|
+
// The browser runs detached, so a test process that ends without close() would leave it running.
|
|
55
|
+
const cancelExitReap = onAbnormalExit(() => reapDirSync(dir, process.platform !== 'win32' ? [-child.pid] : [child.pid]));
|
|
52
56
|
const stop = (signal) => {
|
|
53
57
|
try {
|
|
54
58
|
if (process.platform !== 'win32') process.kill(-child.pid, signal);
|
|
@@ -71,6 +75,8 @@ export async function launchBrowser() {
|
|
|
71
75
|
stop('SIGKILL');
|
|
72
76
|
await new Promise(r => { child.once('exit', r); setTimeout(r, 3000); });
|
|
73
77
|
}
|
|
78
|
+
cancelExitReap();
|
|
79
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
|
74
80
|
throw new Error(`Chromium did not open a debugging port (exit: ${exited ?? 'killed'})\n${stderr}`);
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -84,6 +90,7 @@ export async function launchBrowser() {
|
|
|
84
90
|
return page;
|
|
85
91
|
},
|
|
86
92
|
async close() {
|
|
93
|
+
cancelExitReap();
|
|
87
94
|
for (const p of pages) p.close();
|
|
88
95
|
// The whole group: a helper that outlives the main process keeps writing into the profile.
|
|
89
96
|
stop('SIGTERM');
|
|
@@ -8,6 +8,7 @@ import os from 'node:os';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
import { stopChild, reapDir, reapDirSync, onAbnormalExit } from './helpers.mjs';
|
|
11
12
|
|
|
12
13
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
|
|
@@ -39,6 +40,11 @@ export function baselineConfig(port, blindfoldPort, upstreamBase) {
|
|
|
39
40
|
export async function startDashboardFixture() {
|
|
40
41
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-dashboard-'));
|
|
41
42
|
const cfgPath = path.join(dir, 'config.json');
|
|
43
|
+
let interceptor = null;
|
|
44
|
+
let proxy = null;
|
|
45
|
+
// A run that ends before stop() (a signal, a failed start) still stops the children and an
|
|
46
|
+
// interceptor the gateway started detached, and removes the dir.
|
|
47
|
+
const cancelExitReap = onAbnormalExit(() => reapDirSync(dir, [proxy?.pid, interceptor?.pid]));
|
|
42
48
|
const received = [];
|
|
43
49
|
// 'ok' answers like a healthy provider; 'deny' answers 401; 'down' answers 503.
|
|
44
50
|
const upstream = { mode: 'ok', received, models: ['up-alpha', 'up-beta', 'up-gamma'], latest: '99.0.0' };
|
|
@@ -62,6 +68,23 @@ export async function startDashboardFixture() {
|
|
|
62
68
|
});
|
|
63
69
|
});
|
|
64
70
|
await new Promise(r => upstreamServer.listen(0, '127.0.0.1', r));
|
|
71
|
+
|
|
72
|
+
async function stop() {
|
|
73
|
+
cancelExitReap();
|
|
74
|
+
// The gateway first: once it is gone it can start no interceptor.
|
|
75
|
+
await stopChild(proxy);
|
|
76
|
+
await stopChild(interceptor);
|
|
77
|
+
upstreamServer.close();
|
|
78
|
+
upstreamServer.closeAllConnections?.();
|
|
79
|
+
// A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
|
|
80
|
+
try {
|
|
81
|
+
const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
|
|
82
|
+
if (Number.isInteger(pid) && pid !== interceptor?.pid) process.kill(pid);
|
|
83
|
+
} catch { /* none was started */ }
|
|
84
|
+
// One it had not recorded yet still names the dir on its command line.
|
|
85
|
+
await reapDir(dir);
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
const upstreamBase = `http://127.0.0.1:${upstreamServer.address().port}`;
|
|
66
89
|
|
|
67
90
|
const port = await freePort();
|
|
@@ -96,7 +119,7 @@ export async function startDashboardFixture() {
|
|
|
96
119
|
// The gateway starts an interceptor of its own when it finds none on the port. This one is up
|
|
97
120
|
// first and answers the identity proof with this gateway's port and tool set, so nothing real
|
|
98
121
|
// is spawned.
|
|
99
|
-
|
|
122
|
+
interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
100
123
|
const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT, 'state.mjs')).href)});
|
|
101
124
|
const http = await import('node:http');
|
|
102
125
|
s.ensureAdminToken();
|
|
@@ -112,11 +135,11 @@ export async function startDashboardFixture() {
|
|
|
112
135
|
for (;;) {
|
|
113
136
|
const probe = await fetch(`http://127.0.0.1:${blindfoldPort}/?challenge=probe`).catch(() => null);
|
|
114
137
|
if (probe?.ok) break;
|
|
115
|
-
if (Date.now() > deadline) {
|
|
138
|
+
if (Date.now() > deadline) { await stop(); throw new Error('the stand-in interceptor never came up'); }
|
|
116
139
|
await new Promise(r => setTimeout(r, 100));
|
|
117
140
|
}
|
|
118
141
|
|
|
119
|
-
|
|
142
|
+
proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
120
143
|
let log = '';
|
|
121
144
|
proxy.stdout.on('data', d => { log += d; });
|
|
122
145
|
proxy.stderr.on('data', d => { log += d; });
|
|
@@ -125,7 +148,7 @@ export async function startDashboardFixture() {
|
|
|
125
148
|
try { started = (await fetch(`http://127.0.0.1:${port}/health`)).ok; } catch { /* not listening yet */ }
|
|
126
149
|
if (!started) await new Promise(r => setTimeout(r, 100));
|
|
127
150
|
}
|
|
128
|
-
if (!started) {
|
|
151
|
+
if (!started) { await stop(); throw new Error(`gateway did not start:\n${log}`); }
|
|
129
152
|
|
|
130
153
|
const adminToken = () => fs.readFileSync(path.join(dir, 'admin.token'), 'utf8').trim();
|
|
131
154
|
return {
|
|
@@ -153,17 +176,6 @@ export async function startDashboardFixture() {
|
|
|
153
176
|
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
154
177
|
body: JSON.stringify({ model: 'claude-opus-4-6', max_tokens: 64, messages: [{ role: 'user', content: 'hello from the test' }] })
|
|
155
178
|
}).then(async r => ({ status: r.status, text: await r.text() })),
|
|
156
|
-
|
|
157
|
-
interceptor.kill();
|
|
158
|
-
proxy.kill();
|
|
159
|
-
upstreamServer.close();
|
|
160
|
-
upstreamServer.closeAllConnections?.();
|
|
161
|
-
// A tool set change can start a detached interceptor of its own; stop the one the gateway recorded.
|
|
162
|
-
try {
|
|
163
|
-
const { pid } = JSON.parse(fs.readFileSync(path.join(dir, 'blindfold.json'), 'utf8'));
|
|
164
|
-
if (Number.isInteger(pid) && pid !== interceptor.pid) process.kill(pid);
|
|
165
|
-
} catch { /* none was started */ }
|
|
166
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
167
|
-
}
|
|
179
|
+
stop
|
|
168
180
|
};
|
|
169
181
|
}
|
package/tests/formats.test.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
|
|
13
13
|
smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
|
|
14
14
|
createCodeAssistStream,
|
|
15
|
-
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage
|
|
15
|
+
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage,
|
|
16
|
+
refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT
|
|
16
17
|
} from '../formats.mjs';
|
|
17
18
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
18
19
|
|
|
@@ -39,6 +40,50 @@ test('Chat emitter restores thinking for versioned Claude Opus model IDs', () =>
|
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
// Only an answer that says the model takes no images may turn into a text-only retry.
|
|
44
|
+
test('refusesImageInput matches the known no-image errors and nothing else', () => {
|
|
45
|
+
for (const t of [
|
|
46
|
+
'{"error":{"message":"Invalid content type. image_url is only supported by certain models."}}',
|
|
47
|
+
'Failed to deserialize the JSON body into the target type: messages[0]: unknown variant `image_url`, expected `text`',
|
|
48
|
+
'This model does not support image input',
|
|
49
|
+
'Image input is not supported for this model',
|
|
50
|
+
'Vision is not supported by deepseek-chat'
|
|
51
|
+
]) assert.equal(refusesImageInput(t), true, t);
|
|
52
|
+
for (const t of ['', 'temperature must be between 0 and 2', 'maximum context length exceeded', 'vision model overloaded', 'not supported: tool_choice',
|
|
53
|
+
'Invalid image_url: could not decode base64', "Invalid 'messages[0].content[1].image_url.url'. Expected a base64-encoded data URL"])
|
|
54
|
+
assert.equal(refusesImageInput(t), false, t);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('omitChatImages replaces every image part with text and counts them', () => {
|
|
58
|
+
const body = { messages: [
|
|
59
|
+
{ role: 'system', content: 'sys' },
|
|
60
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA' } }, { type: 'text', text: 'what?' }] },
|
|
61
|
+
{ role: 'assistant', content: 'a cat' },
|
|
62
|
+
{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://x/y.png' } }] }
|
|
63
|
+
] };
|
|
64
|
+
assert.equal(omitChatImages(body), 2);
|
|
65
|
+
assert.deepEqual(body.messages[1].content, [{ type: 'text', text: IMAGE_OMITTED_TEXT }, { type: 'text', text: 'what?' }]);
|
|
66
|
+
assert.deepEqual(body.messages[3].content, [{ type: 'text', text: IMAGE_OMITTED_TEXT }]);
|
|
67
|
+
assert.equal(IMAGE_OMITTED_TEXT, '[image omitted: this model does not accept images]');
|
|
68
|
+
assert.equal(omitChatImages(body), 0, 'nothing left to replace');
|
|
69
|
+
assert.equal(omitChatImages({}), 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// A max_tokens above the model's own output limit is refused upstream, so it is clamped when the limit is known.
|
|
73
|
+
test('emitUpstreamBody clamps max tokens to a known output limit, and leaves it when the limit is unknown', () => {
|
|
74
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] });
|
|
75
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { maxOutputTokens: 8192 }).max_tokens, 8192);
|
|
76
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { thinkingMode: 'native', maxOutputTokens: 8192 }).max_completion_tokens, 8192);
|
|
77
|
+
assert.equal(emitUpstreamBody('anthropic', ir, 'm', { maxOutputTokens: 8192 }).max_tokens, 8192);
|
|
78
|
+
assert.equal(emitUpstreamBody('vertex', ir, 'm', { maxOutputTokens: 8192 }).generationConfig.maxOutputTokens, 8192);
|
|
79
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm').max_tokens, 64000, 'unknown limit: unchanged');
|
|
80
|
+
assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { maxOutputTokens: 128000 }).max_tokens, 64000, 'under the limit: unchanged');
|
|
81
|
+
assert.equal(ir.params.maxTokens, 64000, 'the client IR is not changed');
|
|
82
|
+
const bare = anthropicToIR({ model: 'x', messages: [{ role: 'user', content: 'hi' }] });
|
|
83
|
+
assert.equal('max_tokens' in emitUpstreamBody('openai-chat', bare, 'm', { maxOutputTokens: 8192 }), false, 'no max tokens sent: none invented');
|
|
84
|
+
assert.equal(emitUpstreamBody('anthropic', bare, 'm', { maxOutputTokens: 2048 }).max_tokens, 2048, 'the Anthropic default of 4096 is clamped too');
|
|
85
|
+
});
|
|
86
|
+
|
|
42
87
|
test('emitUpstreamBody: billing header stripped only for antigravity (ag/) models', () => {
|
|
43
88
|
const header = 'x-anthropic-billing-header: cc_version=2.1.275.f15; cc_entrypoint=cli;';
|
|
44
89
|
const fromArray = anthropicToIR({
|
|
@@ -11,14 +11,14 @@ import path from 'node:path';
|
|
|
11
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
import zlib from 'node:zlib';
|
|
13
13
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
-
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
14
|
+
import { assertValidAnthropicEvents, stopChild, reapDir, reapDirSync, onAbnormalExit } from './helpers.mjs';
|
|
15
15
|
import { createFrameReader } from '../blindfold/wsframe.mjs';
|
|
16
16
|
|
|
17
17
|
const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
|
|
18
18
|
|
|
19
19
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
20
|
|
|
21
|
-
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir;
|
|
21
|
+
let upstream, upstreamPort, proxy, proxyPort, tmpDir, fakeInterceptor, blindfoldPort, certsDir, cancelExitReap;
|
|
22
22
|
const received = []; // { url, headers, body }
|
|
23
23
|
const hangState = { closed: false, slowAborted: false };
|
|
24
24
|
const bigState = { finishedAt: 0 };
|
|
@@ -83,7 +83,8 @@ function startUpstream() {
|
|
|
83
83
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
84
84
|
return res.end(JSON.stringify({ data: [
|
|
85
85
|
{ id: 'gpt-5.6-sol', context_length: 1048576, compact_window: 272000 },
|
|
86
|
-
{ id: 'up-opus', context_length: 200000 }
|
|
86
|
+
{ id: 'up-opus', context_length: 200000 },
|
|
87
|
+
{ id: 'up-capped', context_length: 200000, max_output_tokens: 8192 }
|
|
87
88
|
] }));
|
|
88
89
|
}
|
|
89
90
|
if (req.url.startsWith('/intact/v1/messages')) {
|
|
@@ -95,6 +96,20 @@ function startUpstream() {
|
|
|
95
96
|
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
|
|
96
97
|
}
|
|
97
98
|
if (req.url.startsWith('/chat/v1/chat/completions')) {
|
|
99
|
+
// A text-only model: it refuses any image part, and one that refuses for an unrelated reason.
|
|
100
|
+
const hasImage = (json.messages || []).some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
|
|
101
|
+
if (json.model === 'up-textonly' && hasImage) {
|
|
102
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
103
|
+
return res.end(JSON.stringify({ error: { message: 'Failed to deserialize the JSON body: messages[0]: unknown variant `image_url`, expected `text`', type: 'invalid_request_error' } }));
|
|
104
|
+
}
|
|
105
|
+
if (json.model === 'up-textonly' && lastUser.includes('RETRY_FAILS')) {
|
|
106
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
107
|
+
return res.end(JSON.stringify({ error: { message: 'try again later', type: 'invalid_request_error' } }));
|
|
108
|
+
}
|
|
109
|
+
if (json.model === 'up-badreq') {
|
|
110
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
111
|
+
return res.end(JSON.stringify({ error: { message: 'temperature must be between 0 and 2', type: 'invalid_request_error' } }));
|
|
112
|
+
}
|
|
98
113
|
if (lastUser.includes('TOOL_ALIAS_REPLAY')) {
|
|
99
114
|
const reference = json.messages.find(m => m.role === 'tool');
|
|
100
115
|
const name = JSON.parse(reference.content).tool_name;
|
|
@@ -220,6 +235,8 @@ before(async () => {
|
|
|
220
235
|
await startUpstream();
|
|
221
236
|
proxyPort = await freePort();
|
|
222
237
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-test-'));
|
|
238
|
+
// A run that ends with no after() still stops the gateway and the interceptor it started detached.
|
|
239
|
+
cancelExitReap = onAbnormalExit(() => reapDirSync(tmpDir, [proxy?.pid, fakeInterceptor?.pid]));
|
|
223
240
|
|
|
224
241
|
// R3b: both tools are active in this fixture, so every admin write runs through
|
|
225
242
|
// checkBlindfoldTarget. Give the interceptor a port of its own and a certificate minted for
|
|
@@ -243,6 +260,8 @@ before(async () => {
|
|
|
243
260
|
roles: { name: 'Mock Public Roles', mode: 'convert', inFormat: 'responses', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-roles', publicModels: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'], defaultModels: { main: 'ag/mock-flash', review: 'ag/mock-review', subagent: 'ag/mock-low' } },
|
|
244
261
|
native: { name: 'Mock Strict OpenAI', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', thinkingMode: 'native', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-native', defaultModels: models },
|
|
245
262
|
intactcc: { name: 'Mock intact', mode: 'convert', tool: 'claude', outFormat: 'openai-chat', thinkingMode: 'off', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact-user', defaultModels: { opus: 'claude/claude-opus-5', sonnet: 'gem/flash', haiku: 'claude/claude-haiku-4-5', fable: 'gem/flash' } },
|
|
263
|
+
textonly: { name: 'Mock Text Only', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-textonly', defaultModels: { opus: 'up-textonly', sonnet: 'up-badreq', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
264
|
+
capped: { name: 'Mock Capped Output', mode: 'convert', inFormat: 'auto', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-capped', defaultModels: { opus: 'up-capped', sonnet: 'up-sonnet', haiku: 'up-haiku', fable: 'up-fable' } },
|
|
246
265
|
ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
|
|
247
266
|
}
|
|
248
267
|
};
|
|
@@ -302,9 +321,11 @@ before(async () => {
|
|
|
302
321
|
}
|
|
303
322
|
});
|
|
304
323
|
|
|
305
|
-
after(() => {
|
|
306
|
-
|
|
307
|
-
|
|
324
|
+
after(async () => {
|
|
325
|
+
cancelExitReap?.();
|
|
326
|
+
// The gateway first: once it is gone it can start no interceptor.
|
|
327
|
+
await stopChild(proxy);
|
|
328
|
+
await stopChild(fakeInterceptor);
|
|
308
329
|
upstream?.close();
|
|
309
330
|
// A test that changes the tool set makes the gateway start a real interceptor. It is detached on
|
|
310
331
|
// purpose, so it outlives the gateway; stop the one the gateway recorded before its state goes.
|
|
@@ -312,7 +333,8 @@ after(() => {
|
|
|
312
333
|
const { pid } = JSON.parse(fs.readFileSync(path.join(tmpDir, 'blindfold.json'), 'utf8'));
|
|
313
334
|
if (Number.isInteger(pid) && pid !== fakeInterceptor?.pid) process.kill(pid);
|
|
314
335
|
} catch {}
|
|
315
|
-
|
|
336
|
+
// One it had not recorded yet still names the dir on its command line.
|
|
337
|
+
if (tmpDir) await reapDir(tmpDir);
|
|
316
338
|
});
|
|
317
339
|
|
|
318
340
|
const url = (p) => `http://127.0.0.1:${proxyPort}${p}`;
|
|
@@ -590,6 +612,57 @@ test('A8: /v1/chat/completions and /chat/completions answer 404 on the gateway',
|
|
|
590
612
|
assert.equal(received.length, before, 'a removed route never reaches an upstream');
|
|
591
613
|
});
|
|
592
614
|
|
|
615
|
+
// The profile's own list names the output limit, so a max_tokens above it is clamped instead of refused.
|
|
616
|
+
test('max_tokens above the listed output limit is clamped; a model with no listed limit keeps it', async () => {
|
|
617
|
+
const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'capped' } });
|
|
618
|
+
assert.equal(listed.status, 200);
|
|
619
|
+
await listed.text();
|
|
620
|
+
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': 'capped' });
|
|
621
|
+
assert.equal(res.status, 200);
|
|
622
|
+
await res.text();
|
|
623
|
+
assert.equal(received.at(-1).body.model, 'up-capped');
|
|
624
|
+
assert.equal(received.at(-1).body.max_tokens, 8192);
|
|
625
|
+
const free = await post('/v1/messages', { model: 'claude-sonnet-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': 'capped' });
|
|
626
|
+
await free.text();
|
|
627
|
+
assert.equal(received.at(-1).body.model, 'up-sonnet');
|
|
628
|
+
assert.equal(received.at(-1).body.max_tokens, 64000);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// A text-only model refuses an image that stays in the history, so every later turn would fail too.
|
|
632
|
+
test('An image refused by a text-only chat model is retried once as text in each request', async () => {
|
|
633
|
+
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
|
|
634
|
+
const calls = model => received.filter(r => r.url.startsWith('/chat/v1/chat/completions') && r.body.model === model).length;
|
|
635
|
+
const sentImage = r => r.body.messages.some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
|
|
636
|
+
const first = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, messages: [{ role: 'user', content: [image, { type: 'text', text: 'what is this' }] }] }, { 'x-llm-profile': 'textonly' });
|
|
637
|
+
assert.equal(first.status, 200);
|
|
638
|
+
assert.equal((await first.json()).type, 'message');
|
|
639
|
+
assert.equal(calls('up-textonly'), 2, 'one refusal, one retry');
|
|
640
|
+
assert.equal(sentImage(received.at(-1)), false);
|
|
641
|
+
assert.ok(JSON.stringify(received.at(-1).body).includes('[image omitted: this model does not accept images]'));
|
|
642
|
+
|
|
643
|
+
const second = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, stream: true, messages: [
|
|
644
|
+
{ role: 'user', content: [image, { type: 'text', text: 'what is this' }] },
|
|
645
|
+
{ role: 'assistant', content: 'an image' },
|
|
646
|
+
{ role: 'user', content: 'and now?' }
|
|
647
|
+
] }, { 'x-llm-profile': 'textonly' });
|
|
648
|
+
assert.equal(second.status, 200);
|
|
649
|
+
await second.text();
|
|
650
|
+
assert.equal(calls('up-textonly'), 4, 'nothing is remembered: one refusal, one retry again');
|
|
651
|
+
assert.equal(sentImage(received.at(-2)), true);
|
|
652
|
+
assert.equal(sentImage(received.at(-1)), false);
|
|
653
|
+
|
|
654
|
+
const failed = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, messages: [{ role: 'user', content: [image, { type: 'text', text: 'RETRY_FAILS' }] }] }, { 'x-llm-profile': 'textonly' });
|
|
655
|
+
assert.equal(failed.status, 400);
|
|
656
|
+
assert.match((await failed.json()).error.message, /try again later/, 'the client gets the error of the retry, not the first refusal');
|
|
657
|
+
assert.equal(calls('up-textonly'), 6);
|
|
658
|
+
|
|
659
|
+
const other = await post('/v1/messages', { model: 'claude-sonnet-4-6', max_tokens: 100, messages: [{ role: 'user', content: [image, { type: 'text', text: 'hi' }] }] }, { 'x-llm-profile': 'textonly' });
|
|
660
|
+
assert.equal(other.status, 400);
|
|
661
|
+
assert.match((await other.json()).error.message, /temperature must be between 0 and 2/);
|
|
662
|
+
assert.equal(calls('up-badreq'), 1, 'an unrelated 400 is relayed, never retried');
|
|
663
|
+
assert.equal(sentImage(received.at(-1)), true);
|
|
664
|
+
});
|
|
665
|
+
|
|
593
666
|
test('Upstream 429 is returned in Anthropic error shape with retry-after', async () => {
|
|
594
667
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'RATE_LIMIT' }] });
|
|
595
668
|
assert.equal(res.status, 429);
|
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) {
|
|
@@ -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/windows.test.mjs
CHANGED
|
@@ -20,11 +20,22 @@ test('a list entry gives the whole window and the window to compress at', () =>
|
|
|
20
20
|
assert.deepEqual(parseModelWindows({ id: 'groq/q', context_window: 131072 }), { context: 131072, compact: 0 },
|
|
21
21
|
'one window is the whole window, not a threshold');
|
|
22
22
|
assert.deepEqual(parseModelWindows({ id: 'or/r', context_length: 262144, top_provider: { context_length: 262144, max_completion_tokens: 32768 } }),
|
|
23
|
-
{ context: 262144, compact: 0 });
|
|
23
|
+
{ context: 262144, compact: 0, output: 32768 });
|
|
24
24
|
assert.deepEqual(parseModelWindows({ id: 'bare' }), null, 'a model with no limits says nothing');
|
|
25
25
|
assert.deepEqual(parseModelWindows({ id: 'zero', context_length: 0, compact_window: 0 }), null);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// The output limit lets the gateway clamp a max_tokens the model would refuse.
|
|
29
|
+
test('a list entry keeps the output limit it names, and leaves it out when it names none', () => {
|
|
30
|
+
assert.deepEqual(parseModelWindows({ id: 'i/m', context_length: 200000, max_output_tokens: 8192 }),
|
|
31
|
+
{ context: 200000, compact: 0, output: 8192 });
|
|
32
|
+
assert.deepEqual(parseModelWindows({ id: 'only-out', max_output_tokens: 4096 }), { context: 0, compact: 0, output: 4096 },
|
|
33
|
+
'an output limit alone is still worth keeping');
|
|
34
|
+
assert.equal('output' in parseModelWindows({ id: 'g', context_length: 131072 }), false);
|
|
35
|
+
assert.deepEqual(parseModelList({ data: [{ id: 'i/m', context_length: 200000, max_output_tokens: 8192 }] }),
|
|
36
|
+
{ 'i/m': { context: 200000, compact: 0, output: 8192 } });
|
|
37
|
+
});
|
|
38
|
+
|
|
28
39
|
test('parseModelList keys the windows by the id a profile maps, and drops the nameless', () => {
|
|
29
40
|
const out = parseModelList({ data: [{ id: 'models/big', context_length: 1048576 }, { id: 'bare' }, 'plain'] });
|
|
30
41
|
assert.deepEqual(Object.keys(out), ['big'], 'a bare string carries no window, and a models/ prefix is dropped');
|
|
@@ -90,6 +101,25 @@ test('windows are cached for an hour, and a failed refresh keeps the stale answe
|
|
|
90
101
|
}
|
|
91
102
|
});
|
|
92
103
|
|
|
104
|
+
test('the cache persists the output limit, and an old cache file without it still reads', async () => {
|
|
105
|
+
const dir = tmpDir();
|
|
106
|
+
const sink = http.createServer((req, res) => {
|
|
107
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
108
|
+
res.end(JSON.stringify({ data: [{ id: 'm', context_length: 200000, max_output_tokens: 8192 }] }));
|
|
109
|
+
});
|
|
110
|
+
await new Promise(r => sink.listen(0, '127.0.0.1', r));
|
|
111
|
+
const base = `http://127.0.0.1:${sink.address().port}/v1`;
|
|
112
|
+
try {
|
|
113
|
+
await resolveProfileWindows(dir, 'p@' + base, base, 'k', { now: 1_000 });
|
|
114
|
+
assert.equal(readWindowsCache(dir)['p@' + base].models.m.output, 8192);
|
|
115
|
+
fs.writeFileSync(path.join(dir, 'model-windows.json'), JSON.stringify({ old: { at: 1_000, baseURL: base, models: { m: { context: 200000, compact: 0 } } } }));
|
|
116
|
+
assert.deepEqual(await resolveProfileWindows(dir, 'old', base, 'k', { now: 2_000 }), { m: { context: 200000, compact: 0 } });
|
|
117
|
+
} finally {
|
|
118
|
+
sink.close();
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
93
123
|
test('an empty cache file reads as no windows instead of throwing', () => {
|
|
94
124
|
const dir = tmpDir();
|
|
95
125
|
try {
|
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.
|