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.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -2
- package/README.vi.md +3 -2
- package/formats.mjs +158 -13
- package/package.json +1 -1
- package/proxy.mjs +54 -38
- package/scripts/run-tests.mjs +60 -12
- package/switch.mjs +38 -13
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-check.test.mjs +7 -2
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +301 -1
- package/tests/gateway.e2e.test.mjs +231 -1
- package/tests/goalcheck.e2e.test.mjs +90 -8
- package/tests/idlecompact.e2e.test.mjs +6 -1
- package/tests/lifecycle.test.mjs +5 -1
- package/tests/run-tests.test.mjs +50 -0
- package/tests/shim.test.mjs +24 -18
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +147 -3
- package/windows.mjs +63 -6
package/scripts/run-tests.mjs
CHANGED
|
@@ -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
|
-
|
|
9
|
-
const
|
|
10
|
-
(
|
|
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
|
-
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
17
15
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
390
|
-
const key = findProfileKey(config,
|
|
391
|
-
if (!key) {
|
|
392
|
-
console.error(`[Error] Profile "${show(
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
475
|
-
|
|
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
|
|
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;
|
package/tests/blindfold.test.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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.
|
package/tests/datadir.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -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
|
|
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();
|
package/tests/formats.test.mjs
CHANGED
|
@@ -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
|
+
});
|