File size: 9,979 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
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 <remote>`
  // 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 <remote>` 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 <x>` 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;
  }
}