Files
gronod 58cf62a41f openai-codex-proxy 1.0.3: drop models option, clarify api_key
The models option passed an allowlist to openai-oauth --models but was
unrequested and of uncertain upstream effect; remove it entirely.

Add translations/en.yaml so the Configuration tab explains that an empty
api_key auto-generates a persisted key printed in the log, and that a
custom key can be pasted instead. Document a key-generation command in
DOCS.md.
2026-09-22 15:50:10 +01:00

260 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
// OpenAI Codex Proxy add-on entrypoint.
//
// openai-oauth performs no authentication on its local endpoint, so this
// wrapper binds it to loopback and publishes a small Bearer-token proxy on
// the add-on port. The local API key is either taken from the `api_key`
// option or generated once and persisted under /config (addon_config).
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const http = require("http");
const { spawn } = require("child_process");
const { Readable } = require("stream");
const OPTIONS_FILE = "/data/options.json";
const AUTH_FILE = "/share/auth.json";
const KEY_FILE = "/config/.api_key";
const PUBLIC_PORT = 10531;
const UPSTREAM_PORT = 10532;
const UPSTREAM_URL = `http://127.0.0.1:${UPSTREAM_PORT}`;
// ---- options ---------------------------------------------------------------
let options = {};
try {
options = JSON.parse(fs.readFileSync(OPTIONS_FILE, "utf8")) || {};
} catch {
options = {};
}
const LOG_LEVEL = String(options.log_level || "INFO").toUpperCase();
const LEVELS = { DEBUG: 0, INFO: 1, WARN: 2 };
const threshold = LEVELS[LOG_LEVEL] ?? LEVELS.INFO;
const log = (level, msg) => {
if (LEVELS[level] >= threshold) {
console.log(`[${level}] ${msg}`);
}
};
// ---- auth.json guard -------------------------------------------------------
if (!fs.existsSync(AUTH_FILE)) {
console.error(
`ERROR: ${AUTH_FILE} not found!\n` +
"Generate it on a desktop with `npx @openai/codex login`, then copy\n" +
"~/.codex/auth.json (macOS/Linux) or %USERPROFILE%\\.codex\\auth.json\n" +
"(Windows) to the /share folder on this Home Assistant machine.\n" +
"Retrying in 60 seconds..."
);
setTimeout(() => process.exit(1), 60_000);
} else {
main();
}
// ---- main ------------------------------------------------------------------
function main() {
const apiKey = resolveApiKey();
const upstream = startUpstream();
const server = http.createServer((req, res) => {
handleRequest(req, res, apiKey).catch((err) => {
log("WARN", `request error: ${err.message}`);
if (!res.headersSent) {
sendJson(res, 502, {
error: { message: "Upstream not ready.", type: "upstream_error" },
});
}
res.end();
});
});
server.on("error", (err) => {
console.error(`ERROR: cannot listen on 0.0.0.0:${PUBLIC_PORT}: ${err.message}`);
process.exit(1);
});
server.listen(PUBLIC_PORT, "0.0.0.0", () => {
log(
"INFO",
`OpenAI Codex Proxy listening on 0.0.0.0:${PUBLIC_PORT} ` +
`(upstream 127.0.0.1:${UPSTREAM_PORT})`
);
log("INFO", "Base URL for clients: http://<this-host>:10531/v1");
log("INFO", `Local API key: ${apiKey}`);
waitForUpstream();
});
let shuttingDown = false;
const shutdown = (signal) => {
shuttingDown = true;
log("INFO", `${signal} received, shutting down`);
upstream.kill("SIGTERM");
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5_000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
upstream.on("exit", (code, signal) => {
if (shuttingDown) return;
console.error(
`ERROR: openai-oauth exited (code=${code} signal=${signal}); exiting so the add-on restarts`
);
process.exit(code ?? 1);
});
upstream.on("error", (err) => {
console.error(`ERROR: failed to start openai-oauth: ${err.message}`);
process.exit(1);
});
}
function resolveApiKey() {
const fromOption = String(options.api_key || "").trim();
if (fromOption) {
log("INFO", "Using API key from the api_key option");
return fromOption;
}
try {
const stored = fs.readFileSync(KEY_FILE, "utf8").trim();
if (stored) return stored;
} catch {
// not generated yet
}
const generated = crypto.randomBytes(32).toString("base64url");
try {
fs.mkdirSync(require("path").dirname(KEY_FILE), { recursive: true });
fs.writeFileSync(KEY_FILE, generated + "\n", { mode: 0o600 });
log("INFO", `Generated a new local API key (stored at ${KEY_FILE})`);
} catch (err) {
log("WARN", `could not persist generated key to ${KEY_FILE}: ${err.message}`);
}
return generated;
}
function startUpstream() {
const args = [
"openai-oauth",
"--host",
"127.0.0.1",
"--port",
String(UPSTREAM_PORT),
"--oauth-file",
AUTH_FILE,
];
const env = { ...process.env };
env.OPENAI_OAUTH_INTERNAL_RUNTIME_DIR = "/data/openai-oauth";
const wantRequestLogs =
options.log_requests === true || LOG_LEVEL === "DEBUG";
env.CODEX_OPENAI_SERVER_LOG_REQUESTS = wantRequestLogs ? "1" : "0";
log(
"DEBUG",
`spawning: npx ${args.join(" ")} (request logs ${wantRequestLogs ? "on" : "off"})`
);
return spawn("npx", args, { stdio: "inherit", env });
}
async function waitForUpstream() {
for (;;) {
try {
const res = await fetch(`${UPSTREAM_URL}/health`);
if (res.ok) {
log("INFO", "upstream openai-oauth is ready");
return;
}
} catch {
// not up yet
}
await new Promise((r) => setTimeout(r, 1000));
}
}
// ---- request handling ------------------------------------------------------
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
async function handleRequest(req, res, apiKey) {
const url = new URL(req.url, `http://localhost:${PUBLIC_PORT}`);
if (req.method === "GET" && url.pathname === "/health") {
sendJson(res, 200, { ok: true, service: "openai-codex-proxy" });
return;
}
if (!isAuthorized(req, apiKey)) {
sendJson(res, 401, {
error: {
message: "Missing or invalid API key. Send 'Authorization: Bearer <key>'.",
type: "authentication_error",
},
});
return;
}
const headers = {};
for (const [k, v] of Object.entries(req.headers)) {
const key = k.toLowerCase();
if (HOP_BY_HOP.has(key)) continue;
if (key === "host" || key === "authorization" || key === "x-api-key") continue;
if (key === "content-length") continue;
headers[key] = v;
}
const started = Date.now();
const upstreamRes = await fetch(UPSTREAM_URL + req.url, {
method: req.method,
headers,
body: ["GET", "HEAD"].includes(req.method) ? undefined : req,
duplex: "half",
});
const resHeaders = {};
upstreamRes.headers.forEach((v, k) => {
if (!HOP_BY_HOP.has(k)) resHeaders[k] = v;
});
res.writeHead(upstreamRes.status, resHeaders);
if (upstreamRes.body) {
Readable.fromWeb(upstreamRes.body).pipe(res);
} else {
res.end();
}
log(
"INFO",
`${req.method} ${url.pathname} -> ${upstreamRes.status} ${Date.now() - started}ms`
);
}
function isAuthorized(req, apiKey) {
const bearer = req.headers.authorization || "";
const presented = bearer.startsWith("Bearer ")
? bearer.slice(7)
: req.headers["x-api-key"] || "";
if (!presented) return false;
const a = crypto.createHash("sha256").update(String(presented)).digest();
const b = crypto.createHash("sha256").update(apiKey).digest();
return crypto.timingSafeEqual(a, b);
}
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
});
res.end(body);
}