llm-switcher 1.2.12 → 1.3.1
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 +28 -0
- package/README.md +109 -19
- package/README.vi.md +107 -19
- package/config.example.json +13 -1
- package/formats.mjs +128 -12
- package/hook-status.mjs +5 -4
- package/mcp.mjs +6 -5
- package/package.json +1 -1
- package/plugin.mjs +80 -9
- package/proxy.mjs +155 -18
- package/shim.mjs +62 -20
- package/skills/llm-switcher/SKILL.md +6 -6
- package/state.mjs +50 -13
- package/switch.mjs +15 -9
- package/tests/agy.e2e.test.mjs +534 -0
- package/tests/agy.unit.test.mjs +190 -0
- package/tests/dashboard.e2e.test.mjs +18 -17
- package/tests/formats.test.mjs +83 -2
- package/tests/gateway.e2e.test.mjs +20 -2
- package/tests/mcp.test.mjs +5 -5
- package/tests/plugin.test.mjs +4 -2
- package/tests/shim.test.mjs +165 -0
- package/tests/state.test.mjs +8 -8
- package/ui.html +51 -7
- package/verify-gateway.mjs +35 -0
package/tests/shim.test.mjs
CHANGED
|
@@ -452,3 +452,168 @@ test('the Windows shim raises the toast only while this tool is routed', (t) =>
|
|
|
452
452
|
assert.match(off.out, /ran/);
|
|
453
453
|
assert.equal(off.asked, '', 'a tool that is off raises no toast');
|
|
454
454
|
});
|
|
455
|
+
|
|
456
|
+
test('shim status follows the Windows lookup: an .exe earlier on PATH wins over the shim', () => {
|
|
457
|
+
const dirA = fs.mkdtempSync(path.join(STATE, 'real-'));
|
|
458
|
+
const shimDir = fs.mkdtempSync(path.join(STATE, 'shim-'));
|
|
459
|
+
fs.writeFileSync(path.join(dirA, 'agy.exe'), '');
|
|
460
|
+
fs.writeFileSync(path.join(shimDir, 'agy.cmd'), '');
|
|
461
|
+
const opts = { pathEnv: [dirA, shimDir].join(';'), shimDir, platform: 'win32', pathExt: '.COM;.EXE;.BAT;.CMD' };
|
|
462
|
+
const st = shimStatus(['agy'], opts);
|
|
463
|
+
assert.equal(st.shims[0].effective, path.join(dirA, 'agy.exe'));
|
|
464
|
+
assert.equal(st.shims[0].active, false, 'the real agy.exe runs first, so the shim is not active');
|
|
465
|
+
const fixed = shimStatus(['agy'], { ...opts, pathEnv: [shimDir, dirA].join(';') });
|
|
466
|
+
assert.equal(fixed.shims[0].active, true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ---- agy: the shim proves the gateway before it hands over CLOUD_CODE_URL -----------------------
|
|
470
|
+
// agy sends its Google token in clear text to CLOUD_CODE_URL. A process that took the gateway port
|
|
471
|
+
// must therefore never receive it: the shim asks for the identity proof first.
|
|
472
|
+
const { identityProof } = await import(pathToFileURL(path.join(ROOT, 'state.mjs')).href);
|
|
473
|
+
const AGY_TOKEN = 'a'.repeat(64);
|
|
474
|
+
// Outside STATE: the other tests empty STATE file by file.
|
|
475
|
+
const AGY_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'agyconfig-'));
|
|
476
|
+
const AGY_CONFIG = path.join(AGY_CONFIG_DIR, 'config.json');
|
|
477
|
+
test.after(() => fs.rmSync(AGY_CONFIG_DIR, { recursive: true, force: true }));
|
|
478
|
+
fs.writeFileSync(path.join(path.dirname(AGY_CONFIG), 'admin.token'), AGY_TOKEN);
|
|
479
|
+
const VERIFY = path.join(ROOT, 'verify-gateway.mjs');
|
|
480
|
+
// Async on purpose: the /health listener lives in this process, and a spawnSync would block it.
|
|
481
|
+
const { spawn: spawnAsync } = await import('node:child_process');
|
|
482
|
+
function runAsync(cmd, args, opts) {
|
|
483
|
+
return new Promise((resolve) => {
|
|
484
|
+
const child = spawnAsync(cmd, args, opts);
|
|
485
|
+
let out = '', err = '';
|
|
486
|
+
child.stdout?.on('data', d => { out += d; });
|
|
487
|
+
child.stderr?.on('data', d => { err += d; });
|
|
488
|
+
const timer = setTimeout(() => child.kill(), 30000);
|
|
489
|
+
child.on('close', (status) => { clearTimeout(timer); resolve({ status, stdout: out, stderr: err }); });
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const NOTICE = /did not prove it is this switcher/;
|
|
493
|
+
|
|
494
|
+
// A /health listener: 'real' signs like this gateway, 'forged' sends a wrong proof, 'silent' never answers.
|
|
495
|
+
async function agyListener(kind) {
|
|
496
|
+
const http = await import('node:http');
|
|
497
|
+
const net = await import('node:net');
|
|
498
|
+
const server = kind === 'silent'
|
|
499
|
+
? net.createServer((sock) => sock.on('error', () => {}))
|
|
500
|
+
: http.createServer((req, res) => {
|
|
501
|
+
const port = server.address().port;
|
|
502
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
503
|
+
const proof = kind === 'real' ? identityProof(nonce, { role: 'gateway', port, pid: 4242 }, AGY_TOKEN) : 'forged';
|
|
504
|
+
res.setHeader('content-type', 'application/json');
|
|
505
|
+
res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', port, pid: 4242, proof }));
|
|
506
|
+
});
|
|
507
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
508
|
+
return server;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function runAgyWindowsShim(port) {
|
|
512
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyshim-'));
|
|
513
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyfake-'));
|
|
514
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
|
|
515
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
516
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
517
|
+
fs.writeFileSync(path.join(STATE, 'env-agy.cmd'), `SET "CLOUD_CODE_URL=http://127.0.0.1:${port}"\r\n`);
|
|
518
|
+
const shim = path.join(shimDir, 'agy.cmd');
|
|
519
|
+
fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
|
|
520
|
+
const started = Date.now();
|
|
521
|
+
const child = await runAsync('cmd.exe', ['/d', '/c', shim], {
|
|
522
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
|
|
523
|
+
});
|
|
524
|
+
fs.rmSync(shimDir, { recursive: true, force: true });
|
|
525
|
+
fs.rmSync(fakeDir, { recursive: true, force: true });
|
|
526
|
+
return { out: child.stdout || '', err: child.stderr || '', ms: Date.now() - started };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
|
|
530
|
+
test(`the Windows agy shim with a ${kind} listener on the gateway port ${routed ? 'keeps' : 'drops'} CLOUD_CODE_URL`, async (t) => {
|
|
531
|
+
if (process.platform !== 'win32') return t.skip('cmd.exe only');
|
|
532
|
+
const server = await agyListener(kind);
|
|
533
|
+
t.after(() => server.close());
|
|
534
|
+
const port = server.address().port;
|
|
535
|
+
const { out, err, ms } = await runAgyWindowsShim(port);
|
|
536
|
+
if (routed) {
|
|
537
|
+
assert.match(out, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
|
|
538
|
+
assert.doesNotMatch(err, NOTICE);
|
|
539
|
+
} else {
|
|
540
|
+
assert.match(out, /CCU=\[\]/, 'the token must not go to a port that did not prove itself');
|
|
541
|
+
assert.match(err, NOTICE);
|
|
542
|
+
}
|
|
543
|
+
assert.ok(ms < 8000, `the shim took ${ms} ms`);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const [kind, routed] of [['forged', false], ['silent', false], ['real', true]]) {
|
|
548
|
+
test(`the POSIX agy shim with a ${kind} listener on the gateway port ${routed ? 'keeps' : 'drops'} CLOUD_CODE_URL`, async (t) => {
|
|
549
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
550
|
+
const server = await agyListener(kind);
|
|
551
|
+
t.after(() => server.close());
|
|
552
|
+
const port = server.address().port;
|
|
553
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyposix-'));
|
|
554
|
+
fs.writeFileSync(path.join(dir, 'agy'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
|
|
555
|
+
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
556
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
557
|
+
fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
|
|
558
|
+
fs.writeFileSync(path.join(STATE, 'env-agy.sh'), `export CLOUD_CODE_URL='http://127.0.0.1:${port}'\n`);
|
|
559
|
+
const started = Date.now();
|
|
560
|
+
const out = await runAsync(path.join(RENDER_DIR, 'agy'), [], {
|
|
561
|
+
env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [RENDER_DIR, dir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
|
|
562
|
+
});
|
|
563
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
564
|
+
if (routed) {
|
|
565
|
+
assert.match(out.stdout, new RegExp(`CCU=\\[http://127\\.0\\.0\\.1:${port}\\]`));
|
|
566
|
+
assert.doesNotMatch(out.stderr, NOTICE);
|
|
567
|
+
} else {
|
|
568
|
+
assert.match(out.stdout, /CCU=\[\]/);
|
|
569
|
+
assert.match(out.stderr, NOTICE);
|
|
570
|
+
}
|
|
571
|
+
assert.ok(Date.now() - started < 8000, `the shim took ${Date.now() - started} ms`);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
test('verify-gateway accepts only 127.0.0.1 with a real port, whatever listens', async (t) => {
|
|
576
|
+
const server = await agyListener('real');
|
|
577
|
+
t.after(() => server.close());
|
|
578
|
+
const port = server.address().port;
|
|
579
|
+
const run = async (u) => (await runAsync(process.execPath, [VERIFY, u], { env: { ...process.env, LLM_SWITCHER_CONFIG: AGY_CONFIG } })).status;
|
|
580
|
+
assert.equal(await run(`http://127.0.0.1:${port}`), 0, 'the real gateway passes');
|
|
581
|
+
assert.notEqual(await run('http://127.0.0.1/'), 0, 'no port');
|
|
582
|
+
assert.notEqual(await run(`http://localhost:${port}`), 0, 'localhost can resolve to ::1, which the probe never checked');
|
|
583
|
+
assert.notEqual(await run(`http://other.example:${port}`), 0);
|
|
584
|
+
assert.notEqual(await run('not a url'), 0);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// While agy is off, a gateway address left in the shell from an earlier run is the switcher's own
|
|
588
|
+
// value and goes; an address the person set for another host is theirs and stays.
|
|
589
|
+
for (const [value, kept] of [['http://127.0.0.1:3456', false], ['https://cloudcode.corp.example', true]]) {
|
|
590
|
+
test(`the Windows agy shim, with agy off, ${kept ? 'keeps' : 'scrubs'} CLOUD_CODE_URL=${value}`, async (t) => {
|
|
591
|
+
if (process.platform !== 'win32') return t.skip('cmd.exe only');
|
|
592
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyoff-'));
|
|
593
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyofffake-'));
|
|
594
|
+
t.after(() => { fs.rmSync(shimDir, { recursive: true, force: true }); fs.rmSync(fakeDir, { recursive: true, force: true }); });
|
|
595
|
+
fs.writeFileSync(path.join(fakeDir, 'agy.cmd'), '@echo off\r\necho CCU=[%CLOUD_CODE_URL%]\r\n');
|
|
596
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
597
|
+
const shim = path.join(shimDir, 'agy.cmd');
|
|
598
|
+
fs.writeFileSync(shim, renderShim('agy', 'win32'), 'utf8');
|
|
599
|
+
const out = await runAsync('cmd.exe', ['/d', '/c', shim], {
|
|
600
|
+
env: { ...process.env, CLOUD_CODE_URL: value, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)].join(path.delimiter) }
|
|
601
|
+
});
|
|
602
|
+
assert.ok(out.stdout.includes(`CCU=[${kept ? value : ''}]`), out.stdout);
|
|
603
|
+
assert.doesNotMatch(out.stderr, NOTICE, 'no gateway check runs while agy is off');
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test(`the POSIX agy shim, with agy off, ${kept ? 'keeps' : 'scrubs'} CLOUD_CODE_URL=${value}`, async (t) => {
|
|
607
|
+
if (process.platform === 'win32') return t.skip('posix only');
|
|
608
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agyoffposix-'));
|
|
609
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
610
|
+
fs.writeFileSync(path.join(dir, 'agy'), '#!/usr/bin/env bash\necho "CCU=[${CLOUD_CODE_URL:-}]"\n');
|
|
611
|
+
fs.chmodSync(path.join(dir, 'agy'), 0o755);
|
|
612
|
+
for (const f of fs.readdirSync(STATE)) if (fs.statSync(path.join(STATE, f)).isFile()) fs.rmSync(path.join(STATE, f), { force: true });
|
|
613
|
+
const out = await runAsync(path.join(RENDER_DIR, 'agy'), [], {
|
|
614
|
+
env: { ...process.env, CLOUD_CODE_URL: value, LLM_SWITCHER_CONFIG: AGY_CONFIG, PATH: [RENDER_DIR, dir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
|
|
615
|
+
});
|
|
616
|
+
assert.ok(out.stdout.includes(`CCU=[${kept ? value : ''}]`), out.stdout);
|
|
617
|
+
assert.doesNotMatch(out.stderr, NOTICE);
|
|
618
|
+
});
|
|
619
|
+
}
|
package/tests/state.test.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const makeCfg = () => ({
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test('legacy config without activeProfiles maps every target to activeProfile', () => {
|
|
33
|
-
assert.deepEqual(getActiveMap(makeCfg()), { claude: 'router', codex: 'router' });
|
|
33
|
+
assert.deepEqual(getActiveMap(makeCfg()), { claude: 'router', codex: 'router', agy: null });
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
test('setTargetProfile validates the target, existence (no prototype keys) and the tool', () => {
|
|
@@ -40,16 +40,16 @@ test('setTargetProfile validates the target, existence (no prototype keys) and t
|
|
|
40
40
|
assert.match(setTargetProfile(cfg, 'anthropic', 'codexOnly'), /cannot serve/);
|
|
41
41
|
assert.equal(setTargetProfile(cfg, 'responses', 'codexOnly'), null);
|
|
42
42
|
// `anthropic` and `responses` are spellings of the two tools, and the map holds two keys.
|
|
43
|
-
assert.deepEqual(cfg.activeProfiles, { claude: 'router', codex: 'codexOnly' });
|
|
43
|
+
assert.deepEqual(cfg.activeProfiles, { claude: 'router', codex: 'codexOnly', agy: null });
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
test('activateProfile only assigns compatible tools; deactivateProfile leaves the other alone', () => {
|
|
47
47
|
const cfg = makeCfg();
|
|
48
48
|
setTargetProfile(cfg, 'claude', null);
|
|
49
49
|
activateProfile(cfg, 'codexOnly');
|
|
50
|
-
assert.deepEqual(cfg.activeProfiles, { claude: null, codex: 'codexOnly' });
|
|
50
|
+
assert.deepEqual(cfg.activeProfiles, { claude: null, codex: 'codexOnly', agy: null });
|
|
51
51
|
deactivateProfile(cfg, 'codexOnly');
|
|
52
|
-
assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null });
|
|
52
|
+
assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null, agy: null });
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test('switching a migrated config does not make it need migration again', () => {
|
|
@@ -1315,15 +1315,15 @@ test('R1: rollBack proceeds to restore oldPort gateway even if saveConfig throws
|
|
|
1315
1315
|
assert.equal(caught, false);
|
|
1316
1316
|
});
|
|
1317
1317
|
|
|
1318
|
-
test('R5: getActiveMap returns exactly { claude, codex } and a present pointer wins over the legacy one', () => {
|
|
1318
|
+
test('R5: getActiveMap returns exactly { claude, codex, agy } and a present pointer wins over the legacy one', () => {
|
|
1319
1319
|
const cfg = {
|
|
1320
1320
|
activeProfiles: { claude: 'c-prof', codex: 'cdx-prof' },
|
|
1321
1321
|
profiles: { 'c-prof': { tool: 'claude' }, 'cdx-prof': { tool: 'codex' } }
|
|
1322
1322
|
};
|
|
1323
|
-
assert.deepEqual(getActiveMap(cfg), { claude: 'c-prof', codex: 'cdx-prof' });
|
|
1323
|
+
assert.deepEqual(getActiveMap(cfg), { claude: 'c-prof', codex: 'cdx-prof', agy: null });
|
|
1324
1324
|
// An explicit "off" is a present pointer, not a missing one: it must not fall back to
|
|
1325
1325
|
// activeProfile and switch the tool back on under another key.
|
|
1326
|
-
assert.deepEqual(getActiveMap({ activeProfile: 'legacy', activeProfiles: { claude: null } }), { claude: null, codex: 'legacy' });
|
|
1326
|
+
assert.deepEqual(getActiveMap({ activeProfile: 'legacy', activeProfiles: { claude: null } }), { claude: null, codex: 'legacy', agy: null });
|
|
1327
1327
|
});
|
|
1328
1328
|
|
|
1329
1329
|
test('R7e: saves work again after the file is fixed', (t) => {
|
|
@@ -1564,7 +1564,7 @@ test('A5: config.example.json loads and passes validation without requiring migr
|
|
|
1564
1564
|
}
|
|
1565
1565
|
|
|
1566
1566
|
assert.equal('activeProfile' in example, false, 'the retired top-level pointer is gone');
|
|
1567
|
-
assert.deepEqual(Object.keys(example.activeProfiles).sort(), ['claude', 'codex'], 'exactly the
|
|
1567
|
+
assert.deepEqual(Object.keys(example.activeProfiles).sort(), ['agy', 'claude', 'codex'], 'exactly the three tools');
|
|
1568
1568
|
assert.deepEqual(example.blindfold, { port: 3457 }, 'the interceptor port is top-level, and it is only a port');
|
|
1569
1569
|
for (const [key, p] of Object.entries(example.profiles)) {
|
|
1570
1570
|
for (const retired of ['inFormat', 'blindfold', 'blindfoldPort', 'blindfoldHost', 'blindfoldPrefix']) {
|
package/ui.html
CHANGED
|
@@ -936,7 +936,7 @@
|
|
|
936
936
|
<div class="ph">
|
|
937
937
|
<div class="title-box">
|
|
938
938
|
<h1 id="page-title">Routes & Profiles</h1>
|
|
939
|
-
<p id="status-sub">Route Claude Code and
|
|
939
|
+
<p id="status-sub">Route Claude Code, Codex and the Antigravity CLI independently.</p>
|
|
940
940
|
</div>
|
|
941
941
|
<div class="ph-actions" id="ph-actions">
|
|
942
942
|
<!-- Rendered dynamically per route -->
|
|
@@ -983,6 +983,21 @@
|
|
|
983
983
|
</div>
|
|
984
984
|
<span id="badge-slot-codex" class="badge outline">OFF</span>
|
|
985
985
|
</div>
|
|
986
|
+
|
|
987
|
+
<!-- Antigravity CLI Target Row -->
|
|
988
|
+
<div class="row" id="card-slot-agy">
|
|
989
|
+
<span class="av logo" title="Antigravity CLI"><img src="/icons/antigravity.png" alt="Antigravity"></span>
|
|
990
|
+
<div class="who">
|
|
991
|
+
<div class="l">Antigravity CLI (agy)</div>
|
|
992
|
+
<div class="meta">Code Assist over <code>CLOUD_CODE_URL</code> · <code>/v1internal:*</code></div>
|
|
993
|
+
</div>
|
|
994
|
+
<div style="min-width:14rem;">
|
|
995
|
+
<select id="select-slot-agy" aria-label="Antigravity CLI route" onchange="switchCliTarget('agy', this.value)">
|
|
996
|
+
<option value="">Official endpoint</option>
|
|
997
|
+
</select>
|
|
998
|
+
</div>
|
|
999
|
+
<span id="badge-slot-agy" class="badge outline">OFF</span>
|
|
1000
|
+
</div>
|
|
986
1001
|
</div>
|
|
987
1002
|
|
|
988
1003
|
<!-- Configured Profiles Header -->
|
|
@@ -1123,6 +1138,7 @@
|
|
|
1123
1138
|
<span class="hint" style="font-weight:600; display:block; margin-bottom:0.4rem;">Template Quick-Fill:</span>
|
|
1124
1139
|
<div style="display:flex; gap:0.4rem; flex-wrap:wrap;">
|
|
1125
1140
|
<button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('intact')">intact</button>
|
|
1141
|
+
<button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('agy-intact')">agy on intact</button>
|
|
1126
1142
|
<button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
|
|
1127
1143
|
<button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
|
|
1128
1144
|
<button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('vertex')">Vertex AI</button>
|
|
@@ -1156,6 +1172,7 @@
|
|
|
1156
1172
|
<option value="auto">All compatible tools (auto)</option>
|
|
1157
1173
|
<option value="claude">Claude Code only (claude)</option>
|
|
1158
1174
|
<option value="codex">Codex only (codex)</option>
|
|
1175
|
+
<option value="agy">Antigravity CLI only (agy)</option>
|
|
1159
1176
|
</select>
|
|
1160
1177
|
</label>
|
|
1161
1178
|
<label class="f">Outbound Format
|
|
@@ -1443,6 +1460,7 @@
|
|
|
1443
1460
|
const parts = [];
|
|
1444
1461
|
if (activeMap.claude) parts.push('Claude: ' + activeMap.claude);
|
|
1445
1462
|
if (activeMap.codex) parts.push('Codex: ' + activeMap.codex);
|
|
1463
|
+
if (activeMap.agy) parts.push('agy: ' + activeMap.agy);
|
|
1446
1464
|
subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
|
|
1447
1465
|
} else {
|
|
1448
1466
|
subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
|
|
@@ -1503,7 +1521,10 @@
|
|
|
1503
1521
|
let iconFile = 'openai.png';
|
|
1504
1522
|
let brandName = 'AI Provider';
|
|
1505
1523
|
|
|
1506
|
-
if (
|
|
1524
|
+
if (t === 'agy') {
|
|
1525
|
+
iconFile = 'antigravity.png';
|
|
1526
|
+
brandName = 'Antigravity CLI';
|
|
1527
|
+
} else if (k.includes('claude') || n.includes('claude') || t === 'claude' || t === 'anthropic' || u.includes('anthropic')) {
|
|
1507
1528
|
iconFile = 'claude.png';
|
|
1508
1529
|
brandName = 'Anthropic Claude';
|
|
1509
1530
|
} else if (k.includes('codex') || n.includes('codex') || t === 'codex' || t === 'responses') {
|
|
@@ -1574,12 +1595,21 @@
|
|
|
1574
1595
|
{ id: 'review', name: 'Review Model', placeholder: 'gpt-5.6-terra' },
|
|
1575
1596
|
{ id: 'subagent', name: 'Subagent Model', placeholder: 'gpt-5.6-luna' }
|
|
1576
1597
|
]
|
|
1598
|
+
},
|
|
1599
|
+
agy: {
|
|
1600
|
+
title: 'Antigravity CLI Agent Model (Code Assist)',
|
|
1601
|
+
// agy sizes its own window; * stands for the model agy picked (antigravity/* keeps it on intact).
|
|
1602
|
+
no1m: true,
|
|
1603
|
+
slots: [
|
|
1604
|
+
{ id: 'main', name: 'Agent Model (* = the model agy picked)', placeholder: 'antigravity/* or claude/claude-opus-5' }
|
|
1605
|
+
]
|
|
1577
1606
|
}
|
|
1578
1607
|
};
|
|
1579
1608
|
|
|
1580
1609
|
function getActiveSlotConfig(fmt) {
|
|
1581
1610
|
if (fmt === 'claude' || fmt === 'anthropic') return SLOT_CONFIGS.claude;
|
|
1582
1611
|
if (fmt === 'codex' || fmt === 'responses') return SLOT_CONFIGS.codex;
|
|
1612
|
+
if (fmt === 'agy') return SLOT_CONFIGS.agy;
|
|
1583
1613
|
return SLOT_CONFIGS.auto;
|
|
1584
1614
|
}
|
|
1585
1615
|
|
|
@@ -1612,10 +1642,10 @@
|
|
|
1612
1642
|
slotRow.innerHTML = `
|
|
1613
1643
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
1614
1644
|
<label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
|
|
1615
|
-
|
|
1645
|
+
${cfg.no1m ? '' : `<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
|
|
1616
1646
|
<input type="checkbox" id="p-${s.id}-1m" aria-label="1M context for ${escapeHtml(s.name)}" aria-describedby="p-${s.id}-1m-note" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
|
|
1617
1647
|
1M context
|
|
1618
|
-
</label
|
|
1648
|
+
</label>`}
|
|
1619
1649
|
</div>
|
|
1620
1650
|
<div class="combo">
|
|
1621
1651
|
<input type="text" id="p-${s.id}" oninput="update1mWarnings()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
|
|
@@ -1784,7 +1814,8 @@
|
|
|
1784
1814
|
// Render CLI Targets Rows
|
|
1785
1815
|
const slots = [
|
|
1786
1816
|
{ id: 'claude', name: 'Claude Code', key: 'claude' },
|
|
1787
|
-
{ id: 'codex', name: 'Codex', key: 'codex' }
|
|
1817
|
+
{ id: 'codex', name: 'Codex', key: 'codex' },
|
|
1818
|
+
{ id: 'agy', name: 'Antigravity CLI', key: 'agy' }
|
|
1788
1819
|
];
|
|
1789
1820
|
|
|
1790
1821
|
for (const slot of slots) {
|
|
@@ -1796,7 +1827,8 @@
|
|
|
1796
1827
|
select.innerHTML = '<option value="">Official endpoint</option>';
|
|
1797
1828
|
for (const [key, p] of profilesList) {
|
|
1798
1829
|
const tool = p.tool || 'auto';
|
|
1799
|
-
|
|
1830
|
+
// agy has no auto profile: only a profile made for it can serve it.
|
|
1831
|
+
if (tool === slot.key || (tool === 'auto' && slot.key !== 'agy')) {
|
|
1800
1832
|
const opt = document.createElement('option');
|
|
1801
1833
|
opt.value = key;
|
|
1802
1834
|
opt.textContent = p.name + ' (' + key + ')';
|
|
@@ -1826,6 +1858,7 @@
|
|
|
1826
1858
|
const activeCLIs = [];
|
|
1827
1859
|
if (activeMap.claude === key) activeCLIs.push('Claude');
|
|
1828
1860
|
if (activeMap.codex === key) activeCLIs.push('Codex');
|
|
1861
|
+
if (activeMap.agy === key) activeCLIs.push('agy');
|
|
1829
1862
|
const isActiveAny = activeCLIs.length > 0;
|
|
1830
1863
|
const isEditing = (key === currentlyEditingKey);
|
|
1831
1864
|
|
|
@@ -1836,7 +1869,7 @@
|
|
|
1836
1869
|
const toolAv = getToolAvatar(key, p);
|
|
1837
1870
|
const routeBadge = (p.tool || 'auto') + ' → ' + (p.outFormat || p.mode || 'chat');
|
|
1838
1871
|
|
|
1839
|
-
const slotFmt = p.tool === 'claude' ? 'anthropic' : p.tool === 'codex' ? 'responses' : 'auto';
|
|
1872
|
+
const slotFmt = p.tool === 'claude' ? 'anthropic' : p.tool === 'codex' ? 'responses' : p.tool === 'agy' ? 'agy' : 'auto';
|
|
1840
1873
|
const pCfg = getActiveSlotConfig(slotFmt);
|
|
1841
1874
|
const modelChips = pCfg.slots.map(s => {
|
|
1842
1875
|
const val = valueForSlot(p.defaultModels, s.id);
|
|
@@ -2002,6 +2035,17 @@
|
|
|
2002
2035
|
thinkingMode: 'auto',
|
|
2003
2036
|
defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet', opus: 'claude-3-opus', haiku: 'claude-3-5-haiku' }
|
|
2004
2037
|
},
|
|
2038
|
+
// agy on an intact Antigravity pool: antigravity/* keeps the model agy picked, and intact names
|
|
2039
|
+
// the CLI as its own client (bifrost_ua), so the request crosses unchanged.
|
|
2040
|
+
'agy-intact': {
|
|
2041
|
+
name: 'Antigravity CLI on intact',
|
|
2042
|
+
baseURL: 'http://127.0.0.1:8080/v1',
|
|
2043
|
+
tool: 'agy',
|
|
2044
|
+
outFormat: 'openai-chat',
|
|
2045
|
+
mode: 'convert',
|
|
2046
|
+
thinkingMode: 'auto',
|
|
2047
|
+
defaultModels: { main: 'antigravity/*' }
|
|
2048
|
+
},
|
|
2005
2049
|
'9router': {
|
|
2006
2050
|
name: '9Router Client Proxy',
|
|
2007
2051
|
baseURL: 'http://127.0.0.1:20128/v1',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Exits 0 only when the gateway named by a CLOUD_CODE_URL proves that it is this switcher.
|
|
3
|
+
//
|
|
4
|
+
// agy sends its Google token in clear text to CLOUD_CODE_URL, so the agy shim runs this before it
|
|
5
|
+
// hands the variable over. Any doubt fails closed: then agy keeps its official endpoint.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node verify-gateway.mjs <url>
|
|
8
|
+
import { probeGateway } from './state.mjs';
|
|
9
|
+
|
|
10
|
+
// A probe that hangs must never hold agy at its start.
|
|
11
|
+
const LIMIT_MS = 3000;
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
process.stdout.write('timeout');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}, LIMIT_MS).unref();
|
|
16
|
+
|
|
17
|
+
async function check(raw) {
|
|
18
|
+
let u;
|
|
19
|
+
try { u = new URL(raw); } catch { return 'invalid'; }
|
|
20
|
+
// The switcher writes 127.0.0.1 only. localhost can resolve to ::1, which the probe never asks.
|
|
21
|
+
if (u.protocol !== 'http:' || u.hostname !== '127.0.0.1') return 'not-the-switcher-address';
|
|
22
|
+
const port = Number(u.port);
|
|
23
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return 'no-port';
|
|
24
|
+
return probeGateway(port);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
check(process.argv[2])
|
|
28
|
+
.then((state) => {
|
|
29
|
+
process.stdout.write(String(state));
|
|
30
|
+
process.exit(state === 'ours' ? 0 : 1);
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {
|
|
33
|
+
process.stdout.write('error');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|