#!/usr/bin/env node // Local OpenAI proxy replicating the upstream-merge workflow behavior. // // Features: // - Injects real OPENAI_API_KEY server-side; clients can use a dummy key // - Allows only /v1/chat/completions and /v1/responses // - Adds OpenAI-Beta: responses=v1 for Responses API // - Sets Accept: text/event-stream by default to stabilize streaming // - Scrubs sensitive headers from logs; logs are structured JSON // - Reuses connections and sets generous timeouts for SSE // // Usage (local): // # 1) Export your real API key for the proxy process // export OPENAI_API_KEY="sk-..." // # 2) Start the proxy (default port 5055) // node scripts/openai-proxy.js // # or choose a port / upstream base // PORT=5055 OPENAI_BASE_URL=https://api.openai.com/v1 node scripts/openai-proxy.js // // # 3) Run the local Codex binary with the proxy (dummy key to client) // # Build the binary first: ./build-fast.sh // OPENAI_API_KEY="x" OPENAI_BASE_URL="http://127.0.0.1:${PORT:-5055}/v1" \ // ./hanzo-dev/target/dev-fast/dev llm request \ // --developer "Say 'pong' as plain text" \ // --message "ping" \ // --format-type json_schema \ // --schema-json '{"type":"object","properties":{},"additionalProperties":false}' \ // --model gpt-4o-mini // // # Or run your usual `dev` subcommand (Exec/TUI) with the same env vars. const http = require("http"); const https = require("https"); const crypto = require("crypto"); const { URL } = require("url"); const PORT = Number(process.env.PORT || 5055); const API_KEY = process.env.OPENAI_API_KEY || ""; const UPSTREAM = new URL( process.env.OPENAI_BASE_URL || "https://api.openai.com/v1", ); const ALLOWED = ["/v1/chat/completions", "/v1/responses"]; // CI options: // EXIT_ON_5XX=1 -> Exit process non‑zero if a 5xx response head is seen // READY_FILE=path -> Touch this file when the server is listening // LOG_DEST=stdout|stderr (default stdout) // Debug toggles: // DISABLE_KEEPALIVE=1 -> Do not reuse upstream connections // FORCE_CLOSE=1 -> Send 'Connection: close' to upstream // NO_GZIP=1 -> Set 'accept-encoding: identity' // STRIP_SESSION_ID=1 -> Remove 'session_id' from upstream request headers // IDEMPOTENCY_KEY=auto -> Add an Idempotency-Key header per request (uuid) // LOG_ERROR_BODY=1 -> Log non-2xx response body (truncated) // LOG_ERROR_BODY_BYTES=1024 -> Max bytes to log from error body // STRICT_HEADERS=1 -> Rebuild upstream headers from a minimal allowlist // RESPONSES_BETA="responses=v1" (override beta header) const EXIT_ON_5XX = process.env.EXIT_ON_5XX === "1" || false; const READY_FILE = process.env.READY_FILE || ""; const LOG_DEST = (process.env.LOG_DEST || "stdout").toLowerCase(); const DISABLE_KEEPALIVE = process.env.DISABLE_KEEPALIVE === "1" || false; const FORCE_CLOSE = process.env.FORCE_CLOSE === "1" || false; const NO_GZIP = process.env.NO_GZIP === "1" || false; const STRIP_SESSION_ID = process.env.STRIP_SESSION_ID === "1" || false; const IDEMPOTENCY_KEY = (process.env.IDEMPOTENCY_KEY || "").toLowerCase(); const LOG_ERROR_BODY = process.env.LOG_ERROR_BODY === "1" || false; const LOG_ERROR_BODY_BYTES = Number(process.env.LOG_ERROR_BODY_BYTES || 2048); const STRICT_HEADERS = process.env.STRICT_HEADERS === "1" || false; const RESPONSES_BETA = process.env.RESPONSES_BETA || "responses=v1"; function outWrite(s) { if (LOG_DEST === "stderr") process.stderr.write(s + "\n"); else process.stdout.write(s + "\n"); } if (!API_KEY) { console.error("[fatal] OPENAI_API_KEY missing"); process.exit(1); } function redactHeaders(h) { const out = {}; for (const [k, v] of Object.entries(h || {})) { const key = k.toLowerCase(); if ( key === "authorization" || key === "proxy-authorization" || key === "cookie" || key === "set-cookie" ) { out[key] = "REDACTED"; } else { out[key] = Array.isArray(v) ? v.map(() => "") : String(v); } } return out; } function log(ev) { try { outWrite(JSON.stringify({ ts: new Date().toISOString(), ...ev })); } catch {} } const server = http.createServer((req, res) => { const rid = crypto.randomUUID(); if (!ALLOWED.some((p) => req.url.startsWith(p))) { res.writeHead(403, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "blocked", path: req.url })); return; } const chunks = []; let size = 0; req.on("data", (c) => { chunks.push(c); size += c.length; if (size > 2 * 1024 * 1024) { req.destroy(new Error("body too large")); } }); req.on("error", (err) => { log({ level: "error", rid, msg: "client request error", err: String(err) }); if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "bad_request" })); }); req.on("end", () => { const body = Buffer.concat(chunks); const up = new URL(req.url, UPSTREAM); let incoming; if (STRICT_HEADERS) { incoming = {}; incoming["authorization"] = `Bearer ${API_KEY}`; incoming["content-type"] = "application/json"; incoming["accept"] = "text/event-stream"; if (NO_GZIP) incoming["accept-encoding"] = "identity"; if (up.pathname.startsWith("/v1/responses")) incoming["openai-beta"] = RESPONSES_BETA; incoming["user-agent"] = "code-proxy/1.0"; incoming["originator"] = "codex_cli_rs"; if (IDEMPOTENCY_KEY === "auto") incoming["idempotency-key"] = crypto.randomUUID(); } else { incoming = { ...req.headers }; if (!incoming["content-type"]) incoming["content-type"] = "application/json"; if (!incoming["accept"]) incoming["accept"] = "text/event-stream"; if (NO_GZIP) incoming["accept-encoding"] = "identity"; incoming["authorization"] = `Bearer ${API_KEY}`; // replace with real key if (up.pathname.startsWith("/v1/responses")) { if (!incoming["openai-beta"]) incoming["openai-beta"] = RESPONSES_BETA; } if (!incoming["originator"]) incoming["originator"] = "codex_cli_rs"; if (STRIP_SESSION_ID && "session_id" in incoming) delete incoming["session_id"]; if (IDEMPOTENCY_KEY === "auto") incoming["idempotency-key"] = crypto.randomUUID(); } delete incoming["host"]; incoming["host"] = up.host; incoming["connection"] = FORCE_CLOSE ? "close" : "keep-alive"; if (incoming["content-length"]) incoming["content-length"] = String(body.length); delete incoming["proxy-connection"]; delete incoming["proxy-authorization"]; log({ level: "info", rid, phase: "request", method: req.method, url: up.toString(), headers: redactHeaders(incoming), body_bytes: body.length, }); const opts = { protocol: up.protocol, hostname: up.hostname, port: up.port || (up.protocol === "https:" ? 443 : 80), path: up.pathname + up.search, method: req.method, headers: incoming, servername: up.hostname, agent: up.protocol === "https:" ? new https.Agent({ keepAlive: !DISABLE_KEEPALIVE, maxSockets: 64, servername: up.hostname, }) : new http.Agent({ keepAlive: !DISABLE_KEEPALIVE, maxSockets: 64 }), }; const upstream = (up.protocol === "https:" ? https : http).request( opts, (upr) => { const resHeaders = { ...upr.headers }; res.writeHead(upr.statusCode || 500, resHeaders); log({ level: "info", rid, phase: "response_head", status: upr.statusCode, headers: redactHeaders(resHeaders), }); if (EXIT_ON_5XX && upr.statusCode && upr.statusCode >= 500) { // Propagate response then schedule exit(2) once stream completes upr.on("end", () => { try { process.exitCode = 2; } catch {} }); } let total = 0; const chunks = []; upr.on("data", (chunk) => { total += chunk.length; if (LOG_ERROR_BODY && upr.statusCode && upr.statusCode >= 400) { if (chunks.length < 32 && total <= LOG_ERROR_BODY_BYTES) chunks.push(chunk); } }); upr.on("end", () => { if (LOG_ERROR_BODY && upr.statusCode && upr.statusCode >= 400) { let bodyStr = ""; try { bodyStr = Buffer.concat(chunks).toString("utf8"); } catch {} log({ level: "error", rid, phase: "response_error_body", status: upr.statusCode, body: bodyStr.slice(0, LOG_ERROR_BODY_BYTES), }); } log({ level: "info", rid, phase: "response_end", status: upr.statusCode, bytes: total, request_id: resHeaders["x-request-id"] || null, }); }); upr.on("error", (e) => { log({ level: "error", rid, phase: "response_error", err: String(e) }); }); upr.pipe(res); }, ); upstream.setTimeout(600000, () => { upstream.destroy(new Error("upstream timeout")); }); upstream.on("error", (e) => { log({ level: "error", rid, phase: "upstream_error", err: String(e) }); if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_error" })); }); upstream.end(body); }); }); server.headersTimeout = 650000; server.keepAliveTimeout = 650000; server.listen(PORT, "127.0.0.1", () => { log({ level: "info", msg: "proxy listening", addr: "127.0.0.1", port: PORT }); if (READY_FILE) { try { require("fs").writeFileSync(READY_FILE, String(Date.now())); } catch {} } });