Files
dev/scripts/openai-proxy.js
T
zeekay ef3db6c2f6 fix(ci): fix codespell and prettier issues
- Fix typos: parellel→parallel, neccessary→necessary, mulitple→multiple,
  responible→responsible, geniune→genuine, yeild→yield
- Add ignore words: Nd, nd, edn, lightening (valid terms)
- Run prettier --write to fix all formatting issues
2026-02-05 22:17:28 -08:00

276 lines
9.8 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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" \
// ./code-rs/target/dev-fast/code 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 `code` 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(() => "<omitted>") : 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 {}
}
});