llm-switcher 1.1.11 → 1.2.2
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 +63 -0
- package/README.md +202 -257
- package/README.vi.md +200 -256
- package/blindfold/blindfold.mjs +200 -53
- package/blindfold/make-certs.sh +26 -7
- package/catalog.mjs +246 -0
- package/classifier.mjs +238 -0
- package/config.example.json +12 -34
- package/docs/codex-blindfold.md +28 -17
- package/docs/cross-platform.md +16 -7
- package/docs/diagrams/ir-healer-pipeline.mmd +16 -0
- package/docs/diagrams/ir-healer-pipeline.png +0 -0
- package/docs/diagrams/ir-healer-pipeline.svg +90 -0
- package/docs/diagrams/ir-translation-pipeline.html +14925 -0
- package/docs/diagrams/ir-translation-pipeline.sequence.json +31 -0
- package/docs/diagrams/ir-translation-pipeline.svg +5128 -0
- package/docs/diagrams/system-architecture.architecture.json +76 -0
- package/docs/diagrams/system-architecture.html +14978 -0
- package/docs/diagrams/system-architecture.svg +5147 -0
- package/docs/diagrams/system-topology.mmd +30 -0
- package/docs/diagrams/system-topology.png +0 -0
- package/docs/diagrams/system-topology.svg +125 -0
- package/ensure-ca-bundle.mjs +28 -0
- package/formats.mjs +43 -156
- package/icons/antigravity.png +0 -0
- package/icons/claude.png +0 -0
- package/icons/codex.png +0 -0
- package/icons/deepseek.png +0 -0
- package/icons/gemini.png +0 -0
- package/icons/github.png +0 -0
- package/icons/groq.png +0 -0
- package/icons/intact.svg +1 -0
- package/icons/ollama.png +0 -0
- package/icons/openai.png +0 -0
- package/icons/openrouter.png +0 -0
- package/icons/qwen.png +0 -0
- package/icons/vertex.png +0 -0
- package/mcp.mjs +39 -11
- package/package.json +1 -1
- package/proxy.mjs +114 -37
- package/shim.mjs +200 -57
- package/skills/llm-switcher/SKILL.md +15 -10
- package/state.mjs +1100 -191
- package/switch.cmd +2 -2
- package/switch.mjs +228 -53
- package/tests/blindfold-e2e.test.mjs +380 -0
- package/tests/blindfold-task5.test.mjs +429 -0
- package/tests/blindfold.task3.test.mjs +700 -0
- package/tests/blindfold.test.mjs +10 -5
- package/tests/catalog.test.mjs +147 -0
- package/tests/classifier.test.mjs +210 -0
- package/tests/contract-lab.test.mjs +22 -7
- package/tests/formats.test.mjs +63 -46
- package/tests/gateway.e2e.test.mjs +136 -36
- package/tests/lifecycle.test.mjs +16 -10
- package/tests/mcp.test.mjs +78 -2
- package/tests/real-user-sim.test.mjs +464 -0
- package/tests/shim.test.mjs +159 -66
- package/tests/state.test.mjs +975 -193
- package/tests/switch.test.mjs +446 -2
- package/ui.html +1710 -1726
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Task 7 — A3, end to end: a Claude Code shaped request through the interceptor, with a fake
|
|
2
|
+
// upstream standing in for api.anthropic.com.
|
|
3
|
+
//
|
|
4
|
+
// What this proves is the whole invariant: the switcher sits in the middle and nowhere else.
|
|
5
|
+
// /v1/messages is healed by the local gateway, /api/oauth/* reaches the real host, the OAuth body
|
|
6
|
+
// never lands in a capture, and the client is told nothing except a normal HTTPS answer.
|
|
7
|
+
//
|
|
8
|
+
// The one test-only trick is a `--import` hook on the interceptor child: it points the upstream
|
|
9
|
+
// hop of api.anthropic.com at the fake server on this machine. Production code is untouched —
|
|
10
|
+
// the interceptor still asks for `api.anthropic.com` by name and still gets that name in SNI.
|
|
11
|
+
|
|
12
|
+
import test from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import tls from 'node:tls';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import https from 'node:https';
|
|
21
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
22
|
+
import { once } from 'node:events';
|
|
23
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-e2e-'));
|
|
27
|
+
const CERTS = path.join(TMP, 'blindfold', 'certs');
|
|
28
|
+
const CA_PEM = path.join(CERTS, 'ca.pem');
|
|
29
|
+
const CAPTURES = path.join(TMP, 'captures');
|
|
30
|
+
|
|
31
|
+
process.env.LLM_SWITCHER_HOME = TMP;
|
|
32
|
+
process.env.LLM_SWITCHER_STATE_DIR = TMP;
|
|
33
|
+
process.env.LLM_SWITCHER_CONFIG = path.join(TMP, 'config.json');
|
|
34
|
+
process.env.LLM_SWITCHER_BLINDFOLD_CERTS = CERTS;
|
|
35
|
+
delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
|
|
36
|
+
|
|
37
|
+
const s = await import('../state.mjs');
|
|
38
|
+
const TOKEN_FILE = s.adminTokenPath;
|
|
39
|
+
s.ensureAdminToken();
|
|
40
|
+
|
|
41
|
+
const INTERCEPTOR = path.join(ROOT, 'blindfold', 'blindfold.mjs');
|
|
42
|
+
const CERT_SCRIPT = path.join(ROOT, 'blindfold', 'make-certs.sh');
|
|
43
|
+
|
|
44
|
+
// ---------- certificates ----------
|
|
45
|
+
|
|
46
|
+
function findBash() {
|
|
47
|
+
for (const candidate of ['bash', 'C:\\Program Files\\Git\\bin\\bash.exe', '/usr/bin/bash', '/bin/bash']) {
|
|
48
|
+
try {
|
|
49
|
+
const probe = spawnSync(candidate, ['--version'], { stdio: 'ignore' });
|
|
50
|
+
if (!probe.error && probe.status === 0) return candidate;
|
|
51
|
+
} catch { /* try the next one */ }
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const BASH = findBash();
|
|
56
|
+
|
|
57
|
+
let certProblem = null;
|
|
58
|
+
try {
|
|
59
|
+
if (!BASH) throw new Error('no bash (needs Git for Windows on this machine)');
|
|
60
|
+
const r = spawnSync(BASH, [CERT_SCRIPT, 'chatgpt.com', CERTS.replace(/\\/g, '/')], { encoding: 'utf8' });
|
|
61
|
+
if (r.status !== 0) throw new Error(`make-certs.sh failed: ${r.stderr || r.stdout}`);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
certProblem = err.message;
|
|
64
|
+
}
|
|
65
|
+
const noCerts = certProblem ? `needs certificates: ${certProblem}` : false;
|
|
66
|
+
|
|
67
|
+
// ---------- the two servers the traffic is split between ----------
|
|
68
|
+
|
|
69
|
+
// The provider. It holds a certificate for api.anthropic.com signed by the test CA, so the
|
|
70
|
+
// interceptor's re-originated hop verifies exactly as it would against the real host.
|
|
71
|
+
async function fakeUpstream() {
|
|
72
|
+
const server = https.createServer({
|
|
73
|
+
key: fs.readFileSync(path.join(CERTS, 'leaf.key')),
|
|
74
|
+
cert: fs.readFileSync(path.join(CERTS, 'leaf.pem'))
|
|
75
|
+
}, (req, res) => {
|
|
76
|
+
const chunks = [];
|
|
77
|
+
req.on('data', (c) => chunks.push(c));
|
|
78
|
+
req.on('end', () => {
|
|
79
|
+
const body = req.url.startsWith('/api/oauth/token')
|
|
80
|
+
? '{"ok":"upstream-oauth"}'
|
|
81
|
+
: '{"ok":"upstream-messages"}';
|
|
82
|
+
res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
83
|
+
res.end(body);
|
|
84
|
+
server.seen.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString('utf8') });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
server.seen = [];
|
|
88
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
89
|
+
return { port: server.address().port, seen: server.seen, close: () => new Promise((r) => server.close(r)) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The upstream the gateway forwards to. A marker only this server can return is what tells the
|
|
93
|
+
// test that the answer came through the gateway and not from anywhere else.
|
|
94
|
+
async function echoUpstream() {
|
|
95
|
+
const server = http.createServer((req, res) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
req.on('data', (c) => chunks.push(c));
|
|
98
|
+
req.on('end', () => {
|
|
99
|
+
const body = JSON.stringify({ via: 'gateway-upstream', url: req.url });
|
|
100
|
+
res.writeHead(200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
101
|
+
res.end(body);
|
|
102
|
+
server.seen.push({ method: req.method, url: req.url });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
server.seen = [];
|
|
106
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
107
|
+
return { port: server.address().port, seen: server.seen, close: () => new Promise((r) => server.close(r)) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- the interceptor child, with the upstream hop pointed at this machine ----------
|
|
111
|
+
|
|
112
|
+
const REDIRECT = path.join(TMP, 'redirect-upstream.mjs');
|
|
113
|
+
fs.writeFileSync(REDIRECT, [
|
|
114
|
+
"import https from 'node:https';",
|
|
115
|
+
'const UP = Number(process.env.TEST_UPSTREAM_PORT);',
|
|
116
|
+
'const orig = https.request;',
|
|
117
|
+
'https.request = function (options, ...rest) {',
|
|
118
|
+
' let o = options;',
|
|
119
|
+
" if (o && typeof o === 'object' && o.host === 'api.anthropic.com' && Number(o.port) === 443) {",
|
|
120
|
+
" o = { ...o, host: '127.0.0.1', port: UP }; // SNI and the identity check keep api.anthropic.com",
|
|
121
|
+
' }',
|
|
122
|
+
' return orig.call(this, o, ...rest);',
|
|
123
|
+
'};',
|
|
124
|
+
''
|
|
125
|
+
].join('\n'));
|
|
126
|
+
|
|
127
|
+
async function startInterceptor({ port, gatewayPort, config, upstreamPort }) {
|
|
128
|
+
fs.mkdirSync(CAPTURES, { recursive: true });
|
|
129
|
+
const importArg = `--import=${pathToFileURL(REDIRECT).href}`;
|
|
130
|
+
const nodeOptions = [process.env.NODE_OPTIONS, importArg].filter(Boolean).join(' ');
|
|
131
|
+
const child = spawn(process.execPath, [
|
|
132
|
+
INTERCEPTOR,
|
|
133
|
+
'--port', String(port),
|
|
134
|
+
'--gateway-port', String(gatewayPort),
|
|
135
|
+
'--config', config,
|
|
136
|
+
'--token-file', TOKEN_FILE,
|
|
137
|
+
'--certs', CERTS,
|
|
138
|
+
'--active-tools', 'claude',
|
|
139
|
+
'--capture', CAPTURES
|
|
140
|
+
], {
|
|
141
|
+
env: {
|
|
142
|
+
...process.env,
|
|
143
|
+
NODE_OPTIONS: nodeOptions,
|
|
144
|
+
NODE_EXTRA_CA_CERTS: CA_PEM,
|
|
145
|
+
TEST_UPSTREAM_PORT: String(upstreamPort)
|
|
146
|
+
},
|
|
147
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
148
|
+
});
|
|
149
|
+
const output = [];
|
|
150
|
+
child.stdout.on('data', (d) => output.push(String(d)));
|
|
151
|
+
child.stderr.on('data', (d) => output.push(String(d)));
|
|
152
|
+
const stop = async () => {
|
|
153
|
+
if (child.exitCode !== null) return;
|
|
154
|
+
child.kill();
|
|
155
|
+
await once(child, 'exit').catch(() => {});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const deadline = Date.now() + 8000;
|
|
159
|
+
let probe = { state: 'free' };
|
|
160
|
+
while (Date.now() < deadline && probe.state !== 'ours') {
|
|
161
|
+
if (child.exitCode !== null) throw new Error(`interceptor exited ${child.exitCode}:\n${output.join('')}`);
|
|
162
|
+
probe = await s.probeBlindfold(port);
|
|
163
|
+
if (probe.state !== 'ours') await new Promise((r) => setTimeout(r, 100));
|
|
164
|
+
}
|
|
165
|
+
if (probe.state !== 'ours') {
|
|
166
|
+
await stop();
|
|
167
|
+
throw new Error(`interceptor never answered as ours (state=${probe.state}):\n${output.join('')}`);
|
|
168
|
+
}
|
|
169
|
+
return { child, stop, port };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function startGateway(t, port, cfgPath) {
|
|
173
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(port)], {
|
|
174
|
+
env: {
|
|
175
|
+
...process.env, LLM_SWITCHER_CONFIG: cfgPath, LLM_SWITCHER_STATE_DIR: TMP,
|
|
176
|
+
CLAUDE_CONFIG_DIR: path.join(TMP, 'claude'), LLM_SWITCHER_PORT: '', PORT: ''
|
|
177
|
+
},
|
|
178
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
179
|
+
});
|
|
180
|
+
let log = '';
|
|
181
|
+
child.stdout.on('data', (d) => { log += d; });
|
|
182
|
+
child.stderr.on('data', (d) => { log += d; });
|
|
183
|
+
const stop = async () => {
|
|
184
|
+
if (child.exitCode !== null) return;
|
|
185
|
+
child.kill();
|
|
186
|
+
await once(child, 'exit').catch(() => {});
|
|
187
|
+
};
|
|
188
|
+
t.after(stop);
|
|
189
|
+
const deadline = Date.now() + 8000;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
try { if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) return stop; } catch { /* not up yet */ }
|
|
192
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
193
|
+
}
|
|
194
|
+
await stop();
|
|
195
|
+
throw new Error(`gateway did not start: ${log}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------- an HTTPS client behind the interceptor ----------
|
|
199
|
+
|
|
200
|
+
const readUntilHead = (socket) => new Promise((resolve, reject) => {
|
|
201
|
+
let buf = '';
|
|
202
|
+
const onData = (c) => {
|
|
203
|
+
buf += String(c);
|
|
204
|
+
if (buf.includes('\r\n\r\n')) { socket.off('data', onData); socket.off('error', onError); resolve(buf); }
|
|
205
|
+
};
|
|
206
|
+
const onError = (e) => reject(e);
|
|
207
|
+
socket.on('data', onData);
|
|
208
|
+
socket.on('error', onError);
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
socket.off('data', onData);
|
|
211
|
+
reject(new Error(`no response head within 8s: ${JSON.stringify(buf.slice(0, 400))}`));
|
|
212
|
+
}, 8000);
|
|
213
|
+
socket.once('close', () => clearTimeout(timer));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function tryParse(buf, ended) {
|
|
217
|
+
const text = buf.toString('latin1');
|
|
218
|
+
const i = text.indexOf('\r\n\r\n');
|
|
219
|
+
if (i === -1) return null;
|
|
220
|
+
const head = text.slice(0, i);
|
|
221
|
+
const m = /^HTTP\/1\.[01] (\d{3})/.exec(head);
|
|
222
|
+
if (!m) return null;
|
|
223
|
+
const status = Number(m[1]);
|
|
224
|
+
const start = i + 4;
|
|
225
|
+
const len = /content-length:\s*(\d+)/i.exec(head);
|
|
226
|
+
if (len) {
|
|
227
|
+
const need = Number(len[1]);
|
|
228
|
+
if (buf.length < start + need) return null;
|
|
229
|
+
return { status, head, body: buf.slice(start, start + need).toString('utf8') };
|
|
230
|
+
}
|
|
231
|
+
if (/transfer-encoding:\s*chunked/i.test(head)) {
|
|
232
|
+
// A gateway answer is streamed, so it arrives in chunks. Reading the raw bytes as JSON
|
|
233
|
+
// would parse the hex length as the value and stop on the first letter of the real one.
|
|
234
|
+
let pos = start;
|
|
235
|
+
const parts = [];
|
|
236
|
+
for (;;) {
|
|
237
|
+
const eol = buf.indexOf('\r\n', pos);
|
|
238
|
+
if (eol === -1) return ended ? { status, head, body: Buffer.concat(parts).toString('utf8') } : null;
|
|
239
|
+
const size = parseInt(buf.slice(pos, eol).toString('latin1').split(';')[0], 16);
|
|
240
|
+
if (Number.isNaN(size)) return null;
|
|
241
|
+
if (size === 0) return { status, head, body: Buffer.concat(parts).toString('utf8') };
|
|
242
|
+
const dataStart = eol + 2;
|
|
243
|
+
if (buf.length < dataStart + size + 2) return ended ? { status, head, body: Buffer.concat(parts).toString('utf8') } : null;
|
|
244
|
+
parts.push(buf.slice(dataStart, dataStart + size));
|
|
245
|
+
pos = dataStart + size + 2;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (ended || status === 421 || status === 502 || status === 400 || status === 404) {
|
|
249
|
+
return { status, head, body: buf.slice(start).toString('utf8') };
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readResponse(stream) {
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
const chunks = [];
|
|
257
|
+
let settled = false;
|
|
258
|
+
const done = () => {
|
|
259
|
+
if (settled) return;
|
|
260
|
+
settled = true;
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
const all = Buffer.concat(chunks);
|
|
263
|
+
resolve(tryParse(all, true) ?? { status: 0, head: all.toString('latin1'), body: '' });
|
|
264
|
+
};
|
|
265
|
+
const timer = setTimeout(done, 8000);
|
|
266
|
+
stream.on('data', (c) => {
|
|
267
|
+
chunks.push(c);
|
|
268
|
+
const parsed = tryParse(Buffer.concat(chunks), false);
|
|
269
|
+
if (parsed) { settled = true; clearTimeout(timer); resolve(parsed); }
|
|
270
|
+
});
|
|
271
|
+
stream.on('end', done);
|
|
272
|
+
stream.on('close', done);
|
|
273
|
+
stream.on('error', done);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Exactly what Claude Code does: CONNECT to the proxy, TLS to the target name inside it, then
|
|
278
|
+
// a plain POST as if the switcher were not there.
|
|
279
|
+
async function callThrough(proxyPort, url, body, { host = 'api.anthropic.com', headers = {} } = {}) {
|
|
280
|
+
const socket = net.connect(proxyPort, '127.0.0.1');
|
|
281
|
+
await once(socket, 'connect');
|
|
282
|
+
socket.write(`CONNECT ${host}:443 HTTP/1.1\r\nHost: ${host}:443\r\n\r\n`);
|
|
283
|
+
const head = await readUntilHead(socket);
|
|
284
|
+
assert.match(head, /^HTTP\/1\.1 200 /, `CONNECT refused: ${head}`);
|
|
285
|
+
|
|
286
|
+
const secure = tls.connect({
|
|
287
|
+
socket, servername: host, ca: fs.readFileSync(CA_PEM), rejectUnauthorized: true
|
|
288
|
+
});
|
|
289
|
+
await once(secure, 'secureConnect');
|
|
290
|
+
|
|
291
|
+
const extra = Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
|
|
292
|
+
secure.write(`POST ${url} HTTP/1.1\r\nHost: ${host}\r\n`
|
|
293
|
+
+ 'Content-Type: application/json\r\n'
|
|
294
|
+
+ `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${extra}\r\n${body}`);
|
|
295
|
+
return readResponse(secure);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ==================================================================
|
|
299
|
+
|
|
300
|
+
test('A3: a Claude Code request through the interceptor reaches the gateway for /v1/messages and the real host for /api/oauth/*', { skip: noCerts }, async (t) => {
|
|
301
|
+
const provider = await fakeUpstream();
|
|
302
|
+
t.after(() => provider.close());
|
|
303
|
+
const echoed = await echoUpstream();
|
|
304
|
+
t.after(() => echoed.close());
|
|
305
|
+
|
|
306
|
+
const gwPort = await freePort();
|
|
307
|
+
const bfPort = await freePort();
|
|
308
|
+
const cfgPath = path.join(TMP, 'config.json');
|
|
309
|
+
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
310
|
+
port: gwPort,
|
|
311
|
+
blindfold: { port: bfPort },
|
|
312
|
+
activeProfiles: { claude: 'c', codex: null },
|
|
313
|
+
profiles: {
|
|
314
|
+
c: {
|
|
315
|
+
name: 'Claude direct',
|
|
316
|
+
mode: 'direct',
|
|
317
|
+
tool: 'claude',
|
|
318
|
+
outFormat: 'anthropic',
|
|
319
|
+
baseURL: `http://127.0.0.1:${echoed.port}/v1`,
|
|
320
|
+
apiKey: 'sk-test',
|
|
321
|
+
defaultModels: { opus: 'claude-opus-4-7', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001', fable: 'claude-haiku-4-5-20251001' },
|
|
322
|
+
model1M: { opus: false, sonnet: false, haiku: false, fable: false }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}, null, 2), { mode: 0o600 });
|
|
326
|
+
|
|
327
|
+
// The interceptor first: the gateway reconciles the interceptor it finds, and one that is
|
|
328
|
+
// already ours must not be replaced by a second process on the same port.
|
|
329
|
+
const it = await startInterceptor({ port: bfPort, gatewayPort: gwPort, config: cfgPath, upstreamPort: provider.port });
|
|
330
|
+
t.after(() => it.stop());
|
|
331
|
+
await startGateway(t, gwPort, cfgPath);
|
|
332
|
+
|
|
333
|
+
const payload = JSON.stringify({
|
|
334
|
+
model: 'claude-sonnet-4-6',
|
|
335
|
+
max_tokens: 16,
|
|
336
|
+
messages: [{ role: 'user', content: 'hello' }]
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// 1. The API call is healed by the gateway: the marker can only come from the upstream the
|
|
340
|
+
// gateway forwards to, so nothing on api.anthropic.com was asked.
|
|
341
|
+
const api = await callThrough(bfPort, '/v1/messages', payload);
|
|
342
|
+
assert.equal(api.status, 200, `through the gateway: ${api.head}`);
|
|
343
|
+
assert.deepEqual(JSON.parse(api.body), { via: 'gateway-upstream', url: '/v1/messages' },
|
|
344
|
+
'the answer came from this switcher, not from the provider');
|
|
345
|
+
assert.ok(echoed.seen.some((r) => r.url === '/v1/messages'), 'the gateway forwarded it');
|
|
346
|
+
|
|
347
|
+
// 2. Sign-in traffic keeps going to the real host, with nothing captured.
|
|
348
|
+
const oauth = await callThrough(bfPort, '/api/oauth/token',
|
|
349
|
+
JSON.stringify({ grant_type: 'refresh_token', refresh_token: 'super-secret-refresh' }));
|
|
350
|
+
assert.equal(oauth.status, 200, `through to the provider: ${oauth.head}`);
|
|
351
|
+
assert.deepEqual(JSON.parse(oauth.body), { ok: 'upstream-oauth' },
|
|
352
|
+
'the OAuth exchange reached the fake provider directly');
|
|
353
|
+
assert.ok(provider.seen.some((r) => r.url === '/api/oauth/token'), 'the provider saw the sign-in call');
|
|
354
|
+
assert.ok(!provider.seen.some((r) => r.url === '/v1/messages'),
|
|
355
|
+
'the API call never left this machine for the provider');
|
|
356
|
+
|
|
357
|
+
// 3. The capture holds the shape of the OAuth exchange and not a byte of it.
|
|
358
|
+
const oauthCaptures = fs.readdirSync(CAPTURES).filter((f) => f.includes('api_oauth_token'));
|
|
359
|
+
assert.equal(oauthCaptures.length, 1, `one capture for the OAuth call, got: ${oauthCaptures.join(', ') || 'none'}`);
|
|
360
|
+
const record = JSON.parse(fs.readFileSync(path.join(CAPTURES, oauthCaptures[0]), 'utf8'));
|
|
361
|
+
assert.equal('requestBody' in record, false, 'R3c: a pass-through body is never stored');
|
|
362
|
+
assert.equal('responseBody' in record, false, 'R3c: nor the answer to it');
|
|
363
|
+
assert.ok(!JSON.stringify(record).includes('super-secret-refresh'),
|
|
364
|
+
'the refresh token is not anywhere in the capture');
|
|
365
|
+
|
|
366
|
+
// 4. Through all of it, the interceptor is still the same process on the same port.
|
|
367
|
+
const after = await s.probeBlindfold(bfPort);
|
|
368
|
+
assert.equal(after.state, 'ours', 'the interceptor is still ours');
|
|
369
|
+
assert.equal(after.activeTools, 'claude', 'and it serves exactly the tool that was asked for');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const freePort = () => new Promise((resolve) => {
|
|
373
|
+
const srv = net.createServer();
|
|
374
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
375
|
+
const { port } = srv.address();
|
|
376
|
+
srv.close(() => resolve(port));
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
process.on('exit', () => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best effort */ } });
|