// Stateless onboarding probes: the caller owns consent for the metered call; credentials and replies are never logged. export const API_ENDPOINTS = [ { id: "openai", label: "OpenAI API", url: "https://api.openai.com/v1", keyUrl: "https://platform.openai.com/api-keys" }, { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/api/v1", keyUrl: "https://openrouter.ai/settings/keys" }, { id: "groq", label: "Groq", url: "https://api.groq.com/openai/v1", keyUrl: "https://console.groq.com/keys" }, { id: "meta", label: "Meta Model API", url: "https://api.meta.ai/v1", keyUrl: "https://dev.meta.ai/" }, ] as const; export function normalizeApiUrl(value: string): string { const input = value.trim(); const invalid = () => new Error("Enter an HTTPS API base URL without credentials, a query, or a fragment. HTTP is allowed only for localhost."); let url: URL; try { url = new URL(input); } catch { throw invalid(); } const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); if ( // Reject invisible URL characters and ambiguous authority/path separators. // eslint-disable-next-line no-control-regex /[\u0000-\u0020\u007f\\?#]/u.test(input) || url.username || url.password || input.slice(input.indexOf("://") + 3).split("/")[0]?.includes("@") || (url.protocol !== "https:" && !(url.protocol === "http:" && local)) ) throw invalid(); return url.href.replace(/\/+$/u, ""); } class SetupApiError extends Error {} function httpFailure(status: number, completion: boolean): SetupApiError { const prefix = `API returned HTTP ${status}.`; if (status === 401 || status === 403) return new SetupApiError(`${prefix} Check the API key and its access permissions.`); if (status === 402) return new SetupApiError(`${prefix} Check the provider's credits and billing settings.`); if (status === 429) return new SetupApiError(`${prefix} A rate or quota limit was reached. Check credits and limits, then try again later.`); if (status >= 300 && status < 400) return new SetupApiError(`${prefix} Redirects are not followed. Use the provider's direct API base URL.`); if (completion && [400, 404, 422].includes(status)) return new SetupApiError(`${prefix} Check the model ID and access, or choose a model that supports Chat Completions at this API URL.`); if (status === 404) return new SetupApiError(`${prefix} Check the API base URL: it must provide a /models endpoint.`); if (status >= 500) return new SetupApiError(`${prefix} The provider is unavailable. Try again later.`); return new SetupApiError(`${prefix} Check the API URL and provider settings, then try again.`); } async function setupJson(url: string, key: string, body?: Record): Promise { const base = normalizeApiUrl(url); // Existing credentials are tested exactly as the runtime sends them, never silently repaired only here. const token = key; // Header credentials must not contain control characters or whitespace. // eslint-disable-next-line no-control-regex if (!token || /[\u0000-\u0020\u007f]/u.test(token)) throw new SetupApiError("Enter a non-empty API key without spaces or line breaks."); const completion = body !== undefined; const operation = completion ? "The test reply" : "The model list"; const timeoutMs = completion ? 30_000 : 8_000; const abort = new AbortController(); let timer: ReturnType | undefined; try { return await Promise.race([ (async () => { const response = await fetch(`${base}${completion ? "/chat/completions" : "/models"}`, { method: completion ? "POST" : "GET", headers: { authorization: `Bearer ${token}`, ...(completion ? { "content-type": "application/json" } : {}) }, ...(completion ? { body: JSON.stringify(body) } : {}), signal: abort.signal, redirect: "error", }); if (!response.ok) { void response.body?.cancel().catch(() => {}); throw httpFailure(response.status, completion); } try { return await response.json(); } catch { throw new SetupApiError(`${operation} returned invalid JSON. Check that the API URL supports the OpenAI-compatible API.`); } })(), new Promise((_resolve, reject) => { timer = setTimeout(() => { abort.abort(); reject(new SetupApiError(`${operation} timed out after ${timeoutMs / 1000} seconds. Check the connection and try again.`)); }, timeoutMs); }), ]); } catch (error) { if (error instanceof SetupApiError) throw error; // fetch errors can carry a URL, echoed credentials or provider content: expose neither message nor cause. throw new SetupApiError("Could not reach the API. Check the network and direct API URL; redirects are not followed."); } finally { clearTimeout(timer); } } const record = (value: unknown): Record | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; export async function fetchSetupModels(url: string, key: string): Promise> { const json = record(await setupJson(url, key)); const models = new Map(); for (const entry of Array.isArray(json?.data) ? json.data : []) { const item = record(entry); const id = typeof item?.id === "string" ? item.id.trim() : ""; // Model IDs and display labels reach the terminal; block control sequences. // eslint-disable-next-line no-control-regex if (!id || /[\u0000-\u001f\u007f]/u.test(id) || models.has(id)) continue; // eslint-disable-next-line no-control-regex const name = typeof item?.name === "string" ? item.name.replace(/[\u0000-\u001f\u007f]/gu, "").trim() : ""; models.set(id, { id, label: name || id }); } if (!models.size) throw new SetupApiError("The API returned no usable models. Check the API URL and model access, or enter a model ID from your provider manually."); return [...models.values()]; } /** Call only after consent, passing the effective instance's provider routing. */ export async function verifySetupCompletion(url: string, key: string, model: string, provider?: string): Promise { const id = model.trim(); // Keep manually entered IDs safe to show in the terminal and saved setup. // eslint-disable-next-line no-control-regex if (!id || /[\u0000-\u001f\u007f]/u.test(id)) throw new SetupApiError("Choose a non-empty model ID without control characters."); const host = new URL(normalizeApiUrl(url)).hostname.toLowerCase(); // The same OpenRouter-only provider pin as OpenAICompatDriver, without its environment overrides. const pinProvider = provider && (host === "openrouter.ai" || host.endsWith(".openrouter.ai")); const json = record(await setupJson(url, key, { model: id, messages: [{ role: "user", content: "Reply with exactly OK." }], stream: false, ...(pinProvider ? { provider: { order: [provider], allow_fallbacks: false } } : {}), })); const choice = record(Array.isArray(json?.choices) ? json.choices[0] : undefined); const message = record(choice?.message); if (message?.role !== "assistant" || typeof message.content !== "string" || !message.content.trim()) { throw new SetupApiError("The API did not return an assistant text reply. Choose a model that supports Chat Completions, then try again."); } }