|
| 1 | +// Custom engine config, device-local. Mirrors saveCodexApiKey: the endpoint config |
| 2 | +// (base URL + API key + model) lives in tokens/custom-engine.json with 0o600 perms, |
| 3 | +// per device, never synced. spawn.ts turns it into -c model_providers overrides on |
| 4 | +// the codex binary; the key rides the CUSTOM_ENGINE_API_KEY env var, never argv. |
| 5 | + |
| 6 | +import { readFile, writeFile, rm } from "node:fs/promises"; |
| 7 | +import { tokenFile, tokensDir, ensureDir } from "../core/paths.js"; |
| 8 | +import { engineBinary } from "../runtime/engineRegistry.js"; |
| 9 | +import { detectCli, type CliReport, type CliStatus } from "../runtime/detect.js"; |
| 10 | + |
| 11 | +export interface CustomEngineConfig { |
| 12 | + baseUrl: string; |
| 13 | + apiKey: string; |
| 14 | + model: string; |
| 15 | + presetId: string; |
| 16 | + label?: string; |
| 17 | +} |
| 18 | + |
| 19 | +export interface CustomEnginePreset { |
| 20 | + id: string; |
| 21 | + label: string; |
| 22 | + baseUrl: string; |
| 23 | + defaultModel: string; |
| 24 | +} |
| 25 | + |
| 26 | +// OpenRouter has been exercised through the Responses API. Other compatible local |
| 27 | +// or hosted endpoints can be configured manually without implying provider support. |
| 28 | +export const CUSTOM_ENGINE_PRESETS: CustomEnginePreset[] = [ |
| 29 | + { id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", defaultModel: "" }, |
| 30 | + { id: "manual", label: "Manual", baseUrl: "", defaultModel: "" }, |
| 31 | +]; |
| 32 | + |
| 33 | +// Connect-UI warning copy lives in the browser-safe leaf (customEngineMeta) so the |
| 34 | +// SPA form can import it without this file's node:fs dependency; re-exported here so |
| 35 | +// node hosts keep one import site for everything custom-engine. |
| 36 | +export { CUSTOM_ENGINE_EGRESS_WARNING, CUSTOM_ENGINE_TOOL_WARNING } from "./customEngineMeta.js"; |
| 37 | + |
| 38 | +const PROVIDER = "custom-engine"; |
| 39 | + |
| 40 | +// The base URL rides codex argv as a -c override, where ps can read it, so anything |
| 41 | +// secret-shaped is stripped before it is stored: userinfo (user:pass@) and the query |
| 42 | +// string. The trailing slash is trimmed so the same endpoint always stores one form. |
| 43 | +function normalizeBaseUrl(raw: string): string { |
| 44 | + let url: URL; |
| 45 | + try { |
| 46 | + url = new URL(raw.trim()); |
| 47 | + } catch { |
| 48 | + throw new Error("The custom engine base URL is not a valid URL."); |
| 49 | + } |
| 50 | + if (url.protocol !== "http:" && url.protocol !== "https:") { |
| 51 | + throw new Error(`The custom engine base URL must be http or https, got "${url.protocol.slice(0, -1)}".`); |
| 52 | + } |
| 53 | + // origin + pathname drops userinfo, query, and fragment in one move. |
| 54 | + return (url.origin + url.pathname).replace(/\/+$/, ""); |
| 55 | +} |
| 56 | + |
| 57 | +export async function saveCustomEngineConfig(cfg: CustomEngineConfig): Promise<void> { |
| 58 | + // Reject a blank model at the door, not only at spawn (customProviderFlags), so a |
| 59 | + // bad config never even reaches disk. |
| 60 | + if (!cfg.model.trim()) { |
| 61 | + throw new Error("The custom engine needs a model id; codex would otherwise silently use its own default model."); |
| 62 | + } |
| 63 | + const normalized: CustomEngineConfig = { ...cfg, baseUrl: normalizeBaseUrl(cfg.baseUrl), model: cfg.model.trim() }; |
| 64 | + await ensureDir(tokensDir()); |
| 65 | + const path = tokenFile(PROVIDER); |
| 66 | + // Unlink before write so the new file is always created with 0o600: if the file |
| 67 | + // already existed with looser permissions a plain writeFile would not downgrade them. |
| 68 | + await rm(path, { force: true }); |
| 69 | + await writeFile(path, JSON.stringify(normalized), { mode: 0o600 }); |
| 70 | +} |
| 71 | + |
| 72 | +export async function loadCustomEngineConfig(): Promise<CustomEngineConfig | null> { |
| 73 | + try { |
| 74 | + const data = JSON.parse(await readFile(tokenFile(PROVIDER), "utf8")) as CustomEngineConfig; |
| 75 | + return data.baseUrl ? data : null; |
| 76 | + } catch { |
| 77 | + return null; |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +export async function clearCustomEngineConfig(): Promise<void> { |
| 82 | + await rm(tokenFile(PROVIDER), { force: true }); |
| 83 | +} |
| 84 | + |
| 85 | +export async function hasCustomEngine(): Promise<boolean> { |
| 86 | + return (await loadCustomEngineConfig()) !== null; |
| 87 | +} |
| 88 | + |
| 89 | +// The custom engine's status in CliReport's own vocabulary, so a custom row reads like the |
| 90 | +// claude and codex rows everywhere: "missing" = no codex binary (custom runs through it), |
| 91 | +// "no-login" = the binary is there but no endpoint is saved (the saved config IS its |
| 92 | +// sign-in), "ok" = a custom session can actually spawn. One derivation for every host |
| 93 | +// and surface instead of each re-deriving "codex present AND config saved". Pass a |
| 94 | +// CliReport when one is already in hand; without one detectCli runs, the same probe |
| 95 | +// behind every engine status line (it resolves the binary through engineBin.ts, so a |
| 96 | +// GUI-launched host without a shell PATH still finds it). |
| 97 | +export async function customEngineStatus(report?: CliReport): Promise<CliStatus> { |
| 98 | + const status = (report ?? await detectCli())[engineBinary("custom")]; |
| 99 | + if (status === "missing" || status === "node-missing") return status; |
| 100 | + return (await hasCustomEngine()) ? "ok" : "no-login"; |
| 101 | +} |
| 102 | + |
| 103 | +// Masked view for the UI: endpoint host + last 4 chars of the key, rest dotted (like |
| 104 | +// maskedHeliusKey). null when no config is stored; a keyless local endpoint shows |
| 105 | +// just the host. The full key never leaves the host as plain text. |
| 106 | +export async function maskedCustomEngine(): Promise<string | null> { |
| 107 | + const cfg = await loadCustomEngineConfig(); |
| 108 | + if (!cfg) return null; |
| 109 | + let host = cfg.baseUrl; |
| 110 | + try { |
| 111 | + host = new URL(cfg.baseUrl).host; |
| 112 | + } catch { |
| 113 | + // not a parseable URL: show the raw value |
| 114 | + } |
| 115 | + if (!cfg.apiKey) return host; |
| 116 | + const tail = cfg.apiKey.slice(-4); |
| 117 | + return `${host} · ${cfg.apiKey.length <= 4 ? tail : "••••" + tail}`; |
| 118 | +} |
0 commit comments