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.
@@ -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
+ }
@@ -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 two tools');
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 Codex independently.</p>
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 (k.includes('claude') || n.includes('claude') || t === 'claude' || t === 'anthropic' || u.includes('anthropic')) {
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
- <label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
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
- if (tool === 'auto' || tool === slot.key) {
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
+ });