llm-switcher 1.2.11 → 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.
package/ui.html CHANGED
@@ -184,6 +184,8 @@
184
184
  .update-notice[hidden] { display: none; }
185
185
  .update-notice b { color: var(--warn); font-weight: 600; }
186
186
  .update-notice code { font-family: var(--font-mono); font-size: 0.72rem; overflow-wrap: anywhere; color: var(--muted); }
187
+ .update-notice .update-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
188
+ .update-notice #update-status { color: var(--muted); overflow-wrap: anywhere; }
187
189
 
188
190
  aside nav { display: grid; gap: 0.25rem; }
189
191
  aside a.nav-link {
@@ -886,7 +888,11 @@
886
888
  <div id="update-notice" class="update-notice" role="status" hidden>
887
889
  <b>Update available: v<span id="update-latest"></span></b>
888
890
  <code id="update-command"></code>
889
- <button type="button" class="btn btn-outline btn-sm" onclick="copyUpdateCommand()">Copy command</button>
891
+ <div class="update-actions">
892
+ <button type="button" id="update-now" class="btn btn-primary btn-sm" onclick="runUpdate()">Update now</button>
893
+ <button type="button" class="btn btn-outline btn-sm" onclick="copyUpdateCommand()">Copy command</button>
894
+ </div>
895
+ <span id="update-status" aria-live="polite" hidden></span>
890
896
  </div>
891
897
 
892
898
  <nav id="sideNav">
@@ -930,7 +936,7 @@
930
936
  <div class="ph">
931
937
  <div class="title-box">
932
938
  <h1 id="page-title">Routes & Profiles</h1>
933
- <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>
934
940
  </div>
935
941
  <div class="ph-actions" id="ph-actions">
936
942
  <!-- Rendered dynamically per route -->
@@ -977,6 +983,21 @@
977
983
  </div>
978
984
  <span id="badge-slot-codex" class="badge outline">OFF</span>
979
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>
980
1001
  </div>
981
1002
 
982
1003
  <!-- Configured Profiles Header -->
@@ -1117,6 +1138,7 @@
1117
1138
  <span class="hint" style="font-weight:600; display:block; margin-bottom:0.4rem;">Template Quick-Fill:</span>
1118
1139
  <div style="display:flex; gap:0.4rem; flex-wrap:wrap;">
1119
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>
1120
1142
  <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('9router')">9Router</button>
1121
1143
  <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('openrouter')">OpenRouter</button>
1122
1144
  <button type="button" class="btn btn-outline btn-sm" onclick="applyTemplate('vertex')">Vertex AI</button>
@@ -1150,6 +1172,7 @@
1150
1172
  <option value="auto">All compatible tools (auto)</option>
1151
1173
  <option value="claude">Claude Code only (claude)</option>
1152
1174
  <option value="codex">Codex only (codex)</option>
1175
+ <option value="agy">Antigravity CLI only (agy)</option>
1153
1176
  </select>
1154
1177
  </label>
1155
1178
  <label class="f">Outbound Format
@@ -1437,6 +1460,7 @@
1437
1460
  const parts = [];
1438
1461
  if (activeMap.claude) parts.push('Claude: ' + activeMap.claude);
1439
1462
  if (activeMap.codex) parts.push('Codex: ' + activeMap.codex);
1463
+ if (activeMap.agy) parts.push('agy: ' + activeMap.agy);
1440
1464
  subEl.textContent = 'Port ' + state.port + ' · Active for ' + parts.join(' · ');
1441
1465
  } else {
1442
1466
  subEl.textContent = 'Gateway is idle. Clients connect directly to official endpoints.';
@@ -1497,7 +1521,10 @@
1497
1521
  let iconFile = 'openai.png';
1498
1522
  let brandName = 'AI Provider';
1499
1523
 
1500
- 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')) {
1501
1528
  iconFile = 'claude.png';
1502
1529
  brandName = 'Anthropic Claude';
1503
1530
  } else if (k.includes('codex') || n.includes('codex') || t === 'codex' || t === 'responses') {
@@ -1568,12 +1595,21 @@
1568
1595
  { id: 'review', name: 'Review Model', placeholder: 'gpt-5.6-terra' },
1569
1596
  { id: 'subagent', name: 'Subagent Model', placeholder: 'gpt-5.6-luna' }
1570
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
+ ]
1571
1606
  }
1572
1607
  };
1573
1608
 
1574
1609
  function getActiveSlotConfig(fmt) {
1575
1610
  if (fmt === 'claude' || fmt === 'anthropic') return SLOT_CONFIGS.claude;
1576
1611
  if (fmt === 'codex' || fmt === 'responses') return SLOT_CONFIGS.codex;
1612
+ if (fmt === 'agy') return SLOT_CONFIGS.agy;
1577
1613
  return SLOT_CONFIGS.auto;
1578
1614
  }
1579
1615
 
@@ -1606,10 +1642,10 @@
1606
1642
  slotRow.innerHTML = `
1607
1643
  <div style="display:flex; justify-content:space-between; align-items:center;">
1608
1644
  <label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
1609
- <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;">
1610
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' : ''}>
1611
1647
  1M context
1612
- </label>
1648
+ </label>`}
1613
1649
  </div>
1614
1650
  <div class="combo">
1615
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)}">
@@ -1778,7 +1814,8 @@
1778
1814
  // Render CLI Targets Rows
1779
1815
  const slots = [
1780
1816
  { id: 'claude', name: 'Claude Code', key: 'claude' },
1781
- { id: 'codex', name: 'Codex', key: 'codex' }
1817
+ { id: 'codex', name: 'Codex', key: 'codex' },
1818
+ { id: 'agy', name: 'Antigravity CLI', key: 'agy' }
1782
1819
  ];
1783
1820
 
1784
1821
  for (const slot of slots) {
@@ -1790,7 +1827,8 @@
1790
1827
  select.innerHTML = '<option value="">Official endpoint</option>';
1791
1828
  for (const [key, p] of profilesList) {
1792
1829
  const tool = p.tool || 'auto';
1793
- 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')) {
1794
1832
  const opt = document.createElement('option');
1795
1833
  opt.value = key;
1796
1834
  opt.textContent = p.name + ' (' + key + ')';
@@ -1820,6 +1858,7 @@
1820
1858
  const activeCLIs = [];
1821
1859
  if (activeMap.claude === key) activeCLIs.push('Claude');
1822
1860
  if (activeMap.codex === key) activeCLIs.push('Codex');
1861
+ if (activeMap.agy === key) activeCLIs.push('agy');
1823
1862
  const isActiveAny = activeCLIs.length > 0;
1824
1863
  const isEditing = (key === currentlyEditingKey);
1825
1864
 
@@ -1830,7 +1869,7 @@
1830
1869
  const toolAv = getToolAvatar(key, p);
1831
1870
  const routeBadge = (p.tool || 'auto') + ' → ' + (p.outFormat || p.mode || 'chat');
1832
1871
 
1833
- 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';
1834
1873
  const pCfg = getActiveSlotConfig(slotFmt);
1835
1874
  const modelChips = pCfg.slots.map(s => {
1836
1875
  const val = valueForSlot(p.defaultModels, s.id);
@@ -1996,6 +2035,17 @@
1996
2035
  thinkingMode: 'auto',
1997
2036
  defaultModels: { default: 'gpt-4o', sonnet: 'claude-3-7-sonnet', opus: 'claude-3-opus', haiku: 'claude-3-5-haiku' }
1998
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
+ },
1999
2049
  '9router': {
2000
2050
  name: '9Router Client Proxy',
2001
2051
  baseURL: 'http://127.0.0.1:20128/v1',
@@ -2678,6 +2728,69 @@
2678
2728
  navigator.clipboard.writeText(cmd).then(() => showToast('Copied: ' + cmd), () => showToast('Copy failed. Select the command and copy it by hand.', true));
2679
2729
  }
2680
2730
 
2731
+ async function gatewayPid() {
2732
+ try {
2733
+ return (await (await fetch('/health?challenge=ui', { cache: 'no-store' })).json()).pid ?? null;
2734
+ } catch {
2735
+ return null;
2736
+ }
2737
+ }
2738
+
2739
+ // The gateway streams the update as SSE, then hands over to the new code. A new pid on /health
2740
+ // means the new gateway answers; the reload then loads the new dashboard too.
2741
+ async function runUpdate() {
2742
+ const btn = document.getElementById('update-now');
2743
+ const status = document.getElementById('update-status');
2744
+ const say = (text) => { status.textContent = text; status.hidden = false; };
2745
+ const reset = () => { btn.disabled = false; btn.textContent = 'Update now'; };
2746
+ btn.disabled = true;
2747
+ btn.textContent = 'Updating...';
2748
+ say('Checking for the new release...');
2749
+ try {
2750
+ const before = await gatewayPid();
2751
+ const res = await api('/api/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
2752
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'HTTP ' + res.status);
2753
+ let result = null, failed = null, buf = '';
2754
+ const reader = res.body.getReader();
2755
+ const decoder = new TextDecoder();
2756
+ for (;;) {
2757
+ const { done, value } = await reader.read();
2758
+ if (done) break;
2759
+ buf += decoder.decode(value, { stream: true });
2760
+ let i;
2761
+ while ((i = buf.indexOf('\n\n')) !== -1) {
2762
+ const block = buf.slice(0, i);
2763
+ buf = buf.slice(i + 2);
2764
+ const event = /^event: (.*)$/m.exec(block)?.[1];
2765
+ const data = JSON.parse(/^data: (.*)$/m.exec(block)?.[1] || 'null');
2766
+ if (event === 'log') say(data.text);
2767
+ else if (event === 'result') result = data;
2768
+ else if (event === 'error') failed = data.message;
2769
+ }
2770
+ }
2771
+ if (failed || !result) throw new Error(failed || 'The gateway closed the update without a result.');
2772
+ if (!result.updated) {
2773
+ say(result.reason);
2774
+ reset();
2775
+ return;
2776
+ }
2777
+ say('Installed v' + result.to + '. Restarting the gateway...');
2778
+ for (let n = 0; n < 60; n++) {
2779
+ await new Promise(r => setTimeout(r, 500));
2780
+ const pid = await gatewayPid();
2781
+ if (pid && pid !== before) {
2782
+ location.reload();
2783
+ return;
2784
+ }
2785
+ }
2786
+ throw new Error('The new gateway did not answer. Read proxy.log.');
2787
+ } catch (err) {
2788
+ say(err.message);
2789
+ showToast('Update failed: ' + err.message, true);
2790
+ reset();
2791
+ }
2792
+ }
2793
+
2681
2794
  document.addEventListener('DOMContentLoaded', () => {
2682
2795
  // Setup initial theme buttons state
2683
2796
  const currentTheme = document.documentElement.dataset.theme || 'dark';
package/update.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Brings this install to the newest release: a git checkout pulls fast-forward only, an npm install
2
+ // asks the registry and installs that exact version. Restarting the gateway is the caller's job.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { execFile } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { isNewer } from './version.mjs';
10
+
11
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
12
+ const REGISTRY_URL = 'https://registry.npmjs.org/llm-switcher/latest';
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ // Async on purpose: the gateway runs this in-process, and a blocking install would stall every request.
16
+ async function runCommand(cmd, args, opts = {}) {
17
+ const { stdout } = await execFileAsync(cmd, args, {
18
+ encoding: 'utf8',
19
+ windowsHide: true,
20
+ timeout: 120000,
21
+ ...opts,
22
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...opts.env }
23
+ });
24
+ return stdout;
25
+ }
26
+
27
+ function readVersion(root) {
28
+ try { return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version || '0.0.0'; } catch { return '0.0.0'; }
29
+ }
30
+
31
+ // launchd and systemd --user give the gateway a short PATH without a Homebrew or nvm npm. The npm
32
+ // next to the running node is the right one, and its launcher finds node on the same PATH.
33
+ function envWithNodeFirst(env = process.env) {
34
+ const key = Object.keys(env).find(k => k.toUpperCase() === 'PATH') || 'PATH';
35
+ return { ...env, [key]: [path.dirname(process.execPath), env[key]].filter(Boolean).join(path.delimiter) };
36
+ }
37
+
38
+ const failure = (what, err) => new Error(`${what} failed: ${String(err.stderr || err.message).trim()}`);
39
+ const noUpdate = (from, reason) => ({ updated: false, from, to: from, reason });
40
+
41
+ async function updateCheckout(root, run, log) {
42
+ const git = (...args) => run('git', ['-C', root, ...args]);
43
+ const from = readVersion(root);
44
+ // A pull into edited files would mix someone's work in progress with the release.
45
+ if ((await git('status', '--porcelain', '--untracked-files=no')).trim()) {
46
+ return noUpdate(from, 'The checkout has local changes. Commit or stash them, then update.');
47
+ }
48
+ try {
49
+ await git('rev-parse', '--abbrev-ref', '@{u}');
50
+ } catch {
51
+ return noUpdate(from, 'The current branch has no upstream branch to update from.');
52
+ }
53
+ log('Fetching the upstream branch...');
54
+ try { await git('fetch', '--quiet'); } catch (err) { throw failure('git fetch', err); }
55
+ const behind = Number((await git('rev-list', '--count', 'HEAD..@{u}')).trim());
56
+ if (!behind) return noUpdate(from, `Already on the latest version (v${from}).`);
57
+ log(`Pulling ${behind} new commit(s)...`);
58
+ // --ff-only: a branch with local commits fails here instead of getting a merge commit.
59
+ try { await git('pull', '--ff-only', '--quiet'); } catch (err) { throw failure('git pull --ff-only', err); }
60
+ return { updated: true, from, to: readVersion(root), reason: `Pulled ${behind} commit(s).` };
61
+ }
62
+
63
+ async function updateNpmInstall(root, run, log, registryUrl) {
64
+ const from = readVersion(root);
65
+ let latest = null;
66
+ try {
67
+ const r = await fetch(registryUrl, { signal: AbortSignal.timeout(5000), headers: { accept: 'application/json' } });
68
+ if (r.ok) latest = (await r.json())?.version ?? null;
69
+ } catch {}
70
+ if (!latest) return noUpdate(from, 'Could not reach the npm registry.');
71
+ if (!isNewer(latest, from)) return noUpdate(from, `Already on the latest version (v${from}).`);
72
+ log(`Installing llm-switcher@${latest} from npm...`);
73
+ // npm.cmd is a batch file, and Node runs one only through cmd.exe. The cwd is the home directory,
74
+ // because Windows cannot replace a package directory that a process holds as its cwd.
75
+ const npmArgs = ['install', '-g', `llm-switcher@${latest}`];
76
+ const [cmd, args] = process.platform === 'win32' ? ['cmd.exe', ['/d', '/s', '/c', 'npm.cmd', ...npmArgs]] : ['npm', npmArgs];
77
+ try {
78
+ await run(cmd, args, { cwd: os.homedir(), timeout: 300000, env: envWithNodeFirst() });
79
+ } catch (err) {
80
+ throw failure('npm install', err);
81
+ }
82
+ const to = readVersion(root);
83
+ // npm can install into another prefix than the one this gateway runs from; then nothing changed here.
84
+ if (to === from) return noUpdate(from, `npm installed v${latest}, but not into ${root}.`);
85
+ return { updated: true, from, to, reason: `Installed v${to} from npm.` };
86
+ }
87
+
88
+ /** Returns { updated, from, to, reason }. Throws only when an update started and failed. */
89
+ export async function applyUpdate({
90
+ root = ROOT,
91
+ run = runCommand,
92
+ logger = () => {},
93
+ registryUrl = process.env.LLM_SWITCHER_REGISTRY_URL || REGISTRY_URL
94
+ } = {}) {
95
+ return fs.existsSync(path.join(root, '.git'))
96
+ ? updateCheckout(root, run, logger)
97
+ : updateNpmInstall(root, run, logger, registryUrl);
98
+ }
@@ -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
+ });