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/CHANGELOG.md +31 -0
- package/README.md +124 -20
- package/README.vi.md +123 -20
- package/config.example.json +13 -1
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- 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 +211 -20
- package/service.mjs +12 -6
- package/shim.mjs +62 -20
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +50 -13
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +115 -24
- package/tests/agy.e2e.test.mjs +529 -0
- package/tests/agy.unit.test.mjs +190 -0
- package/tests/dashboard.e2e.test.mjs +18 -17
- package/tests/formats.test.mjs +67 -1
- package/tests/gateway.e2e.test.mjs +20 -2
- package/tests/helpers.mjs +24 -24
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/mcp.test.mjs +5 -5
- package/tests/plugin.test.mjs +4 -2
- package/tests/service.test.mjs +27 -1
- package/tests/shim.test.mjs +165 -0
- package/tests/state.test.mjs +8 -8
- package/tests/update.e2e.test.mjs +197 -0
- package/tests/update.test.mjs +145 -0
- package/ui.html +121 -8
- package/update.mjs +98 -0
- package/verify-gateway.mjs +35 -0
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
|
-
<
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|