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.
- package/CHANGELOG.md +23 -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 +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/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/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
|
+
});
|