llm-switcher 1.6.11 → 1.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,69 @@
1
1
  // Runs only tests/**/*.test.mjs. A bare `node --test` also collects test files from git-ignored
2
2
  // folders (an old copy in temp/), and Node 18 and 20 do not expand a glob argument.
3
3
  import fs from 'node:fs';
4
+ import os from 'node:os';
4
5
  import path from 'node:path';
5
6
  import { spawnSync } from 'node:child_process';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
8
- const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
9
- const files = [];
10
- (function walk(dir) {
11
- for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
12
- const p = path.join(dir, e.name);
13
- if (e.isDirectory()) walk(p);
14
- else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
9
+ function holdsFile(dir) {
10
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
11
+ if (!e.isDirectory() || holdsFile(path.join(dir, e.name))) return true;
15
12
  }
16
- })(path.join(root, 'tests'));
13
+ return false;
14
+ }
17
15
 
18
- // A version check must never reach the real npm registry.
19
- const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
20
- const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
21
- process.exit(r.status ?? 1);
16
+ // npm makes node-compile-cache in TMPDIR as it starts and writes files only as it exits, so an empty cache is taken
17
+ // as made by this run. Not exact: the empty cache of another npm run still in progress goes too, which costs that
18
+ // run a rebuild only. npm makes the directory before this script starts, so no earlier existence check can tell.
19
+ export function compileCacheIsNew(dir, env = process.env) {
20
+ if (!env.npm_lifecycle_event) return false;
21
+ try {
22
+ return !holdsFile(dir);
23
+ } catch {
24
+ return false; // missing or unreadable: nothing of ours to remove
25
+ }
26
+ }
27
+
28
+ export function removeQuietly(dir, rm = fs.rmSync) {
29
+ try {
30
+ rm(dir, { recursive: true, force: true });
31
+ } catch (err) {
32
+ console.error(`run-tests: could not remove ${dir}: ${err.message}`);
33
+ }
34
+ }
35
+
36
+ function main() {
37
+ const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ const files = [];
39
+ (function walk(dir) {
40
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
41
+ const p = path.join(dir, e.name);
42
+ if (e.isDirectory()) walk(p);
43
+ else if (e.name.endsWith('.test.mjs')) files.push(path.relative(root, p));
44
+ }
45
+ })(path.join(root, 'tests'));
46
+
47
+ // A version check must never reach the real npm registry.
48
+ const env = { ...process.env, LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' };
49
+ const compileCache = path.join(os.tmpdir(), 'node-compile-cache');
50
+ const npmMadeCache = compileCacheIsNew(compileCache);
51
+
52
+ const r = spawnSync(process.execPath, ['--test', ...process.argv.slice(2), ...files], { cwd: root, stdio: 'inherit', env });
53
+ if (npmMadeCache) removeQuietly(compileCache);
54
+ process.exit(r.status ?? 1);
55
+ }
56
+
57
+ // node runs a symlinked script from its real path, so only real paths compare. A path that cannot be
58
+ // resolved is not this script: returning false keeps an import by the test from running the suite.
59
+ export function isMainModule(argv1, selfUrl = import.meta.url) {
60
+ if (!argv1) return false;
61
+ try {
62
+ return fs.realpathSync(path.resolve(argv1)) === fs.realpathSync(fileURLToPath(selfUrl));
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Imported by its test, run by `npm test`.
69
+ if (isMainModule(process.argv[1])) main();
package/switch.mjs CHANGED
@@ -357,6 +357,7 @@ function printTargets(activeMap) {
357
357
  // Applies the switch to a copy of `base`. Exits on an error; nothing is written here.
358
358
  function planSwitch(base, key, cliTarget) {
359
359
  const planned = structuredClone(base);
360
+ delete planned.resumeProfiles;
360
361
  const err = cliTarget ? setTargetProfile(planned, cliTarget, key) : activateProfile(planned, key);
361
362
  if (err) {
362
363
  console.error(`[Error] ${show(err)}`);
@@ -365,6 +366,18 @@ function planSwitch(base, key, cliTarget) {
365
366
  return planned;
366
367
  }
367
368
 
369
+ // Bare `switch on`: the targets `switch off` turned off come back as they were. A target the user
370
+ // switched off stays off, and a target that is on keeps its profile.
371
+ function planResume(base) {
372
+ const planned = structuredClone(base);
373
+ const saved = planned.resumeProfiles;
374
+ delete planned.resumeProfiles;
375
+ if (saved && typeof saved === 'object' && !Object.values(getActiveMap(planned)).some(Boolean)) {
376
+ for (const t of TOOLS) if (saved[t]) setTargetProfile(planned, t, saved[t]);
377
+ }
378
+ return planned;
379
+ }
380
+
368
381
  function refuseBlindfoldProblem(planned, port) {
369
382
  const bf = computeLaunchState(planned, port).blindfold;
370
383
  // A missing or outdated certificate is built here; only a set that cannot be built stops the switch.
@@ -386,21 +399,26 @@ async function turnOn(profileName, cliTarget) {
386
399
  }
387
400
 
388
401
  const port = getTargetPort();
389
- const wanted = profileName || config.activeProfile || Object.keys(config.profiles)[0];
390
- const key = findProfileKey(config, wanted);
391
- if (!key) {
392
- console.error(`[Error] Profile "${show(wanted)}" not found in config.json!`);
402
+ const resume = !profileName && !cliTarget;
403
+ const key = resume ? null : findProfileKey(config, profileName);
404
+ if (!resume && !key) {
405
+ console.error(`[Error] Profile "${show(profileName)}" not found in config.json!`);
393
406
  console.error(`Available profiles: ${show(Object.keys(config.profiles).join(', ')) || '(none)'}`);
394
407
  process.exit(1);
395
408
  }
396
409
 
397
410
  // Plan on a copy. Nothing is written until every check below passes.
398
- let planned = planSwitch(config, key, cliTarget);
411
+ const plan = (base) => (resume ? planResume(base) : planSwitch(base, key, cliTarget));
412
+ let planned = plan(config);
399
413
  // The example config routes to YOUR-ROUTER-HOST; activating it silently makes every request fail.
400
- if (hasPlaceholder(planned.profiles[key])) {
401
- console.warn(`[WARN] Profile "${show(key)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
414
+ for (const k of new Set(resume ? Object.values(getActiveMap(planned)).filter(Boolean) : [key])) {
415
+ if (hasPlaceholder(planned.profiles[k])) {
416
+ console.warn(`[WARN] Profile "${show(k)}" still has a placeholder baseURL/apiKey. Edit ${configPath} first, or requests will fail.`);
417
+ }
402
418
  }
403
- console.log(`Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
419
+ console.log(resume
420
+ ? `Starting the gateway on port ${port} with the targets as they were...`
421
+ : `Activating profile: [${show(planned.profiles[key].name || key)}] (${show(key)})${cliTarget ? ` for ${cliTarget}` : ''} on port ${port}...`);
404
422
 
405
423
  const gatewayState = await probeGateway(port);
406
424
  if (isHeld(gatewayState)) refuseForeignPort(port, 'this switcher', gatewayState);
@@ -417,9 +435,8 @@ async function turnOn(profileName, cliTarget) {
417
435
  console.error(`[Error] config.json does not parse any more: ${getLastLoadError()?.message}. Nothing was changed.`);
418
436
  process.exit(1);
419
437
  }
420
- planned = planSwitch(current, key, cliTarget);
438
+ planned = plan(current);
421
439
  refuseBlindfoldProblem(planned, port);
422
- const profile = planned.profiles[key];
423
440
 
424
441
  const previousBytes = fs.readFileSync(configPath);
425
442
  try {
@@ -471,8 +488,14 @@ async function turnOn(profileName, cliTarget) {
471
488
  console.warn(`[Shim] ${err.message}`);
472
489
  }
473
490
 
474
- console.log(`\n[SUCCESS] Switched to profile "${show(profile.name || key)}".`);
475
- printProfile(profile);
491
+ if (resume) {
492
+ console.log('\n[SUCCESS] Gateway is on.');
493
+ if (!Object.values(getActiveMap(planned)).some(Boolean)) console.log('No target is on. Turn one on with: switch <profile>');
494
+ } else {
495
+ const profile = planned.profiles[key];
496
+ console.log(`\n[SUCCESS] Switched to profile "${show(profile.name || key)}".`);
497
+ printProfile(profile);
498
+ }
476
499
  console.log('\nActive targets:');
477
500
  printTargets(getActiveMap(planned));
478
501
  const codexKey = getActiveMap(planned).codex;
@@ -578,6 +601,7 @@ async function turnOff(targetArg) {
578
601
  console.log('\n[SUCCESS] Switched back to Claude Official Subscription. Run `switch on` to re-enable.');
579
602
  return;
580
603
  }
604
+ config.resumeProfiles = getActiveMap(config);
581
605
  deactivateAll(config);
582
606
  saveConfig(config);
583
607
  reportSettings(clearLaunchState(port));
@@ -1337,7 +1361,8 @@ if (cmd === 'off' || cmd === 'stop') {
1337
1361
  console.log(' switch version # Show the version and check for a newer one');
1338
1362
  console.log(' switch doctor # Audit environment, settings & routing');
1339
1363
  console.log(' switch update # Install the newest release and restart the gateway');
1340
- console.log(' switch on [profile] # Start gateway & activate profile for all compatible targets');
1364
+ console.log(' switch on # Start gateway; each target gets back what it had before `switch off`');
1365
+ console.log(' switch on <profile> # Start gateway & activate profile for all compatible targets');
1341
1366
  console.log(' switch <profile> # Activate profile for all compatible targets');
1342
1367
  console.log(' switch claude <profile> # Set active profile for Claude Code');
1343
1368
  console.log(' switch codex <profile> # Set active profile for Codex');
@@ -24,6 +24,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
24
24
 
25
25
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
26
  const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-task3-'));
27
+ test.after(() => fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }));
27
28
  process.env.LLM_SWITCHER_HOME = TMP;
28
29
  process.env.LLM_SWITCHER_CONFIG = path.join(TMP, 'config.json');
29
30
  delete process.env.LLM_SWITCHER_BLINDFOLD_CERTS;
@@ -207,7 +207,7 @@ test('make-certs.sh writes private files and refuses a directory it does not own
207
207
  }
208
208
  // A failed rebuild (here: no openssl on PATH) must leave the working set untouched.
209
209
  const caBefore = fs.readFileSync(path.join(out, 'ca.pem'));
210
- const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-bin-'));
210
+ const bin = fs.mkdtempSync(path.join(base, 'bin-'));
211
211
  for (const tool of ['mkdir', 'stat', 'id', 'chmod', 'rm', 'cat', 'dirname', 'mv', 'mktemp']) {
212
212
  const real = execFileSync('bash', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).trim();
213
213
  fs.symlinkSync(real, path.join(bin, tool));
@@ -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';
@@ -9,9 +9,13 @@ import { fileURLToPath } from 'node:url';
9
9
  import { openResponsesWs } from './ws-client.mjs';
10
10
  const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
11
11
  const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
12
+ // Every temp directory goes once more after the file, so a setup that fails early leaves none behind.
13
+ const made = [];
14
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); });
12
15
  async function until(check) { const deadline = Date.now() + 6000; while (Date.now() < deadline) { if (await check()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('timeout'); }
13
16
  async function fixture(t) {
14
17
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compaction-'));
18
+ made.push(dir);
15
19
  const received = []; let finish = 'stop', truncate = false;
16
20
  const up = http.createServer((req, res) => {
17
21
  let raw = ''; req.on('data', b => raw += b); req.on('end', () => {
@@ -1,7 +1,7 @@
1
1
  // `switch contract-check`: it turns open findings of intact into JSON test data, and never into
2
2
  // JavaScript. Every path, enum value and name in a finding or a fixture is untrusted text, so the
3
3
  // hostile-finding test hashes every .mjs file of the repository before and after a run.
4
- import { test } from 'node:test';
4
+ import { test, after } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import http from 'node:http';
7
7
  import crypto from 'node:crypto';
@@ -15,8 +15,13 @@ import { runCheck, loadExclusions, matchExclusion, FINDING_ID_RE } from '../cont
15
15
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
16
  const HOSTILE = "x');require('child_process')";
17
17
 
18
+ // Every directory is removed once more after the file, so a failed test leaves none behind.
19
+ const made = [];
20
+ after(() => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
18
21
  function tmpDir(name) {
19
- return fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
22
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `lsw-${name}-`));
23
+ made.push(dir);
24
+ return dir;
20
25
  }
21
26
 
22
27
  // One fake intact: it serves the findings list and one fixture per known trace id.
@@ -70,7 +70,7 @@ test('the tool version is read from the User-Agent, and a bad one is dropped', (
70
70
 
71
71
  // ---------------------------------------------------------------- unit: config block
72
72
 
73
- test('contractLab is absent by default, and a saved block survives a rewrite', () => {
73
+ test('contractLab is absent by default, and a saved block survives a rewrite', (t) => {
74
74
  assert.deepEqual(contractLabSettings({}), { url: '', apiKey: '', enabled: false });
75
75
  assert.deepEqual(contractLabSettings(null), { url: '', apiKey: '', enabled: false });
76
76
  // Enabled needs a real http(s) URL and a key: a half-filled block stays off.
@@ -80,6 +80,7 @@ test('contractLab is absent by default, and a saved block survives a rewrite', (
80
80
  assert.equal(contractLabSettings({ contractLab: { url: 'http://127.0.0.1:9/', apiKey: 'k', enabled: true } }).url, 'http://127.0.0.1:9');
81
81
 
82
82
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-lab-'));
83
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
83
84
  const cfgPath = path.join(dir, 'config.json');
84
85
  const cfg = { port: 3456, profiles: {}, contractLab: { url: 'http://127.0.0.1:20142', apiKey: 'sk-lab', enabled: true } };
85
86
  fs.writeFileSync(cfgPath, JSON.stringify(cfg));
@@ -94,7 +95,6 @@ test('contractLab is absent by default, and a saved block survives a rewrite', (
94
95
  // The dashboard must never read the intact key back out of /api/config.
95
96
  assert.equal(out.masked.apiKey, MASKED_KEY);
96
97
  assert.equal(out.masked.hasApiKey, true);
97
- fs.rmSync(dir, { recursive: true, force: true });
98
98
  });
99
99
 
100
100
  // state.mjs reads its config path once per process, so a config test runs in its own process.
@@ -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();
@@ -13,9 +13,12 @@ import {
13
13
  smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
14
14
  createCodeAssistStream,
15
15
  budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage,
16
- refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT
16
+ refusesImageInput, omitChatImages, IMAGE_OMITTED_TEXT,
17
+ stripGeminiPlaceholders, geminiPaddedTools, withGeminiPlaceholderStrip, toolVocabForIR, recordGeminiPadding
17
18
  } from '../formats.mjs';
19
+ import { NULL_TOOL_VOCAB, createToolVocab } from '../toolvocab.mjs';
18
20
  import { assertValidAnthropicEvents } from './helpers.mjs';
21
+ import { encodeCompaction } from '../compaction.mjs';
19
22
 
20
23
  test('anthropicToIR: stream defaults to false and thinking budget_tokens is preserved', () => {
21
24
  const ir = anthropicToIR({ model: 'claude-opus-4-6', max_tokens: 64000, messages: [{ role: 'user', content: 'hi' }], thinking: { type: 'enabled', budget_tokens: 31999 } });
@@ -109,6 +112,15 @@ test('healAnthropicPayload: native Anthropic passthrough keeps the billing heade
109
112
  assert.equal(payload.system[0].text, header);
110
113
  });
111
114
 
115
+ test('anthropicToIR: a system turn folds into the IR system prompt', () => {
116
+ const ir = anthropicToIR({
117
+ model: 'claude-opus-4-6', max_tokens: 10,
118
+ messages: [{ role: 'system', content: 'You are Rem.' }, { role: 'user', content: 'hi' }]
119
+ });
120
+ assert.equal(ir.system, 'You are Rem.');
121
+ assert.deepEqual(ir.messages.map(m => m.role), ['user']);
122
+ });
123
+
112
124
  test('healToolPairs: orphan result -> user text, missing result -> placeholder, adjacency kept', () => {
113
125
  const healed = healToolPairs([
114
126
  { role: 'user', content: 'start' },
@@ -1388,3 +1400,291 @@ test('healAnthropicPayload dedupes repeated tool_use ids', () => {
1388
1400
  assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
1389
1401
  assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
1390
1402
  });
1403
+
1404
+ // Reasoning from an aborted turn must not attach to the assistant turn after the next user message.
1405
+ test('responsesToIR drops pending reasoning at a user message and joins reasoning pieces with a newline', () => {
1406
+ const ir = responsesToIR({ model: 'x', input: [
1407
+ { type: 'message', role: 'user', content: 'plan it' },
1408
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1409
+ { type: 'message', role: 'user', content: 'start over' },
1410
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'fresh plan' }] },
1411
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'second piece' }] },
1412
+ { type: 'message', role: 'assistant', content: 'done' }
1413
+ ] });
1414
+ const assistant = ir.messages.find(m => m.role === 'assistant');
1415
+ assert.equal(assistant.reasoning, 'fresh plan\nsecond piece');
1416
+ const viaString = responsesToIR({ model: 'x', input: [
1417
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1418
+ 'start over',
1419
+ { type: 'message', role: 'assistant', content: 'done' }
1420
+ ] });
1421
+ assert.equal(viaString.messages.find(m => m.role === 'assistant').reasoning, undefined);
1422
+ });
1423
+
1424
+ // The direct Anthropic route has no system role inside messages; dropping the message loses its instruction.
1425
+ test('healAnthropicPayload folds a mid-conversation system message into a user turn', () => {
1426
+ const roles = (msgs) => msgs.map(m => m.role);
1427
+ const texts = (msgs) => JSON.stringify(msgs);
1428
+ const alternates = (msgs) => msgs.every((m, i) => i === 0 || m.role !== msgs[i - 1].role);
1429
+
1430
+ const r = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1431
+ { role: 'user', content: 'list files' },
1432
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
1433
+ { role: 'system', content: 'Use British spelling from now on.' },
1434
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
1435
+ { role: 'assistant', content: 'one file' },
1436
+ { role: 'system', content: [{ type: 'text', text: 'Be brief.' }] }
1437
+ ] });
1438
+ assert.equal(r.changed, true);
1439
+ const msgs = r.payload.messages;
1440
+ assert.ok(!msgs.some(m => m.role === 'system'), 'no system role remains');
1441
+ assert.ok(alternates(msgs), `roles alternate: ${roles(msgs)}`);
1442
+ assert.deepEqual(roles(msgs), ['user', 'assistant', 'user', 'assistant', 'user']);
1443
+ assert.deepEqual(msgs[2].content, [
1444
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' },
1445
+ { type: 'text', text: '[system] Use British spelling from now on.' }
1446
+ ], 'a tool_result stays first');
1447
+ assert.ok(!texts(msgs[3]).includes('[system]'));
1448
+ assert.deepEqual(msgs[4], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.' }] }, 'a system message after the last assistant turn stays after it');
1449
+ assert.ok(r.notes.includes('folded system messages into user turns'));
1450
+ assert.ok(!r.notes.includes('dropped system content with no text'));
1451
+
1452
+ const only = healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1453
+ { role: 'system', content: 'Answer in French.' },
1454
+ { role: 'assistant', content: 'Bonjour' }
1455
+ ] }).payload.messages;
1456
+ assert.deepEqual(only, [{ role: 'user', content: [{ type: 'text', text: '[system] Answer in French.' }] }, { role: 'assistant', content: 'Bonjour' }]);
1457
+ });
1458
+
1459
+ // Vertex path: the schema is deep-copied before it is sent, and an Anthropic client streams the call.
1460
+ test('a Vertex tool padded for Gemini loses _placeholder in streamed arguments, and only where padded', () => {
1461
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
1462
+ type: 'object', properties: { env: { type: 'object' }, list: { type: 'array', items: { type: 'object' } }, opts: { type: 'object', properties: { _placeholder: { type: 'string' } } } }
1463
+ } }, { name: 'plain', input_schema: { type: 'object', properties: { a: { type: 'string' } } } }] });
1464
+ const body = JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')));
1465
+ const padded = geminiPaddedTools(body);
1466
+ assert.deepEqual([...padded.keys()], ['run'], 'only the tool with a padded node');
1467
+ const args = { env: { A: '1', _placeholder: true }, list: [{ _placeholder: false, b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 };
1468
+ assert.deepEqual(stripGeminiPlaceholders(args, padded.get('run')), { env: { A: '1' }, list: [{ b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 });
1469
+
1470
+ const events = [];
1471
+ const r = createAnthropicStream((e, d) => events.push(d), 'm', { vocab: withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, padded) });
1472
+ r.start();
1473
+ const json = JSON.stringify(args);
1474
+ r.tool({ index: 0, id: 'c1', name: 'run', args: json.slice(0, 12) });
1475
+ r.tool({ index: 0, args: json.slice(12) });
1476
+ r.finish('tool_calls');
1477
+ const streamed = events.filter(d => d.delta?.type === 'input_json_delta').map(d => d.delta.partial_json).join('');
1478
+ assert.deepEqual(JSON.parse(streamed), { env: { A: '1' }, list: [{ b: 2 }], opts: { _placeholder: 'keep' }, _placeholder: 1 });
1479
+ assert.equal(withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, padded).args('plain', '{"_placeholder":true}'), '{"_placeholder":true}');
1480
+ assert.equal(withGeminiPlaceholderStrip(NULL_TOOL_VOCAB, new Map()), NULL_TOOL_VOCAB, 'no padding, no wrapper');
1481
+ });
1482
+
1483
+ // A vertex client gets complete functionCalls, so the strip applies when the call is written out.
1484
+ test('a vertex client gets tool-call args without the padded _placeholder, stream and non-stream', () => {
1485
+ const declared = [{ name: 'run', parameters: { type: 'object', properties: { env: { type: 'object' }, opts: { type: 'object', properties: { _placeholder: { type: 'string' } } } } } }];
1486
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: declared.map(t => ({ name: t.name, input_schema: t.parameters })) });
1487
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
1488
+ const vocab = withGeminiPlaceholderStrip(createToolVocab(ir.tools, { allowHyphens: false }), padded);
1489
+ const args = JSON.stringify({ env: { A: '1', _placeholder: true }, opts: { _placeholder: 'keep' } });
1490
+ const want = { env: { A: '1' }, opts: { _placeholder: 'keep' } };
1491
+
1492
+ const chunks = [];
1493
+ const s = createVertexStream((e, d) => chunks.push(d), 'm', { vocab });
1494
+ s.tool({ index: 0, id: 'c1', name: 'run', args: args.slice(0, 9) });
1495
+ s.tool({ index: 0, args: args.slice(9) });
1496
+ s.finish('tool_calls');
1497
+ const streamed = chunks.flatMap(c => c.candidates?.[0]?.content?.parts || []).find(p => p.functionCall);
1498
+ assert.deepEqual(streamed.functionCall.args, want);
1499
+
1500
+ const whole = buildVertexMessage({ model: 'm', tools: [{ index: 0, id: 'c1', name: 'run', args }], finish: 'tool_calls', vocab });
1501
+ assert.deepEqual(whole.candidates[0].content.parts.find(p => p.functionCall).functionCall.args, want);
1502
+ });
1503
+
1504
+ // The Vertex body names a tool without hyphens, so the strip must find the schema by either name.
1505
+ test('the placeholder strip finds a hyphenated tool by its upstream name and by its client name', () => {
1506
+ 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' } } } }] });
1507
+ const vocab = createToolVocab(ir.tools, { allowHyphens: false, names: ['my-tool'] });
1508
+ assert.equal(vocab.name('my_tool'), 'my-tool', 'fixture: the upstream name is the sanitized one');
1509
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
1510
+ assert.deepEqual([...padded.keys()], ['my_tool']);
1511
+ const strip = withGeminiPlaceholderStrip(vocab, padded);
1512
+ const args = '{"env":{"A":"1","_placeholder":true}}';
1513
+ assert.deepEqual(JSON.parse(strip.args('my_tool', args)), { env: { A: '1' } }, 'upstream name');
1514
+ assert.deepEqual(JSON.parse(strip.args('my-tool', args)), { env: { A: '1' } }, 'client name');
1515
+ });
1516
+
1517
+ // A union with an object branch becomes anyOf for Gemini, and that object branch is padded too.
1518
+ test('the placeholder strip reaches a padded object inside an anyOf branch', () => {
1519
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
1520
+ type: 'object', properties: { env: { oneOf: [{ type: 'object' }, { type: 'string' }] } }
1521
+ } }] });
1522
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))));
1523
+ const schema = padded.get('run');
1524
+ assert.ok(Array.isArray(schema?.properties?.env?.anyOf), 'fixture: env is an anyOf with a padded branch');
1525
+ assert.deepEqual(stripGeminiPlaceholders({ env: { A: '1', _placeholder: true } }, schema), { env: { A: '1' } });
1526
+ assert.deepEqual(stripGeminiPlaceholders({ env: 'plain' }, schema), { env: 'plain' });
1527
+ });
1528
+
1529
+ // One anyOf branch padded, another declaring a real _placeholder: the key may be the client's own.
1530
+ test('the placeholder strip keeps _placeholder when another anyOf branch declares it', () => {
1531
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'run', input_schema: {
1532
+ type: 'object', properties: { env: { oneOf: [{ type: 'object' }, { type: 'object', properties: { _placeholder: { type: 'string' }, x: { type: 'string' } } }] } }
1533
+ } }] });
1534
+ const schema = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')))).get('run');
1535
+ assert.equal(schema?.properties?.env?.anyOf?.length, 2, 'fixture: a padded branch and a declaring branch');
1536
+ assert.deepEqual(stripGeminiPlaceholders({ env: { _placeholder: 'keep', x: '1' } }, schema), { env: { _placeholder: 'keep', x: '1' } });
1537
+ const lone = { anyOf: [schema.properties.env.anyOf[0], { type: 'STRING' }] };
1538
+ assert.deepEqual(stripGeminiPlaceholders({ A: '1', _placeholder: true }, lone), { A: '1' }, 'with no declaring branch the padded branch still strips');
1539
+ });
1540
+
1541
+ // The fold keeps each system text between the same turns it sat between, and says what it did.
1542
+ test('healAnthropicPayload keeps a folded system text in place and reports dropped system content', () => {
1543
+ const heal = (messages) => healAnthropicPayload({ model: 'x', max_tokens: 10, messages });
1544
+ const before = heal([
1545
+ { role: 'user', content: 'first' },
1546
+ { role: 'system', content: 'Be brief.' },
1547
+ { role: 'assistant', content: 'ok' },
1548
+ { role: 'user', content: 'second' }
1549
+ ]).payload.messages;
1550
+ assert.deepEqual(before, [
1551
+ { role: 'user', content: [{ type: 'text', text: 'first' }, { type: 'text', text: '[system] Be brief.' }] },
1552
+ { role: 'assistant', content: 'ok' },
1553
+ { role: 'user', content: 'second' }
1554
+ ], 'a system message before an assistant turn stays before it');
1555
+
1556
+ const next = heal([
1557
+ { role: 'user', content: 'first' },
1558
+ { role: 'assistant', content: 'ok' },
1559
+ { role: 'system', content: 'Be brief.' },
1560
+ { role: 'user', content: 'second' }
1561
+ ]).payload.messages;
1562
+ assert.deepEqual(next[2], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.' }, { type: 'text', text: 'second' }] }, 'it precedes the user text that followed it');
1563
+
1564
+ const blank = heal([
1565
+ { role: 'user', content: 'first' },
1566
+ { role: 'system', content: ' ' },
1567
+ { role: 'system', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } }] },
1568
+ { role: 'assistant', content: 'ok' }
1569
+ ]);
1570
+ assert.equal(blank.changed, true);
1571
+ assert.deepEqual(blank.payload.messages, [{ role: 'user', content: 'first' }, { role: 'assistant', content: 'ok' }]);
1572
+ assert.deepEqual(blank.notes, ['dropped system content with no text'], 'nothing was folded, so the note does not say so');
1573
+ });
1574
+
1575
+ // Only the blank or non-text blocks of a system message go; its text blocks stay, joined by a newline.
1576
+ test('healAnthropicPayload keeps the text of a partly blank system message and joins its blocks with a newline', () => {
1577
+ const heal = (system) => healAnthropicPayload({ model: 'x', max_tokens: 10, messages: [
1578
+ { role: 'user', content: 'first' },
1579
+ { role: 'assistant', content: 'ok' },
1580
+ { role: 'system', content: system },
1581
+ { role: 'user', content: 'second' }
1582
+ ] });
1583
+ const joined = heal([{ type: 'text', text: 'Be brief.' }, { type: 'text', text: 'Use British spelling.' }]);
1584
+ assert.deepEqual(joined.payload.messages[2], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.\nUse British spelling.' }, { type: 'text', text: 'second' }] });
1585
+ assert.deepEqual(joined.notes, ['folded system messages into user turns'], 'nothing was dropped');
1586
+
1587
+ const partial = heal([
1588
+ { type: 'text', text: 'Be brief.' },
1589
+ { type: 'text', text: ' ' },
1590
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AA==' } },
1591
+ { type: 'text', text: 'Use British spelling.' }
1592
+ ]);
1593
+ assert.equal(partial.changed, true);
1594
+ assert.deepEqual(partial.payload.messages[2], { role: 'user', content: [{ type: 'text', text: '[system] Be brief.\nUse British spelling.' }, { type: 'text', text: 'second' }] }, 'the text is kept, the blank and image blocks are not');
1595
+ assert.deepEqual(partial.notes, ['folded system messages into user turns', 'dropped system content with no text'], 'the note reports the dropped part');
1596
+ });
1597
+
1598
+ // A compaction item replaces the turns before it, so reasoning still pending there belongs to none after it.
1599
+ test('responsesToIR drops pending reasoning at a compaction item', () => {
1600
+ const ir = responsesToIR({ model: 'x', input: [
1601
+ { type: 'message', role: 'user', content: 'go' },
1602
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1603
+ { type: 'compaction', encrypted_content: encodeCompaction('earlier work') },
1604
+ { type: 'message', role: 'assistant', content: 'done' }
1605
+ ] });
1606
+ const reply = ir.messages.find(m => m.role === 'assistant');
1607
+ assert.equal(reply.content, 'done');
1608
+ assert.equal(reply.reasoning, undefined);
1609
+
1610
+ const opaque = responsesToIR({ model: 'x', input: [
1611
+ { type: 'message', role: 'user', content: 'go' },
1612
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1613
+ { type: 'compaction', encrypted_content: 'opaque-blob' },
1614
+ { type: 'message', role: 'assistant', content: 'done' }
1615
+ ] }, { allowOpaqueCompaction: true });
1616
+ assert.equal(opaque.opaqueCompaction, true, 'fixture: the opaque path');
1617
+ assert.equal(opaque.messages.find(m => m.role === 'assistant').reasoning, undefined);
1618
+
1619
+ // compaction_trigger must be the last item, so nothing after it can take the reasoning.
1620
+ const trigger = responsesToIR({ model: 'x', input: [
1621
+ { type: 'message', role: 'user', content: 'go' },
1622
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'stale plan' }] },
1623
+ { type: 'compaction_trigger' }
1624
+ ] });
1625
+ assert.equal(trigger.compaction, true);
1626
+ assert.ok(!trigger.messages.some(m => m.reasoning), 'no turn carries the pending reasoning');
1627
+ });
1628
+
1629
+ test('responsesToIR joins a second reasoning item onto an assistant turn that already has reasoning', () => {
1630
+ const ir = responsesToIR({ model: 'x', input: [
1631
+ { type: 'message', role: 'user', content: 'go' },
1632
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'first' }] },
1633
+ { type: 'message', role: 'assistant', content: 'looking' },
1634
+ { type: 'reasoning', summary: [{ type: 'summary_text', text: 'second' }] },
1635
+ { type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{}' }
1636
+ ] });
1637
+ const turns = ir.messages.filter(m => m.role === 'assistant');
1638
+ assert.equal(turns.length, 1, 'fixture: the call joins the assistant turn');
1639
+ assert.equal(turns[0].reasoning, 'first\nsecond');
1640
+ assert.equal(turns[0].toolCalls.length, 1);
1641
+ });
1642
+
1643
+ // A retry or fallback can emit the same IR again for another upstream; each emit gets its own strip.
1644
+ test('a rebuild for another upstream format does not reuse the tool vocabulary of the first', () => {
1645
+ const schema = { type: 'object', properties: { env: { type: 'object' } } };
1646
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [{ name: 'my-tool', input_schema: schema }, { name: 'run', input_schema: schema }] });
1647
+ const filled = '{"env":{"A":"1","_placeholder":true}}';
1648
+
1649
+ recordGeminiPadding(ir, irToChatBody(ir, 'gpt-5'), false);
1650
+ assert.equal(toolVocabForIR(ir, 'openai-chat').args('my-tool', filled), filled, 'a chat body padded nothing');
1651
+ recordGeminiPadding(ir, JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro'))), true);
1652
+ const vertex = toolVocabForIR(ir, 'vertex');
1653
+ assert.equal(vertex.name('my_tool'), 'my-tool', 'the Vertex name maps back');
1654
+ assert.deepEqual(JSON.parse(vertex.args('my_tool', filled)), { env: { A: '1' } }, 'the Vertex body padded env');
1655
+
1656
+ assert.deepEqual(JSON.parse(vertex.args('run', filled)), { env: { A: '1' } });
1657
+
1658
+ recordGeminiPadding(ir, irToChatBody(ir, 'gpt-5'), false);
1659
+ assert.equal(toolVocabForIR(ir, 'openai-chat').args('run', filled), filled, 'the padding of the Vertex emit is gone');
1660
+ });
1661
+
1662
+ // Two padded upstream tools that one client name maps back to: neither schema may strip for that name.
1663
+ test('the placeholder strip refuses a client name that two padded tools share', () => {
1664
+ const padded = geminiPaddedTools(JSON.parse(JSON.stringify(irToVertexBody(anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [
1665
+ { name: 'a_b', input_schema: { type: 'object', properties: { env: { type: 'object' }, opts: { type: 'string' } } } },
1666
+ { name: 'a_c', input_schema: { type: 'object', properties: { opts: { type: 'object' }, env: { type: 'string' } } } }
1667
+ ] }), 'gemini-3-pro'))));
1668
+ assert.deepEqual([...padded.keys()], ['a_b', 'a_c'], 'fixture: both tools are padded, on different keys');
1669
+ const strip = withGeminiPlaceholderStrip({ ...NULL_TOOL_VOCAB, name: n => (n === 'a_b' || n === 'a_c' ? 'a.b' : n) }, padded);
1670
+ const args = '{"env":{"_placeholder":true},"opts":{"_placeholder":true}}';
1671
+ assert.equal(strip.args('a.b', args), args, 'the client name is ambiguous: nothing is stripped');
1672
+ assert.deepEqual(JSON.parse(strip.args('a_b', args)), { env: {}, opts: { _placeholder: true } }, 'an upstream name keeps its own schema');
1673
+ assert.deepEqual(JSON.parse(strip.args('a_c', args)), { env: { _placeholder: true }, opts: {} });
1674
+
1675
+ // A real collision: `a.b` and `a_b` sanitize alike for Vertex, so `a.b` goes up under an alias with its own schema.
1676
+ const ir = anthropicToIR({ model: 'x', max_tokens: 10, messages: [{ role: 'user', content: 'go' }], tools: [
1677
+ { name: 'a.b', input_schema: { type: 'object', properties: { env: { type: 'object' } } } },
1678
+ { name: 'a_b', input_schema: { type: 'object', properties: { env: { type: 'object', properties: { _placeholder: { type: 'boolean' } } } } } }
1679
+ ] });
1680
+ const body = JSON.parse(JSON.stringify(irToVertexBody(ir, 'gemini-3-pro')));
1681
+ recordGeminiPadding(ir, body, true);
1682
+ const alias = [...ir._geminiPadded.keys()];
1683
+ assert.equal(alias.length, 1, 'fixture: only a.b is padded');
1684
+ assert.notEqual(alias[0], 'a_b');
1685
+ const vocab = toolVocabForIR(ir, 'vertex');
1686
+ const filled = '{"env":{"_placeholder":true}}';
1687
+ assert.equal(vocab.name(alias[0]), 'a.b');
1688
+ assert.deepEqual(JSON.parse(vocab.args(alias[0], filled)), { env: {} });
1689
+ assert.equal(vocab.args('a_b', filled), filled, 'a_b declared its own _placeholder');
1690
+ });