// Thin client over the gateway's /admin/api surface. Adds the Bearer admin token, // parses JSON, and throws Error(message) on non-2xx so views can try/catch. // A 401 sets an "unauthorized" flag the app uses to show the login screen. const BASE = "/admin/api"; const TOKEN_KEY = "allaigate_token"; let onUnauthorized = null; export function setUnauthorizedHandler(fn) { onUnauthorized = fn; } export function getToken() { return localStorage.getItem(TOKEN_KEY) || ""; } export function setToken(tok) { if (tok) localStorage.setItem(TOKEN_KEY, tok); else localStorage.removeItem(TOKEN_KEY); } async function j(method, path, body) { const tok = getToken(); const headers = {}; if (tok) headers["Authorization"] = "Bearer " + tok; const opt = { method, headers }; if (body !== undefined) { opt.headers["content-type"] = "application/json"; opt.body = JSON.stringify(body); } const r = await fetch(BASE + path, opt); if (r.status === 401) { if (onUnauthorized) onUnauthorized(); throw new Error("unauthorized"); } const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; } if (!r.ok) { const msg = (data && data.error && (data.error.message || data.error)) || (data && (data.detail || data.message)) || r.statusText; throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); } return data; } export const api = { // meta / health meta: () => j("GET", "/meta"), health: () => j("GET", "/health"), probeRouter: () => j("POST", "/router/probe"), // config getConfig: () => j("GET", "/config"), putConfig: (b) => j("PUT", "/config", b), // models listModels: () => j("GET", "/models"), createModel: (b) => j("POST", "/models", b), updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b), deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)), probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"), // routing getRouting: () => j("GET", "/routing"), routingLabels: () => j("GET", "/routing/labels"), putRouting: (b) => j("PUT", "/routing", b), // protocols getProtocols: () => j("GET", "/protocols"), harnesses: () => j("GET", "/harnesses"), balance: () => j("GET", "/balance"), putProtocols: (b) => j("PUT", "/protocols", b), // settings getSettings: () => j("GET", "/settings"), putSettings: (b) => j("PUT", "/settings", b), // keys listKeys: () => j("GET", "/keys"), createKey: (b) => j("POST", "/keys", b), deleteKey: (k) => j("DELETE", "/keys/" + encodeURIComponent(k)), // secrets listSecrets: () => j("GET", "/secrets"), setSecret: (name, value) => j("PUT", "/secrets", { name, value }), clearSecret: (name) => j("DELETE", "/secrets?name=" + encodeURIComponent(name)), // stats stats: (range, groupby) => j("GET", `/stats?range=${range}&groupby=${groupby}`), clearStats: () => j("DELETE", "/stats"), requests: (limit = 50, offset = 0) => j("GET", `/requests?limit=${limit}&offset=${offset}`), // playground test: (b) => j("POST", "/test", b), // shadow / self-warming loop shadow: () => j("GET", "/shadow"), // CMF runtime (the `cortiq` CLI that runs the .cmf format) cmfStatus: () => j("GET", "/cmf"), cmfInstall: () => j("POST", "/cmf/install"), cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)), cmfFiles: () => j("GET", "/cmf/files"), providerModels: (b) => j("POST", "/provider/models", b), // CMF model factory (HuggingFace → local .cmf) hfSearch: (q, limit = 24) => j("GET", `/hf/search?q=${encodeURIComponent(q || "")}&limit=${limit}`), startImport: (b) => j("POST", "/import", b), listImports: () => j("GET", "/import"), importStatus: (job) => j("GET", "/import/" + encodeURIComponent(job)), cancelImport: (job) => j("POST", "/import/" + encodeURIComponent(job) + "/cancel"), deleteImport: (job) => j("DELETE", "/import/" + encodeURIComponent(job)), registerImport: (job) => j("POST", "/import/" + encodeURIComponent(job) + "/register"), // media generation (cortiq imagine / animate over local .cmf) mediaModels: () => j("GET", "/media/models"), mediaGenerate: (b) => j("POST", "/media", b), mediaJobs: () => j("GET", "/media"), mediaJob: (id) => j("GET", "/media/" + encodeURIComponent(id)), mediaCancel: (id) => j("POST", "/media/" + encodeURIComponent(id) + "/cancel"), mediaDelete: (id) => j("DELETE", "/media/" + encodeURIComponent(id)), // system / logs / lifecycle system: () => j("GET", "/system"), logs: (limit = 300) => j("GET", "/logs?limit=" + limit), clearLogs: () => j("DELETE", "/logs"), restart: () => j("POST", "/restart"), shutdown: () => j("POST", "/shutdown"), benchStart: (model) => j("POST", "/bench", { model }), benchList: () => j("GET", "/bench"), // first-run onboarding setupStatus: () => j("GET", "/setup/status"), // Connects every finished download as a local model unless register === false. setupComplete: (lang, register) => j("POST", "/setup/complete", { lang, register }), setupFeatured: () => j("GET", "/setup/featured"), // `name` is the exact output stem from setup/featured — it wins over the // repo/variant guess so the delete always hits the file the download wrote. deleteSetupFile: (repo, variant, name) => j( "DELETE", `/setup/file?repo=${encodeURIComponent(repo)}` + (variant ? `&variant=${encodeURIComponent(variant)}` : "") + (name ? `&name=${encodeURIComponent(name)}` : "") ), }; // Raw-body POST (no JSON encoding) — the config import takes TOML text as-is. export async function postRaw(path, text) { const tok = getToken(); const headers = { "content-type": "application/toml" }; if (tok) headers["Authorization"] = "Bearer " + tok; const r = await fetch(BASE + path, { method: "POST", headers, body: text }); const body = await r.text(); if (!r.ok) { let msg = body; try { msg = JSON.parse(body).error?.message || body; } catch { /* raw */ } throw new Error(msg || r.statusText); } return body ? JSON.parse(body) : null; } // Streaming playground: POST /test/stream, calls onDelta(text) per content chunk, // returns the routing decision read from X-Cortiq-* response headers. export async function testStream(body, onDelta) { const tok = getToken(); const headers = { "content-type": "application/json" }; if (tok) headers["Authorization"] = "Bearer " + tok; const r = await fetch(BASE + "/test/stream", { method: "POST", headers, body: JSON.stringify(body), }); if (r.status === 401) { if (onUnauthorized) onUnauthorized(); throw new Error("unauthorized"); } if (!r.ok || !r.body) { throw new Error((await r.text()) || r.statusText); } const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = ""; const t0 = performance.now(); let ttftMs = null; // time to first content token let usage = null; // OpenAI-style final-chunk usage, when the provider sends it let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth let costUsd = null; // final cost from the gateway's closing event (headers carry 0) for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { const line = buf.slice(0, idx).split("\n").find((l) => l.startsWith("data:")); buf = buf.slice(idx + 2); if (!line) continue; const data = line.slice(5).trim(); if (!data || data === "[DONE]") continue; try { const chunk = JSON.parse(data); if (chunk.usage) { usage = chunk.usage; usageEstimated = !!chunk.cortiq_estimated; } if (chunk.cortiq_cost_usd != null) costUsd = chunk.cortiq_cost_usd; const d = chunk.choices?.[0]?.delta?.content; if (d) { if (ttftMs == null) ttftMs = Math.round(performance.now() - t0); onDelta(d); } } catch { /* ignore malformed chunk */ } } } const h = r.headers; return { task_label: h.get("x-cortiq-task-label") || "", tier: h.get("x-cortiq-complexity-tier") || "", score: parseFloat(h.get("x-cortiq-complexity-score") || "0"), selected_model: h.get("x-cortiq-selected-model") || "", route_source: h.get("x-cortiq-route-source") || "", cost_usd: costUsd != null ? costUsd : parseFloat(h.get("x-cortiq-cost-usd") || "0"), usage, usage_estimated: usageEstimated, ttft_ms: ttftMs, }; } // Fetch a media job's output with the admin token and hand back an object URL // (plain /