/** * xskill.ts — a thin REST client for the apiz.ai / suitui-ai aggregator (also * branded "xskill" / "速推" / "NEX AI"), which fronts the fal.ai post-production * models we need: Topaz video upscale and OmniHuman audio-driven lip-sync. * * It is NOT a per-scene generation route (those go through the storyboard → * execute pipeline). It is the async-task client the new `finish` / `lipsync` * commands call directly: * xskillSubmit(modelId, params) → taskId * → xskillAwait(taskId) polls until completed → the output URL * → xskillDownload(url, dest). * * The HTTP contract (verified against the live API + the MCP tool schemas): * POST {base}/api/v3/tasks/create Bearer { model_id, params } → { task_id, status } * GET {base}/api/v3/tasks/{task_id} Bearer → { status, result } * status ∈ pending | processing | completed | failed * output URL lives in `result` — `result.video.url` (video) / `result.images[].url` (image). * The key is read from APIZ_API_KEY (the official CLI's var) or XSKILL_API_KEY * (kept for continuity); its form is `sk-...`. Transports are injectable so the * whole client is unit-testable offline. */ export const XSKILL_BASE = 'https://api.apiz.ai'; export const XSKILL_SUBMIT_PATH = '/api/v3/tasks/create'; export const xskillTaskPath = (taskId: string): string => `/api/v3/tasks/${encodeURIComponent(taskId)}`; export type XskillTaskStatus = 'pending' | 'processing' | 'completed' | 'failed'; /** Minimal fetch surface for JSON submit/poll — injectable for offline tests. */ export type XskillFetchLike = ( url: string, init: { method: string; headers: Record; body?: string }, ) => Promise<{ ok: boolean; status: number; text(): Promise }>; /** Fetch surface for binary download — injectable for offline tests. */ export type XskillDownloadFetchLike = ( url: string, ) => Promise<{ ok: boolean; status: number; arrayBuffer(): Promise }>; export interface XskillClientOptions { /** Explicit key; falls back to APIZ_API_KEY / XSKILL_API_KEY. */ apiKey?: string; /** Base URL override (default {@link XSKILL_BASE}). */ baseUrl?: string; env?: NodeJS.ProcessEnv; fetchImpl?: XskillFetchLike; /** Transient-retry budget for 429/5xx/network errors (default 3). */ maxRetries?: number; /** Base backoff between retries, ms (default 800; grows linearly). */ retryBackoffMs?: number; } /** Resolve the API key from options/env, or throw a clear, actionable error. */ export function resolveXskillApiKey(options: XskillClientOptions = {}): string { const env = options.env ?? process.env; const key = options.apiKey ?? env.APIZ_API_KEY ?? env.XSKILL_API_KEY; if (!key) { throw new Error( 'xskill: no API key. Create one in the apiz.ai console (sk-...) and `export APIZ_API_KEY=sk-...` ' + '(XSKILL_API_KEY is also accepted).', ); } return key; } const TRANSIENT_BODY = /downstream_service_unavailable|temporarily|rate.?limit|try again/i; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); function authHeaders(key: string): Record { return { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }; } /** * A failed xskill HTTP exchange, carrying the status (0 = network error — the * request may or may not have reached the server) and the response/error body * so callers can discriminate failure modes instead of regexing messages. */ export class XskillHttpError extends Error { constructor( public readonly status: number, public readonly body: string, ) { super(`xskill request failed (${status || '?'}): ${body.slice(0, 300)}`); this.name = 'XskillHttpError'; } } /** * Is this status/body worth a transient retry? 429/5xx/transient-message * always; a network error (status 0) ONLY when the request is idempotent — * a dropped POST to the paid task-create endpoint is ambiguous (the task may * already have been created), so re-POSTing risks double spend. */ function isTransient(status: number, body: string, idempotent: boolean): boolean { if (status === 0) return idempotent; return status === 429 || status >= 500 || TRANSIENT_BODY.test(body); } async function requestWithRetry( fetchImpl: XskillFetchLike, url: string, init: { method: string; headers: Record; body?: string }, maxRetries: number, backoffMs: number, idempotent: boolean, ): Promise<{ status: number; text: string }> { let lastErr: { status: number; text: string } | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { let status = 0; let text = ''; try { const res = await fetchImpl(url, init); status = res.status; text = await res.text(); if (res.ok) return { status, text }; } catch (err) { status = 0; text = (err as Error).message ?? 'network error'; } lastErr = { status, text }; if (attempt < maxRetries && isTransient(status, text, idempotent)) { await sleep(backoffMs * (attempt + 1)); continue; } break; } throw new XskillHttpError(lastErr?.status ?? 0, lastErr?.text ?? ''); } function parseJson(text: string): Record { try { return JSON.parse(text) as Record; } catch { throw new Error(`xskill: unparseable response body: ${text.slice(0, 200)}`); } } /** Pull the task id from a submit response (`task_id` / `data.task_id` / `id`). */ function extractTaskId(parsed: Record): string { const data = (parsed.data as Record | undefined) ?? parsed; const id = (data.task_id ?? data.id ?? parsed.task_id) as unknown; if (typeof id !== 'string' || !id) { throw new Error(`xskill submit: no task_id in response: ${JSON.stringify(parsed).slice(0, 200)}`); } return id; } /** * Submit an async task. Body is `{ model_id, params }`. If the API rejects * `model_id` with a 4xx validation error, it retries once with the alternate * `model` field name (both appear in the CLI docs). Returns the task id. */ export async function xskillSubmit( modelId: string, parameters: Record, options: XskillClientOptions = {}, ): Promise { const key = resolveXskillApiKey(options); const base = options.baseUrl ?? XSKILL_BASE; const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as XskillFetchLike); const maxRetries = options.maxRetries ?? 3; const backoffMs = options.retryBackoffMs ?? 800; const url = `${base}${XSKILL_SUBMIT_PATH}`; const submitWith = async (field: 'model_id' | 'model'): Promise => { // The live v3 API keys the input object as `params` (the MCP tool exposes it // as `parameters`, but that name 422s the REST endpoint — verified live). // NOT idempotent: a dropped POST may still have created (and charged) the // task, so network errors are never auto-retried here. const body = JSON.stringify({ [field]: modelId, params: parameters }); const { text } = await requestWithRetry(fetchImpl, url, { method: 'POST', headers: authHeaders(key), body }, maxRetries, backoffMs, false); return extractTaskId(parseJson(text)); }; try { return await submitWith('model_id'); } catch (err) { // One alternate-field retry, ONLY for a 4xx that actually rejects the // `model_id` field name. The old message-regex (/4\d\d|model|.../) re-POSTed // the paid create endpoint on EVERY 4xx (auth, quota, bad params) and even // on task-id parse failures after a SUCCESSFUL submit (the echoed model id // matched /model/) — masking the original error and risking double spend. // Two live-observed rejection shapes: the body calls `model_id` itself // unknown/invalid, OR (2026-06-11) a FastAPI-style 422 demands the missing // required `model` field ({"type":"missing","loc":["body","model"], // "msg":"Field required"}) while echoing our `model_id` in `input`. Either // way the task was NOT created, so the alternate-field retry is spend-safe. const fieldNameRejection = err instanceof XskillHttpError && err.status >= 400 && err.status < 500 && /model_id/i.test(err.body) && /unknown|unexpected|unrecognized|not (allowed|recognized|supported)|invalid|validation|missing|required/i.test(err.body); if (fieldNameRejection) { return await submitWith('model'); } throw err; } } export interface XskillTaskResult { status: XskillTaskStatus; /** Model-specific output container (`result.video.url`, `result.images[].url`, …). */ result: Record | undefined; /** The full parsed response, for diagnostics. */ raw: Record; } /** Poll a task once. Returns the normalized status + result container. */ export async function xskillPoll(taskId: string, options: XskillClientOptions = {}): Promise { const key = resolveXskillApiKey(options); const base = options.baseUrl ?? XSKILL_BASE; const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as XskillFetchLike); const maxRetries = options.maxRetries ?? 3; const backoffMs = options.retryBackoffMs ?? 800; const url = `${base}${xskillTaskPath(taskId)}`; // Idempotent GET — network drops here ARE retried (a socket blip must not // abort a 10-minute await of a paid, still-running task). const { text } = await requestWithRetry(fetchImpl, url, { method: 'GET', headers: authHeaders(key) }, maxRetries, backoffMs, true); const parsed = parseJson(text); const data = (parsed.data as Record | undefined) ?? parsed; const status = (data.status ?? parsed.status) as XskillTaskStatus; const result = (data.result ?? parsed.result) as Record | undefined; return { status, result, raw: parsed }; } /** Extract the first output media URL from a task `result` container. */ export function extractOutputUrl(result: Record | undefined): string | null { if (!result || typeof result !== 'object') return null; const video = result.video as { url?: unknown } | undefined; if (video && typeof video.url === 'string') return video.url; const images = result.images as Array<{ url?: unknown }> | undefined; if (Array.isArray(images) && images[0] && typeof images[0].url === 'string') return images[0].url; if (typeof (result as { url?: unknown }).url === 'string') return (result as { url: string }).url; // Last resort: a URL-looking string field — but never one whose key suggests // it is an INPUT echo (source/input/origin) or a preview/thumbnail, which // would silently save the uploaded input back as the "finished" output. for (const [k, v] of Object.entries(result)) { if (/source|input|origin|thumb|preview/i.test(k)) continue; if (typeof v === 'string' && /^https?:\/\//.test(v)) return v; } return null; } export interface XskillAwaitOptions extends XskillClientOptions { /** Poll interval, ms (default 5000). */ pollIntervalMs?: number; /** Give up after this long, ms (default 600000 = 10 min). */ timeoutMs?: number; /** Called after each poll with the latest status (for progress logging). */ onTick?: (status: XskillTaskStatus, elapsedMs: number) => void; /** Injected clock (ms) for deterministic tests; defaults to Date.now. */ now?: () => number; } export interface XskillCompleted { taskId: string; videoUrl: string; result: Record; } /** * Poll `taskId` until it completes. Resolves with the output URL on `completed`, * throws on `failed` or when `timeoutMs` elapses. Pure of wall-clock except the * injected `now`/`sleep` (overridable for tests). */ export async function xskillAwait(taskId: string, options: XskillAwaitOptions = {}): Promise { const interval = options.pollIntervalMs ?? 5000; const timeout = options.timeoutMs ?? 600000; const now = options.now ?? Date.now; const start = now(); for (;;) { const { status, result, raw } = await xskillPoll(taskId, options); const elapsed = now() - start; options.onTick?.(status, elapsed); if (status === 'completed') { const videoUrl = extractOutputUrl(result); if (!videoUrl) throw new Error(`xskill: task ${taskId} completed but no output URL in result: ${JSON.stringify(raw).slice(0, 200)}`); return { taskId, videoUrl, result: result ?? {} }; } if (status === 'failed') { throw new Error(`xskill: task ${taskId} failed: ${JSON.stringify(raw).slice(0, 300)}`); } if (elapsed >= timeout) { throw new Error(`xskill: task ${taskId} timed out after ${Math.round(elapsed / 1000)}s (last status: ${status}).`); } await sleep(interval); } } /** Download a completed output URL to `dest`. Uses global fetch unless injected. */ export async function xskillDownload( url: string, dest: string, options: { fetchImpl?: XskillDownloadFetchLike } = {}, ): Promise { const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as XskillDownloadFetchLike); const res = await fetchImpl(url); if (!res.ok) throw new Error(`xskill download failed (${res.status}) for ${url}`); const buf = Buffer.from(await res.arrayBuffer()); const { writeFile } = await import('node:fs/promises'); await writeFile(dest, buf); return dest; }