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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Unit tests for the Antigravity CLI (agy) in the launch state, the shims and the launch hook.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-agy-unit-'));
|
|
9
|
+
process.env.LLM_SWITCHER_STATE_DIR = tmp;
|
|
10
|
+
process.env.LLM_SWITCHER_CONFIG = path.join(tmp, 'config.json');
|
|
11
|
+
const s = await import('../state.mjs');
|
|
12
|
+
const shim = await import('../shim.mjs');
|
|
13
|
+
const plugin = await import('../plugin.mjs');
|
|
14
|
+
|
|
15
|
+
const cfgWith = (active) => ({
|
|
16
|
+
activeProfiles: { claude: null, codex: null, agy: null, ...active },
|
|
17
|
+
profiles: { pool: { name: 'pool', tool: 'agy', baseURL: 'https://intact.example/v1', apiKey: 'k', defaultModels: { main: 'antigravity/*' } } }
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('agy alone gets CLOUD_CODE_URL and starts no interceptor', () => {
|
|
21
|
+
const st = s.computeLaunchState(cfgWith({ agy: 'pool' }), 3456);
|
|
22
|
+
assert.equal(st.active, true);
|
|
23
|
+
assert.equal(st.blindfold, null);
|
|
24
|
+
assert.deepEqual(st.envAgy, [['CLOUD_CODE_URL', 'http://127.0.0.1:3456']]);
|
|
25
|
+
assert.deepEqual(st.envClaude, []);
|
|
26
|
+
assert.match(st.routeAgy, /^agy -> pool \| intact\.example \| antigravity\/\*/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('agy off writes no agy variables', () => {
|
|
30
|
+
const st = s.computeLaunchState(cfgWith({}), 3456);
|
|
31
|
+
assert.equal(st.active, false);
|
|
32
|
+
assert.deepEqual(st.envAgy, []);
|
|
33
|
+
assert.equal(st.routeAgy, '');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('only an agy profile serves agy, and the old single pointer never turns agy on', () => {
|
|
37
|
+
assert.equal(s.profileAcceptsTarget({ tool: 'agy' }, 'agy'), true);
|
|
38
|
+
assert.equal(s.profileAcceptsTarget({ inFormat: 'auto' }, 'agy'), false);
|
|
39
|
+
assert.equal(s.profileAcceptsTarget({ tool: 'agy' }, 'claude'), false);
|
|
40
|
+
assert.equal(s.getActiveMap({ activeProfile: 'legacy' }).agy, null);
|
|
41
|
+
assert.deepEqual(s.modelSlotsForProfile({ tool: 'agy' }), ['main']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the launch files of agy are written and emptied with the others', () => {
|
|
45
|
+
s.applyLaunchState(cfgWith({ agy: 'pool' }), 3456);
|
|
46
|
+
assert.match(fs.readFileSync(s.paths.envAgySh, 'utf8'), /export CLOUD_CODE_URL='http:\/\/127\.0\.0\.1:3456'/);
|
|
47
|
+
assert.match(fs.readFileSync(s.paths.envAgyCmd, 'utf8'), /SET "CLOUD_CODE_URL=http:\/\/127\.0\.0\.1:3456"/);
|
|
48
|
+
assert.ok(fs.existsSync(s.paths.activeFlag));
|
|
49
|
+
s.emptyToolEnvFiles('agy');
|
|
50
|
+
assert.equal(fs.readFileSync(s.paths.envAgySh, 'utf8'), '');
|
|
51
|
+
assert.equal(fs.readFileSync(s.paths.routeAgy, 'utf8'), '');
|
|
52
|
+
s.applyLaunchState(cfgWith({ agy: 'pool' }), 3456);
|
|
53
|
+
s.clearLaunchState(3456);
|
|
54
|
+
assert.equal(fs.readFileSync(s.paths.envAgyCmd, 'utf8'), '');
|
|
55
|
+
assert.equal(fs.existsSync(s.paths.activeFlag), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the agy shim reads only its own files, takes no CA, and scrubs a stale gateway URL only while off', () => {
|
|
59
|
+
const win = shim.renderShim('agy', 'win32');
|
|
60
|
+
assert.match(win, /env-agy\.cmd/);
|
|
61
|
+
assert.match(win, /route-agy\.txt/);
|
|
62
|
+
assert.doesNotMatch(win, /env-claude|NODE_EXTRA_CA_CERTS|STATE_HELPER/);
|
|
63
|
+
assert.match(win, /if not "%TOOL_ACTIVE%"=="1" if defined CLOUD_CODE_URL call :SCRUB_URL CLOUD_CODE_URL/);
|
|
64
|
+
assert.match(win, /where agy\.exe/);
|
|
65
|
+
const posix = shim.renderShim('agy', 'linux');
|
|
66
|
+
assert.match(posix, /env-agy\.sh/);
|
|
67
|
+
assert.doesNotMatch(posix, /export NODE_EXTRA_CA_CERTS/);
|
|
68
|
+
assert.match(posix, /if \[ "\$TOOL_ACTIVE" != "1" \] && \[ -n "\$_u" \]; then/);
|
|
69
|
+
assert.match(shim.renderShim('claude', 'win32'), /NODE_EXTRA_CA_CERTS/, 'Claude keeps its CA block');
|
|
70
|
+
assert.equal(shim.routeEvidence('agy', ' CLOUD_CODE_URL=http://127.0.0.1:3456 PATH=x'), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('the agy hook is merged into hooks.json, unquoted, and removed without touching other hooks', () => {
|
|
74
|
+
const agyHome = path.join(tmp, 'agy');
|
|
75
|
+
fs.mkdirSync(agyHome, { recursive: true });
|
|
76
|
+
const file = path.join(agyHome, 'hooks.json');
|
|
77
|
+
fs.writeFileSync(file, JSON.stringify({ 'lint-checker': { PostToolUse: [] } }));
|
|
78
|
+
const opts = { agyHome, claudeSkillsDir: path.join(tmp, 'skills'), codexHome: path.join(tmp, 'codex') };
|
|
79
|
+
const r = plugin.installPlugin(opts, 'test');
|
|
80
|
+
assert.ok(r.installed.includes('agy'), JSON.stringify(r));
|
|
81
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
82
|
+
assert.ok(doc['lint-checker'], 'another hook stays');
|
|
83
|
+
const cmd = doc[plugin.AGY_HOOK_NAME].SessionStart[0].command;
|
|
84
|
+
assert.match(cmd, /^node \S+\/hook-status\.mjs agy$/);
|
|
85
|
+
assert.doesNotMatch(cmd, /["\\]/, 'agy keeps quotes literally, so the command has none');
|
|
86
|
+
assert.equal(plugin.pluginStatus(opts).agy.installed, true);
|
|
87
|
+
plugin.uninstallPlugin(opts);
|
|
88
|
+
assert.deepEqual(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))), ['lint-checker']);
|
|
89
|
+
assert.equal(plugin.agyHookCommand('C:/Program Files/x/hook-status.mjs'), null, 'a path with a space cannot be run by agy');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
|
93
|
+
|
|
94
|
+
test('the launch files of every tool stay out of git and out of the npm package', () => {
|
|
95
|
+
const ignored = fs.readFileSync(new URL('../.gitignore', import.meta.url), 'utf8').split(/\r?\n/);
|
|
96
|
+
for (const tool of s.TOOLS) {
|
|
97
|
+
for (const f of [`env-${tool}.cmd`, `env-${tool}.sh`, `route-${tool}.txt`]) {
|
|
98
|
+
assert.ok(ignored.includes(f), `${f} must be ignored: it holds this machine's route`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('migration keeps the agy pointer and renames every pointer of a command-word profile', () => {
|
|
104
|
+
const mig = (cfg) => s.migrateConfigInMemory(cfg).config.activeProfiles;
|
|
105
|
+
assert.equal(mig({ profiles: { old: { baseURL: 'x' }, a: { tool: 'agy', baseURL: 'x' } }, activeProfiles: { claude: null, codex: null, agy: 'a' } }).agy, 'a');
|
|
106
|
+
assert.equal(mig({ profiles: { agy: { tool: 'claude', baseURL: 'x' } }, activeProfiles: { claude: 'agy' } }).claude, 'agy-profile');
|
|
107
|
+
assert.equal(mig({ profiles: { agy: { tool: 'agy', baseURL: 'x' } }, activeProfiles: { agy: 'agy' } }).agy, 'agy-profile');
|
|
108
|
+
assert.equal(mig({ profiles: { agy: { tool: 'codex', baseURL: 'x' } }, activeProfiles: { codex: 'agy' } }).codex, 'agy-profile');
|
|
109
|
+
assert.equal(mig({ profiles: { c: { tool: 'claude', baseURL: 'x' } }, activeProfiles: { agy: 'c' } }).agy, null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('every target alias of the switch command is a reserved command word', () => {
|
|
113
|
+
// A word that is not reserved can also be a profile key, and then `switch <word>` is ambiguous.
|
|
114
|
+
const src = fs.readFileSync(new URL('../switch.mjs', import.meta.url), 'utf8');
|
|
115
|
+
const literal = src.match(/const TARGET_ALIASES = (\{[^}]*\})/)[1];
|
|
116
|
+
const aliases = Function(`return ${literal}`)();
|
|
117
|
+
for (const key of Object.keys(aliases)) assert.ok(s.COMMAND_WORDS.has(key), `${key} is not in COMMAND_WORDS`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const pluginDirs = (name) => {
|
|
121
|
+
const root = path.join(tmp, name);
|
|
122
|
+
const opts = { agyHome: path.join(root, 'agy'), claudeSkillsDir: path.join(root, 'skills'), codexHome: path.join(root, 'codex') };
|
|
123
|
+
for (const d of Object.values(opts)) fs.mkdirSync(d, { recursive: true });
|
|
124
|
+
return opts;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
test('a hook script path with a space skips agy, and the other tools still install', () => {
|
|
128
|
+
const opts = { ...pluginDirs('space'), hookScript: path.join(tmp, 'Program Files', 'hook-status.mjs') };
|
|
129
|
+
const r = plugin.installPlugin(opts);
|
|
130
|
+
assert.deepEqual(r.installed.sort(), ['claude', 'codex']);
|
|
131
|
+
assert.deepEqual(r.failed, []);
|
|
132
|
+
assert.equal(r.skipped.length, 1);
|
|
133
|
+
assert.equal(r.skipped[0].tool, 'agy');
|
|
134
|
+
assert.match(r.skipped[0].reason, /space/);
|
|
135
|
+
assert.equal(fs.existsSync(path.join(opts.agyHome, 'hooks.json')), false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('an agy hooks.json whose root is not an object is left exactly as it is', () => {
|
|
139
|
+
for (const body of ['[{"a":1}]', '"text"', '7']) {
|
|
140
|
+
const opts = pluginDirs(`root-${body.length}`);
|
|
141
|
+
const file = path.join(opts.agyHome, 'hooks.json');
|
|
142
|
+
fs.writeFileSync(file, body);
|
|
143
|
+
const inst = plugin.installPlugin(opts);
|
|
144
|
+
assert.equal(inst.failed.find(f => f.tool === 'agy')?.tool, 'agy', `install ${body}`);
|
|
145
|
+
assert.equal(fs.readFileSync(file, 'utf8'), body);
|
|
146
|
+
const un = plugin.uninstallPlugin(opts);
|
|
147
|
+
assert.ok(un.installed.includes('agy') && !un.failed.length, `uninstall ${body}: our hook cannot be there`);
|
|
148
|
+
assert.equal(fs.readFileSync(file, 'utf8'), body);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a tool whose install throws is reported, and the other tools still install', () => {
|
|
153
|
+
const opts = pluginDirs('throws');
|
|
154
|
+
// A directory where the file must go makes the write throw.
|
|
155
|
+
fs.mkdirSync(path.join(opts.codexHome, 'hooks.json'));
|
|
156
|
+
const r = plugin.installPlugin(opts);
|
|
157
|
+
assert.ok(r.installed.includes('claude') && r.installed.includes('agy'), JSON.stringify(r));
|
|
158
|
+
assert.equal(r.failed.find(f => f.tool === 'codex')?.tool, 'codex');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('plugin status names an agy hooks.json that does not parse', () => {
|
|
162
|
+
const opts = pluginDirs('status-broken');
|
|
163
|
+
fs.writeFileSync(path.join(opts.agyHome, 'hooks.json'), '{ broken');
|
|
164
|
+
const st = plugin.pluginStatus(opts).agy;
|
|
165
|
+
assert.equal(st.installed, false);
|
|
166
|
+
assert.match(st.error, /parse/);
|
|
167
|
+
fs.writeFileSync(path.join(opts.agyHome, 'hooks.json'), '[]');
|
|
168
|
+
assert.match(plugin.pluginStatus(opts).agy.error, /object/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('the agy launch hook warns when agy is routed and no gateway answers, and is silent when it is not routed', async () => {
|
|
172
|
+
const { spawnSync } = await import('node:child_process');
|
|
173
|
+
const net = await import('node:net');
|
|
174
|
+
const closed = await new Promise((resolve) => { const sv = net.createServer().listen(0, '127.0.0.1', () => { const { port } = sv.address(); sv.close(() => resolve(port)); }); });
|
|
175
|
+
const run = () => spawnSync(process.execPath, [new URL('../hook-status.mjs', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), 'agy'], {
|
|
176
|
+
env: { ...process.env, LLM_SWITCHER_STATE_DIR: tmp, LLM_SWITCHER_CONFIG: path.join(tmp, 'config.json'), LLM_SWITCHER_PORT: String(closed) },
|
|
177
|
+
encoding: 'utf8'
|
|
178
|
+
});
|
|
179
|
+
fs.writeFileSync(s.paths.routeAgy, 'agy -> pool | intact.example | antigravity/*');
|
|
180
|
+
fs.writeFileSync(s.paths.activeFlag, '1');
|
|
181
|
+
try {
|
|
182
|
+
const warned = JSON.parse(run().stdout);
|
|
183
|
+
assert.match(warned.systemMessage, /^WARNING: .*agy -> pool/);
|
|
184
|
+
fs.writeFileSync(s.paths.routeAgy, '');
|
|
185
|
+
assert.deepEqual(JSON.parse(run().stdout), {});
|
|
186
|
+
} finally {
|
|
187
|
+
fs.rmSync(s.paths.routeAgy, { force: true });
|
|
188
|
+
fs.rmSync(s.paths.activeFlag, { force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
@@ -7,11 +7,11 @@ import assert from 'node:assert/strict';
|
|
|
7
7
|
import { launchBrowser, skipReason } from './cdp.mjs';
|
|
8
8
|
import { startDashboardFixture } from './dashboard-fixture.mjs';
|
|
9
9
|
|
|
10
|
-
const TOOLS = ['claude', 'codex'];
|
|
11
|
-
const LABEL = { claude: 'Claude', codex: 'Codex' };
|
|
10
|
+
const TOOLS = ['claude', 'codex', 'agy'];
|
|
11
|
+
const LABEL = { claude: 'Claude', codex: 'Codex', agy: 'agy' };
|
|
12
12
|
const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
|
|
13
13
|
// A profile's `tool` in config.json is one tool, or nothing for a profile that serves both.
|
|
14
|
-
const VALID_TOOLS = [undefined, null, 'claude', 'codex'];
|
|
14
|
+
const VALID_TOOLS = [undefined, null, 'claude', 'codex', 'agy'];
|
|
15
15
|
|
|
16
16
|
describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
17
17
|
let fx, browser, page;
|
|
@@ -59,8 +59,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
59
59
|
const readScreen = () => page.evaluate(`(() => {
|
|
60
60
|
const text = id => document.getElementById(id)?.textContent.trim();
|
|
61
61
|
return {
|
|
62
|
-
badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex') },
|
|
63
|
-
selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value },
|
|
62
|
+
badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex'), agy: text('badge-slot-agy') },
|
|
63
|
+
selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value, agy: document.getElementById('select-slot-agy').value },
|
|
64
64
|
aside: text('status-aside-indicator'),
|
|
65
65
|
cards: [...document.querySelectorAll('#profiles-grid .pcard')].map(c => ({
|
|
66
66
|
key: c.querySelector('.mono.faint').textContent.trim(),
|
|
@@ -75,8 +75,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
75
75
|
const active = cfg.activeProfiles;
|
|
76
76
|
const on = TOOLS.filter(t => active[t]);
|
|
77
77
|
return {
|
|
78
|
-
badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF' },
|
|
79
|
-
selects: { claude: active.claude || '', codex: active.codex || '' },
|
|
78
|
+
badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF', agy: active.agy || 'OFF' },
|
|
79
|
+
selects: { claude: active.claude || '', codex: active.codex || '', agy: active.agy || '' },
|
|
80
80
|
aside: on.length ? `Active (${on.length})` : 'Idle',
|
|
81
81
|
cards: Object.keys(cfg.profiles).map(key => {
|
|
82
82
|
const tools = TOOLS.filter(t => active[t] === key);
|
|
@@ -100,7 +100,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
100
100
|
async function expectScreenMatchesDisk(step, opts) {
|
|
101
101
|
const cfg = fx.config();
|
|
102
102
|
const served = (await fx.api('/api/status')).json;
|
|
103
|
-
|
|
103
|
+
// A config written before agy existed has no agy key: on disk that means agy is off.
|
|
104
|
+
assert.deepEqual(served.activeProfiles, { agy: null, ...cfg.activeProfiles }, `${step}: the gateway reports routes that are not on disk`);
|
|
104
105
|
assert.deepEqual(await readScreen(), expectedScreen(cfg), `${step}: the page and the file disagree`);
|
|
105
106
|
expectQuiet(step, opts);
|
|
106
107
|
}
|
|
@@ -139,22 +140,22 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
139
140
|
it('turns one profile off and on again for the tool it serves, and leaves the other tool alone', async () => {
|
|
140
141
|
await act(() => page.click(switchOf('intact-claude')));
|
|
141
142
|
assert.match(await toast(), /intact-claude deactivated/);
|
|
142
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex' });
|
|
143
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex', agy: null });
|
|
143
144
|
await expectScreenMatchesDisk('claude profile off');
|
|
144
145
|
|
|
145
146
|
await act(() => page.click(switchOf('intact-claude')));
|
|
146
147
|
assert.match(await toast(), /intact-claude activated/);
|
|
147
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' });
|
|
148
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex', agy: null });
|
|
148
149
|
await expectScreenMatchesDisk('claude profile back on');
|
|
149
150
|
});
|
|
150
151
|
|
|
151
152
|
it('turns a profile that serves both tools on for both, and off again', async () => {
|
|
152
153
|
await act(() => page.click(switchOf('shared')));
|
|
153
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared' });
|
|
154
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared', agy: null });
|
|
154
155
|
await expectScreenMatchesDisk('shared on');
|
|
155
156
|
|
|
156
157
|
await act(() => page.click(switchOf('shared')));
|
|
157
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
|
|
158
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
|
|
158
159
|
await expectScreenMatchesDisk('shared off');
|
|
159
160
|
});
|
|
160
161
|
|
|
@@ -173,11 +174,11 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
173
174
|
describe('the route list of each tool', () => {
|
|
174
175
|
it('sets a route for one tool and can return it to the official endpoint', async () => {
|
|
175
176
|
await act(() => page.select('#select-slot-claude', 'shared'));
|
|
176
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex' });
|
|
177
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex', agy: null });
|
|
177
178
|
await expectScreenMatchesDisk('claude to shared');
|
|
178
179
|
|
|
179
180
|
await act(() => page.select('#select-slot-codex', ''));
|
|
180
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null });
|
|
181
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null, agy: null });
|
|
181
182
|
await expectScreenMatchesDisk('codex to official');
|
|
182
183
|
});
|
|
183
184
|
|
|
@@ -191,7 +192,7 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
191
192
|
describe('the buttons that turn every route off and on', () => {
|
|
192
193
|
it('"Use official endpoints" in the page header turns every route off', async () => {
|
|
193
194
|
await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
|
|
194
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
|
|
195
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
|
|
195
196
|
await expectScreenMatchesDisk('all off');
|
|
196
197
|
const labels = await page.evaluate(`[...document.querySelectorAll('#ph-actions button')].map(b => b.textContent.trim())`);
|
|
197
198
|
assert.deepEqual(labels, ['+ Add Profile', 'Activate compatible routes', 'Refresh']);
|
|
@@ -200,13 +201,13 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
|
|
|
200
201
|
it('turning every route off and on again gives each tool its route back', async () => {
|
|
201
202
|
await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
|
|
202
203
|
await act(() => page.clickText('#ph-actions button', 'Activate compatible routes'));
|
|
203
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' }, 'off and on lost a route');
|
|
204
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex', agy: null }, 'off and on lost a route');
|
|
204
205
|
await expectScreenMatchesDisk('off and on');
|
|
205
206
|
});
|
|
206
207
|
|
|
207
208
|
it('"Toggle All" in the sidebar does the same as the header button', async () => {
|
|
208
209
|
await act(() => page.click('.prefs button.btn-outline'));
|
|
209
|
-
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
|
|
210
|
+
assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
|
|
210
211
|
await expectScreenMatchesDisk('sidebar toggle');
|
|
211
212
|
});
|
|
212
213
|
});
|
package/tests/formats.test.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { test } from 'node:test';
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import {
|
|
5
|
-
anthropicToIR, responsesToIR,
|
|
5
|
+
anthropicToIR, responsesToIR, codeAssistToIR,
|
|
6
6
|
healToolPairs, irToChatBody, irToAnthropicBody, irToVertexBody, toGeminiSchema,
|
|
7
7
|
createUpstreamNormalizer, createCollector, createThinkTagSplitter,
|
|
8
8
|
createAnthropicStream, createResponsesStream, createVertexStream,
|
|
@@ -832,3 +832,69 @@ test('structured output: Responses text.format keeps name and strict; JSON mode
|
|
|
832
832
|
assert.equal(irToChatBody(txt, 'm').response_format, undefined);
|
|
833
833
|
assert.equal(irToVertexBody(txt, 'm').generationConfig, undefined);
|
|
834
834
|
});
|
|
835
|
+
|
|
836
|
+
// ---- Code Assist (Antigravity CLI) input ----
|
|
837
|
+
const ca = (contents, request = {}) => codeAssistToIR({ model: 'm', request: { contents, ...request } });
|
|
838
|
+
const call = (name, id) => ({ functionCall: { ...(id ? { id } : {}), name, args: {} } });
|
|
839
|
+
const resp = (name, id, output = 'r') => ({ functionResponse: { ...(id ? { id } : {}), name, response: { output } } });
|
|
840
|
+
const toolMsgs = (ir) => ir.messages.filter(m => m.role === 'tool');
|
|
841
|
+
|
|
842
|
+
test('codeAssistToIR: an answered id leaves the queue, so a later id-less result pairs with its own call', () => {
|
|
843
|
+
const ir = ca([
|
|
844
|
+
{ role: 'user', parts: [{ text: 'go' }] },
|
|
845
|
+
{ role: 'model', parts: [call('f', 'x')] }, { role: 'model', parts: [resp('f', 'x')] },
|
|
846
|
+
{ role: 'model', parts: [call('f')] }, { role: 'model', parts: [resp('f')] }
|
|
847
|
+
]);
|
|
848
|
+
const generated = ir.messages.find(m => m.role === 'assistant' && m.toolCalls?.[0]?.id !== 'x').toolCalls[0].id;
|
|
849
|
+
assert.deepEqual(toolMsgs(ir).map(m => m.toolCallId), ['x', generated]);
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test('codeAssistToIR: two id-less parallel calls of one name pair first in, first out', () => {
|
|
853
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'model', parts: [call('f'), call('f')] }, { role: 'user', parts: [resp('f', null, 'a'), resp('f', null, 'b')] }]);
|
|
854
|
+
const ids = ir.messages.find(m => m.role === 'assistant').toolCalls.map(c => c.id);
|
|
855
|
+
assert.deepEqual(toolMsgs(ir).map(m => [m.toolCallId, m.content]), [[ids[0], 'a'], [ids[1], 'b']]);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test('codeAssistToIR: two id-less orphans of one name get distinct ids', () => {
|
|
859
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'user', parts: [resp('f'), resp('f')] }]);
|
|
860
|
+
const ids = toolMsgs(ir).map(m => m.toolCallId);
|
|
861
|
+
assert.equal(ids.length, 2);
|
|
862
|
+
assert.notEqual(ids[0], ids[1]);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('codeAssistToIR: an unknown explicit id does not consume the open call of that name', () => {
|
|
866
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'model', parts: [call('f')] }, { role: 'model', parts: [resp('f', 'zzz')] }, { role: 'model', parts: [resp('f')] }]);
|
|
867
|
+
assert.deepEqual(toolMsgs(ir).map(m => m.toolCallId), ['zzz', 'call_agy_0_f']);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test('codeAssistToIR: thought parts stay out of the text', () => {
|
|
871
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }, { role: 'model', parts: [{ text: 'secret', thought: true }, { text: 'answer' }] }]);
|
|
872
|
+
assert.equal(ir.messages[1].content, 'answer');
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test('codeAssistToIR: nested schema types are lowered', () => {
|
|
876
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }], { tools: [{ functionDeclarations: [{ name: 't', parameters: {
|
|
877
|
+
type: 'OBJECT', properties: { list: { type: 'ARRAY', items: { type: 'STRING' } }, pick: { anyOf: [{ type: 'INTEGER' }, { type: 'BOOLEAN' }] } }
|
|
878
|
+
} }] }] });
|
|
879
|
+
const p = ir.tools[0].parameters;
|
|
880
|
+
assert.equal(p.type, 'object');
|
|
881
|
+
assert.equal(p.properties.list.type, 'array');
|
|
882
|
+
assert.equal(p.properties.list.items.type, 'string');
|
|
883
|
+
assert.ok(JSON.stringify(p.properties.pick).includes('"integer"'));
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('codeAssistToIR: each thinkingConfig form maps to ir.thinking', () => {
|
|
887
|
+
const think = (thinkingConfig) => ca([{ role: 'user', parts: [{ text: 'q' }] }], { generationConfig: { thinkingConfig } }).thinking;
|
|
888
|
+
assert.deepEqual(think({ thinkingBudget: 0 }), { type: 'disabled' });
|
|
889
|
+
assert.equal(think({ thinkingBudget: 4000 }).type, 'enabled');
|
|
890
|
+
assert.equal(think({ thinkingLevel: 'HIGH' }).effort, 'high');
|
|
891
|
+
assert.deepEqual(think({ includeThoughts: true, thinkingBudget: -1 }), { type: 'enabled', budget: 2048 });
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test('codeAssistToIR: generation parameters map to ir.params', () => {
|
|
895
|
+
const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }], { generationConfig: { maxOutputTokens: 900, temperature: 0.2, topP: 0.9, stopSequences: ['END'] } });
|
|
896
|
+
assert.equal(ir.params.maxTokens, 900);
|
|
897
|
+
assert.equal(ir.params.temperature, 0.2);
|
|
898
|
+
assert.equal(ir.params.topP, 0.9);
|
|
899
|
+
assert.deepEqual(ir.params.stop, ['END']);
|
|
900
|
+
});
|
|
@@ -616,6 +616,24 @@ test('Bifrost: Claude Code to a Claude Code account on intact changes only the k
|
|
|
616
616
|
assert.equal(logs[0].outFormat, 'bifrost');
|
|
617
617
|
});
|
|
618
618
|
|
|
619
|
+
// intact pins a conversation to one account by this header, so its prompt cache hits and a
|
|
620
|
+
// compacted conversation can move. Every route that reaches intact must carry it.
|
|
621
|
+
test('The Claude Code session id reaches the upstream on every route', async () => {
|
|
622
|
+
const routes = [
|
|
623
|
+
{ name: 'direct', model: 'claude-opus-4-6', profile: 'ant', ua: 'claude-cli/2.1.300 (external, cli)', url: '/ant/' },
|
|
624
|
+
{ name: 'convert', model: 'claude-sonnet-5', profile: 'intactcc', ua: 'claude-cli/2.1.300 (external, cli)', url: '/intact/v1/chat/completions' },
|
|
625
|
+
{ name: 'bifrost', model: 'claude-opus-4-6', profile: 'intactcc', ua: 'claude-cli/2.1.300 (external, cli)', url: '/intact/v1/messages' }
|
|
626
|
+
];
|
|
627
|
+
for (const r of routes) {
|
|
628
|
+
const res = await post('/v1/messages', { model: r.model, max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
|
|
629
|
+
{ 'x-llm-profile': r.profile, 'user-agent': r.ua, 'x-claude-code-session-id': `sess-${r.name}` });
|
|
630
|
+
assert.equal(res.status, 200, r.name);
|
|
631
|
+
const up = received.at(-1);
|
|
632
|
+
assert.ok(up.url.startsWith(r.url), `${r.name} went to ${up.url}`);
|
|
633
|
+
assert.equal(up.headers['x-claude-code-session-id'], `sess-${r.name}`, `${r.name} dropped the session id`);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
619
637
|
test('Bifrost stays off for a model that is not a Claude Code account', async () => {
|
|
620
638
|
const res = await post('/v1/messages', { model: 'claude-sonnet-5', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
|
|
621
639
|
{ 'x-llm-profile': 'intactcc', 'user-agent': 'claude-cli/2.1.300 (external, cli)' });
|
|
@@ -1279,7 +1297,7 @@ test('/api/switch deactivate: the dashboard payload works, the CLI payload works
|
|
|
1279
1297
|
// What the dashboard sends: the profile in `profile`, a boolean in `deactivate`.
|
|
1280
1298
|
const dash = await post('/api/switch', { profile: 'chat', deactivate: true });
|
|
1281
1299
|
assert.equal(dash.status, 200);
|
|
1282
|
-
assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null });
|
|
1300
|
+
assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null, agy: null });
|
|
1283
1301
|
|
|
1284
1302
|
// The same profile is now inactive, so asking again must not report success.
|
|
1285
1303
|
const again = await post('/api/switch', { profile: 'chat', deactivate: true });
|
|
@@ -1290,7 +1308,7 @@ test('/api/switch deactivate: the dashboard payload works, the CLI payload works
|
|
|
1290
1308
|
assert.equal((await post('/api/switch', { profile: 'chat' })).status, 200);
|
|
1291
1309
|
const cli = await post('/api/switch', { deactivate: 'chat' });
|
|
1292
1310
|
assert.equal(cli.status, 200);
|
|
1293
|
-
assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null });
|
|
1311
|
+
assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null, agy: null });
|
|
1294
1312
|
|
|
1295
1313
|
// `deactivate: true` with no profile names nothing.
|
|
1296
1314
|
assert.equal((await post('/api/switch', { deactivate: true })).status, 400);
|
package/tests/helpers.mjs
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
|
|
3
|
-
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
|
-
export function assertValidAnthropicEvents(events) {
|
|
5
|
-
const open = new Set();
|
|
6
|
-
const seen = new Set();
|
|
7
|
-
let expectedNext = 0;
|
|
8
|
-
for (const { event, data } of events) {
|
|
9
|
-
if (event === 'content_block_start') {
|
|
10
|
-
assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
|
|
11
|
-
assert.ok(!seen.has(data.index), `index ${data.index} reused`);
|
|
12
|
-
seen.add(data.index);
|
|
13
|
-
open.add(data.index);
|
|
14
|
-
expectedNext++;
|
|
15
|
-
} else if (event === 'content_block_delta') {
|
|
16
|
-
assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
|
|
17
|
-
} else if (event === 'content_block_stop') {
|
|
18
|
-
assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
|
|
19
|
-
open.delete(data.index);
|
|
20
|
-
} else if (event === 'message_stop') {
|
|
21
|
-
assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
// Validate an Anthropic event sequence: indexes increase in start order, deltas only target an open block.
|
|
4
|
+
export function assertValidAnthropicEvents(events) {
|
|
5
|
+
const open = new Set();
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
let expectedNext = 0;
|
|
8
|
+
for (const { event, data } of events) {
|
|
9
|
+
if (event === 'content_block_start') {
|
|
10
|
+
assert.equal(data.index, expectedNext, `block index must be sequential (got ${data.index}, want ${expectedNext})`);
|
|
11
|
+
assert.ok(!seen.has(data.index), `index ${data.index} reused`);
|
|
12
|
+
seen.add(data.index);
|
|
13
|
+
open.add(data.index);
|
|
14
|
+
expectedNext++;
|
|
15
|
+
} else if (event === 'content_block_delta') {
|
|
16
|
+
assert.ok(open.has(data.index), `delta for block ${data.index} which is not open`);
|
|
17
|
+
} else if (event === 'content_block_stop') {
|
|
18
|
+
assert.ok(open.has(data.index), `stop for block ${data.index} which is not open`);
|
|
19
|
+
open.delete(data.index);
|
|
20
|
+
} else if (event === 'message_stop') {
|
|
21
|
+
assert.equal(open.size, 0, 'all blocks must be closed before message_stop');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|