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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.10",
3
+ "version": "1.6.11",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
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 fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal: ac.signal });
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 fetch(url, { method: 'POST', headers, body: JSON.stringify(upBody), signal: ac.signal });
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
- const child = spawn(findChromium(), args, { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, HOME: dir }, detached: process.platform !== 'win32' });
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
- const interceptor = spawn(process.execPath, ['--input-type=module', '-e', `
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) { interceptor.kill(); throw new Error('the stand-in interceptor never came up'); }
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
- const proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], { env, stdio: ['ignore', 'pipe', 'pipe'] });
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) { proxy.kill(); interceptor.kill(); throw new Error(`gateway did not start:\n${log}`); }
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
- async stop() {
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
  }
@@ -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
- fakeInterceptor?.kill();
307
- proxy?.kill();
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
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
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
+ });
@@ -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, or null when it names none.
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
- if (!context && !compact) return null;
34
- return { context, compact };
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.