llm-switcher 1.1.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/.gitattributes +16 -0
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/README.vi.md +585 -0
- package/blindfold/blindfold.mjs +633 -0
- package/blindfold/make-certs.sh +88 -0
- package/blindfold/wsframe.mjs +176 -0
- package/codex-catalog-template.json +1 -0
- package/config.example.json +84 -0
- package/contract-exclusions.json +41 -0
- package/contract.mjs +561 -0
- package/docs/LLM-RESPONSE-MATRIX.md +165 -0
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -0
- package/docs/codex-blindfold.md +214 -0
- package/docs/cross-platform.md +136 -0
- package/docs/diagrams/blindfold-request-routing.html +14972 -0
- package/docs/diagrams/blindfold-request-routing.sequence.json +175 -0
- package/docs/diagrams/blindfold-switch-lifecycle.html +14958 -0
- package/docs/diagrams/blindfold-switch-lifecycle.lifecycle.json +159 -0
- package/docs/diagrams/codex-model-name-resolution.html +15005 -0
- package/docs/diagrams/codex-model-name-resolution.workflow.json +71 -0
- package/docs/response-matrix.json +1131 -0
- package/formats.mjs +2308 -0
- package/mcp.mjs +340 -0
- package/package.json +36 -0
- package/proxy.mjs +1743 -0
- package/service.mjs +132 -0
- package/shim.mjs +292 -0
- package/skills/llm-switcher/SKILL.md +88 -0
- package/state.mjs +978 -0
- package/switch +5 -0
- package/switch.cmd +2 -0
- package/switch.mjs +930 -0
- package/tests/blindfold.test.mjs +307 -0
- package/tests/blindfold.wire.test.mjs +170 -0
- package/tests/contract/run.test.mjs +214 -0
- package/tests/contract-check.test.mjs +458 -0
- package/tests/contract-lab.test.mjs +755 -0
- package/tests/datadir.test.mjs +37 -0
- package/tests/formats.test.mjs +794 -0
- package/tests/gateway.e2e.test.mjs +999 -0
- package/tests/helpers.mjs +24 -0
- package/tests/lifecycle.test.mjs +416 -0
- package/tests/live-optimizer-interop.mjs +205 -0
- package/tests/mcp.test.mjs +91 -0
- package/tests/service.test.mjs +69 -0
- package/tests/shim.test.mjs +228 -0
- package/tests/state.test.mjs +675 -0
- package/tests/switch.test.mjs +156 -0
- package/tests/wsframe.test.mjs +154 -0
- package/ui.html +2234 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Tests for process identity and the blindfold interceptor lifecycle (switch.mjs, proxy.mjs, state.mjs).
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-09-23: the CLI trusted any TCP accept on the interceptor port and any /health body on the
|
|
4
|
+
// gateway port, `switch on` saved config.json before it refused, and only the CLI ever started the
|
|
5
|
+
// interceptor, so the dashboard, `switch port` and a service start left HTTPS_PROXY pointing at nothing.
|
|
6
|
+
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { spawn, execFile, execFileSync } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const POSIX = process.platform !== 'win32';
|
|
20
|
+
const HAS_OPENSSL = POSIX && fs.existsSync('/usr/bin/openssl');
|
|
21
|
+
|
|
22
|
+
// switch.mjs writes the launcher files into the repository root; keep whatever was there.
|
|
23
|
+
const LAUNCH_FILES = ['active.flag', '1m.flag', 'codex-1m.flag', 'openai-1m.flag', 'env.sh', 'env.cmd', 'env-codex.sh', 'env-codex.cmd', 'model-catalog.json'];
|
|
24
|
+
// Every child runs with LLM_SWITCHER_STATE_DIR = ws.dir, so the launch files of the checkout stay untouched.
|
|
25
|
+
function snapshotLaunchFiles(dir) {
|
|
26
|
+
return Object.fromEntries(LAUNCH_FILES.map(f => {
|
|
27
|
+
const p = path.join(dir, f);
|
|
28
|
+
return [f, fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null];
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
function restoreLaunchFiles(snap, dir) {
|
|
32
|
+
for (const [f, content] of Object.entries(snap)) {
|
|
33
|
+
const p = path.join(dir, f);
|
|
34
|
+
if (content === null) fs.rmSync(p, { force: true });
|
|
35
|
+
else fs.writeFileSync(p, content);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const freePort = () => new Promise(r => {
|
|
40
|
+
const s = net.createServer().listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => r(port)); });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function makeWorkspace({ certs = false } = {}) {
|
|
44
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-life-'));
|
|
45
|
+
const certDir = path.join(dir, 'certs');
|
|
46
|
+
if (certs) execFileSync('bash', [path.join(ROOT, 'blindfold', 'make-certs.sh'), 'chatgpt.com', certDir], { stdio: 'ignore' });
|
|
47
|
+
return { dir, certDir, cfgPath: path.join(dir, 'config.json'), claudeDir: path.join(dir, 'claude') };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function envFor(ws) {
|
|
51
|
+
// HOME: `switch on` installs shims under the home directory; a test must never rewrite the real ones.
|
|
52
|
+
return { ...process.env, HOME: path.join(ws.dir, 'home'), USERPROFILE: path.join(ws.dir, 'home'), LLM_SWITCHER_CONFIG: ws.cfgPath, LLM_SWITCHER_STATE_DIR: ws.dir, LLM_SWITCHER_BLINDFOLD_CERTS: ws.certDir, CLAUDE_CONFIG_DIR: ws.claudeDir, LLM_SWITCHER_PORT: '', PORT: '' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeConfig(ws, gwPort, bfPort, activeResponses = null) {
|
|
56
|
+
fs.writeFileSync(ws.cfgPath, JSON.stringify({
|
|
57
|
+
port: gwPort,
|
|
58
|
+
activeProfiles: { anthropic: null, responses: activeResponses, 'openai-chat': null, vertex: null },
|
|
59
|
+
profiles: {
|
|
60
|
+
plain: { name: 'Plain', mode: 'convert', inFormat: 'auto', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k', defaultModels: { opus: 'o' } },
|
|
61
|
+
bf: {
|
|
62
|
+
name: 'Blindfold', mode: 'convert', inFormat: 'responses', baseURL: 'http://127.0.0.1:9/v1', apiKey: 'k',
|
|
63
|
+
defaultModels: { main: 'm' }, blindfold: true, blindfoldPort: bfPort
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}, null, 2), { mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Children run asynchronously: the replay and squatter servers live in this process, and a
|
|
70
|
+
// synchronous exec would block them, so a probe would time out and read 'foreign' for the wrong reason.
|
|
71
|
+
function probe(ws, expr) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
execFile(process.execPath, ['--input-type=module', '-e',
|
|
74
|
+
`const s = await import(${JSON.stringify(path.join(ROOT, 'state.mjs'))}); console.log(JSON.stringify(await (${expr})));`
|
|
75
|
+
], { env: envFor(ws), encoding: 'utf8' }, (err, stdout) => (err ? reject(err) : resolve(JSON.parse(stdout.trim()))));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runSwitch(ws, args) {
|
|
80
|
+
return new Promise(resolve => {
|
|
81
|
+
execFile(process.execPath, [path.join(ROOT, 'switch.mjs'), ...args], { env: envFor(ws), encoding: 'utf8', timeout: 30000 },
|
|
82
|
+
(err, stdout, stderr) => resolve({ status: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout, stderr }));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function startGateway(ws, gwPort) {
|
|
87
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(gwPort)], { env: envFor(ws), stdio: 'ignore' });
|
|
88
|
+
for (let i = 0; i < 50; i++) {
|
|
89
|
+
try { if ((await fetch(`http://127.0.0.1:${gwPort}/health`)).ok) return child; } catch {}
|
|
90
|
+
await new Promise(r => setTimeout(r, 100));
|
|
91
|
+
}
|
|
92
|
+
throw new Error('gateway did not start');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const token = (ws) => fs.readFileSync(path.join(ws.dir, 'admin.token'), 'utf8').trim();
|
|
96
|
+
const api = (ws, gwPort, p, body) => fetch(`http://127.0.0.1:${gwPort}${p}`, {
|
|
97
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) }, body: JSON.stringify(body)
|
|
98
|
+
}).then(r => r.json());
|
|
99
|
+
|
|
100
|
+
async function waitFor(fn, ms = 6000) {
|
|
101
|
+
const end = Date.now() + ms;
|
|
102
|
+
let last;
|
|
103
|
+
while (Date.now() < end) {
|
|
104
|
+
last = await fn();
|
|
105
|
+
if (last) return last;
|
|
106
|
+
await new Promise(r => setTimeout(r, 200));
|
|
107
|
+
}
|
|
108
|
+
return last;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('the gateway proves its identity to a fresh challenge; a replayed /health is foreign', { skip: !POSIX && 'posix' }, async () => {
|
|
112
|
+
const ws = makeWorkspace();
|
|
113
|
+
const gwPort = await freePort();
|
|
114
|
+
writeConfig(ws, gwPort, await freePort());
|
|
115
|
+
const gw = await startGateway(ws, gwPort);
|
|
116
|
+
const replay = http.createServer((req, res) => res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher', proof: 'recorded' })));
|
|
117
|
+
const replayPort = await new Promise(r => replay.listen(0, '127.0.0.1', () => r(replay.address().port)));
|
|
118
|
+
try {
|
|
119
|
+
const body = await (await fetch(`http://127.0.0.1:${gwPort}/health?challenge=abc`)).json();
|
|
120
|
+
// The proof binds the role, the port it answers on and its pid, so it cannot be relayed or edited.
|
|
121
|
+
assert.equal(body.port, gwPort);
|
|
122
|
+
assert.equal(body.proof, crypto.createHmac('sha256', token(ws)).update(['gateway', gwPort, body.pid, '', '', '', 'abc'].join('|')).digest('hex'));
|
|
123
|
+
assert.equal(await probe(ws, `s.probeGateway(${gwPort})`), 'ours');
|
|
124
|
+
assert.equal(await probe(ws, `s.probeGateway(${replayPort})`), 'foreign');
|
|
125
|
+
assert.equal(await probe(ws, `s.probeGateway(${await freePort()})`), 'free');
|
|
126
|
+
|
|
127
|
+
// A squatter that relays the fresh challenge to the real gateway, or edits the pid it returns.
|
|
128
|
+
const relay = http.createServer(async (req, res) => {
|
|
129
|
+
const real = await (await fetch(`http://127.0.0.1:${gwPort}${req.url}`)).json();
|
|
130
|
+
res.end(JSON.stringify(req.headers['x-tamper'] ? { ...real, pid: 1 } : real));
|
|
131
|
+
});
|
|
132
|
+
const relayPort = await new Promise(r => relay.listen(0, '127.0.0.1', () => r(relay.address().port)));
|
|
133
|
+
const tamper = http.createServer(async (req, res) => {
|
|
134
|
+
const real = await (await fetch(`http://127.0.0.1:${gwPort}${req.url}`)).json();
|
|
135
|
+
res.end(JSON.stringify({ ...real, port: tamperPort, pid: 1 }));
|
|
136
|
+
});
|
|
137
|
+
const tamperPort = await new Promise(r => tamper.listen(0, '127.0.0.1', () => r(tamper.address().port)));
|
|
138
|
+
try {
|
|
139
|
+
assert.equal(await probe(ws, `s.probeGateway(${relayPort})`), 'foreign', 'a relayed proof names another port');
|
|
140
|
+
assert.equal(await probe(ws, `s.probeGateway(${tamperPort})`), 'foreign', 'an edited port or pid breaks the proof');
|
|
141
|
+
} finally {
|
|
142
|
+
relay.close(); tamper.close();
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
gw.kill(); replay.close(); fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('switch on refuses a foreign gateway port and changes nothing', { skip: !POSIX && 'posix' }, async () => {
|
|
150
|
+
const ws = makeWorkspace();
|
|
151
|
+
const gwPort = await freePort();
|
|
152
|
+
writeConfig(ws, gwPort, await freePort());
|
|
153
|
+
const replay = http.createServer((req, res) => res.end(JSON.stringify({ status: 'ok', proxy: 'llm-switcher' })));
|
|
154
|
+
await new Promise(r => replay.listen(gwPort, '127.0.0.1', r));
|
|
155
|
+
const before = fs.readFileSync(ws.cfgPath);
|
|
156
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
157
|
+
try {
|
|
158
|
+
const r = await runSwitch(ws, ['on', 'plain']);
|
|
159
|
+
assert.notEqual(r.status, 0);
|
|
160
|
+
assert.match(r.stderr, /held by another process/);
|
|
161
|
+
assert.deepEqual(fs.readFileSync(ws.cfgPath), before);
|
|
162
|
+
assert.deepEqual(snapshotLaunchFiles(ws.dir), launch, 'no launcher file changed');
|
|
163
|
+
} finally {
|
|
164
|
+
replay.close(); restoreLaunchFiles(launch, ws.dir); fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('switch codex <blindfold profile> refuses missing certificates, a missing leaf.key and a foreign interceptor port, changing nothing', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
169
|
+
const cases = [];
|
|
170
|
+
try {
|
|
171
|
+
// 1. no certificates at all
|
|
172
|
+
const a = makeWorkspace(); cases.push(a);
|
|
173
|
+
writeConfig(a, await freePort(), await freePort());
|
|
174
|
+
// 2. leaf.key missing
|
|
175
|
+
const b = makeWorkspace({ certs: true }); cases.push(b);
|
|
176
|
+
fs.rmSync(path.join(b.certDir, 'leaf.key'));
|
|
177
|
+
writeConfig(b, await freePort(), await freePort());
|
|
178
|
+
// 3. valid certificates, but another process holds the interceptor port
|
|
179
|
+
const c = makeWorkspace({ certs: true }); cases.push(c);
|
|
180
|
+
const squatter = net.createServer(s => s.end('HTTP/1.1 200 OK\r\n\r\nnot the switcher'));
|
|
181
|
+
const bfPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
182
|
+
writeConfig(c, await freePort(), bfPort);
|
|
183
|
+
try {
|
|
184
|
+
for (const [ws, why] of [[a, /missing/], [b, /leaf\.key is missing/], [c, /held by another process/]]) {
|
|
185
|
+
const before = fs.readFileSync(ws.cfgPath);
|
|
186
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
187
|
+
const r = await runSwitch(ws, ['codex', 'bf']);
|
|
188
|
+
assert.notEqual(r.status, 0, r.stdout);
|
|
189
|
+
assert.match(r.stderr, why);
|
|
190
|
+
assert.deepEqual(fs.readFileSync(ws.cfgPath), before, 'config.json is byte-identical');
|
|
191
|
+
assert.ok(!fs.existsSync(path.join(ws.dir, 'blindfold.json')));
|
|
192
|
+
assert.deepEqual(snapshotLaunchFiles(ws.dir), launch, 'no launcher file changed');
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
squatter.close();
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
for (const ws of cases) fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('the gateway owns the interceptor: dashboard changes, a lost interceptor and a gateway restart are reconciled', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
203
|
+
const ws = makeWorkspace({ certs: true });
|
|
204
|
+
const gwPort = await freePort();
|
|
205
|
+
const bfPort = await freePort();
|
|
206
|
+
writeConfig(ws, gwPort, bfPort);
|
|
207
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
208
|
+
let gw = await startGateway(ws, gwPort);
|
|
209
|
+
const bfState = () => probe(ws, `s.probeBlindfold(${bfPort})`);
|
|
210
|
+
try {
|
|
211
|
+
// 1. the dashboard turns blindfold on for the Codex target
|
|
212
|
+
const on = await api(ws, gwPort, '/api/switch', { target: 'responses', profile: 'bf' });
|
|
213
|
+
assert.equal(on.success, true, JSON.stringify(on));
|
|
214
|
+
let st = await waitFor(async () => { const s = await bfState(); return s.state === 'ours' && s; });
|
|
215
|
+
assert.equal(st.gatewayPort, gwPort);
|
|
216
|
+
assert.equal(st.prefix, '/backend-api/codex');
|
|
217
|
+
|
|
218
|
+
// 2. the dashboard changes the prefix: the interceptor is respawned with it
|
|
219
|
+
const saved = await api(ws, gwPort, '/api/save-profile', { key: 'bf', profile: { blindfoldPrefix: '/backend-api/codex2' } });
|
|
220
|
+
assert.notEqual(saved.success, false, JSON.stringify(saved));
|
|
221
|
+
st = await waitFor(async () => { const s = await bfState(); return s.prefix === '/backend-api/codex2' && s; });
|
|
222
|
+
assert.equal(st.prefix, '/backend-api/codex2');
|
|
223
|
+
|
|
224
|
+
// 3. the interceptor dies; a sync brings it back
|
|
225
|
+
process.kill(st.pid, 'SIGTERM');
|
|
226
|
+
await waitFor(async () => (await bfState()).state === 'free');
|
|
227
|
+
const sync = await api(ws, gwPort, '/api/blindfold/sync', {});
|
|
228
|
+
assert.equal(sync.ok, true, JSON.stringify(sync));
|
|
229
|
+
assert.equal((await bfState()).state, 'ours');
|
|
230
|
+
|
|
231
|
+
// 4. reboot case: interceptor and gateway gone, the gateway starts again on its own
|
|
232
|
+
process.kill((await bfState()).pid, 'SIGTERM');
|
|
233
|
+
gw.kill();
|
|
234
|
+
await waitFor(async () => (await bfState()).state === 'free');
|
|
235
|
+
gw = await startGateway(ws, gwPort);
|
|
236
|
+
st = await waitFor(async () => { const s = await bfState(); return s.state === 'ours' && s; });
|
|
237
|
+
assert.equal(st.gatewayPort, gwPort, 'the gateway starts the interceptor at boot');
|
|
238
|
+
|
|
239
|
+
// 5. a foreign process on the new interceptor port: the caller sees the failure
|
|
240
|
+
const squatter = net.createServer(s => s.end());
|
|
241
|
+
const squatPort = await new Promise(r => squatter.listen(0, '127.0.0.1', () => r(squatter.address().port)));
|
|
242
|
+
try {
|
|
243
|
+
const badRes = await fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
|
|
244
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) },
|
|
245
|
+
body: JSON.stringify({ key: 'bf', profile: { blindfoldPort: squatPort } })
|
|
246
|
+
});
|
|
247
|
+
assert.equal(badRes.status, 502, 'a failure is not answered with 200');
|
|
248
|
+
const bad = await badRes.json();
|
|
249
|
+
assert.equal(bad.success, false);
|
|
250
|
+
assert.match(bad.error, /held by another process/);
|
|
251
|
+
const onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8')).profiles.bf.blindfoldPort;
|
|
252
|
+
assert.equal(onDisk, bfPort, 'a refused change is not saved, so HTTPS_PROXY never points at the squatter');
|
|
253
|
+
assert.equal((await bfState()).state, 'ours', 'the working interceptor stays up');
|
|
254
|
+
const status = await fetch(`http://127.0.0.1:${gwPort}/api/status`, { headers: { 'x-llm-switcher-token': token(ws) } }).then(r => r.json());
|
|
255
|
+
assert.equal(status.config.profiles.bf.blindfoldPort, bfPort, 'the gateway does not keep the refused change in memory');
|
|
256
|
+
} finally {
|
|
257
|
+
squatter.close();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 6. turning the Codex target off stops the interceptor
|
|
261
|
+
await api(ws, gwPort, '/api/save-profile', { key: 'bf', profile: { blindfoldPort: bfPort } });
|
|
262
|
+
await waitFor(async () => (await bfState()).state === 'ours');
|
|
263
|
+
await api(ws, gwPort, '/api/switch', { target: 'responses', profile: null });
|
|
264
|
+
assert.equal(await waitFor(async () => (await bfState()).state === 'free'), true);
|
|
265
|
+
} finally {
|
|
266
|
+
try { const s = await bfState(); if (s.state === 'ours') process.kill(s.pid, 'SIGTERM'); } catch {}
|
|
267
|
+
gw.kill();
|
|
268
|
+
restoreLaunchFiles(launch, ws.dir);
|
|
269
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Two admin changes must not interleave while one waits for its interceptor check (audit J1).
|
|
274
|
+
test('concurrent admin changes: a refused change never reaches disk, and no accepted change is lost', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
275
|
+
const ws = makeWorkspace({ certs: true });
|
|
276
|
+
const gwPort = await freePort();
|
|
277
|
+
const bfPort = await freePort();
|
|
278
|
+
writeConfig(ws, gwPort, bfPort, 'bf');
|
|
279
|
+
const launch = snapshotLaunchFiles(ws.dir);
|
|
280
|
+
const gw = await startGateway(ws, gwPort);
|
|
281
|
+
// A squatter that accepts and never answers keeps the identity probe waiting.
|
|
282
|
+
const silent = net.createServer(() => {});
|
|
283
|
+
const silentPort = await new Promise(r => silent.listen(0, '127.0.0.1', () => r(silent.address().port)));
|
|
284
|
+
try {
|
|
285
|
+
await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
|
|
286
|
+
const post = (body) => fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
|
|
287
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) }, body: JSON.stringify(body)
|
|
288
|
+
});
|
|
289
|
+
const refused = post({ key: 'bf', profile: { blindfoldPort: silentPort } });
|
|
290
|
+
await new Promise(r => setTimeout(r, 300));
|
|
291
|
+
const other = post({ key: 'plain', profile: { name: 'Renamed' } });
|
|
292
|
+
const [a, b] = await Promise.all([refused, other]);
|
|
293
|
+
assert.equal(a.status, 502);
|
|
294
|
+
assert.equal(b.status, 200);
|
|
295
|
+
const onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
296
|
+
assert.equal(onDisk.profiles.bf.blindfoldPort, bfPort, 'the refused port is not saved by the other request');
|
|
297
|
+
assert.equal(onDisk.profiles.plain.name, 'Renamed');
|
|
298
|
+
|
|
299
|
+
// Two accepted changes to the active profile: the one that saves last keeps the other.
|
|
300
|
+
const [c, d] = await Promise.all([
|
|
301
|
+
post({ key: 'bf', profile: { name: 'First' } }),
|
|
302
|
+
post({ key: 'bf', profile: { defaultModels: { main: 'm2' } } })
|
|
303
|
+
]);
|
|
304
|
+
assert.equal(c.status, 200);
|
|
305
|
+
assert.equal(d.status, 200);
|
|
306
|
+
const both = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8')).profiles.bf;
|
|
307
|
+
assert.equal(both.name, 'First', 'no accepted change is lost');
|
|
308
|
+
assert.equal(both.defaultModels.main, 'm2');
|
|
309
|
+
} finally {
|
|
310
|
+
silent.close();
|
|
311
|
+
try { const st = await probe(ws, `s.probeBlindfold(${bfPort})`); if (st.state === 'ours') process.kill(st.pid, 'SIGTERM'); } catch {}
|
|
312
|
+
gw.kill();
|
|
313
|
+
restoreLaunchFiles(launch, ws.dir);
|
|
314
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// An interceptor that proves its identity but ignores SIGTERM. Reconcile used to report success and
|
|
319
|
+
// forget it (follow-up breaker-2).
|
|
320
|
+
test('reconcile reports an interceptor that does not stop instead of calling it done', { skip: !POSIX && 'posix' }, async () => {
|
|
321
|
+
const ws = makeWorkspace();
|
|
322
|
+
const gwPort = await freePort();
|
|
323
|
+
const bfPort = await freePort();
|
|
324
|
+
writeConfig(ws, gwPort, bfPort);
|
|
325
|
+
const fake = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
326
|
+
const s = await import(${JSON.stringify(path.join(ROOT, 'state.mjs'))});
|
|
327
|
+
const http = await import('node:http');
|
|
328
|
+
s.ensureAdminToken();
|
|
329
|
+
process.on('SIGTERM', () => {});
|
|
330
|
+
const port = ${bfPort};
|
|
331
|
+
http.createServer((req, res) => {
|
|
332
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
333
|
+
const f = { role: 'blindfold', port, pid: process.pid, gatewayPort: ${gwPort}, host: 'chatgpt.com', prefix: '/backend-api/codex' };
|
|
334
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort: f.gatewayPort, host: f.host, prefix: f.prefix, proof: s.identityProof(nonce, f) }));
|
|
335
|
+
}).listen(port, '127.0.0.1');
|
|
336
|
+
`], { env: envFor(ws), stdio: 'ignore' });
|
|
337
|
+
try {
|
|
338
|
+
await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours');
|
|
339
|
+
fs.writeFileSync(path.join(ws.dir, 'blindfold.json'), JSON.stringify({ pid: fake.pid, port: bfPort, gatewayPort: gwPort, host: 'chatgpt.com', prefix: '/backend-api/codex' }));
|
|
340
|
+
const r = await probe(ws, `s.reconcileBlindfold(s.loadConfig(), ${gwPort})`);
|
|
341
|
+
assert.equal(r.ok, false, JSON.stringify(r));
|
|
342
|
+
assert.match(r.error, /did not stop/);
|
|
343
|
+
assert.ok(fs.existsSync(path.join(ws.dir, 'blindfold.json')), 'the record stays while the interceptor runs');
|
|
344
|
+
} finally {
|
|
345
|
+
fake.kill('SIGKILL');
|
|
346
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// While a dashboard change waits for its interceptor check, the CLI can save config.json. The gateway
|
|
351
|
+
// must not then save over it (follow-up RACER-3).
|
|
352
|
+
test('a dashboard change never overwrites a config.json that another process saved during its check', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
353
|
+
const ws = makeWorkspace({ certs: true });
|
|
354
|
+
const gwPort = await freePort();
|
|
355
|
+
const bfPort = await freePort();
|
|
356
|
+
writeConfig(ws, gwPort, bfPort, 'bf');
|
|
357
|
+
// A genuine-looking interceptor that answers each probe after 1 s.
|
|
358
|
+
const fake = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
359
|
+
const s = await import(${JSON.stringify(path.join(ROOT, 'state.mjs'))});
|
|
360
|
+
const http = await import('node:http');
|
|
361
|
+
s.ensureAdminToken();
|
|
362
|
+
const port = ${bfPort};
|
|
363
|
+
http.createServer((req, res) => setTimeout(() => {
|
|
364
|
+
const nonce = new URL(req.url, 'http://x').searchParams.get('challenge');
|
|
365
|
+
const f = { role: 'blindfold', port, pid: process.pid, gatewayPort: ${gwPort}, host: 'chatgpt.com', prefix: '/backend-api/codex' };
|
|
366
|
+
res.end(JSON.stringify({ proxy: 'llm-switcher-blindfold', port, pid: process.pid, gatewayPort: f.gatewayPort, host: f.host, prefix: f.prefix, proof: s.identityProof(nonce, f) }));
|
|
367
|
+
}, 1000)).listen(port, '127.0.0.1');
|
|
368
|
+
`], { env: envFor(ws), stdio: 'ignore' });
|
|
369
|
+
let gw;
|
|
370
|
+
try {
|
|
371
|
+
await waitFor(async () => (await probe(ws, `s.probeBlindfold(${bfPort})`)).state === 'ours', 10000);
|
|
372
|
+
gw = await startGateway(ws, gwPort);
|
|
373
|
+
await new Promise(r => setTimeout(r, 3000)); // the startup reconcile (two 1 s probes) keeps the fake
|
|
374
|
+
const pending = fetch(`http://127.0.0.1:${gwPort}/api/save-profile`, {
|
|
375
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-llm-switcher-token': token(ws) },
|
|
376
|
+
body: JSON.stringify({ key: 'bf', profile: { name: 'From the dashboard' } })
|
|
377
|
+
});
|
|
378
|
+
await new Promise(r => setTimeout(r, 300));
|
|
379
|
+
const cli = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
380
|
+
cli.profiles.plain.name = 'Saved by the CLI';
|
|
381
|
+
fs.writeFileSync(`${ws.cfgPath}.cli`, JSON.stringify(cli, null, 2));
|
|
382
|
+
fs.renameSync(`${ws.cfgPath}.cli`, ws.cfgPath);
|
|
383
|
+
const r = await pending;
|
|
384
|
+
assert.equal(r.status, 409, await r.text());
|
|
385
|
+
const onDisk = JSON.parse(fs.readFileSync(ws.cfgPath, 'utf8'));
|
|
386
|
+
assert.equal(onDisk.profiles.plain.name, 'Saved by the CLI');
|
|
387
|
+
assert.equal(onDisk.profiles.bf.name, 'Blindfold');
|
|
388
|
+
} finally {
|
|
389
|
+
gw?.kill();
|
|
390
|
+
fake.kill('SIGKILL');
|
|
391
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// An interceptor that comes up after the 5 s wait was neither recorded nor stopped, so it ran on with
|
|
396
|
+
// nothing able to find it (follow-up racer, next-time note).
|
|
397
|
+
test('reconcile stops an interceptor that comes up too late instead of leaving it unrecorded', { skip: !HAS_OPENSSL && 'posix + openssl' }, async () => {
|
|
398
|
+
const ws = makeWorkspace({ certs: true });
|
|
399
|
+
const gwPort = await freePort();
|
|
400
|
+
const bfPort = await freePort();
|
|
401
|
+
writeConfig(ws, gwPort, bfPort, 'bf');
|
|
402
|
+
const slow = `--import=data:text/javascript,${encodeURIComponent("if ((process.argv[1] || '').endsWith('blindfold.mjs')) { const end = Date.now() + 7000; while (Date.now() < end) {} }")}`;
|
|
403
|
+
const gw = spawn(process.execPath, [path.join(ROOT, 'proxy.mjs'), '--port', String(gwPort)], { env: { ...envFor(ws), NODE_OPTIONS: slow }, stdio: 'ignore' });
|
|
404
|
+
try {
|
|
405
|
+
await new Promise(r => setTimeout(r, 13000));
|
|
406
|
+
const state = await probe(ws, `s.probeBlindfold(${bfPort})`);
|
|
407
|
+
assert.equal(state.state, 'free', 'no interceptor runs unrecorded on the port');
|
|
408
|
+
} finally {
|
|
409
|
+
gw.kill();
|
|
410
|
+
try {
|
|
411
|
+
const st = await probe(ws, `s.probeBlindfold(${bfPort})`);
|
|
412
|
+
if (st.state === 'ours') process.kill(st.pid, 'SIGKILL');
|
|
413
|
+
} catch {}
|
|
414
|
+
fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// LIVE test: requires a running gateway (switch on) and a real upstream (costs tokens).
|
|
2
|
+
// Offline test, no network needed: npm test
|
|
3
|
+
const GATEWAY_PORT = Number(process.env.LLM_SWITCHER_PORT) || 3456;
|
|
4
|
+
const BASE_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
|
|
5
|
+
|
|
6
|
+
console.log('================================================================');
|
|
7
|
+
console.log(' LLM SWITCHER — TOKEN OPTIMIZER INTEROPERABILITY TEST SUITE ');
|
|
8
|
+
console.log(' Simulating failure modes from Headroom, RTK, and Ponytail ');
|
|
9
|
+
console.log('================================================================\n');
|
|
10
|
+
|
|
11
|
+
let passed = 0;
|
|
12
|
+
let failed = 0;
|
|
13
|
+
|
|
14
|
+
async function runTest(name, fn) {
|
|
15
|
+
process.stdout.write(`[TEST] ${name}... `);
|
|
16
|
+
const t0 = Date.now();
|
|
17
|
+
try {
|
|
18
|
+
const detail = await fn();
|
|
19
|
+
console.log(`PASS (${Date.now() - t0}ms)`);
|
|
20
|
+
if (detail) console.log(` ↳ ${detail}`);
|
|
21
|
+
passed++;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.log(`FAIL (${Date.now() - t0}ms)`);
|
|
24
|
+
console.log(` ↳ ERROR: ${err.message}`);
|
|
25
|
+
failed++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// -----------------------------------------------------------------------------
|
|
30
|
+
// TEST 1: Headroom Failure Mode — Orphaned tool_result
|
|
31
|
+
// When Headroom prunes history to save tokens, it drops the assistant tool_use turn.
|
|
32
|
+
// Anthropic strictly throws: 400 invalid_request_error: 'tool_use_id does not correspond to any tool_use'
|
|
33
|
+
// LLM Switcher Healer Engine: Repairs orphaned result into context text -> 200 OK
|
|
34
|
+
// -----------------------------------------------------------------------------
|
|
35
|
+
await runTest('Headroom Simulation: Orphaned tool_result turn', async () => {
|
|
36
|
+
const payload = {
|
|
37
|
+
model: 'claude-opus-4-6',
|
|
38
|
+
max_tokens: 40,
|
|
39
|
+
messages: [
|
|
40
|
+
{ role: 'user', content: 'Context turn before pruning' },
|
|
41
|
+
{
|
|
42
|
+
role: 'user',
|
|
43
|
+
content: [
|
|
44
|
+
{ type: 'tool_result', tool_use_id: 'headroom_pruned_call_99', content: 'Database query result: 42 rows found' },
|
|
45
|
+
{ type: 'text', text: 'Reply: healed successfully' }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
stream: false
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const res = await fetch(`${BASE_URL}/v1/messages`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
55
|
+
body: JSON.stringify(payload)
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (res.status !== 200) {
|
|
59
|
+
const err = await res.text();
|
|
60
|
+
throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
|
|
61
|
+
}
|
|
62
|
+
const json = await res.json();
|
|
63
|
+
const text = json.content?.map(c => c.text).join('') || '';
|
|
64
|
+
return `Healed orphaned tool_result. Response HTTP 200: "${text.slice(0, 50).replace(/\n/g, ' ')}..."`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// -----------------------------------------------------------------------------
|
|
68
|
+
// TEST 2: Headroom Failure Mode — Consecutive User turns (Roles must alternate)
|
|
69
|
+
// When Headroom collapses history, multiple user turns occur back-to-back.
|
|
70
|
+
// Anthropic strictly throws: 400 invalid_request_error: 'roles must alternate'
|
|
71
|
+
// LLM Switcher Healer Engine: Merges consecutive same-role turns -> 200 OK
|
|
72
|
+
// -----------------------------------------------------------------------------
|
|
73
|
+
await runTest('Headroom Simulation: Consecutive User turns (Role alternation violation)', async () => {
|
|
74
|
+
const payload = {
|
|
75
|
+
model: 'claude-opus-4-6',
|
|
76
|
+
max_tokens: 30,
|
|
77
|
+
messages: [
|
|
78
|
+
{ role: 'user', content: 'User message turn 1' },
|
|
79
|
+
{ role: 'user', content: 'User message turn 2 (no assistant in between)' },
|
|
80
|
+
{ role: 'user', content: 'User message turn 3: reply pong' }
|
|
81
|
+
],
|
|
82
|
+
stream: false
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const res = await fetch(`${BASE_URL}/v1/messages`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
88
|
+
body: JSON.stringify(payload)
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (res.status !== 200) {
|
|
92
|
+
const err = await res.text();
|
|
93
|
+
throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
|
|
94
|
+
}
|
|
95
|
+
const json = await res.json();
|
|
96
|
+
return `Merged consecutive turns seamlessly. Stop reason: ${json.stop_reason}`;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// -----------------------------------------------------------------------------
|
|
100
|
+
// TEST 3: Headroom / RTK Failure Mode — Stripped Thinking Parameter
|
|
101
|
+
// Optimizer stripped the 'thinking' object to reduce tokens.
|
|
102
|
+
// LLM Switcher Thinking Guard: Detects reasoning model (ag/claude-opus-4-6-thinking)
|
|
103
|
+
// and restores thinking.budget_tokens automatically -> thinking_delta emitted!
|
|
104
|
+
// -----------------------------------------------------------------------------
|
|
105
|
+
await runTest('Thinking Guard: Restoring stripped thinking parameter on reasoning models', async () => {
|
|
106
|
+
const payload = {
|
|
107
|
+
model: 'claude-opus-4-6',
|
|
108
|
+
max_tokens: 200,
|
|
109
|
+
// Note: NO thinking parameter included (simulating optimizer stripping it)
|
|
110
|
+
messages: [
|
|
111
|
+
{ role: 'user', content: 'Solve step by step: what is 17 * 23? Show reasoning.' }
|
|
112
|
+
],
|
|
113
|
+
stream: true
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const res = await fetch(`${BASE_URL}/v1/messages`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
119
|
+
body: JSON.stringify(payload)
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (res.status !== 200) {
|
|
123
|
+
throw new Error(`Expected HTTP 200, got ${res.status}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const text = await res.text();
|
|
127
|
+
const hasThinkingDelta = text.includes('thinking_delta');
|
|
128
|
+
const hasSignatureDelta = text.includes('signature_delta');
|
|
129
|
+
const hasTextDelta = text.includes('text_delta');
|
|
130
|
+
|
|
131
|
+
if (!hasThinkingDelta) {
|
|
132
|
+
throw new Error('Thinking Guard failed: thinking_delta was NOT emitted in stream!');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return `Automatically restored thinking: thinking_delta=${hasThinkingDelta}, signature_delta=${hasSignatureDelta}, text_delta=${hasTextDelta}`;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// -----------------------------------------------------------------------------
|
|
139
|
+
// TEST 4: RTK & Intermediary Headers Passthrough
|
|
140
|
+
// RTK / tracing tools inject headers: x-rtk-version, traceparent, x-request-id.
|
|
141
|
+
// LLM Switcher: Transparently preserves all tracking headers without rejection.
|
|
142
|
+
// -----------------------------------------------------------------------------
|
|
143
|
+
await runTest('RTK Intermediary: Custom headers and traceparent passthrough', async () => {
|
|
144
|
+
const payload = {
|
|
145
|
+
model: 'claude-opus-4-6',
|
|
146
|
+
max_tokens: 20,
|
|
147
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
148
|
+
stream: false
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const res = await fetch(`${BASE_URL}/v1/messages`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
'anthropic-version': '2023-06-01',
|
|
156
|
+
'x-rtk-version': '0.37.2',
|
|
157
|
+
'x-optimizer-id': 'rtk-cli-hook',
|
|
158
|
+
'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify(payload)
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (res.status !== 200) {
|
|
164
|
+
throw new Error(`Expected HTTP 200, got ${res.status}`);
|
|
165
|
+
}
|
|
166
|
+
return `Headers accepted cleanly with HTTP 200 OK`;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// -----------------------------------------------------------------------------
|
|
170
|
+
// TEST 5: OpenAI Chat Healer Mode — Orphaned Tool Result in Chat Completions
|
|
171
|
+
// Same test but over /v1/chat/completions: tool role message without prior tool_calls assistant message.
|
|
172
|
+
// OpenAI API strictly throws: 400 'messages with role tool must be a response to a preceding message with tool_calls'
|
|
173
|
+
// LLM Switcher: Converts orphaned tool to user text message -> 200 OK
|
|
174
|
+
// -----------------------------------------------------------------------------
|
|
175
|
+
await runTest('OpenAI Chat Healer: Orphaned role tool without preceding assistant tool_calls', async () => {
|
|
176
|
+
const payload = {
|
|
177
|
+
model: 'ag/gpt-oss-120b-medium',
|
|
178
|
+
max_tokens: 30,
|
|
179
|
+
messages: [
|
|
180
|
+
{ role: 'user', content: 'Turn 1 context' },
|
|
181
|
+
{ role: 'tool', tool_call_id: 'orphaned_chat_call_77', content: 'Tool execution logs: OK' },
|
|
182
|
+
{ role: 'user', content: 'reply pong' }
|
|
183
|
+
],
|
|
184
|
+
stream: false
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const res = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
190
|
+
body: JSON.stringify(payload)
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (res.status !== 200) {
|
|
194
|
+
const err = await res.text();
|
|
195
|
+
throw new Error(`Expected HTTP 200, got ${res.status}: ${err}`);
|
|
196
|
+
}
|
|
197
|
+
const json = await res.json();
|
|
198
|
+
return `Chat Healer rescued orphaned tool role. Stop reason: ${json.choices?.[0]?.finish_reason}`;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.log('\n================================================================');
|
|
202
|
+
console.log(` TEST RESULTS: ${passed} PASSED / ${failed} FAILED `);
|
|
203
|
+
console.log('================================================================\n');
|
|
204
|
+
|
|
205
|
+
process.exit(failed ? 1 : 0);
|