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,529 @@
|
|
|
1
|
+
// End-to-end tests for the Antigravity CLI (agy) route and the Bifrost route of agy and Codex.
|
|
2
|
+
// A mock upstream stands in for Google Code Assist, intact and a chat API; nothing leaves the host.
|
|
3
|
+
import { test, before, after } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const AGY_UA = 'antigravity/cli/1.2.14 (aidev_client; os_type=windows; arch=amd64; cl=990662481; auth_method=consumer)';
|
|
15
|
+
const CODEX_UA = 'codex_cli_rs/0.160.0 (Windows 10.0.26300; x86_64) WindowsTerminal';
|
|
16
|
+
|
|
17
|
+
let upstream, base, proxy, proxyPort, tmpDir;
|
|
18
|
+
const received = [];
|
|
19
|
+
const stalled = [];
|
|
20
|
+
const extraGateways = [];
|
|
21
|
+
let flakyLookups = 0;
|
|
22
|
+
let notFoundLookups = 0;
|
|
23
|
+
|
|
24
|
+
function freePort() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const s = http.createServer();
|
|
27
|
+
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
|
28
|
+
s.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sse = (res, objs) => {
|
|
33
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
34
|
+
for (const o of objs) res.write(`data: ${JSON.stringify(o)}\n\n`);
|
|
35
|
+
res.end();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function startUpstream() {
|
|
39
|
+
upstream = http.createServer((req, res) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
req.on('data', c => chunks.push(c));
|
|
42
|
+
req.on('end', () => {
|
|
43
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
44
|
+
let json = null;
|
|
45
|
+
try { json = raw ? JSON.parse(raw) : null; } catch {}
|
|
46
|
+
received.push({ method: req.method, url: req.url, headers: req.headers, raw, body: json });
|
|
47
|
+
|
|
48
|
+
if (req.url.startsWith('/fail/v1/')) {
|
|
49
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
50
|
+
return res.end(JSON.stringify({ error: { message: 'upstream exploded' } }));
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'GET' && req.url.startsWith('/flaky/v1/models/')) {
|
|
53
|
+
// The first lookup fails; intact names the agy CLI from then on.
|
|
54
|
+
if (flakyLookups++ === 0) { res.writeHead(503); return res.end('{}'); }
|
|
55
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
56
|
+
return res.end(JSON.stringify({ bifrost_ua: 'antigravity/cli/' }));
|
|
57
|
+
}
|
|
58
|
+
if (req.method === 'GET' && req.url.startsWith('/nf/v1/models/')) {
|
|
59
|
+
// An intact that does not know the model yet (404), then knows it.
|
|
60
|
+
if (notFoundLookups++ === 0) { res.writeHead(404); return res.end('{}'); }
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
62
|
+
return res.end(JSON.stringify({ bifrost_ua: 'antigravity/cli/' }));
|
|
63
|
+
}
|
|
64
|
+
if (req.url.startsWith('/flaky/v1/v1internal:') || req.url.startsWith('/nf/v1/v1internal:')) {
|
|
65
|
+
return sse(res, [{ response: { candidates: [{ content: { role: 'model', parts: [{ text: 'flaky bifrost' }] }, finishReason: 'STOP' }] }, traceId: 'f' }]);
|
|
66
|
+
}
|
|
67
|
+
if (req.url.startsWith('/flaky/v1/chat/completions') || req.url.startsWith('/nf/v1/chat/completions')) {
|
|
68
|
+
return sse(res, [
|
|
69
|
+
{ choices: [{ index: 0, delta: { content: 'flaky converted' } }] },
|
|
70
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
if (req.method === 'GET' && req.url.startsWith('/broken/v1/models/')) {
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
75
|
+
return res.end(JSON.stringify({ bifrost_ua: 'antigravity/cli/' }));
|
|
76
|
+
}
|
|
77
|
+
if (req.url.startsWith('/broken/v1/v1internal:')) {
|
|
78
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
79
|
+
res.write('data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"half"}]}}]}}\n\n');
|
|
80
|
+
return setTimeout(() => res.socket.destroy(), 50);
|
|
81
|
+
}
|
|
82
|
+
if (req.url.startsWith('/google/v1internal:breakMidway')) {
|
|
83
|
+
// Headers and part of a body, then the connection dies.
|
|
84
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
85
|
+
res.write('{"partial":');
|
|
86
|
+
return setTimeout(() => res.socket.destroy(), 50);
|
|
87
|
+
}
|
|
88
|
+
if (req.url.startsWith('/google/v1internal:stallMidway')) {
|
|
89
|
+
// Headers and part of a body, then silence with the socket open.
|
|
90
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
91
|
+
res.write('{"partial":');
|
|
92
|
+
stalled.push(res);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (req.url.startsWith('/google/v1internal:')) {
|
|
96
|
+
if (req.url.includes('streamGenerateContent')) {
|
|
97
|
+
return sse(res, [{ response: { candidates: [{ content: { role: 'model', parts: [{ text: 'from google' }] }, finishReason: 'STOP' }] }, traceId: 'g' }]);
|
|
98
|
+
}
|
|
99
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
100
|
+
return res.end(JSON.stringify({ answeredBy: 'google', path: req.url }));
|
|
101
|
+
}
|
|
102
|
+
if (req.method === 'GET' && req.url.startsWith('/intact/v1/models/')) {
|
|
103
|
+
const id = decodeURIComponent(req.url.slice('/intact/v1/models/'.length));
|
|
104
|
+
const ua = id.startsWith('antigravity/') ? 'antigravity/cli/' : id.startsWith('codex/') ? 'codex_cli_rs/' : '';
|
|
105
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
106
|
+
return res.end(JSON.stringify({ id, ...(ua ? { bifrost_ua: ua } : {}) }));
|
|
107
|
+
}
|
|
108
|
+
if (req.url.startsWith('/intact/v1/v1internal:streamGenerateContent')) {
|
|
109
|
+
return sse(res, [
|
|
110
|
+
{ response: { candidates: [{ content: { role: 'model', parts: [{ text: 'pool answer' }] } }] }, traceId: 't' },
|
|
111
|
+
{ response: { candidates: [{ content: { role: 'model', parts: [{ text: '' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 40, candidatesTokenCount: 3, thoughtsTokenCount: 2, cachedContentTokenCount: 30 } }, traceId: 't' }
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
if (req.url.startsWith('/intact/v1/v1internal:generateContent')) {
|
|
115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
116
|
+
return res.end(JSON.stringify({ response: { candidates: [{ content: { role: 'model', parts: [{ text: 'pool whole' }] }, finishReason: 'STOP' }], usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 1 } }, traceId: 't' }));
|
|
117
|
+
}
|
|
118
|
+
if (req.url.startsWith('/intact/v1/chat/completions')) {
|
|
119
|
+
// The convert route of intact: usage 9/2, never the 40/5 of the Bifrost answer.
|
|
120
|
+
if (!json?.stream) {
|
|
121
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
122
|
+
return res.end(JSON.stringify({ id: 'c2', object: 'chat.completion', model: json?.model, choices: [{ index: 0, message: { role: 'assistant', content: 'intact converted' }, finish_reason: 'stop' }], usage: { prompt_tokens: 9, completion_tokens: 2 } }));
|
|
123
|
+
}
|
|
124
|
+
return sse(res, [
|
|
125
|
+
{ choices: [{ index: 0, delta: { content: 'intact converted' } }] },
|
|
126
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 9, completion_tokens: 2 } }
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
if (req.url.startsWith('/intact/v1/responses')) {
|
|
130
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
|
131
|
+
res.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: { id: 'r1', usage: { input_tokens: 5, output_tokens: 1 } } })}\n\n`);
|
|
132
|
+
return res.end();
|
|
133
|
+
}
|
|
134
|
+
if (req.url.startsWith('/chat/v1/chat/completions')) {
|
|
135
|
+
const last = json?.messages?.at(-1);
|
|
136
|
+
if (json?.tools?.length && last?.role === 'user') {
|
|
137
|
+
return sse(res, [
|
|
138
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_up_1', type: 'function', function: { name: 'view_file', arguments: '{"AbsolutePath":"/n"}' } }] } }] },
|
|
139
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 9, completion_tokens: 4 } }
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
if (!json?.stream) {
|
|
143
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
144
|
+
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted answer' }, finish_reason: 'stop' }], usage: { prompt_tokens: 9, completion_tokens: 2 } }));
|
|
145
|
+
}
|
|
146
|
+
return sse(res, [
|
|
147
|
+
{ choices: [{ index: 0, delta: { content: 'converted answer' } }] },
|
|
148
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 9, completion_tokens: 2 } }
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
res.writeHead(404);
|
|
152
|
+
res.end('{}');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return new Promise(r => upstream.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${upstream.address().port}`; r(); }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
before(async () => {
|
|
159
|
+
await startUpstream();
|
|
160
|
+
proxyPort = await freePort();
|
|
161
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-agy-'));
|
|
162
|
+
const cfg = {
|
|
163
|
+
port: proxyPort,
|
|
164
|
+
activeProfiles: { claude: null, codex: null, agy: 'agyb' },
|
|
165
|
+
profiles: {
|
|
166
|
+
agyb: { name: 'agy on intact', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact', defaultModels: { main: 'antigravity/*' } },
|
|
167
|
+
agyc: { name: 'agy converted', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-chat', defaultModels: { main: 'gem/flash' } },
|
|
168
|
+
agye: { name: 'agy empty slot', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/chat/v1`, apiKey: 'sk-chat', defaultModels: {} },
|
|
169
|
+
agy500: { name: 'agy failing upstream', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/fail/v1`, apiKey: 'sk-fail', defaultModels: { main: 'gem/flash' } },
|
|
170
|
+
agyf: { name: 'agy flaky lookup', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/flaky/v1`, apiKey: 'sk-flaky', defaultModels: { main: 'antigravity/*' } },
|
|
171
|
+
agynf: { name: 'agy lookup 404', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/nf/v1`, apiKey: 'sk-nf', defaultModels: { main: 'antigravity/*' } },
|
|
172
|
+
agyg: { name: 'agy broken stream', mode: 'convert', tool: 'agy', outFormat: 'openai-chat', baseURL: `${base}/broken/v1`, apiKey: 'sk-broken', defaultModels: { main: 'antigravity/*' } },
|
|
173
|
+
cdx: { name: 'codex on intact', mode: 'convert', tool: 'codex', outFormat: 'openai-chat', baseURL: `${base}/intact/v1`, apiKey: 'sk-intact', defaultModels: { main: 'codex/gpt-5.5' } }
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify(cfg, null, 2));
|
|
177
|
+
proxy = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(proxyPort)], {
|
|
178
|
+
env: {
|
|
179
|
+
...process.env,
|
|
180
|
+
LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'),
|
|
181
|
+
LLM_SWITCHER_STATE_DIR: tmpDir,
|
|
182
|
+
CLAUDE_CONFIG_DIR: path.join(tmpDir, 'claude'),
|
|
183
|
+
LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google`,
|
|
184
|
+
LLM_SWITCHER_PORT: ''
|
|
185
|
+
},
|
|
186
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
187
|
+
});
|
|
188
|
+
let log = '';
|
|
189
|
+
proxy.stdout.on('data', d => { log += d; });
|
|
190
|
+
proxy.stderr.on('data', d => { log += d; });
|
|
191
|
+
for (let i = 0; i < 50; i++) {
|
|
192
|
+
try { if ((await fetch(`http://127.0.0.1:${proxyPort}/health`)).ok) return; } catch {}
|
|
193
|
+
await new Promise(r => setTimeout(r, 100));
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`proxy did not start:\n${log}`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Another gateway on the same config, with its own environment.
|
|
199
|
+
async function startGateway(env) {
|
|
200
|
+
const port = await freePort();
|
|
201
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], {
|
|
202
|
+
env: {
|
|
203
|
+
...process.env,
|
|
204
|
+
LLM_SWITCHER_CONFIG: path.join(tmpDir, 'config.json'),
|
|
205
|
+
LLM_SWITCHER_STATE_DIR: tmpDir,
|
|
206
|
+
CLAUDE_CONFIG_DIR: path.join(tmpDir, 'claude'),
|
|
207
|
+
LLM_SWITCHER_PORT: '',
|
|
208
|
+
...env
|
|
209
|
+
},
|
|
210
|
+
stdio: 'ignore'
|
|
211
|
+
});
|
|
212
|
+
extraGateways.push(child);
|
|
213
|
+
for (let i = 0; i < 50; i++) {
|
|
214
|
+
try { if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) return port; } catch {}
|
|
215
|
+
await new Promise(r => setTimeout(r, 100));
|
|
216
|
+
}
|
|
217
|
+
throw new Error('the extra gateway did not start');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
after(() => {
|
|
221
|
+
for (const g of extraGateways) g.kill();
|
|
222
|
+
for (const r of stalled) r.socket?.destroy();
|
|
223
|
+
proxy?.kill();
|
|
224
|
+
upstream?.close();
|
|
225
|
+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const url = (p) => `http://127.0.0.1:${proxyPort}${p}`;
|
|
229
|
+
const agyHeaders = (extra = {}) => ({ 'content-type': 'application/json', 'user-agent': AGY_UA, authorization: 'Bearer google-user-token', ...extra });
|
|
230
|
+
const envelope = (over = {}) => ({
|
|
231
|
+
project: 'aicode-consumers', requestId: 'agent/s/1/t/1', model: 'gemini-3.8-flash-high', userAgent: 'antigravity', requestType: 'agent',
|
|
232
|
+
request: {
|
|
233
|
+
contents: [{ role: 'user', parts: [{ text: 'Read note' }] }],
|
|
234
|
+
tools: [{ functionDeclarations: [{ name: 'view_file', description: 'View a file.', parameters: { type: 'OBJECT', properties: { AbsolutePath: { type: 'STRING' } }, required: ['AbsolutePath'] } }] }],
|
|
235
|
+
generationConfig: { maxOutputTokens: 65536, thinkingConfig: { includeThoughts: true, thinkingBudget: -1 } },
|
|
236
|
+
sessionId: '-1'
|
|
237
|
+
},
|
|
238
|
+
...over
|
|
239
|
+
});
|
|
240
|
+
const lastTo = (prefix) => [...received].reverse().find(r => r.url.startsWith(prefix));
|
|
241
|
+
const dataLines = (text) => text.split('\n').filter(l => l.startsWith('data:')).map(l => JSON.parse(l.slice(5)));
|
|
242
|
+
|
|
243
|
+
test('agy side calls go to Google with the person\'s own token, bytes unchanged', async () => {
|
|
244
|
+
const r = await fetch(url('/v1internal:loadCodeAssist'), { method: 'POST', headers: agyHeaders(), body: '{"metadata":{"ideType":"ANTIGRAVITY"}}' });
|
|
245
|
+
assert.equal(r.status, 200);
|
|
246
|
+
assert.equal((await r.json()).answeredBy, 'google');
|
|
247
|
+
const got = lastTo('/google/v1internal:loadCodeAssist');
|
|
248
|
+
assert.equal(got.headers.authorization, 'Bearer google-user-token');
|
|
249
|
+
assert.equal(got.raw, '{"metadata":{"ideType":"ANTIGRAVITY"}}');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('an agy checkpoint stays on Google even while a profile routes agy', async () => {
|
|
253
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope({ requestType: 'checkpoint', model: 'gemini-3.5-flash-lite' })) });
|
|
254
|
+
assert.match(await r.text(), /from google/);
|
|
255
|
+
assert.equal(lastTo('/google/v1internal:streamGenerateContent').url, '/google/v1internal:streamGenerateContent?alt=sse');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('agy agent turn crosses Bifrost: intact key, no Google token, the CLI model under antigravity/', async () => {
|
|
259
|
+
const before = received.length;
|
|
260
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope()) });
|
|
261
|
+
const text = await r.text();
|
|
262
|
+
assert.equal(r.status, 200);
|
|
263
|
+
assert.match(text, /pool answer/);
|
|
264
|
+
const got = received.slice(before).find(x => x.url.startsWith('/intact/v1/v1internal:'));
|
|
265
|
+
assert.ok(got, 'the agent turn must reach intact');
|
|
266
|
+
assert.equal(got.url, '/intact/v1/v1internal:streamGenerateContent?alt=sse');
|
|
267
|
+
assert.equal(got.headers['x-api-key'], 'sk-intact');
|
|
268
|
+
assert.equal(got.headers.authorization, undefined, 'the Google token must never reach intact');
|
|
269
|
+
assert.equal(got.headers['user-agent'], AGY_UA);
|
|
270
|
+
assert.equal(got.body.model, 'antigravity/gemini-3.8-flash-high');
|
|
271
|
+
assert.deepEqual(got.body.request, envelope().request);
|
|
272
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/google/')), false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('agy agent turn on a non-intact profile is converted and answered in Code Assist shape', async () => {
|
|
276
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agyc' }), body: JSON.stringify(envelope()) });
|
|
277
|
+
assert.equal(r.status, 200);
|
|
278
|
+
const events = dataLines(await r.text());
|
|
279
|
+
const up = lastTo('/chat/v1/chat/completions');
|
|
280
|
+
assert.equal(up.body.model, 'gem/flash');
|
|
281
|
+
assert.equal(up.headers.authorization, 'Bearer sk-chat');
|
|
282
|
+
assert.equal(up.body.tools[0].function.parameters.properties.AbsolutePath.type, 'string');
|
|
283
|
+
const call = events.flatMap(e => e.response?.candidates?.[0]?.content?.parts || []).find(p => p.functionCall);
|
|
284
|
+
assert.deepEqual(call.functionCall, { id: 'call_up_1', name: 'view_file', args: { AbsolutePath: '/n' } });
|
|
285
|
+
assert.ok(events.every(e => typeof e.traceId === 'string' && e.response), 'every chunk is wrapped as Code Assist does');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('agy tool result in a model turn reaches the upstream as a tool message', async () => {
|
|
289
|
+
const body = envelope({
|
|
290
|
+
request: {
|
|
291
|
+
...envelope().request,
|
|
292
|
+
contents: [
|
|
293
|
+
{ role: 'user', parts: [{ text: 'Read note' }] },
|
|
294
|
+
{ role: 'model', parts: [{ functionCall: { id: 'call_up_1', name: 'view_file', args: { AbsolutePath: '/n' } } }] },
|
|
295
|
+
{ role: 'model', parts: [{ functionResponse: { id: 'call_up_1', name: 'view_file', response: { output: 'line-one-marker' } } }] }
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const r = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agyc' }), body: JSON.stringify(body) });
|
|
300
|
+
assert.equal(r.status, 200);
|
|
301
|
+
const whole = await r.json();
|
|
302
|
+
assert.match(JSON.stringify(whole.response), /converted answer/);
|
|
303
|
+
const msgs = lastTo('/chat/v1/chat/completions').body.messages;
|
|
304
|
+
const tool = msgs.find(m => m.role === 'tool');
|
|
305
|
+
assert.equal(tool.tool_call_id, 'call_up_1');
|
|
306
|
+
assert.equal(tool.content, 'line-one-marker');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('Codex HTTP turn crosses Bifrost to intact when intact names the Codex CLI', async () => {
|
|
310
|
+
const before = received.length;
|
|
311
|
+
const body = { model: 'gpt-5.5', instructions: 'x', input: [{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], stream: true, store: false };
|
|
312
|
+
const r = await fetch(url('/v1/responses'), { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': CODEX_UA, 'x-llm-profile': 'cdx', authorization: 'Bearer chatgpt-user-token' }, body: JSON.stringify(body) });
|
|
313
|
+
assert.match(await r.text(), /response\.completed/);
|
|
314
|
+
const got = received.slice(before).find(x => x.url.startsWith('/intact/v1/responses'));
|
|
315
|
+
assert.ok(got, 'the turn must reach intact /responses unchanged');
|
|
316
|
+
assert.equal(got.headers['x-api-key'], 'sk-intact');
|
|
317
|
+
assert.equal(got.headers.authorization, undefined);
|
|
318
|
+
assert.equal(got.body.model, 'codex/gpt-5.5');
|
|
319
|
+
assert.deepEqual(got.body.input, body.input);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('an agy turn whose client is not the CLI keeps the convert route on intact', async () => {
|
|
323
|
+
const before = received.length;
|
|
324
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'user-agent': 'curl/8' }), body: JSON.stringify(envelope()) });
|
|
325
|
+
assert.equal(r.status, 200);
|
|
326
|
+
assert.match(await r.text(), /intact converted/);
|
|
327
|
+
const mine = received.slice(before);
|
|
328
|
+
assert.ok(mine.some(x => x.url.startsWith('/intact/v1/chat/completions')), 'the convert route of intact took the turn');
|
|
329
|
+
assert.equal(mine.some(x => x.url.startsWith('/intact/v1/v1internal:')), false, 'no Bifrost request for another client');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('a Codex turn whose client is not the Codex CLI keeps the convert route', async () => {
|
|
333
|
+
const before = received.length;
|
|
334
|
+
const body = { model: 'gpt-5.5', instructions: 'x', input: [{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] }], stream: true, store: false };
|
|
335
|
+
const r = await fetch(url('/v1/responses'), { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'curl/8', 'x-llm-profile': 'cdx' }, body: JSON.stringify(body) });
|
|
336
|
+
assert.equal(r.status, 200);
|
|
337
|
+
await r.text();
|
|
338
|
+
const mine = received.slice(before);
|
|
339
|
+
assert.ok(mine.some(x => x.url.startsWith('/intact/v1/chat/completions')), 'the convert route took the turn');
|
|
340
|
+
assert.equal(mine.some(x => x.url.startsWith('/intact/v1/responses')), false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('the inspector reads the Gemini usage of a Bifrost agy turn', async () => {
|
|
344
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope({ requestId: 'agent/s/1/t/usage' })) });
|
|
345
|
+
await r.text();
|
|
346
|
+
const token = fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
|
|
347
|
+
const { logs } = await (await fetch(url('/api/logs'), { headers: { 'x-llm-switcher-token': token } })).json();
|
|
348
|
+
const entry = logs.find(l => l.clientFormat === 'codeassist' && l.outFormat === 'bifrost' && l.profile === 'agyb');
|
|
349
|
+
assert.ok(entry, 'the Bifrost agy turn is in the inspector');
|
|
350
|
+
assert.deepEqual(entry.tokens, { prompt: 40, completion: 5 });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('a non-streaming agy turn crosses Bifrost at the generateContent path', async () => {
|
|
354
|
+
const before = received.length;
|
|
355
|
+
const r = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope()) });
|
|
356
|
+
assert.equal(r.status, 200);
|
|
357
|
+
assert.match(await r.text(), /pool whole/);
|
|
358
|
+
assert.ok(received.slice(before).some(x => x.url === '/intact/v1/v1internal:generateContent'));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('a Code Assist host that cannot be reached gives a 502 in the Code Assist error shape', async () => {
|
|
362
|
+
const closed = await freePort();
|
|
363
|
+
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `http://127.0.0.1:${closed}` });
|
|
364
|
+
const r = await fetch(`http://127.0.0.1:${port}/v1internal:loadCodeAssist`, { method: 'POST', headers: agyHeaders(), body: '{}' });
|
|
365
|
+
assert.equal(r.status, 502);
|
|
366
|
+
assert.equal(typeof (await r.json()).error.status, 'string');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('a Code Assist host that never answers gives a 504 after the idle timeout', async () => {
|
|
370
|
+
const silent = net.createServer(() => {}).listen(0, '127.0.0.1');
|
|
371
|
+
await new Promise(r => silent.once('listening', r));
|
|
372
|
+
try {
|
|
373
|
+
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `http://127.0.0.1:${silent.address().port}`, LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS: '1000' });
|
|
374
|
+
const started = Date.now();
|
|
375
|
+
const r = await fetch(`http://127.0.0.1:${port}/v1internal:loadCodeAssist`, { method: 'POST', headers: agyHeaders(), body: '{}' });
|
|
376
|
+
assert.equal(r.status, 504);
|
|
377
|
+
assert.equal(typeof (await r.json()).error.status, 'string');
|
|
378
|
+
assert.ok(Date.now() - started < 8000);
|
|
379
|
+
} finally {
|
|
380
|
+
silent.close();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('an upstream that stops in the middle of a body ends the answer to agy with an error', async () => {
|
|
385
|
+
const started = Date.now();
|
|
386
|
+
const r = await fetch(url('/v1internal:breakMidway'), { method: 'POST', headers: agyHeaders(), body: '{}' });
|
|
387
|
+
await assert.rejects(r.text());
|
|
388
|
+
assert.ok(Date.now() - started < 10000);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('an upstream that stalls after its headers is cut by the idle timeout', async () => {
|
|
392
|
+
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google`, LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS: '1000' });
|
|
393
|
+
const started = Date.now();
|
|
394
|
+
const r = await fetch(`http://127.0.0.1:${port}/v1internal:stallMidway`, { method: 'POST', headers: agyHeaders(), body: '{}' });
|
|
395
|
+
await assert.rejects(r.text());
|
|
396
|
+
assert.ok(Date.now() - started < 5000, `took ${Date.now() - started} ms`);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('a timeout value that Node cannot hold falls back to the default, so calls still pass', async () => {
|
|
400
|
+
for (const value of ['3000000000', 'abc']) {
|
|
401
|
+
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google`, LLM_SWITCHER_CODE_ASSIST_TIMEOUT_MS: value });
|
|
402
|
+
const r = await fetch(`http://127.0.0.1:${port}/v1internal:loadCodeAssist`, { method: 'POST', headers: agyHeaders(), body: '{}' });
|
|
403
|
+
assert.equal(r.status, 200, `timeout ${value}`);
|
|
404
|
+
assert.equal((await r.json()).answeredBy, 'google');
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('an agy turn with no model and a * slot gets a 400, not a request for model ""', async () => {
|
|
409
|
+
const before = received.length;
|
|
410
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope({ model: '' })) });
|
|
411
|
+
assert.equal(r.status, 400);
|
|
412
|
+
assert.equal(typeof (await r.json()).error.status, 'string');
|
|
413
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/intact/') && x.method === 'POST'), false);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('an agy profile with no main slot sends the model that agy picked', async () => {
|
|
417
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agye' }), body: JSON.stringify(envelope({ request: { contents: [{ role: 'user', parts: [{ text: 'hi' }] }] } })) });
|
|
418
|
+
assert.equal(r.status, 200);
|
|
419
|
+
await r.text();
|
|
420
|
+
assert.equal(lastTo('/chat/v1/chat/completions').body.model, 'gemini-3.8-flash-high');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('a converted agy turn whose upstream fails gets the Code Assist error shape', async () => {
|
|
424
|
+
const r = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agy500' }), body: JSON.stringify(envelope()) });
|
|
425
|
+
assert.ok(r.status >= 500, `status ${r.status}`);
|
|
426
|
+
const body = await r.json();
|
|
427
|
+
assert.equal(typeof body.error.status, 'string');
|
|
428
|
+
assert.equal(typeof body.error.message, 'string');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('a body that does not parse, while agy is routed, gets a 400 and never reaches Google', async () => {
|
|
432
|
+
const before = received.length;
|
|
433
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: '{"requestType":"agent",' });
|
|
434
|
+
assert.equal(r.status, 400);
|
|
435
|
+
assert.equal(typeof (await r.json()).error.status, 'string');
|
|
436
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/google/')), false);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('a gateway that never loaded its config answers agy model calls with 503', async () => {
|
|
440
|
+
const bad = path.join(tmpDir, 'broken-config.json');
|
|
441
|
+
fs.writeFileSync(bad, '{ "profiles": ');
|
|
442
|
+
const port = await startGateway({ LLM_SWITCHER_CONFIG: bad, LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google` });
|
|
443
|
+
const before = received.length;
|
|
444
|
+
const r = await fetch(`http://127.0.0.1:${port}/v1internal:streamGenerateContent?alt=sse`, { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope()) });
|
|
445
|
+
assert.equal(r.status, 503);
|
|
446
|
+
assert.equal(typeof (await r.json()).error.status, 'string');
|
|
447
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/google/')), false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('an agy model call with no requestType stays on Google', async () => {
|
|
451
|
+
const body = envelope();
|
|
452
|
+
delete body.requestType;
|
|
453
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(body) });
|
|
454
|
+
assert.match(await r.text(), /from google/);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('a GET to a Code Assist method passes through to Google', async () => {
|
|
458
|
+
const r = await fetch(url('/v1internal:fetchUserInfo?x=1'), { headers: agyHeaders() });
|
|
459
|
+
assert.equal(r.status, 200);
|
|
460
|
+
assert.equal((await r.json()).answeredBy, 'google');
|
|
461
|
+
const got = lastTo('/google/v1internal:fetchUserInfo');
|
|
462
|
+
assert.equal(got.method, 'GET');
|
|
463
|
+
assert.equal(got.url, '/google/v1internal:fetchUserInfo?x=1');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('the passthrough keeps the Google headers of agy and drops only the gateway control headers', async () => {
|
|
467
|
+
await fetch(url('/v1internal:loadCodeAssist'), {
|
|
468
|
+
method: 'POST',
|
|
469
|
+
headers: agyHeaders({ 'x-goog-api-client': 'gl-go/1.24', 'x-goog-user-project': 'proj-7', 'x-llm-profile': 'agyc', 'x-forwarded-for': '10.0.0.9', 'x-forwarded-server': 'edge', 'x-real-ip': '10.0.0.9' }),
|
|
470
|
+
body: '{}'
|
|
471
|
+
});
|
|
472
|
+
const got = lastTo('/google/v1internal:loadCodeAssist');
|
|
473
|
+
assert.equal(got.headers['x-goog-api-client'], 'gl-go/1.24');
|
|
474
|
+
assert.equal(got.headers['x-goog-user-project'], 'proj-7');
|
|
475
|
+
assert.equal(got.headers['x-llm-profile'], undefined);
|
|
476
|
+
assert.equal(got.headers['x-forwarded-for'], undefined);
|
|
477
|
+
assert.equal(got.headers['x-forwarded-server'], undefined, 'every x-forwarded-* header goes');
|
|
478
|
+
assert.equal(got.headers['x-real-ip'], undefined);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('the inspector reads the usage of a non-streaming Bifrost agy turn', async () => {
|
|
482
|
+
const r = await fetch(url('/v1internal:generateContent'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope({ requestId: 'agent/s/1/t/whole-usage' })) });
|
|
483
|
+
await r.text();
|
|
484
|
+
const token = fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
|
|
485
|
+
const { logs } = await (await fetch(url('/api/logs'), { headers: { 'x-llm-switcher-token': token } })).json();
|
|
486
|
+
const entry = logs.find(l => l.clientFormat === 'codeassist' && l.outFormat === 'bifrost' && l.profile === 'agyb' && l.stream === false);
|
|
487
|
+
assert.ok(entry, 'the non-streaming Bifrost turn is in the inspector');
|
|
488
|
+
assert.deepEqual(entry.tokens, { prompt: 7, completion: 1 });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('a failed Bifrost lookup is retried soon, not pinned for ten minutes', async () => {
|
|
492
|
+
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google`, LLM_SWITCHER_BIFROST_RETRY_MS: '200' });
|
|
493
|
+
const send = async () => (await fetch(`http://127.0.0.1:${port}/v1internal:streamGenerateContent?alt=sse`, { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agyf' }), body: JSON.stringify(envelope()) })).text();
|
|
494
|
+
assert.match(await send(), /flaky converted/);
|
|
495
|
+
await new Promise(r => setTimeout(r, 400));
|
|
496
|
+
assert.match(await send(), /flaky bifrost/);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('a Bifrost lookup that answers 404 is retried soon too', async () => {
|
|
500
|
+
const port = await startGateway({ LLM_SWITCHER_CODE_ASSIST_URL: `${base}/google`, LLM_SWITCHER_BIFROST_RETRY_MS: '200' });
|
|
501
|
+
const send = async () => (await fetch(`http://127.0.0.1:${port}/v1internal:streamGenerateContent?alt=sse`, { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agynf' }), body: JSON.stringify(envelope()) })).text();
|
|
502
|
+
assert.match(await send(), /flaky converted/);
|
|
503
|
+
await new Promise(r => setTimeout(r, 400));
|
|
504
|
+
assert.match(await send(), /flaky bifrost/);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('an agent turn that names a profile that does not exist gets a 400, never Google', async () => {
|
|
508
|
+
const before = received.length;
|
|
509
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'no-such-profile' }), body: JSON.stringify(envelope()) });
|
|
510
|
+
assert.equal(r.status, 400);
|
|
511
|
+
assert.match((await r.json()).error.message, /no-such-profile/);
|
|
512
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/google/')), false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('a Bifrost stream that breaks ends the answer to agy with an error, not a clean end', async () => {
|
|
516
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders({ 'x-llm-profile': 'agyg' }), body: JSON.stringify(envelope()) });
|
|
517
|
+
assert.equal(r.status, 200);
|
|
518
|
+
await assert.rejects(r.text());
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('with agy switched off, its agent turns go back to Google', async () => {
|
|
522
|
+
const token = fs.readFileSync(path.join(tmpDir, 'admin.token'), 'utf8').trim();
|
|
523
|
+
const off = await fetch(url('/api/switch'), { method: 'POST', headers: { 'content-type': 'application/json', 'x-llm-switcher-token': token }, body: JSON.stringify({ target: 'agy', profile: null }) });
|
|
524
|
+
assert.equal(off.status, 200, await off.text());
|
|
525
|
+
const before = received.length;
|
|
526
|
+
const r = await fetch(url('/v1internal:streamGenerateContent?alt=sse'), { method: 'POST', headers: agyHeaders(), body: JSON.stringify(envelope()) });
|
|
527
|
+
assert.match(await r.text(), /from google/);
|
|
528
|
+
assert.equal(received.slice(before).some(x => x.url.startsWith('/intact/')), false);
|
|
529
|
+
});
|