llm-switcher 1.6.10 → 1.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { test, after } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
@@ -6,7 +6,9 @@ import path from 'node:path';
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { resolveDataDir } from '../state.mjs';
8
8
 
9
- const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'llm-sw-datadir-'));
9
+ const made = [];
10
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
11
+ const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-sw-datadir-')); made.push(d); return d; };
10
12
 
11
13
  test('a git checkout keeps its data next to the code', () => {
12
14
  const root = tmp();
@@ -12,8 +12,11 @@ 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,
17
+ stripGeminiPlaceholders, geminiPaddedTools, withGeminiPlaceholderStrip
16
18
  } from '../formats.mjs';
19
+ import { NULL_TOOL_VOCAB, createToolVocab } from '../toolvocab.mjs';
17
20
  import { assertValidAnthropicEvents } from './helpers.mjs';
18
21
 
19
22
  test('anthropicToIR: stream defaults to false and thinking budget_tokens is preserved', () => {
@@ -39,6 +42,50 @@ test('Chat emitter restores thinking for versioned Claude Opus model IDs', () =>
39
42
  }
40
43
  });
41
44
 
45
+ // Only an answer that says the model takes no images may turn into a text-only retry.
46
+ test('refusesImageInput matches the known no-image errors and nothing else', () => {
47
+ for (const t of [
48
+ '{"error":{"message":"Invalid content type. image_url is only supported by certain models."}}',
49
+ 'Failed to deserialize the JSON body into the target type: messages[0]: unknown variant `image_url`, expected `text`',
50
+ 'This model does not support image input',
51
+ 'Image input is not supported for this model',
52
+ 'Vision is not supported by deepseek-chat'
53
+ ]) assert.equal(refusesImageInput(t), true, t);
54
+ for (const t of ['', 'temperature must be between 0 and 2', 'maximum context length exceeded', 'vision model overloaded', 'not supported: tool_choice',
55
+ 'Invalid image_url: could not decode base64', "Invalid 'messages[0].content[1].image_url.url'. Expected a base64-encoded data URL"])
56
+ assert.equal(refusesImageInput(t), false, t);
57
+ });
58
+
59
+ test('omitChatImages replaces every image part with text and counts them', () => {
60
+ const body = { messages: [
61
+ { role: 'system', content: 'sys' },
62
+ { role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AA' } }, { type: 'text', text: 'what?' }] },
63
+ { role: 'assistant', content: 'a cat' },
64
+ { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://x/y.png' } }] }
65
+ ] };
66
+ assert.equal(omitChatImages(body), 2);
67
+ assert.deepEqual(body.messages[1].content, [{ type: 'text', text: IMAGE_OMITTED_TEXT }, { type: 'text', text: 'what?' }]);
68
+ assert.deepEqual(body.messages[3].content, [{ type: 'text', text: IMAGE_OMITTED_TEXT }]);
69
+ assert.equal(IMAGE_OMITTED_TEXT, '[image omitted: this model does not accept images]');
70
+ assert.equal(omitChatImages(body), 0, 'nothing left to replace');
71
+ assert.equal(omitChatImages({}), 0);
72
+ });
73
+
74
+ // A max_tokens above the model's own output limit is refused upstream, so it is clamped when the limit is known.
75
+ test('emitUpstreamBody clamps max tokens to a known output limit, and leaves it when the limit is unknown', () => {
76
+ const ir = anthropicToIR({ model: 'x', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] });
77
+ assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { maxOutputTokens: 8192 }).max_tokens, 8192);
78
+ assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { thinkingMode: 'native', maxOutputTokens: 8192 }).max_completion_tokens, 8192);
79
+ assert.equal(emitUpstreamBody('anthropic', ir, 'm', { maxOutputTokens: 8192 }).max_tokens, 8192);
80
+ assert.equal(emitUpstreamBody('vertex', ir, 'm', { maxOutputTokens: 8192 }).generationConfig.maxOutputTokens, 8192);
81
+ assert.equal(emitUpstreamBody('openai-chat', ir, 'm').max_tokens, 64000, 'unknown limit: unchanged');
82
+ assert.equal(emitUpstreamBody('openai-chat', ir, 'm', { maxOutputTokens: 128000 }).max_tokens, 64000, 'under the limit: unchanged');
83
+ assert.equal(ir.params.maxTokens, 64000, 'the client IR is not changed');
84
+ const bare = anthropicToIR({ model: 'x', messages: [{ role: 'user', content: 'hi' }] });
85
+ assert.equal('max_tokens' in emitUpstreamBody('openai-chat', bare, 'm', { maxOutputTokens: 8192 }), false, 'no max tokens sent: none invented');
86
+ assert.equal(emitUpstreamBody('anthropic', bare, 'm', { maxOutputTokens: 2048 }).max_tokens, 2048, 'the Anthropic default of 4096 is clamped too');
87
+ });
88
+
42
89
  test('emitUpstreamBody: billing header stripped only for antigravity (ag/) models', () => {
43
90
  const header = 'x-anthropic-billing-header: cc_version=2.1.275.f15; cc_entrypoint=cli;';
44
91
  const fromArray = anthropicToIR({
@@ -1343,3 +1390,126 @@ test('healAnthropicPayload dedupes repeated tool_use ids', () => {
1343
1390
  assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
1344
1391
  assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
1345
1392
  });
1393
+
1394
+ // Reasoning from an aborted turn must not attach to the assistant turn after the next user message.
1395
+ test('responsesToIR drops pending reasoning at a user message and joins reasoning pieces with a newline', () => {
1396
+ const ir = responsesToIR({ model: 'x', input: [
1397
+ { type: 'message', role: 'user', content: 'plan it' },
1398
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1399
+ { type: 'message', role: 'user', content: 'start over' },
1400
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'fresh plan' }] },
1401
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'second piece' }] },
1402
+ { type: 'message', role: 'assistant', content: 'done' }
1403
+ ] });
1404
+ const assistant = ir.messages.find(m => m.role === 'assistant');
1405
+ assert.equal(assistant.reasoning, 'fresh plan\nsecond piece');
1406
+ const viaString = responsesToIR({ model: 'x', input: [
1407
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1408
+ 'start over',
1409
+ { type: 'message', role: 'assistant', content: 'done' }
1410
+ ] });
1411
+ assert.equal(viaString.messages.find(m => m.role === 'assistant').reasoning, undefined);
1412
+ });
1413
+
1414
+ // The direct Anthropic route has no system role inside messages; dropping the message loses its instruction.
1415
+ test('healAnthropicPayload folds a mid-conversation system message into a user turn', () => {
1416
+ const roles = (msgs) => msgs.map(m => m.role);
1417
+ const texts = (msgs) => JSON.stringify(msgs);
1418
+ const alternates = (msgs) => msgs.every((m, i) => i === 0 || m.role !== msgs[i - 1].role);
1419
+
1420
+ const r = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1421
+ { role: 'user', content: 'list files' },
1422
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
1423
+ { role: 'system', content: 'Use British spelling from now on.' },
1424
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
1425
+ { role: 'assistant', content: 'one file' },
1426
+ { role: 'system', content: [{ type: 'text', text: 'Be brief.' }] }
1427
+ ] });
1428
+ assert.equal(r.changed, true);
1429
+ const msgs = r.payload.messages;
1430
+ assert.ok(!msgs.some(m => m.role === 'system'), 'no system role remains');
1431
+ assert.ok(alternates(msgs), `roles alternate: ${roles(msgs)}`);
1432
+ assert.deepEqual(roles(msgs), ['user', 'assistant', 'user', 'assistant']);
1433
+ assert.deepEqual(msgs[2].content, [
1434
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' },
1435
+ { type: 'text', text: '[system] Use British spelling from now on.' },
1436
+ { type: 'text', text: '[system] Be brief.' }
1437
+ ], 'a tool_result stays first; with no user turn after it, the last text joins the previous user turn');
1438
+ assert.ok(!texts(msgs[3]).includes('[system]'));
1439
+
1440
+ const only = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1441
+ { role: 'system', content: 'Answer in French.' },
1442
+ { role: 'assistant', content: 'Bonjour' }
1443
+ ] }).payload.messages;
1444
+ assert.deepEqual(only, [{ role: 'user', content: [{ type: 'text', text: '[system] Answer in French.' }] }, { role: 'assistant', content: 'Bonjour' }]);
1445
+ });
1446
+
1447
+ // Vertex path: the schema is deep-copied before it is sent, and an Anthropic client streams the call.
1448
+ test('a Vertex tool padded for Gemini loses _placeholder in streamed arguments, and only where padded', () => {
1449
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
1450
+ type: 'object', properties: { env: { type: 'object' }, list: { type: 'array', items: { type: 'object' } }, opts: { type: 'object', properties: { _placeholder: { type: 'string' } } } }
1451
+ } }, { name: 'plain', input_schema: { type: 'object', properties: { a: { type: 'string' } } } }] });
1452
+ const body = JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')));
1453
+ const padded = geminiPaddedTools(body);
1454
+ assert.deepEqual([...padded.keys()], ['run'], 'only the tool with a padded node');
1455
+ const args = { env: { A: '1', _placeholder: true }, list: [{ _placeholder: false, b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 };
1456
+ assert.deepEqual(stripGeminiPlaceholders(args, padded.get('run')), { env: { A: '1' }, list: [{ b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 });
1457
+
1458
+ const events = [];
1459
+ const r = createAnthropicStream((e, d) => events.push(d), 'm', { vocab: withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, padded) });
1460
+ r.start();
1461
+ const json = JSON.stringify(args);
1462
+ r.tool({ index: 0, id: 'c1', name: 'run', args: json.slice(0, 12) });
1463
+ r.tool({ index: 0, args: json.slice(12) });
1464
+ r.finish('tool_calls');
1465
+ const streamed = events.filter(d => d.delta?.type === 'input_json_delta').map(d => d.delta.partial_json).join('');
1466
+ assert.deepEqual(JSON.parse(streamed), { env: { A: '1' }, list: [{ b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 });
1467
+ assert.equal(withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, padded).args('plain', '{"_placeholder":true}'), '{"_placeholder":true}');
1468
+ assert.equal(withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, new Map()), NULL_TOOL_VOCAB, 'no padding, no wrapper');
1469
+ });
1470
+
1471
+ // A vertex client gets complete functionCalls, so the strip applies when the call is written out.
1472
+ test('a vertex client gets tool-call args without the padded _placeholder, stream and non-stream', () => {
1473
+ const declared = [{ name: 'run', parameters: { type: 'object', properties: { env: { type: 'object' }, opts: { type: 'object', properties: { _placeholder: { type: 'string' } } } } } }];
1474
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: declared.map(t => ({ name: t.name, input_schema: t.parameters })) });
1475
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
1476
+ const vocab = withGeminiPlaceholderStrip(createToolVocab(ir.tools, { allowHyphens: false }), padded);
1477
+ const args = JSON.stringify({ env: { A: '1', _placeholder: true }, opts: { _placeholder: 'keep' } });
1478
+ const want = { env: { A: '1' }, opts: { _placeholder: 'keep' } };
1479
+
1480
+ const chunks = [];
1481
+ const s = createVertexStream((e, d) => chunks.push(d), 'm', { vocab });
1482
+ s.tool({ index: 0, id: 'c1', name: 'run', args: args.slice(0, 9) });
1483
+ s.tool({ index: 0, args: args.slice(9) });
1484
+ s.finish('tool_calls');
1485
+ const streamed = chunks.flatMap(c => c.candidates?.[0]?.content?.parts || []).find(p => p.functionCall);
1486
+ assert.deepEqual(streamed.functionCall.args, want);
1487
+
1488
+ const whole = buildVertexMessage({ model: 'm', tools: [{ index: 0, id: 'c1', name: 'run', args }], finish: 'tool_calls', vocab });
1489
+ assert.deepEqual(whole.candidates[0].content.parts.find(p => p.functionCall).functionCall.args, want);
1490
+ });
1491
+
1492
+ // The Vertex body names a tool without hyphens, so the strip must find the schema by either name.
1493
+ test('the placeholder strip finds a hyphenated tool by its upstream name and by its client name', () => {
1494
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'my-tool', input_schema: { type: 'object', properties: { env: { type: 'object' } } } }] });
1495
+ const vocab = createToolVocab(ir.tools, { allowHyphens: false, names: ['my-tool'] });
1496
+ assert.equal(vocab.name('my_tool'), 'my-tool', 'fixture: the upstream name is the sanitized one');
1497
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
1498
+ assert.deepEqual([...padded.keys()], ['my_tool']);
1499
+ const strip = withGeminiPlaceholderStrip(vocab, padded);
1500
+ const args = '{"env":{"A":"1","_placeholder":true}}';
1501
+ assert.deepEqual(JSON.parse(strip.args('my_tool', args)), { env: { A: '1' } }, 'upstream name');
1502
+ assert.deepEqual(JSON.parse(strip.args('my-tool', args)), { env: { A: '1' } }, 'client name');
1503
+ });
1504
+
1505
+ // A union with an object branch becomes anyOf for Gemini, and that object branch is padded too.
1506
+ test('the placeholder strip reaches a padded object inside an anyOf branch', () => {
1507
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
1508
+ type: 'object', properties: { env: { oneOf: [{ type: 'object' }, { type: 'string' }] } }
1509
+ } }] });
1510
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
1511
+ const schema = padded.get('run');
1512
+ assert.ok(Array.isArray(schema?.properties?.env?.anyOf), 'fixture: env is an anyOf with a padded branch');
1513
+ assert.deepEqual(stripGeminiPlaceholders({ env: { A: '1', _placeholder: true } }, schema), { env: { A: '1' } });
1514
+ assert.deepEqual(stripGeminiPlaceholders({ env: 'plain' }, schema), { env: 'plain' });
1515
+ });
@@ -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 };
@@ -62,6 +62,18 @@ const sse = (res, objs, { raw = [] } = {}) => {
62
62
  res.end();
63
63
  };
64
64
 
65
+ // Upstream errors keyed by a MOCK_ERR:<key> marker in the last user message: [status, message].
66
+ const MOCK_ERRORS = {
67
+ comma: [400, "This model's maximum context length is 131,072 tokens. However, your messages resulted in 140,000 tokens."],
68
+ dot: [400, 'Input exceeds the context window of 131.072 tokens: requested 140.000 tokens'],
69
+ space: [400, 'maximum context length is 131 072 tokens, you requested 140 000 tokens'],
70
+ underscore: [400, 'maximum context length is 131_072 tokens, you requested 140_000 tokens'],
71
+ prefixed: [400, 'prompt is too long: 200000 tokens > 199999 maximum'],
72
+ nonumber: [400, 'The input exceeds the context window of this model.'],
73
+ rate: [429, 'Rate limit reached on context tokens per minute: maximum context length is 131,072'],
74
+ server: [500, "Internal error: this model's maximum context length is 131,072 tokens"]
75
+ };
76
+
65
77
  function startUpstream() {
66
78
  upstream = http.createServer((req, res) => {
67
79
  let body = '';
@@ -83,7 +95,8 @@ function startUpstream() {
83
95
  res.writeHead(200, { 'Content-Type': 'application/json' });
84
96
  return res.end(JSON.stringify({ data: [
85
97
  { id: 'gpt-5.6-sol', context_length: 1048576, compact_window: 272000 },
86
- { id: 'up-opus', context_length: 200000 }
98
+ { id: 'up-opus', context_length: 200000 },
99
+ { id: 'up-capped', context_length: 200000, max_output_tokens: 8192 }
87
100
  ] }));
88
101
  }
89
102
  if (req.url.startsWith('/intact/v1/messages')) {
@@ -95,6 +108,20 @@ function startUpstream() {
95
108
  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
109
  }
97
110
  if (req.url.startsWith('/chat/v1/chat/completions')) {
111
+ // A text-only model: it refuses any image part, and one that refuses for an unrelated reason.
112
+ const hasImage = (json.messages || []).some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
113
+ if (json.model === 'up-textonly' && hasImage) {
114
+ res.writeHead(400, { 'Content-Type': 'application/json' });
115
+ 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' } }));
116
+ }
117
+ if (json.model === 'up-textonly' && lastUser.includes('RETRY_FAILS')) {
118
+ res.writeHead(400, { 'Content-Type': 'application/json' });
119
+ return res.end(JSON.stringify({ error: { message: 'try again later', type: 'invalid_request_error' } }));
120
+ }
121
+ if (json.model === 'up-badreq') {
122
+ res.writeHead(400, { 'Content-Type': 'application/json' });
123
+ return res.end(JSON.stringify({ error: { message: 'temperature must be between 0 and 2', type: 'invalid_request_error' } }));
124
+ }
98
125
  if (lastUser.includes('TOOL_ALIAS_REPLAY')) {
99
126
  const reference = json.messages.find(m => m.role === 'tool');
100
127
  const name = JSON.parse(reference.content).tool_name;
@@ -108,10 +135,28 @@ function startUpstream() {
108
135
  { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 10, completion_tokens: 15 } }
109
136
  ], { raw: ['data: [DONE]\n\n'] });
110
137
  }
138
+ if (lastUser.includes('PLACEHOLDER_ECHO')) {
139
+ const args = '{"cmd":"ls","env":{"A":"1","_placeholder":true},"opts":{"_placeholder":"keep"},"_placeholder":true}';
140
+ if (!json.stream) {
141
+ res.writeHead(200, { 'Content-Type': 'application/json' });
142
+ return res.end(JSON.stringify({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [{ id: 'call_ph', type: 'function', function: { name: 'run', arguments: args } }] }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }));
143
+ }
144
+ return sse(res, [
145
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_ph', type: 'function', function: { name: 'run', arguments: args.slice(0, 30) } }] } }] },
146
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: args.slice(30) } }] } }] },
147
+ { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 5, completion_tokens: 5 } }
148
+ ], { raw: ['data: [DONE]\n\n'] });
149
+ }
111
150
  if (lastUser.includes('RATE_LIMIT')) {
112
151
  res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
113
152
  return res.end(JSON.stringify({ error: { message: 'slow down' } }));
114
153
  }
154
+ const mockErr = lastUser.match(/MOCK_ERR:(\w+)/)?.[1];
155
+ if (mockErr && MOCK_ERRORS[mockErr]) {
156
+ const [status, message] = MOCK_ERRORS[mockErr];
157
+ res.writeHead(status, { 'Content-Type': 'application/json' });
158
+ return res.end(JSON.stringify({ error: { message, type: 'invalid_request_error' } }));
159
+ }
115
160
  if (lastUser.includes('CONTEXT_OVERFLOW')) {
116
161
  res.writeHead(400, { 'Content-Type': 'application/json' });
117
162
  return res.end(JSON.stringify({ error: { message: "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens (132000 in the messages, 8000 in the completion). Please reduce the length of the messages or completion.", type: 'invalid_request_error', code: 'invalid_request_error' } }));
@@ -220,6 +265,8 @@ before(async () => {
220
265
  await startUpstream();
221
266
  proxyPort = await freePort();
222
267
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-test-'));
268
+ // A run that ends with no after() still stops the gateway and the interceptor it started detached.
269
+ cancelExitReap = onAbnormalExit(() => reapDirSync(tmpDir, [proxy?.pid, fakeInterceptor?.pid]));
223
270
 
224
271
  // R3b: both tools are active in this fixture, so every admin write runs through
225
272
  // checkBlindfoldTarget. Give the interceptor a port of its own and a certificate minted for
@@ -243,6 +290,11 @@ before(async () => {
243
290
  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
291
  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
292
  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' } },
293
+ 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' } },
294
+ 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' } },
295
+ agyph: { name: 'Mock agy to AG via chat', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-secret-agy', defaultModels: { main: 'ag/mock-flash' } },
296
+ cappedtwin: { name: 'Mock Capped Twin', 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' } },
297
+ cappedold: { name: 'Mock Capped Old', 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
298
  ant: { name: 'Mock Anthropic', mode: 'direct', inFormat: 'auto', outFormat: 'anthropic', baseURL: `${base}/ant`, apiKey: 'sk-secret-ant', defaultModels: models }
247
299
  }
248
300
  };
@@ -302,9 +354,11 @@ before(async () => {
302
354
  }
303
355
  });
304
356
 
305
- after(() => {
306
- fakeInterceptor?.kill();
307
- proxy?.kill();
357
+ after(async () => {
358
+ cancelExitReap?.();
359
+ // The gateway first: once it is gone it can start no interceptor.
360
+ await stopChild(proxy);
361
+ await stopChild(fakeInterceptor);
308
362
  upstream?.close();
309
363
  // A test that changes the tool set makes the gateway start a real interceptor. It is detached on
310
364
  // purpose, so it outlives the gateway; stop the one the gateway recorded before its state goes.
@@ -312,7 +366,8 @@ after(() => {
312
366
  const { pid } = JSON.parse(fs.readFileSync(path.join(tmpDir, 'blindfold.json'), 'utf8'));
313
367
  if (Number.isInteger(pid) && pid !== fakeInterceptor?.pid) process.kill(pid);
314
368
  } catch {}
315
- if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
369
+ // One it had not recorded yet still names the dir on its command line.
370
+ if (tmpDir) await reapDir(tmpDir);
316
371
  });
317
372
 
318
373
  const url = (p) => `http://127.0.0.1:${proxyPort}${p}`;
@@ -590,6 +645,123 @@ test('A8: /v1/chat/completions and /chat/completions answer 404 on the gateway',
590
645
  assert.equal(received.length, before, 'a removed route never reaches an upstream');
591
646
  });
592
647
 
648
+ // The profile's own list names the output limit, so a max_tokens above it is clamped instead of refused.
649
+ test('max_tokens above the listed output limit is clamped; a model with no listed limit keeps it', async () => {
650
+ const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'capped' } });
651
+ assert.equal(listed.status, 200);
652
+ await listed.text();
653
+ const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': 'capped' });
654
+ assert.equal(res.status, 200);
655
+ await res.text();
656
+ assert.equal(received.at(-1).body.model, 'up-capped');
657
+ assert.equal(received.at(-1).body.max_tokens, 8192);
658
+ const free = await post('/v1/messages', { model: 'claude-sonnet-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': 'capped' });
659
+ await free.text();
660
+ assert.equal(received.at(-1).body.model, 'up-sonnet');
661
+ assert.equal(received.at(-1).body.max_tokens, 64000);
662
+ });
663
+
664
+ // The clamp trusts only the list this profile read within the hour, whatever other profile shares its baseURL.
665
+ test('max_tokens is clamped only from a fresh cache entry of the same profile', async () => {
666
+ const listed = await fetch(url('/v1/models'), { headers: { 'x-llm-profile': 'capped' } });
667
+ await listed.text();
668
+ const ask = async (profile) => {
669
+ const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }] }, { 'x-llm-profile': profile });
670
+ assert.equal(res.status, 200, profile);
671
+ await res.text();
672
+ assert.equal(received.at(-1).body.model, 'up-capped', profile);
673
+ return received.at(-1).body.max_tokens;
674
+ };
675
+ assert.equal(await ask('cappedtwin'), 64000, 'another profile on the same baseURL has not read its list');
676
+ const cacheFile = path.join(tmpDir, 'model-windows.json');
677
+ const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
678
+ const base = `http://127.0.0.1:${upstreamPort}/chat/v1`;
679
+ cache[`cappedold@${base}`] = { at: Date.now() - 2 * 3_600_000, baseURL: base, models: { 'up-capped': { context: 200000, compact: 0, output: 4096 } } };
680
+ fs.writeFileSync(cacheFile, JSON.stringify(cache));
681
+ assert.equal(await ask('cappedold'), 64000, 'an entry two hours old is not trusted');
682
+ assert.equal(await ask('capped'), 8192);
683
+ });
684
+
685
+ // Gemini gets `_placeholder` on an empty nested object; the client never declared it, so it must not come back.
686
+ test('A _placeholder the gateway added for Gemini is stripped from tool-call arguments, stream and non-stream', async () => {
687
+ const tools = [{ type: 'function', name: 'run', parameters: { type: 'object', properties: {
688
+ cmd: { type: 'string' },
689
+ env: { type: 'object', additionalProperties: { type: 'string' } },
690
+ opts: { type: 'object', properties: { _placeholder: { type: 'string' } } }
691
+ } } }];
692
+ const want = { cmd: 'ls', env: { A: '1' }, opts: { _placeholder: 'keep' }, _placeholder: true };
693
+
694
+ const plain = await post('/v1/responses', { model: 'main', stream: false, tools, input: 'PLACEHOLDER_ECHO' }, { 'x-llm-profile': 'agmock' });
695
+ assert.equal(plain.status, 200);
696
+ const sent = received.at(-1).body.tools.find(t => t.function.name === 'run').function.parameters;
697
+ assert.deepEqual(Object.keys(sent.properties.env.properties), ['_placeholder'], 'the schema sent upstream was padded');
698
+ const call = (await plain.json()).output.find(o => o.type === 'function_call');
699
+ assert.deepEqual(JSON.parse(call.arguments), want);
700
+
701
+ const streamed = await post('/v1/responses', { model: 'main', stream: true, tools, input: 'PLACEHOLDER_ECHO' }, { 'x-llm-profile': 'agmock' });
702
+ const done = parseSSE(await streamed.text()).find(e => e.data?.type === 'response.completed');
703
+ assert.ok(done, 'the stream completes');
704
+ assert.deepEqual(JSON.parse(done.data.response.output.find(o => o.type === 'function_call').arguments), want);
705
+ });
706
+
707
+ test('A Code Assist (agy) client gets tool-call args without the padded _placeholder, stream and non-stream', async () => {
708
+ const envelope = { project: 'p', requestId: 'agent/s/1/t/1', model: 'gemini-3.8-flash-high', userAgent: 'antigravity', requestType: 'agent', request: {
709
+ contents: [{ role: 'user', parts: [{ text: 'PLACEHOLDER_ECHO' }] }],
710
+ tools: [{ functionDeclarations: [{ name: 'run', description: 'Run.', parameters: { type: 'OBJECT', properties: {
711
+ cmd: { type: 'STRING' }, env: { type: 'OBJECT' }, opts: { type: 'OBJECT', properties: { _placeholder: { type: 'STRING' } } }
712
+ } } }] }]
713
+ } };
714
+ const want = { cmd: 'ls', env: { A: '1' }, opts: { _placeholder: 'keep' }, _placeholder: true };
715
+ const headers = { 'content-type': 'application/json', 'x-llm-profile': 'agyph' };
716
+ const callOf = (chunks) => chunks.flatMap(c => c.response?.candidates?.[0]?.content?.parts || []).find(p => p.functionCall)?.functionCall;
717
+
718
+ const whole = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers, body: JSON.stringify(envelope) });
719
+ assert.equal(whole.status, 200);
720
+ const sent = received.at(-1).body.tools.find(t => t.function.name === 'run').function.parameters;
721
+ assert.deepEqual(Object.keys(sent.properties.env.properties), ['_placeholder'], 'the schema sent upstream was padded');
722
+ assert.deepEqual(callOf([await whole.json()]).args, want);
723
+
724
+ const streamed = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers, body: JSON.stringify(envelope) });
725
+ assert.equal(streamed.status, 200);
726
+ const chunks = parseSSE(await streamed.text()).map(e => e.data).filter(Boolean);
727
+ assert.deepEqual(callOf(chunks).args, want);
728
+ });
729
+
730
+ // A text-only model refuses an image that stays in the history, so every later turn would fail too.
731
+ test('An image refused by a text-only chat model is retried once as text in each request', async () => {
732
+ const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } };
733
+ const calls = model => received.filter(r => r.url.startsWith('/chat/v1/chat/completions') && r.body.model === model).length;
734
+ const sentImage = r => r.body.messages.some(m => Array.isArray(m.content) && m.content.some(p => p?.type === 'image_url'));
735
+ 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' });
736
+ assert.equal(first.status, 200);
737
+ assert.equal((await first.json()).type, 'message');
738
+ assert.equal(calls('up-textonly'), 2, 'one refusal, one retry');
739
+ assert.equal(sentImage(received.at(-1)), false);
740
+ assert.ok(JSON.stringify(received.at(-1).body).includes('[image omitted: this model does not accept images]'));
741
+
742
+ const second = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 100, stream: true, messages: [
743
+ { role: 'user', content: [image, { type: 'text', text: 'what is this' }] },
744
+ { role: 'assistant', content: 'an image' },
745
+ { role: 'user', content: 'and now?' }
746
+ ] }, { 'x-llm-profile': 'textonly' });
747
+ assert.equal(second.status, 200);
748
+ await second.text();
749
+ assert.equal(calls('up-textonly'), 4, 'nothing is remembered: one refusal, one retry again');
750
+ assert.equal(sentImage(received.at(-2)), true);
751
+ assert.equal(sentImage(received.at(-1)), false);
752
+
753
+ 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' });
754
+ assert.equal(failed.status, 400);
755
+ assert.match((await failed.json()).error.message, /try again later/, 'the client gets the error of the retry, not the first refusal');
756
+ assert.equal(calls('up-textonly'), 6);
757
+
758
+ 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' });
759
+ assert.equal(other.status, 400);
760
+ assert.match((await other.json()).error.message, /temperature must be between 0 and 2/);
761
+ assert.equal(calls('up-badreq'), 1, 'an unrelated 400 is relayed, never retried');
762
+ assert.equal(sentImage(received.at(-1)), true);
763
+ });
764
+
593
765
  test('Upstream 429 is returned in Anthropic error shape with retry-after', async () => {
594
766
  const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'RATE_LIMIT' }] });
595
767
  assert.equal(res.status, 429);
@@ -612,6 +784,36 @@ test('sendClientError maps context overflow per client', async () => {
612
784
  assert.match(codexJson.error.message, /maximum context length is 131072/);
613
785
  });
614
786
 
787
+ test('Context overflow numbers with thousands separators reach Claude Code whole', async () => {
788
+ const ask = async (key) => {
789
+ const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: `MOCK_ERR:${key}` }] });
790
+ return { status: res.status, error: (await res.json()).error };
791
+ };
792
+ for (const key of ['comma', 'dot', 'space', 'underscore']) {
793
+ const { status, error } = await ask(key);
794
+ assert.equal(status, 400, key);
795
+ assert.equal(error.type, 'invalid_request_error', key);
796
+ assert.equal(error.message, `prompt is too long: 140000 tokens > 131072 maximum (${MOCK_ERRORS[key][1]})`, key);
797
+ }
798
+ assert.deepEqual(await ask('prefixed'), { status: 400, error: { type: 'invalid_request_error', message: MOCK_ERRORS.prefixed[1] } });
799
+ assert.deepEqual(await ask('nonumber'), { status: 400, error: { type: 'invalid_request_error', message: `prompt is too long: ${MOCK_ERRORS.nonumber[1]}` } });
800
+ assert.deepEqual(await ask('rate'), { status: 429, error: { type: 'rate_limit_error', message: MOCK_ERRORS.rate[1] } });
801
+ assert.deepEqual(await ask('server'), { status: 500, error: { type: 'api_error', message: MOCK_ERRORS.server[1] } });
802
+ });
803
+
804
+ test('Codex WS: an upstream context overflow fails the turn with context_length_exceeded', async () => {
805
+ const ws = await rawWs();
806
+ ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:comma over ws' })));
807
+ assert.ok(await until(() => ws.messages.some(m => m.type === 'response.failed')), 'no response.failed');
808
+ const failed = ws.messages.find(m => m.type === 'response.failed').response.error;
809
+ assert.equal(failed.code, 'context_length_exceeded');
810
+ assert.equal(failed.message, MOCK_ERRORS.comma[1]);
811
+ ws.socket.write(clientFrame(1, JSON.stringify({ type: 'response.create', model: 'main', input: 'MOCK_ERR:rate over ws' })));
812
+ assert.ok(await until(() => ws.messages.filter(m => m.type === 'response.failed').length === 2), 'no second response.failed');
813
+ assert.equal(ws.messages.filter(m => m.type === 'response.failed')[1].response.error.code, 'rate_limit_exceeded');
814
+ ws.socket.destroy();
815
+ });
816
+
615
817
  test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
616
818
  const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
617
819
  const events = parseSSE(await res.text());