202 lines
7.0 KiB
JavaScript
202 lines
7.0 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { t } from "../i18n.mjs";
|
|
import { resolveActiveContext } from "../contexts.mjs";
|
|
|
|
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
|
|
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
|
* free-claude-code's codex adapter. NOTE: this does NOT silence codex's
|
|
* `refresh_token` log noise — that comes from a stored OpenAI session in
|
|
* ~/.codex/auth.json, not the env; it is cosmetic and does not block requests. */
|
|
const STRIPPED_CODEX_ENV_KEYS = [
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_BASE_URL",
|
|
"OPENAI_API_BASE",
|
|
"OPENAI_ORG_ID",
|
|
"OPENAI_ORGANIZATION",
|
|
"CODEX_API_KEY",
|
|
];
|
|
|
|
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
|
|
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
|
|
|
// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve
|
|
// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263):
|
|
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
|
|
export function resolveCodexSpawn(platform) {
|
|
if (platform === "win32") {
|
|
return { command: "codex.cmd", shell: true };
|
|
}
|
|
return { command: "codex", shell: undefined };
|
|
}
|
|
|
|
function stripTrailingSlash(value) {
|
|
let s = String(value);
|
|
let end = s.length;
|
|
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
|
return end === s.length ? s : s.slice(0, end);
|
|
}
|
|
|
|
/** TOML assignment for a `-c key=value` codex flag (strings get quoted). */
|
|
function tomlAssign(key, value) {
|
|
if (typeof value === "boolean" || typeof value === "number") return `${key}=${value}`;
|
|
return `${key}=${JSON.stringify(String(value))}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve the OmniRoute root base URL + auth for codex, honouring (in order):
|
|
* explicit flags → active context (remote mode) → localhost:<port>.
|
|
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
|
*/
|
|
export function resolveCodexTarget(opts = {}) {
|
|
const explicit = opts.remote ?? opts.baseUrl;
|
|
let baseUrl;
|
|
if (explicit) {
|
|
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
|
} else {
|
|
let fromCtx;
|
|
try {
|
|
fromCtx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
|
} catch {
|
|
/* no context */
|
|
}
|
|
baseUrl = fromCtx
|
|
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
|
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
|
}
|
|
|
|
let authToken = opts.apiKey ?? opts["api-key"];
|
|
if (!authToken) {
|
|
try {
|
|
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
|
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
|
} catch {
|
|
/* no context auth */
|
|
}
|
|
}
|
|
if (!authToken) authToken = process.env.OMNIROUTE_API_KEY;
|
|
return { baseUrl, authToken };
|
|
}
|
|
|
|
/** Health-check an OmniRoute root URL before launching Codex. */
|
|
async function healthCheck(baseUrl, timeoutMs = 3000) {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the env for the Codex child: strip stale OpenAI/Codex creds, then set
|
|
* OMNIROUTE_API_KEY (the provider env_key) to the resolved token or a sentinel.
|
|
* @param {Record<string,string>} baseEnv
|
|
* @param {string|undefined} authToken
|
|
* @returns {Record<string,string>}
|
|
*/
|
|
export function buildCodexEnv(baseEnv, authToken) {
|
|
const env = { ...baseEnv };
|
|
for (const key of STRIPPED_CODEX_ENV_KEYS) delete env[key];
|
|
env.OMNIROUTE_API_KEY = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Codex `-c` flags that define the `omniroute` provider inline, so launch works
|
|
* WITHOUT a pre-existing ~/.codex/config.toml. Mirrors free-claude-code.
|
|
* @param {string} baseUrl OmniRoute root URL (no /v1)
|
|
* @returns {string[]}
|
|
*/
|
|
export function buildCodexProviderArgs(baseUrl) {
|
|
return [
|
|
"-c",
|
|
tomlAssign("model_provider", "omniroute"),
|
|
"-c",
|
|
tomlAssign("model_providers.omniroute.name", "OmniRoute"),
|
|
"-c",
|
|
tomlAssign("model_providers.omniroute.base_url", `${baseUrl}/v1`),
|
|
"-c",
|
|
tomlAssign("model_providers.omniroute.env_key", "OMNIROUTE_API_KEY"),
|
|
"-c",
|
|
tomlAssign("model_providers.omniroute.wire_api", "responses"),
|
|
"-c",
|
|
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param {{port?:string, remote?:string, profile?:string, apiKey?:string}} opts
|
|
* @param {string[]} codexArgs pass-through args for the codex binary
|
|
* @returns {Promise<number>} exit code
|
|
*/
|
|
export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
|
const { baseUrl, authToken } = resolveCodexTarget(opts);
|
|
|
|
if (!(await healthCheck(baseUrl))) {
|
|
console.error(
|
|
(
|
|
t("launch.notRunning") ||
|
|
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
|
|
).replace("{port}", baseUrl)
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// Provider injected via -c (works without config.toml); then the profile (model),
|
|
// then the user's pass-through args.
|
|
const providerArgs = buildCodexProviderArgs(baseUrl);
|
|
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
|
|
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
|
const env = buildCodexEnv(process.env, authToken);
|
|
|
|
return await new Promise((resolve) => {
|
|
const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
|
|
const child = spawn(codexLaunch, extraArgs, {
|
|
env,
|
|
stdio: "inherit",
|
|
shell: shellValue,
|
|
});
|
|
child.on("error", (err) => {
|
|
if (err?.code === "ENOENT") {
|
|
console.error(
|
|
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
|
|
);
|
|
resolve(127);
|
|
} else {
|
|
console.error(String(err?.message || err));
|
|
resolve(1);
|
|
}
|
|
});
|
|
child.on("exit", (code) => resolve(code ?? 0));
|
|
});
|
|
}
|
|
|
|
export function registerLaunchCodex(program) {
|
|
program
|
|
.command("launch-codex")
|
|
.description(
|
|
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
|
)
|
|
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
|
.option(
|
|
"--remote <url>",
|
|
"Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)"
|
|
)
|
|
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
|
|
.option("-p, --p <name>", "Alias for --profile")
|
|
.option(
|
|
"--api-key <key>",
|
|
"OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)"
|
|
)
|
|
.allowUnknownOption(true)
|
|
.allowExcessArguments(true)
|
|
.argument("[codexArgs...]", "arguments passed through to the codex binary")
|
|
.action(async (codexArgs, opts) => {
|
|
const merged = { ...opts, profile: opts.profile ?? opts.p };
|
|
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
|
|
if (exitCode !== 0) process.exit(exitCode);
|
|
});
|
|
}
|