import { setTimeout as sleep } from "node:timers/promises"; import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs"; import { resolveActiveContext } from "./contexts.mjs"; export const RETRY_DEFAULTS = Object.freeze({ maxAttempts: 3, baseMs: 500, maxMs: 8000, jitter: true, retryableStatuses: [408, 425, 429, 502, 503, 504], retryableErrorCodes: [ "ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE", ], }); const NON_RETRYABLE_ON_MUTATION = new Set([409, 422, 429]); const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); export function getBaseUrl(opts = {}) { if (opts.baseUrl) return stripTrailingSlash(opts.baseUrl); const envUrl = process.env.OMNIROUTE_BASE_URL; if (envUrl) return stripTrailingSlash(envUrl); // Resolve from the active context (canonical store + legacy profile fallback). // This is what makes "remote mode" work: `omniroute contexts use ` // routes every command at the remote server's baseUrl. try { const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl); } catch { // Config read failures are not fatal — fall through to default. } const port = process.env.PORT || "20128"; return `http://localhost:${port}`; } function stripTrailingSlash(value) { const 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); } function resolveUrl(path, opts) { if (/^https?:\/\//i.test(path)) return path; return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`; } export async function buildHeaders(opts) { const headers = new Headers(opts.headers || {}); if (!headers.has("accept")) headers.set("accept", "application/json"); if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") { headers.set("content-type", "application/json"); } // Auth precedence: explicit key → active-context credential → ambient env key. // // The active context's scoped token MUST win over the ambient OMNIROUTE_API_KEY: // `omniroute connect ` saves the context's token, but users keep // OMNIROUTE_API_KEY in their shell. The global `--api-key` option is bound to // that env var (.env("OMNIROUTE_API_KEY")), so commands that spread // `optsWithGlobals()` into apiFetch carry opts.apiKey === the env value. If that // echoed value outranked the context, every remote management command would send // the local inference key and fail with "Invalid management token" — defeating // remote mode. So an opts.apiKey that merely mirrors the ambient env var is // treated as ambient (a fallback), NOT as an explicit override; only a DISTINCT // key — a real `--api-key ` flag or a command-supplied token like // `connect --key` — counts as explicit and wins. Within a context the scoped // accessToken wins over the legacy apiKey. const ambientKey = process.env.OMNIROUTE_API_KEY || null; const explicitKey = opts.apiKey && opts.apiKey !== ambientKey ? opts.apiKey : null; let auth = explicitKey; if (!auth) { try { const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); auth = ctx?.accessToken || ctx?.apiKey || null; } catch { // No context credential available — fall through to the ambient fallback. } } if (!auth) auth = opts.apiKey || ambientKey || null; if (auth && !headers.has("authorization")) { headers.set("authorization", `Bearer ${auth}`); } // Inject machine-id derived CLI token; env var override for testing. const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken()); if (cliToken && !headers.has(CLI_TOKEN_HEADER)) { headers.set(CLI_TOKEN_HEADER, cliToken); } if (opts.idempotencyKey && !headers.has("idempotency-key")) { headers.set("idempotency-key", opts.idempotencyKey); } return headers; } function serializeBody(body, headers) { if (body == null) return undefined; if (typeof body === "string") return body; if (body instanceof Buffer) return body; if (body instanceof URLSearchParams) return body; if (typeof body.pipe === "function") return body; // stream if (headers.get("content-type")?.includes("application/json")) return JSON.stringify(body); return JSON.stringify(body); } export function computeBackoff(attempt, retryAfterHeader, defaults = RETRY_DEFAULTS) { if (retryAfterHeader != null) { const secs = Number.parseFloat(String(retryAfterHeader)); if (Number.isFinite(secs) && secs >= 0) { return Math.min(secs * 1000, defaults.maxMs); } } const exp = Math.min(defaults.baseMs * 2 ** (attempt - 1), defaults.maxMs); if (!defaults.jitter) return exp; const jitter = exp * 0.25 * (Math.random() * 2 - 1); return Math.max(0, exp + jitter); } export function shouldRetryStatus(status, method, opts = {}) { if (opts.retry === false) return false; const list = opts.retryableStatuses || RETRY_DEFAULTS.retryableStatuses; if (!list.includes(status)) return false; if (MUTATING_METHODS.has(method) && NON_RETRYABLE_ON_MUTATION.has(status)) { return status === 429 ? Boolean(opts.retryMutationsOn429) : false; } return true; } export function shouldRetryError(err, opts = {}) { if (opts.retry === false) return false; const codes = opts.retryableErrorCodes || RETRY_DEFAULTS.retryableErrorCodes; if (err?.code && codes.includes(err.code)) return true; if (err?.name === "AbortError" || /timeout|abort/i.test(err?.message || "")) return true; return false; } export function statusToExitCode(status) { if (status >= 200 && status < 300) return 0; if (status === 408) return 124; if (status === 401 || status === 403) return 4; if (status === 429) return 5; if (status === 400 || status === 404 || status === 422) return 2; if (status >= 500) return 1; return 1; } export class ApiError extends Error { constructor(message, { status, code, exitCode } = {}) { super(message); this.name = "ApiError"; this.status = status; this.code = code; this.exitCode = exitCode ?? (status != null ? statusToExitCode(status) : 1); } } async function readResponseBody(res) { const ct = res.headers.get("content-type") || ""; try { if (ct.includes("application/json")) return await res.json(); return await res.text(); } catch { return null; } } function fetchOnce(url, init, timeoutMs) { if (!timeoutMs) return fetch(url, init); const ac = new AbortController(); const t = setTimeout(() => ac.abort(), timeoutMs); const merged = { ...init, signal: ac.signal }; return fetch(url, merged).finally(() => clearTimeout(t)); } export async function apiFetch(path, opts = {}) { const method = String(opts.method || "GET").toUpperCase(); const url = resolveUrl(path, opts); const headers = await buildHeaders(opts); const body = serializeBody(opts.body, headers); const timeout = opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000); const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts); const verbose = opts.verbose ?? process.env.OMNIROUTE_VERBOSE === "1"; let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const res = await fetchOnce(url, { method, headers, body }, timeout); if (res.ok) return enrichResponse(res, opts); if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) { const delay = computeBackoff(attempt, res.headers.get("retry-after")); if (verbose) { process.stderr.write( `[retry ${attempt}/${maxAttempts - 1}] ${method} ${url} → HTTP ${res.status}; wait ${Math.round(delay)}ms\n` ); } await sleep(delay); continue; } return enrichResponse(res, opts); } catch (err) { lastErr = err; if (attempt < maxAttempts && shouldRetryError(err, opts)) { const delay = computeBackoff(attempt, null); if (verbose) { process.stderr.write( `[retry ${attempt}/${maxAttempts - 1}] ${method} ${url} → ${err.code || err.message}; wait ${Math.round(delay)}ms\n` ); } await sleep(delay); continue; } throw normalizeNetworkError(err); } } throw normalizeNetworkError(lastErr); } function enrichResponse(res, opts) { res.exitCode = statusToExitCode(res.status); res.json = res.json.bind(res); res.text = res.text.bind(res); if (!res.ok && !opts.acceptNotOk) { res.assertOk = async () => { const payload = await readResponseBody(res); const message = extractErrorMessage(payload, res.status); throw new ApiError(message, { status: res.status }); }; } else { res.assertOk = async () => res; } return res; } function extractErrorMessage(payload, status) { if (payload && typeof payload === "object") { if (typeof payload.error === "string") return payload.error; if (payload.error?.message) return String(payload.error.message); if (payload.message) return String(payload.message); } if (typeof payload === "string" && payload.length < 200) return payload; return `HTTP ${status}`; } function normalizeNetworkError(err) { if (err instanceof ApiError) return err; const code = err?.code || (err?.name === "AbortError" ? "ETIMEDOUT" : undefined); const exitCode = code === "ETIMEDOUT" ? 124 : 1; return new ApiError(err?.message || "network error", { code, exitCode }); } export async function isServerUp(opts = {}) { try { const res = await apiFetch("/api/health", { ...opts, retry: false, timeout: opts.timeout ?? 1500, acceptNotOk: true, }); return res.ok || res.status < 500; } catch { return false; } }