add codex support; fix interruption

This commit is contained in:
iceBear67
2026-08-09 05:07:50 +00:00
parent 39502358e1
commit 7ef8cf1861
4 changed files with 416 additions and 41 deletions
+216 -27
View File
@@ -8,10 +8,15 @@
// GET {LOGIN_BASE}/auth/oauth/{provider}/login?redirect_uri=http://127.0.0.1:P/callback
// refresh POST {LOGIN_BASE}/auth/refresh {refresh_token} -> {access_token, refresh_token?}
// device POST {RELAY_BASE}/v1/device/session {publicKey, deviceId} -> {ticket, expiresIn|expiresAt}
// traffic POST {RELAY_BASE}/v1/messages (Anthropic-shaped, plus /v1/chat/completions, /v1/models)
// traffic POST {RELAY_BASE}/v1/messages (Anthropic wire format)
// POST {RELAY_BASE}/v1/responses (OpenAI Responses — what Codex uses)
// POST {RELAY_BASE}/v1/chat/completions (OpenAI Chat Completions)
// GET {RELAY_BASE}/v1/models
//
// The relay speaks the Anthropic wire format, so any Anthropic-compatible agent can point
// ANTHROPIC_BASE_URL at this process and never learn that a relay is involved.
// One relay, two dialects. `server.cjs` routes on the path alone (its classifier is
// reproduced verbatim in classifyPath below), so an Anthropic-compatible agent points
// ANTHROPIC_BASE_URL here and Codex points its `apodex` provider block here, and neither
// learns that a relay is involved.
import http from 'node:http';
import crypto from 'node:crypto';
@@ -19,6 +24,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { spawn } from 'node:child_process';
import readline from 'node:readline/promises';
import { fileURLToPath } from 'node:url';
@@ -59,6 +65,17 @@ const TICKET_DEFAULT_TTL_MS = 600_000;
const PROBE_MODEL = 'claude-haiku-4-5-20251001';
// The sentinel the desktop build hands to a spawned agent in place of a real key; the relay
// recognises it as "use the managed credential" and this proxy substitutes it per request.
const MANAGED_CREDENTIAL = 'mirasim-relay-managed-credential';
// Codex's side of the relay, from the shipped build's model table: the provider block it
// injects is named `apodex`, wire_api is `responses`, and the key comes from OPENAI_API_KEY.
const CODEX_PROVIDER = 'apodex';
const CODEX_MODELS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'];
const CODEX_DEFAULT_MODEL = CODEX_MODELS[0];
const CODEX_DEFAULT_EFFORT = 'xhigh';
const CONFIG_DIR = process.env.MIRASIM_PROXY_HOME || path.join(os.homedir(), '.config', 'mirasim-proxy');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
@@ -425,9 +442,22 @@ function readBody(req) {
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
// A client that vanishes mid-upload ends the request without 'end'; treat the partial
// read as a disconnect rather than leaving this promise pending forever.
req.on('aborted', () => reject(Object.assign(new Error('client aborted the request'), { name: 'AbortError' })));
});
}
// Everything that means "the other end went away" — expected during normal agent use
// (Ctrl-C, a cancelled turn, a closed pane) and never worth reporting as a proxy fault.
function isDisconnect(err) {
if (!err) return false;
if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true;
const code = err.code || err.cause?.code;
if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'ERR_STREAM_PREMATURE_CLOSE') return true;
return isDisconnect(err.cause !== err ? err.cause : null);
}
function forwardHeaders(incoming) {
const out = {};
for (const [k, v] of Object.entries(incoming)) {
@@ -446,23 +476,65 @@ function forwardHeaders(incoming) {
return out;
}
async function serve({ port, host, agent, verbose }) {
const tickets = new TicketManager();
// Which dialect the relay will read a request as. This is `tD()` from server.cjs, regexes
// and all: the relay dispatches on the path alone, so the same endpoint and the same
// credential serve Claude Code and Codex. `/backend-api/codex/responses` is the ChatGPT
// backend shape — the desktop build only ever sees it via its MITM capture of chatgpt.com,
// and the relay itself answers 404 there, but it is recognised for completeness.
function classifyPath(pathname) {
if (/^\/v1\/messages\/?$/.test(pathname)) return 'anthropic';
if (/^\/v1\/responses\/?$/.test(pathname) || /^\/backend-api\/codex\/responses\/?$/.test(pathname)) return 'openai-responses';
if (/(^|\/)chat\/completions\/?$/.test(pathname)) return 'openai-chat';
return null;
}
// Which agent the traffic claims to be, when the caller has not pinned one with --agent.
// The desktop build reports `codex` for both OpenAI dialects.
const AGENT_FOR_KIND = { anthropic: 'claude', 'openai-responses': 'codex', 'openai-chat': 'codex' };
async function serve({ port, host, agent, verbose }) { const tickets = new TicketManager();
const sessionId = crypto.randomUUID();
let reauthNeeded = false;
// Backstop. Every disconnect path below is handled explicitly, but a long-lived proxy
// must not die because one of them was missed — an agent hanging up should cost one
// request, never the whole server. Real bugs still crash loudly.
const survive = (err) => {
if (isDisconnect(err)) { if (verbose) log(` ignored late disconnect: ${err?.code || err?.name}`); return; }
console.error(err);
process.exit(1);
};
process.on('uncaughtException', survive);
process.on('unhandledRejection', survive);
// Warm the credential path once so failures surface at startup, not on first prompt.
const bootToken = await currentToken({ onReauthRequired: () => { reauthNeeded = true; } });
await tickets.ensureFresh(bootToken);
const server = http.createServer((req, res) => {
// Socket-level errors on either half surface as 'error' events; unhandled, they are fatal.
req.on('error', () => {});
res.on('error', () => {});
// A client hanging up is routine, so it must never reach the process as an unhandled
// rejection — and once the socket is gone there is nothing left to reply to either.
handle(req, res).catch((err) => {
if (res.headersSent) { res.destroy(err instanceof Error ? err : new Error(String(err))); return; }
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: String(err?.message || err) } }));
if (isDisconnect(err)) { res.destroy(); return; }
if (res.writableEnded || res.destroyed) return;
if (res.headersSent) { res.destroy(); return; }
try {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: String(err?.message || err) } }));
} catch { res.destroy(); }
});
});
// Malformed requests arrive on the raw socket, before any handler exists.
server.on('clientError', (err, socket) => {
if (!socket.writable) return;
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
server.keepAliveTimeout = 125_000;
server.headersTimeout = 130_000;
@@ -471,6 +543,8 @@ async function serve({ port, host, agent, verbose }) {
const pathname = url.pathname;
const method = req.method || 'POST';
const callId = crypto.randomUUID();
const kind = classifyPath(pathname);
const reportedAgent = agent || AGENT_FOR_KIND[kind] || 'claude';
if (pathname === '/__health') {
const snap = tickets.snapshot();
@@ -482,7 +556,6 @@ async function serve({ port, host, agent, verbose }) {
const body = await readBody(req);
const abort = new AbortController();
res.on('close', () => { if (!res.writableEnded) abort.abort(); });
res.on('error', () => {});
// One retry, but only when retrying can actually change the credential we present:
// a live ticket we can drop, or a refresh token that can mint a new access token.
@@ -499,7 +572,7 @@ async function serve({ port, host, agent, verbose }) {
delete headers['x-api-key'];
headers.authorization = `Bearer ${credential}`;
headers[H_CLIENT] = CLIENT_VERSION;
headers[H_AGENT] = sanitizeHeader(agent);
headers[H_AGENT] = sanitizeHeader(reportedAgent);
headers[H_SESSION] = sessionId;
headers[H_CALL] = callId;
@@ -527,7 +600,7 @@ async function serve({ port, host, agent, verbose }) {
}
if (verbose) {
log(` ${method} ${pathname} -> ${res2.status} ${Date.now() - started}ms${ticket ? ' [ticket]' : ''}`);
log(` ${method} ${pathname} -> ${res2.status} ${Date.now() - started}ms [${kind || 'passthrough'}/${reportedAgent}]${ticket ? ' [ticket]' : ''}`);
}
const outHeaders = {};
@@ -535,10 +608,21 @@ async function serve({ port, host, agent, verbose }) {
if (HOP_BY_HOP.has(k.toLowerCase())) return;
outHeaders[k] = v;
});
if (res.destroyed) { res2.body?.cancel?.().catch(() => {}); return; }
res.writeHead(res2.status, outHeaders);
if (res2.body) Readable.fromWeb(res2.body).pipe(res);
else res.end();
if (!res2.body) { res.end(); return; }
// pipeline(), unlike .pipe(), forwards errors to the caller instead of leaving the
// source stream to emit an unhandled 'error'. A client that hangs up mid-stream trips
// the abort controller above, so this is the common case, not the exceptional one.
try {
await pipeline(Readable.fromWeb(res2.body), res);
} catch (err) {
if (!isDisconnect(err)) throw err;
if (verbose) log(` ${method} ${pathname} client went away mid-stream`);
res.destroy();
}
return;
}
}
@@ -556,12 +640,16 @@ async function serve({ port, host, agent, verbose }) {
log(` upstream : ${RELAY_BASE}`);
log(` device : ${snap.deviceId}${snap.unsupported ? ' (signing unsupported by relay — plain token)' : ''}`);
log(` ticket : ${tickets.credential() ? 'active' : 'none (plain token)'}`);
log(` dialects : /v1/messages (anthropic) · /v1/responses (codex) · /v1/chat/completions`);
if (reauthNeeded) log(' warning : the stored credential looks stale — run `mirasim-relay login` if requests start failing');
log('');
log(' point an agent at it:');
log(` ANTHROPIC_BASE_URL=${base}`);
log(` ANTHROPIC_AUTH_TOKEN=mirasim-relay-managed-credential # replaced per request`);
log(` ANTHROPIC_AUTH_TOKEN=${MANAGED_CREDENTIAL} # replaced per request`);
log(` codex: mirasim-relay codex --port ${bound.port} -- <codex args>`);
log('');
return server;
}
// ---------------------------------------------------------------------------
@@ -624,7 +712,7 @@ async function cmdLogout() {
log('已退出登录(设备密钥保留)。');
}
async function cmdStatus() {
async function cmdStatus(argv = {}) {
const cfg = loadConfig();
if (!cfg.auth?.token) die('not signed in — run `mirasim-relay login` first');
@@ -670,6 +758,48 @@ async function cmdStatus() {
} else {
res.text().catch(() => {});
}
// The OpenAI half of the relay. Worth checking separately: an account can carry Claude
// credit and no Codex credit, and the two dialects answer on different routes.
log('');
log('探测 codex (responses) 路由…');
const res2 = await fetch(`${RELAY_BASE}/v1/responses`, {
method: 'POST',
headers: {
'content-type': 'application/json',
[H_PROBE]: 'usage',
[H_CLIENT]: CLIENT_VERSION,
[H_AGENT]: 'codex',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
model: argv.model || CODEX_DEFAULT_MODEL,
input: 'hi',
max_output_tokens: 16,
stream: false,
}),
}).catch((err) => { log(` 探测失败: ${err.message}`); return null; });
if (!res2) return;
log(` HTTP ${res2.status} -> ${res2.status === 404 ? 'route absent' : res2.status === 401 || res2.status === 403 ? 'not entitled' : res2.status === 429 ? 'rate-limited' : 'ok'}`);
res2.headers.forEach((v, k) => { if (/ratelimit|retry-after/i.test(k)) log(` ${k}: ${v}`); });
const body2 = await res2.text().catch(() => '');
if (res2.status >= 400 && body2) log(` ${body2.slice(0, 400)}`);
log(` models : ${CODEX_MODELS.join(', ')}`);
}
// Codex takes no base-URL env var — it reads a provider block out of config.toml. The
// desktop build injects that block on the command line instead of writing the file, and
// these are the same five overrides it uses (`Hpn()` in server.cjs), with the proxy
// substituted for its own in-process relay.
function codexOverrides(base) {
return [
`model_providers.${CODEX_PROVIDER}.name=${CODEX_PROVIDER}`,
`model_providers.${CODEX_PROVIDER}.base_url=${base}/v1`,
`model_providers.${CODEX_PROVIDER}.wire_api=responses`,
`model_providers.${CODEX_PROVIDER}.env_key=OPENAI_API_KEY`,
`model_provider=${CODEX_PROVIDER}`,
];
}
function cmdEnv(argv) {
@@ -677,13 +807,58 @@ function cmdEnv(argv) {
const host = argv.host || '127.0.0.1';
const base = `http://${host}:${port}`;
const fish = /fish/.test(process.env.SHELL || '') && !argv.bash;
if (fish) {
process.stdout.write(`set -gx ANTHROPIC_BASE_URL ${base}\n`);
process.stdout.write(`set -gx ANTHROPIC_AUTH_TOKEN mirasim-relay-managed-credential\n`);
} else {
process.stdout.write(`export ANTHROPIC_BASE_URL=${base}\n`);
process.stdout.write(`export ANTHROPIC_AUTH_TOKEN=mirasim-relay-managed-credential\n`);
const set = (k, v) => process.stdout.write(fish ? `set -gx ${k} ${v}\n` : `export ${k}=${v}\n`);
if (argv.codex) {
// Only the key is an env var; the endpoint has to arrive as config overrides.
set('OPENAI_API_KEY', MANAGED_CREDENTIAL);
set('CODEX_MODEL', argv.model || CODEX_DEFAULT_MODEL);
process.stdout.write(`\n# codex has no base-URL env var — pass these too:\n`);
process.stdout.write(`# codex ${codexOverrides(base).map((o) => `-c ${o}`).join(' ')}\n`);
process.stdout.write(`# or just: mirasim-relay codex --port ${port} -- <codex args>\n`);
return;
}
set('ANTHROPIC_BASE_URL', base);
set('ANTHROPIC_AUTH_TOKEN', MANAGED_CREDENTIAL);
}
// Launch the real codex against the proxy. Everything after `--` is forwarded verbatim.
async function cmdCodex(argv) {
const port = argv.port || 8787;
const host = argv.host || '127.0.0.1';
const base = `http://${host}:${port}`;
const model = argv.model || CODEX_DEFAULT_MODEL;
const effort = argv.effort || CODEX_DEFAULT_EFFORT;
if (!CODEX_MODELS.includes(model)) {
log(`warning: ${model} is not one of the relay's codex models (${CODEX_MODELS.join(', ')})`);
}
const passthrough = argv._.slice(1);
const args = [...codexOverrides(base).flatMap((o) => ['-c', o]), '--model', model, ...passthrough];
const env = {
...process.env,
OPENAI_API_KEY: MANAGED_CREDENTIAL,
CODEX_MODEL: model,
CODEX_REASONING_EFFORT: effort,
};
const bin = process.env.MIRASIM_CODEX_BIN || 'codex';
log(`${bin} -> ${base}/v1/responses (${model}, effort ${effort})`);
const child = spawn(bin, args, { stdio: 'inherit', env });
child.on('error', (err) => {
if (err.code === 'ENOENT') {
die(`codex not found — install @openai/codex, or set MIRASIM_CODEX_BIN.\n` +
`run it yourself with:\n OPENAI_API_KEY=${MANAGED_CREDENTIAL} codex ${codexOverrides(base).map((o) => `-c ${o}`).join(' ')}`);
}
die(err.message);
});
await new Promise((resolve) => child.on('exit', (code, signal) => {
process.exitCode = signal ? 1 : code ?? 0;
resolve();
}));
}
function usage() {
@@ -691,16 +866,26 @@ function usage() {
用法:
mirasim-relay login [--method email|github|google|token] [--email you@example.com]
mirasim-relay serve [--port 8787] [--host 127.0.0.1] [--agent claude] [-v]
mirasim-relay status
mirasim-relay env [--port 8787] [--bash]
mirasim-relay serve [--port 8787] [--host 127.0.0.1] [--agent claude|codex] [-v]
mirasim-relay codex [--port 8787] [--model ${CODEX_DEFAULT_MODEL}] [--effort ${CODEX_DEFAULT_EFFORT}] [-- <codex 参数>]
mirasim-relay status [--model ${CODEX_DEFAULT_MODEL}]
mirasim-relay env [--port 8787] [--bash] [--codex]
mirasim-relay logout
中继同时提供两种协议(按路径分发,凭据完全一样):
POST /v1/messages Anthropic —— Claude Code 等
POST /v1/responses OpenAI Responses —— Codexwire_api=responses
POST /v1/chat/completions OpenAI Chat Completions
GET /v1/models
codex 模型: ${CODEX_MODELS.join(', ')}
环境变量:
MIRASIM_RELAY_BASE_URL 覆盖中继地址 (默认 ${RELAY_BASE})
MIRASIM_LOGIN_URL 覆盖登录地址 (默认 ${LOGIN_BASE})
MIRASIM_APP_VERSION 覆盖上报的客户端版本 (默认 ${CLIENT_VERSION})
MIRASIM_PROXY_HOME 覆盖配置目录 (默认 ${CONFIG_DIR})
MIRASIM_CODEX_BIN codex 可执行文件 (默认 PATH 上的 codex)
`);
}
@@ -708,8 +893,10 @@ function parseArgs(args) {
const out = { _: [] };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--') { out._.push(...args.slice(i + 1)); break; } // rest belongs to the child process
if (a === '-v' || a === '--verbose') { out.verbose = true; continue; }
if (a === '--bash') { out.bash = true; continue; }
if (a === '--codex') { out.codex = true; continue; }
if (a.startsWith('--')) {
const [k, inline] = a.slice(2).split('=');
out[k] = inline !== undefined ? inline : args[++i];
@@ -727,13 +914,14 @@ async function main() {
switch (cmd) {
case 'login': return cmdLogin(argv);
case 'logout': return cmdLogout();
case 'status': return cmdStatus();
case 'status': return cmdStatus(argv);
case 'env': return cmdEnv(argv);
case 'codex': return cmdCodex(argv);
case 'serve':
return serve({
port: Number(argv.port || 8787),
host: argv.host || '127.0.0.1',
agent: argv.agent || 'claude',
agent: argv.agent, // unset means: report the agent the path implies
verbose: Boolean(argv.verbose),
});
default:
@@ -750,6 +938,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
export {
RELAY_BASE, LOGIN_BASE, CLIENT_VERSION, SIG_SCHEME, DEVICE_SESSION_PATH, KEPT_BETAS,
MANAGED_CREDENTIAL, CODEX_PROVIDER, CODEX_MODELS, CODEX_DEFAULT_MODEL,
deviceIdentity, canonicalString, signatureHeaders, forwardHeaders, sanitizeHeader,
decodeJwt, TicketManager, serve,
classifyPath, codexOverrides, decodeJwt, TicketManager, serve,
};