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.
260 lines
7.3 KiB
JavaScript
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);
|
|
}
|