add codex support; fix interruption
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
// Offline checks for the parts that have to match the shipped build byte-for-byte:
|
||||
// the mrs-sig-v1 canonical string, the device-id derivation, and the header rewriting.
|
||||
// Plus live loopback checks that a client hanging up cannot take the proxy down.
|
||||
// Run: ./vendor/node test.mjs
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -12,6 +14,26 @@ import assert from 'node:assert/strict';
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mirasim-test-'));
|
||||
process.env.MIRASIM_PROXY_HOME = home;
|
||||
|
||||
// A stand-in relay, bound before the import so RELAY_BASE picks it up. It streams SSE one
|
||||
// chunk at a time and never returns on its own, which is what lets the client hang up
|
||||
// mid-response. `held` lets a test see that the upstream read was actually torn down.
|
||||
const upstream = http.createServer();
|
||||
const held = { aborted: 0, requests: 0, last: null };
|
||||
upstream.on('request', (req, res) => {
|
||||
held.requests++;
|
||||
held.last = { path: req.url, headers: req.headers };
|
||||
req.resume();
|
||||
if (req.url.startsWith('/v1/device/session')) { res.writeHead(404).end('{}'); return; }
|
||||
if (req.url.startsWith('/slow-headers')) return; // never responds at all
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
let n = 0;
|
||||
const timer = setInterval(() => { if (!res.write(`data: {"n":${n++}}\n\n`)) clearInterval(timer); }, 5);
|
||||
res.on('close', () => { clearInterval(timer); if (!res.writableEnded) held.aborted++; });
|
||||
});
|
||||
await new Promise((r) => upstream.listen(0, '127.0.0.1', r));
|
||||
process.env.MIRASIM_RELAY_BASE_URL = `http://127.0.0.1:${upstream.address().port}`;
|
||||
process.env.MIRASIM_LOGIN_URL = process.env.MIRASIM_RELAY_BASE_URL;
|
||||
|
||||
const m = await import('./mirasim-relay.mjs');
|
||||
|
||||
let passed = 0;
|
||||
@@ -20,6 +42,11 @@ function check(name, fn) {
|
||||
catch (err) { process.stdout.write(` FAIL ${name}\n ${err.message}\n`); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
async function checkAsync(name, fn) {
|
||||
try { await fn(); process.stdout.write(` ok ${name}\n`); passed++; }
|
||||
catch (err) { process.stdout.write(` FAIL ${name}\n ${err.message}\n`); process.exitCode = 1; }
|
||||
}
|
||||
|
||||
check('device identity is stable across calls and persisted', () => {
|
||||
const a = m.deviceIdentity();
|
||||
const b = m.deviceIdentity();
|
||||
@@ -122,5 +149,122 @@ check('ticket manager reports no credential until a mint succeeds', () => {
|
||||
assert.equal(t.signingIdentity(), null); // unsigned fallback, matching the desktop build
|
||||
});
|
||||
|
||||
check('path classifier matches the shipped router', () => {
|
||||
assert.equal(m.classifyPath('/v1/messages'), 'anthropic');
|
||||
assert.equal(m.classifyPath('/v1/messages/'), 'anthropic');
|
||||
assert.equal(m.classifyPath('/v1/responses'), 'openai-responses');
|
||||
assert.equal(m.classifyPath('/backend-api/codex/responses'), 'openai-responses');
|
||||
assert.equal(m.classifyPath('/v1/chat/completions'), 'openai-chat');
|
||||
assert.equal(m.classifyPath('/openai/v1/chat/completions'), 'openai-chat');
|
||||
assert.equal(m.classifyPath('/v1/messages/count_tokens'), null); // forwarded, just unclassified
|
||||
assert.equal(m.classifyPath('/v1/models'), null);
|
||||
});
|
||||
|
||||
check('codex provider block matches the desktop overrides', () => {
|
||||
const o = m.codexOverrides('http://127.0.0.1:8787');
|
||||
assert.deepEqual(o, [
|
||||
'model_providers.apodex.name=apodex',
|
||||
'model_providers.apodex.base_url=http://127.0.0.1:8787/v1',
|
||||
'model_providers.apodex.wire_api=responses',
|
||||
'model_providers.apodex.env_key=OPENAI_API_KEY',
|
||||
'model_provider=apodex',
|
||||
]);
|
||||
// wire_api=responses means codex POSTs base_url + /responses — the route the relay serves.
|
||||
assert.equal(o[1].split('=')[1] + '/responses', 'http://127.0.0.1:8787/v1/responses');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disconnect resilience — an agent hanging up must cost one request, not the server.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fabricate a long-lived credential so serve() gets past the boot check. It is only ever
|
||||
// presented to the loopback stand-in above.
|
||||
{
|
||||
const file = path.join(home, 'config.json');
|
||||
const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const claims = Buffer.from(JSON.stringify({ sub: 'test', exp: Math.floor(Date.now() / 1000) + 86400 })).toString('base64url');
|
||||
cfg.auth = { token: `h.${claims}.s`, exp: Math.floor(Date.now() / 1000) + 86400 };
|
||||
fs.writeFileSync(file, JSON.stringify(cfg), { mode: 0o600 });
|
||||
}
|
||||
|
||||
const proxy = await m.serve({ port: 0, host: '127.0.0.1', verbose: false });
|
||||
const PROXY = `http://127.0.0.1:${proxy.address().port}`;
|
||||
|
||||
const alive = async () => {
|
||||
const res = await fetch(`${PROXY}/__health`);
|
||||
assert.equal(res.status, 200, `health check returned ${res.status}`);
|
||||
assert.equal((await res.json()).ok, true);
|
||||
};
|
||||
|
||||
await checkAsync('survives a client aborting mid-stream', async () => {
|
||||
const abort = new AbortController();
|
||||
const res = await fetch(`${PROXY}/v1/messages`, {
|
||||
method: 'POST', body: '{"stream":true}', signal: abort.signal,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const reader = res.body.getReader();
|
||||
await reader.read(); // one SSE chunk really arrived
|
||||
const before = held.aborted;
|
||||
abort.abort(); // this is what used to kill the process
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
assert.ok(held.aborted > before, 'upstream read was not torn down');
|
||||
await alive();
|
||||
});
|
||||
|
||||
await checkAsync('survives an abort while the upstream is still silent', async () => {
|
||||
const abort = new AbortController();
|
||||
const pending = fetch(`${PROXY}/slow-headers`, { method: 'POST', body: '{}', signal: abort.signal })
|
||||
.catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
abort.abort();
|
||||
await pending;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await alive();
|
||||
});
|
||||
|
||||
await checkAsync('survives a client vanishing mid-upload', async () => {
|
||||
const net = await import('node:net');
|
||||
const sock = net.connect(proxy.address().port, '127.0.0.1');
|
||||
await new Promise((r) => sock.once('connect', r));
|
||||
// Announce more body than we intend to send, then disappear.
|
||||
sock.write('POST /v1/messages HTTP/1.1\r\nHost: x\r\ncontent-length: 4096\r\n\r\n{"a":1}');
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
sock.destroy();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await alive();
|
||||
});
|
||||
|
||||
await checkAsync('still serves normally after all of that', async () => {
|
||||
const res = await fetch(`${PROXY}/v1/messages`, { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200);
|
||||
const reader = res.body.getReader();
|
||||
const first = await reader.read();
|
||||
assert.match(Buffer.from(first.value).toString(), /^data: /);
|
||||
await reader.cancel();
|
||||
});
|
||||
|
||||
await checkAsync('responses traffic is forwarded and reported as codex', async () => {
|
||||
const res = await fetch(`${PROXY}/v1/responses`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer whatever-codex-sent', 'openai-beta': 'responses=experimental' },
|
||||
body: JSON.stringify({ model: m.CODEX_DEFAULT_MODEL, input: 'hi' }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
await res.body.cancel();
|
||||
assert.equal(held.last.path, '/v1/responses');
|
||||
assert.equal(held.last.headers['x-mirasim-agent'], 'codex');
|
||||
// codex's own key is replaced, never forwarded.
|
||||
assert.match(held.last.headers.authorization, /^Bearer h\./);
|
||||
assert.equal(held.last.headers['openai-beta'], 'responses=experimental');
|
||||
});
|
||||
|
||||
await checkAsync('anthropic traffic is still reported as claude', async () => {
|
||||
const res = await fetch(`${PROXY}/v1/messages`, { method: 'POST', body: '{}' });
|
||||
await res.body.cancel();
|
||||
assert.equal(held.last.headers['x-mirasim-agent'], 'claude');
|
||||
});
|
||||
|
||||
proxy.close();
|
||||
upstream.close();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
process.stdout.write(`\n${passed} passed${process.exitCode ? ', with failures' : ''}\n`);
|
||||
|
||||
Reference in New Issue
Block a user