llm-switcher 1.2.12 → 1.3.0

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.
@@ -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
+ });