/** * Low-level RunPod transport. Every model runs as its own RunPod endpoint; this * handles the common request envelope, bearer auth, and both response modes: * * - `/runsync` (preferred): the job runs synchronously and the body already * contains `output` (or IS the output for a custom handler). * - `/run`: returns `{ id, status }`; we poll `/status/{id}` until the job * reaches a terminal state or the time budget (kept under the 60s serverless * function cap) is exhausted. * * RUNPOD_API_KEY is read here so callers never handle the secret directly, and * it is NEVER echoed into an error message. Upstream error bodies are truncated * to 160 chars before being surfaced. * * PROVENANCE: a faithful port of * `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/client.ts` (same 55s * budget, same polling, same RunpodError.status mapping). * * SERVER-ONLY. */ export class RunpodError extends Error { status: number; constructor(message: string, status = 502) { super(message); this.name = "RunpodError"; this.status = status; } } export interface RunpodCallOpts { /** Human label used in error messages, e.g. "sd-inpaint". */ name: string; /** Full endpoint URL from the per-model env var (…/runsync or …/run). */ url: string; input: Record; /** Total budget for the whole call incl. polling. Default 55s (< the 60s cap). */ timeoutMs?: number; pollIntervalMs?: number; } interface RunpodEnvelope { id?: string; status?: string; output?: unknown; error?: unknown; } export async function runpodCall(opts: RunpodCallOpts): Promise { const { name, url, input } = opts; const timeoutMs = opts.timeoutMs ?? 55_000; const pollIntervalMs = opts.pollIntervalMs ?? 1500; const key = process.env.RUNPOD_API_KEY; if (!key) throw new RunpodError("RUNPOD_API_KEY is not set.", 500); const deadline = Date.now() + timeoutMs; const authHeaders = { authorization: `Bearer ${key}` }; let res: Response; try { res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...authHeaders }, body: JSON.stringify({ input }), signal: AbortSignal.timeout(timeoutMs), cache: "no-store", }); } catch { throw new RunpodError(`Could not reach RunPod (${name}).`, 502); } if (!res.ok) { // Truncated on purpose — never surface a full upstream body to the client. const detail = (await res.text().catch(() => "")).slice(0, 160); throw new RunpodError(`RunPod ${name} error (${res.status}). ${detail}`.trim(), 502); } const data = (await res.json().catch(() => null)) as RunpodEnvelope | null; if (!data || typeof data !== "object") { throw new RunpodError(`RunPod ${name} returned an invalid response.`, 502); } // Terminal failure reported in a job envelope. if (data.status === "FAILED" || data.status === "CANCELLED") { throw new RunpodError(`RunPod ${name} job ${data.status.toLowerCase()}.`, 502); } // No job id → this is not an async envelope; the body itself is the output. // Covers custom /runsync handlers that return their result directly, even when // it carries a `status` field (e.g. "success"). if (data.id === undefined) { return (data.output !== undefined ? data.output : data) as TOut; } // Job envelope that already carries a completed/inline output. if (data.output !== undefined && (data.status === undefined || data.status === "COMPLETED")) { return data.output as TOut; } // Async: poll /status/{id} until COMPLETED / FAILED / the time budget runs out. // Each wait + fetch is clamped to the remaining budget so the whole call stays // under `timeoutMs` (kept below the 60s function cap). const statusUrl = url.replace(/\/run(sync)?(\/?)$/, `/status/${data.id}`); for (;;) { const remaining = deadline - Date.now(); if (remaining < 500) break; // not enough budget for another round await sleep(Math.min(pollIntervalMs, remaining)); const left = deadline - Date.now(); if (left <= 0) break; let sres: Response; try { sres = await fetch(statusUrl, { headers: authHeaders, signal: AbortSignal.timeout(Math.min(10_000, Math.max(1000, left))), cache: "no-store", }); } catch { continue; // transient — keep polling until the deadline } if (!sres.ok) continue; const sdata = (await sres.json().catch(() => null)) as RunpodEnvelope | null; if (!sdata) continue; if (sdata.status === "COMPLETED") return sdata.output as TOut; if (sdata.status === "FAILED" || sdata.status === "CANCELLED") { throw new RunpodError(`RunPod ${name} job ${sdata.status.toLowerCase()}.`, 502); } } throw new RunpodError( `RunPod ${name} timed out (raise the endpoint's speed or use /runsync).`, 504, ); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }