From 93e217e76375d1e250708ea0d0ce85618d346a89 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:54:56 -0300 Subject: [PATCH] feat(video): add Novita AI as video-generation provider (#6658) (#7606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Novita AI to the video-generation subsystem (VIDEO_PROVIDERS), alongside its existing text/chat gateway registration. Novita's async video APIs are per-model (POST /v3/async/, e.g. wan-t2v, kling-v1.6-t2v) sharing one poll endpoint (GET /v3/async/task-result?task_id=...) — confirmed against Novita's published API reference. Seeds Wan 2.1 T2V and Kling V1.6 T2V models; reuses the stored novita provider Bearer apiKey (no separate credential flow). To stay under the frozen videoGeneration.ts file-size cap, extracted the existing Alibaba/DashScope handler into a co-located sibling module (videoGeneration/dashscopeHandler.ts) alongside the new Novita handler (videoGeneration/novitaHandler.ts) and its pure helpers (videoGeneration/novita.ts). Also tags novita in VIDEO_PROVIDER_IDS (src/shared/constants/providers.ts) so it surfaces as a video-capable provider in PROVIDER_REFERENCE.md and A2A provider-discovery, and regenerates the provider reference doc. Tests: tests/unit/video-novita-6658.test.ts (18 cases) covering registry shape, pure helpers (URL building, param normalization, task-id/result parsing), and full handler wiring (submit->poll->mp4, missing credentials, missing task_id, task FAILED, task timeout). --- .../6658-novita-video-gen-provider.md | 1 + docs/reference/PROVIDER_REFERENCE.md | 2 +- open-sse/config/videoRegistry.ts | 16 + open-sse/handlers/videoGeneration.ts | 176 +--------- .../videoGeneration/dashscopeHandler.ts | 174 ++++++++++ open-sse/handlers/videoGeneration/novita.ts | 128 +++++++ .../handlers/videoGeneration/novitaHandler.ts | 146 ++++++++ src/shared/constants/providers.ts | 1 + tests/unit/video-novita-6658.test.ts | 319 ++++++++++++++++++ 9 files changed, 791 insertions(+), 172 deletions(-) create mode 100644 changelog.d/features/6658-novita-video-gen-provider.md create mode 100644 open-sse/handlers/videoGeneration/dashscopeHandler.ts create mode 100644 open-sse/handlers/videoGeneration/novita.ts create mode 100644 open-sse/handlers/videoGeneration/novitaHandler.ts create mode 100644 tests/unit/video-novita-6658.test.ts diff --git a/changelog.d/features/6658-novita-video-gen-provider.md b/changelog.d/features/6658-novita-video-gen-provider.md new file mode 100644 index 0000000000..9050c94327 --- /dev/null +++ b/changelog.d/features/6658-novita-video-gen-provider.md @@ -0,0 +1 @@ +- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index bd86c95efa..615b88d3ec 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -198,7 +198,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | | `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | | `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | -| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | +| `novita` | `novita` | Novita AI | API key, video, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | | `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | | `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | | `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 973240b2ae..843c3d9d88 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -206,6 +206,22 @@ export const VIDEO_PROVIDERS: Record = { models: [{ id: "wan2.7-t2v", name: "Wan 2.7 T2V" }], }, + novita: { + id: "novita", + // Novita's async video APIs are per-model: the model id IS the submit path + // segment (`/v3/async/`), all sharing one task-result poll endpoint. + // Reuses the stored novita provider Bearer apiKey — no separate credential flow. + baseUrl: "https://api.novita.ai/v3/async", + statusUrl: "https://api.novita.ai/v3/async/task-result", + authType: "apikey", + authHeader: "bearer", + format: "novita-video", + models: [ + { id: "wan-t2v", name: "Wan 2.1 Text-to-Video" }, + { id: "kling-v1.6-t2v", name: "Kling V1.6 Text-to-Video" }, + ], + }, + xai: { id: "xai", // xAI Grok Imagine async video-generation API. Reuses the stored xai diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 1bccc54dd5..b77f44c674 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -11,6 +11,8 @@ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateVideo } from "../executors/vertexMedia.ts"; import { handleGoogleFlowVideoGeneration } from "./videoGeneration/googleFlowHandler.ts"; +import { handleDashscopeVideoGeneration } from "./videoGeneration/dashscopeHandler.ts"; +import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"; import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; @@ -115,6 +117,9 @@ export async function handleVideoGeneration({ body, credentials, log }) { }); } + if (providerConfig.format === "novita-video") { + return handleNovitaVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } if (providerConfig.format === "xai-video") { return handleXaiVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } @@ -126,177 +131,6 @@ export async function handleVideoGeneration({ body, credentials, log }) { }; } -/** - * Alibaba (DashScope) Wan video generation: create async task → poll → MP4. - * Targets wan2.7-t2v on the DashScope intl region. Reuses the stored alibaba - * provider Bearer apiKey — no separate credential flow. - */ -async function handleDashscopeVideoGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}: { - model: string; - provider: string; - providerConfig: { baseUrl: string; statusUrl?: string }; - body: Record & { - prompt?: unknown; - negative_prompt?: unknown; - size?: unknown; - aspect_ratio?: unknown; - duration?: unknown; - timeout_ms?: unknown; - poll_interval_ms?: unknown; - }; - credentials?: { apiKey?: string; accessToken?: string } | null; - log?: { - info: (scope: string, message: string) => void; - error: (scope: string, message: string) => void; - } | null; -}) { - const startTime = Date.now(); - const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; - const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; - const token = credentials?.apiKey || credentials?.accessToken; - const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); - const statusUrl = (providerConfig.statusUrl || `${baseUrl}/tasks`).replace(/\/$/, ""); - const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - - if (!token) { - return { success: false, status: 401, error: "Alibaba DashScope API key is required" }; - } - - const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio); - const parameters: Record = {}; - if (sizeParam) parameters.size = sizeParam; - if (body.duration != null) parameters.duration = Number(body.duration); - - const payload = { - model, - input: { - prompt, - ...(typeof body.negative_prompt === "string" - ? { negative_prompt: body.negative_prompt } - : {}), - }, - parameters, - }; - - if (log) { - log.info( - "VIDEO", - `${provider}/${model} (dashscope-video) | prompt: "${prompt.slice(0, 60)}..."` - ); - } - - try { - // Step 1: create async task (X-DashScope-Async: enable) - const createRes = await fetch(`${baseUrl}/services/aigc/video-generation/video-synthesis`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-DashScope-Async": "enable", - }, - body: JSON.stringify(payload), - }); - const createData = await createRes.json().catch(() => ({})); - const taskId = createData?.output?.task_id; - if (!taskId) { - const errorMessage = - createData?.message || - createData?.errors?.[0]?.message || - "DashScope video generation did not return task_id"; - if (log) { - log.error("VIDEO", `DashScope createTask failed: ${JSON.stringify(createData)}`); - } - return { success: false, status: 502, error: String(errorMessage) }; - } - - // Step 2: poll statusUrl/{task_id} until terminal - const deadline = startTime + timeoutMs; - let lastStatus = "PENDING"; - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - const pollRes = await fetch(`${statusUrl}/${taskId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const pollData = await pollRes.json().catch(() => ({})); - lastStatus = pollData?.output?.task_status || "PENDING"; - - if (lastStatus === "SUCCEEDED") { - const videoUrl = pollData?.output?.video_url; - if (!videoUrl) { - return { - success: false, - status: 502, - error: "DashScope task SUCCEEDED but no video_url", - }; - } - saveCallLog({ - method: "POST", - path: "/v1/videos/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { videos_count: 1 }, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ url: videoUrl, format: "mp4" }], - }, - }; - } - - if (lastStatus === "FAILED" || lastStatus === "UNKNOWN_ERROR") { - const errorMessage = - pollData?.output?.message || - pollData?.output?.errors?.[0]?.message || - "DashScope video task FAILED"; - return { success: false, status: 502, error: String(errorMessage) }; - } - // PENDING / RUNNING → keep polling - } - - return { - success: false, - status: 504, - error: `DashScope task ${taskId} timed out (status: ${lastStatus})`, - }; - } catch (err: unknown) { - return { - success: false, - status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, - error: sanitizeErrorMessage(err) || "Video provider error", - }; - } -} - -// Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720). -// Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable -// (then omitted from the payload so DashScope applies its own default). -function normalizeDashscopeSize(size: unknown, aspectRatio: unknown): string | undefined { - if (typeof size === "string") { - if (/^\d+\*\d+$/.test(size)) return size; - if (/^\d+x\d+$/.test(size)) return size.replace("x", "*"); - } - if (typeof aspectRatio === "string") { - const ratioMap: Record = { - "16:9": "1280*720", - "9:16": "720*1280", - "1:1": "960*960", - }; - return ratioMap[aspectRatio]; - } - return undefined; -} - /** * Veo video generation via Vertex AI (predictLongRunning → poll → MP4). * Uses the Vertex chat credentials (Service Account JSON or Express key). diff --git a/open-sse/handlers/videoGeneration/dashscopeHandler.ts b/open-sse/handlers/videoGeneration/dashscopeHandler.ts new file mode 100644 index 0000000000..f1f7665b1b --- /dev/null +++ b/open-sse/handlers/videoGeneration/dashscopeHandler.ts @@ -0,0 +1,174 @@ +import { isJsonObject } from "../../utils/kieTask.ts"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +/** + * Alibaba (DashScope) Wan video generation: create async task → poll → MP4. + * Targets wan2.7-t2v on the DashScope intl region. Reuses the stored alibaba + * provider Bearer apiKey — no separate credential flow. + */ +export async function handleDashscopeVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: Record & { + prompt?: unknown; + negative_prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + duration?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +}) { + const startTime = Date.now(); + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : 300000; + const pollIntervalMs = Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : 2500; + const token = credentials?.apiKey || credentials?.accessToken; + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const statusUrl = (providerConfig.statusUrl || `${baseUrl}/tasks`).replace(/\/$/, ""); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + + if (!token) { + return { success: false, status: 401, error: "Alibaba DashScope API key is required" }; + } + + const sizeParam = normalizeDashscopeSize(body.size, body.aspect_ratio); + const parameters: Record = {}; + if (sizeParam) parameters.size = sizeParam; + if (body.duration != null) parameters.duration = Number(body.duration); + + const payload = { + model, + input: { + prompt, + ...(typeof body.negative_prompt === "string" + ? { negative_prompt: body.negative_prompt } + : {}), + }, + parameters, + }; + + if (log) { + log.info( + "VIDEO", + `${provider}/${model} (dashscope-video) | prompt: "${prompt.slice(0, 60)}..."` + ); + } + + try { + // Step 1: create async task (X-DashScope-Async: enable) + const createRes = await fetch(`${baseUrl}/services/aigc/video-generation/video-synthesis`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + }, + body: JSON.stringify(payload), + }); + const createData = await createRes.json().catch(() => ({})); + const taskId = createData?.output?.task_id; + if (!taskId) { + const errorMessage = + createData?.message || + createData?.errors?.[0]?.message || + "DashScope video generation did not return task_id"; + if (log) { + log.error("VIDEO", `DashScope createTask failed: ${JSON.stringify(createData)}`); + } + return { success: false, status: 502, error: String(errorMessage) }; + } + + // Step 2: poll statusUrl/{task_id} until terminal + const deadline = startTime + timeoutMs; + let lastStatus = "PENDING"; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + const pollRes = await fetch(`${statusUrl}/${taskId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const pollData = await pollRes.json().catch(() => ({})); + lastStatus = pollData?.output?.task_status || "PENDING"; + + if (lastStatus === "SUCCEEDED") { + const videoUrl = pollData?.output?.video_url; + if (!videoUrl) { + return { + success: false, + status: 502, + error: "DashScope task SUCCEEDED but no video_url", + }; + } + saveCallLog({ + method: "POST", + path: "/v1/videos/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { videos_count: 1 }, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url: videoUrl, format: "mp4" }], + }, + }; + } + + if (lastStatus === "FAILED" || lastStatus === "UNKNOWN_ERROR") { + const errorMessage = + pollData?.output?.message || + pollData?.output?.errors?.[0]?.message || + "DashScope video task FAILED"; + return { success: false, status: 502, error: String(errorMessage) }; + } + // PENDING / RUNNING → keep polling + } + + return { + success: false, + status: 504, + error: `DashScope task ${taskId} timed out (status: ${lastStatus})`, + }; + } catch (err: unknown) { + return { + success: false, + status: isJsonObject(err) && Number.isFinite(Number(err.status)) ? Number(err.status) : 502, + error: sanitizeErrorMessage(err) || "Video provider error", + }; + } +} + +// Map OmniRoute size/aspect_ratio → Alibaba DashScope "WxH" (1280*720). +// Accepts "1280*720", "1280x720", or a ratio "16:9". Returns undefined if unparseable +// (then omitted from the payload so DashScope applies its own default). +function normalizeDashscopeSize(size: unknown, aspectRatio: unknown): string | undefined { + if (typeof size === "string") { + if (/^\d+\*\d+$/.test(size)) return size; + if (/^\d+x\d+$/.test(size)) return size.replace("x", "*"); + } + if (typeof aspectRatio === "string") { + const ratioMap: Record = { + "16:9": "1280*720", + "9:16": "720*1280", + "1:1": "960*960", + }; + return ratioMap[aspectRatio]; + } + return undefined; +} diff --git a/open-sse/handlers/videoGeneration/novita.ts b/open-sse/handlers/videoGeneration/novita.ts new file mode 100644 index 0000000000..1107cae981 --- /dev/null +++ b/open-sse/handlers/videoGeneration/novita.ts @@ -0,0 +1,128 @@ +/** + * Novita AI video generation — pure helpers. + * + * Novita exposes per-model async endpoints under `/v3/async/` (e.g. + * `/v3/async/wan-t2v`, `/v3/async/kling-v1.6-t2v`) — unlike DashScope/Kie there is + * no single shared submit path; the model id IS the path segment. Every model + * shares one poll endpoint: `GET /v3/async/task-result?task_id=...`, returning + * `{ task: { status, reason, progress_percent }, videos: [{ video_url }] }`. + * Confirmed against Novita's published API reference (2026-07-17): + * https://novita.ai/docs/api-reference/model-apis-wan-t2v + * https://novita.ai/docs/api-reference/model-apis-kling-v1.6-t2v + */ + +export interface NovitaVideoParams { + prompt: string; + negativePrompt?: string; + duration?: number; + width?: number; + height?: number; +} + +export interface NovitaTaskResult { + /** true when the task has reached a terminal state (succeeded or failed) */ + done: boolean; + status: string; + videoUrl?: string; + errorMessage?: string; +} + +const NOVITA_TERMINAL_SUCCESS = new Set(["TASK_STATUS_SUCCEED", "SUCCEED", "SUCCEEDED"]); +const NOVITA_TERMINAL_FAILURE = new Set(["TASK_STATUS_FAILED", "FAILED", "UNKNOWN_ERROR"]); + +/** Build the submit URL for a given Novita model slug: `/`. */ +export function buildNovitaSubmitUrl(baseUrl: string, model: string): string { + return `${baseUrl.replace(/\/$/, "")}/${model}`; +} + +/** Build the poll URL for a task id: `?task_id=`. */ +export function buildNovitaPollUrl(statusUrl: string, taskId: string): string { + return `${statusUrl.replace(/\/$/, "")}?task_id=${encodeURIComponent(taskId)}`; +} + +/** + * Normalize an OpenAI-style /v1/videos/generations body into Novita params. + * Accepts both snake_case (OpenAI) and a "WxH"/"WxHxN" style `size` string. + */ +export function normalizeNovitaVideoParams( + body: Record | null | undefined +): NovitaVideoParams { + const b = body ?? {}; + const prompt = typeof b.prompt === "string" ? b.prompt : String(b.prompt ?? ""); + const negativePrompt = typeof b.negative_prompt === "string" ? b.negative_prompt : undefined; + + const durationRaw = typeof b.duration === "number" ? b.duration : undefined; + const duration = + typeof durationRaw === "number" && Number.isFinite(durationRaw) && durationRaw > 0 + ? durationRaw + : undefined; + + let width: number | undefined; + let height: number | undefined; + if (typeof b.size === "string") { + const match = /^(\d+)\s*[x*]\s*(\d+)/.exec(b.size); + if (match) { + width = Number(match[1]); + height = Number(match[2]); + } + } + + return { prompt, negativePrompt, duration, width, height }; +} + +/** + * Build the Novita submit request body. Only includes optional fields that were + * actually resolved — Novita applies its own defaults for the rest. + */ +export function buildNovitaSubmitBody(params: NovitaVideoParams): Record { + const payload: Record = { prompt: params.prompt }; + if (params.negativePrompt) payload.negative_prompt = params.negativePrompt; + if (typeof params.duration === "number") payload.duration = params.duration; + if (typeof params.width === "number") payload.width = params.width; + if (typeof params.height === "number") payload.height = params.height; + return payload; +} + +/** Extract the async task id from a submit response. */ +export function parseNovitaTaskId(json: unknown): string | null { + if (!json || typeof json !== "object") return null; + const taskId = (json as { task_id?: unknown }).task_id; + return typeof taskId === "string" && taskId.length > 0 ? taskId : null; +} + +/** + * Interpret a `/v3/async/task-result` poll response into a normalized result. + * `done: false` means still queued/processing — callers should keep polling. + */ +function extractNovitaVideoUrl(json: unknown): string | null { + const videos = (json as { videos?: unknown })?.videos; + if (!Array.isArray(videos) || videos.length === 0) return null; + const first = videos[0]; + if (!first || typeof first !== "object") return null; + const videoUrl = (first as Record).video_url; + return typeof videoUrl === "string" && videoUrl.length > 0 ? videoUrl : null; +} + +export function parseNovitaTaskResult(json: unknown): NovitaTaskResult { + if (!json || typeof json !== "object") { + return { done: false, status: "UNKNOWN" }; + } + + const task = (json as { task?: unknown }).task; + const taskRec = task && typeof task === "object" ? (task as Record) : {}; + const status = typeof taskRec.status === "string" ? taskRec.status : "UNKNOWN"; + + if (NOVITA_TERMINAL_FAILURE.has(status)) { + const reason = typeof taskRec.reason === "string" && taskRec.reason ? taskRec.reason : null; + return { done: true, status, errorMessage: reason || `Novita video task ${status}` }; + } + + if (!NOVITA_TERMINAL_SUCCESS.has(status)) { + return { done: false, status }; + } + + const videoUrl = extractNovitaVideoUrl(json); + if (videoUrl) return { done: true, status, videoUrl }; + + return { done: true, status, errorMessage: "Novita task succeeded but returned no video_url" }; +} diff --git a/open-sse/handlers/videoGeneration/novitaHandler.ts b/open-sse/handlers/videoGeneration/novitaHandler.ts new file mode 100644 index 0000000000..a9ddf1441e --- /dev/null +++ b/open-sse/handlers/videoGeneration/novitaHandler.ts @@ -0,0 +1,146 @@ +/** + * Novita AI video generation — request orchestration. + * + * Reuses the stored Novita provider Bearer apiKey (same credential the Novita + * chat/LLM gateway already uses — no separate credential flow). Submits to the + * model-specific `/v3/async/` endpoint, polls the shared `task-result` + * endpoint by `task_id` with backoff, and returns the OpenAI-like response shape. + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { + buildNovitaPollUrl, + buildNovitaSubmitBody, + buildNovitaSubmitUrl, + normalizeNovitaVideoParams, + parseNovitaTaskId, + parseNovitaTaskResult, +} from "./novita.ts"; + +interface NovitaHandlerArgs { + model: string; + provider: string; + providerConfig: { baseUrl: string; statusUrl?: string }; + body: Record & { timeout_ms?: unknown; poll_interval_ms?: unknown }; + credentials?: { apiKey?: string; accessToken?: string } | null; + log?: { + info?: (scope: string, message: string) => void; + error?: (scope: string, message: string) => void; + } | null; +} + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_POLL_INTERVAL_MS = 2500; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type NovitaHandlerResult = + | { success: true; data: { created: number; data: [{ url: string; format: string }] } } + | { success: false; status: number; error: string }; + +/** Submit the async video task; returns the task_id or a ready-to-return error result. */ +async function submitNovitaTask( + submitUrl: string, + headers: Record, + payload: Record, + log: NovitaHandlerArgs["log"] +): Promise<{ taskId: string } | { error: NovitaHandlerResult }> { + const submitRes = await fetch(submitUrl, { method: "POST", headers, body: JSON.stringify(payload) }); + const submitData = await submitRes.json().catch(() => ({})); + const taskId = parseNovitaTaskId(submitData); + if (taskId) return { taskId }; + + const errorMessage = + (submitData as { message?: unknown })?.message || "Novita did not return a task_id"; + log?.error?.("VIDEO", `Novita createTask failed: ${JSON.stringify(submitData)}`); + return { + error: { + success: false, + status: submitRes.ok ? 502 : submitRes.status, + error: String(errorMessage), + }, + }; +} + +/** Resolve the request timeout + poll interval, falling back to the module defaults. */ +function resolveNovitaTiming(body: NovitaHandlerArgs["body"]): { + timeoutMs: number; + pollIntervalMs: number; +} { + const timeoutMs = Number(body.timeout_ms) > 0 ? Number(body.timeout_ms) : DEFAULT_TIMEOUT_MS; + const pollIntervalMs = + Number(body.poll_interval_ms) > 0 ? Number(body.poll_interval_ms) : DEFAULT_POLL_INTERVAL_MS; + return { timeoutMs, pollIntervalMs }; +} + +/** Poll task-result until terminal (success/failure) or the deadline elapses. */ +async function pollNovitaTask( + pollUrl: string, + token: string, + taskId: string, + deadline: number, + pollIntervalMs: number +): Promise { + let lastStatus = "UNKNOWN"; + + while (Date.now() < deadline) { + await sleep(pollIntervalMs); + const pollRes = await fetch(pollUrl, { headers: { Authorization: `Bearer ${token}` } }); + const pollData = await pollRes.json().catch(() => ({})); + const result = parseNovitaTaskResult(pollData); + lastStatus = result.status; + + if (!result.done) continue; + + if (result.videoUrl) { + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [{ url: result.videoUrl, format: "mp4" }] }, + }; + } + + return { success: false, status: 502, error: sanitizeErrorMessage(result.errorMessage) }; + } + + return { success: false, status: 504, error: `Novita task ${taskId} timed out (status: ${lastStatus})` }; +} + +export async function handleNovitaVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: NovitaHandlerArgs): Promise { + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return { success: false, status: 401, error: "Novita AI API key is required" }; + } + + const { timeoutMs, pollIntervalMs } = resolveNovitaTiming(body); + + const statusUrl = providerConfig.statusUrl || `${providerConfig.baseUrl}/task-result`; + const submitUrl = buildNovitaSubmitUrl(providerConfig.baseUrl, model); + const params = normalizeNovitaVideoParams(body); + const payload = buildNovitaSubmitBody(params); + const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + + log?.info?.("VIDEO", `${provider}/${model} (novita-video) | prompt: "${params.prompt.slice(0, 60)}..."`); + + try { + const submitted = await submitNovitaTask(submitUrl, headers, payload, log); + if ("error" in submitted) return submitted.error; + + const pollUrl = buildNovitaPollUrl(statusUrl, submitted.taskId); + return await pollNovitaTask(pollUrl, token, submitted.taskId, Date.now() + timeoutMs, pollIntervalMs); + } catch (err) { + const e = (err ?? {}) as { message?: string; status?: number }; + log?.error?.("VIDEO", `Novita video generation failed: ${e.message}`); + return { + success: false, + status: typeof e.status === "number" ? e.status : 502, + error: sanitizeErrorMessage(e.message || "Novita video generation failed"), + }; + } +} diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index f509444f74..4ffde68df3 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -107,6 +107,7 @@ export const VIDEO_PROVIDER_IDS = new Set([ "replicate", "haiper", "leonardo", + "novita", ]); // IDE Providers: editors with built-in AI subscription (separate section in UI). diff --git a/tests/unit/video-novita-6658.test.ts b/tests/unit/video-novita-6658.test.ts new file mode 100644 index 0000000000..37509045dc --- /dev/null +++ b/tests/unit/video-novita-6658.test.ts @@ -0,0 +1,319 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-video-novita-")); + +const { handleVideoGeneration } = await import("../../open-sse/handlers/videoGeneration.ts"); +const { VIDEO_PROVIDERS } = await import("../../open-sse/config/videoRegistry.ts"); +const { VIDEO_PROVIDER_IDS } = await import("../../src/shared/constants/providers.ts"); +const { + buildNovitaSubmitUrl, + buildNovitaPollUrl, + normalizeNovitaVideoParams, + buildNovitaSubmitBody, + parseNovitaTaskId, + parseNovitaTaskResult, +} = await import("../../open-sse/handlers/videoGeneration/novita.ts"); + +// Makes poll-interval waits resolve instantly so tests don't sleep. +function immediateTimeout(callback, _ms, ...args) { + if (typeof callback === "function") callback(...args); + return 0; +} + +const SUBMIT_URL = "https://api.novita.ai/v3/async/wan-t2v"; +const POLL_URL_PREFIX = "https://api.novita.ai/v3/async/task-result?task_id="; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +// --- Registry shape ------------------------------------------------------- + +test("VIDEO_PROVIDERS exposes the novita novita-video entry", () => { + assert.ok(VIDEO_PROVIDERS.novita, "novita video provider is registered"); + assert.equal(VIDEO_PROVIDERS.novita.format, "novita-video"); + assert.equal(VIDEO_PROVIDERS.novita.authType, "apikey"); + assert.equal(VIDEO_PROVIDERS.novita.authHeader, "bearer"); + assert.equal(VIDEO_PROVIDERS.novita.baseUrl, "https://api.novita.ai/v3/async"); + assert.equal(VIDEO_PROVIDERS.novita.statusUrl, "https://api.novita.ai/v3/async/task-result"); + assert.ok( + VIDEO_PROVIDERS.novita.models.some((m) => m.id === "wan-t2v"), + "wan-t2v is listed" + ); + assert.ok( + VIDEO_PROVIDERS.novita.models.some((m) => m.id === "kling-v1.6-t2v"), + "kling-v1.6-t2v is listed" + ); +}); + +test("VIDEO_PROVIDER_IDS (docs/A2A discovery tag set) includes novita", () => { + assert.ok(VIDEO_PROVIDER_IDS.has("novita"), "novita should be tagged as a video provider"); +}); + +// --- Pure helpers ----------------------------------------------------------- + +test("buildNovitaSubmitUrl joins baseUrl and model slug", () => { + assert.equal( + buildNovitaSubmitUrl("https://api.novita.ai/v3/async", "wan-t2v"), + "https://api.novita.ai/v3/async/wan-t2v" + ); + assert.equal( + buildNovitaSubmitUrl("https://api.novita.ai/v3/async/", "kling-v1.6-t2v"), + "https://api.novita.ai/v3/async/kling-v1.6-t2v" + ); +}); + +test("buildNovitaPollUrl appends the task_id query param", () => { + assert.equal( + buildNovitaPollUrl("https://api.novita.ai/v3/async/task-result", "abc 123"), + "https://api.novita.ai/v3/async/task-result?task_id=abc%20123" + ); +}); + +test("normalizeNovitaVideoParams parses prompt/negative_prompt/duration/size", () => { + const params = normalizeNovitaVideoParams({ + prompt: "a cat surfing", + negative_prompt: "blurry", + duration: 5, + size: "832x480", + }); + assert.equal(params.prompt, "a cat surfing"); + assert.equal(params.negativePrompt, "blurry"); + assert.equal(params.duration, 5); + assert.equal(params.width, 832); + assert.equal(params.height, 480); +}); + +test("normalizeNovitaVideoParams tolerates missing/invalid fields", () => { + const params = normalizeNovitaVideoParams(null); + assert.equal(params.prompt, ""); + assert.equal(params.negativePrompt, undefined); + assert.equal(params.duration, undefined); + assert.equal(params.width, undefined); + + const negativeDuration = normalizeNovitaVideoParams({ prompt: "x", duration: -5 }); + assert.equal(negativeDuration.duration, undefined); +}); + +test("buildNovitaSubmitBody omits unset optional fields", () => { + assert.deepEqual(buildNovitaSubmitBody({ prompt: "hello" }), { prompt: "hello" }); + assert.deepEqual( + buildNovitaSubmitBody({ prompt: "hello", negativePrompt: "bad", duration: 5, width: 832, height: 480 }), + { prompt: "hello", negative_prompt: "bad", duration: 5, width: 832, height: 480 } + ); +}); + +test("parseNovitaTaskId extracts task_id and rejects malformed payloads", () => { + assert.equal(parseNovitaTaskId({ task_id: "abc" }), "abc"); + assert.equal(parseNovitaTaskId({}), null); + assert.equal(parseNovitaTaskId(null), null); + assert.equal(parseNovitaTaskId({ task_id: 123 }), null); +}); + +test("parseNovitaTaskResult: queued/processing → not done", () => { + const result = parseNovitaTaskResult({ task: { status: "TASK_STATUS_QUEUED" } }); + assert.equal(result.done, false); + assert.equal(result.status, "TASK_STATUS_QUEUED"); +}); + +test("parseNovitaTaskResult: succeeded with video_url → done + videoUrl", () => { + const result = parseNovitaTaskResult({ + task: { status: "TASK_STATUS_SUCCEED" }, + videos: [{ video_url: "https://cdn.novita.ai/out.mp4", video_type: "mp4" }], + }); + assert.equal(result.done, true); + assert.equal(result.videoUrl, "https://cdn.novita.ai/out.mp4"); +}); + +test("parseNovitaTaskResult: succeeded without videos → done + error", () => { + const result = parseNovitaTaskResult({ task: { status: "TASK_STATUS_SUCCEED" }, videos: [] }); + assert.equal(result.done, true); + assert.equal(result.videoUrl, undefined); + assert.match(result.errorMessage, /no video_url/); +}); + +test("parseNovitaTaskResult: failed → done + reason surfaced", () => { + const result = parseNovitaTaskResult({ + task: { status: "TASK_STATUS_FAILED", reason: "content policy violation" }, + }); + assert.equal(result.done, true); + assert.equal(result.errorMessage, "content policy violation"); +}); + +test("parseNovitaTaskResult: malformed payload does not crash", () => { + assert.deepEqual(parseNovitaTaskResult(null), { done: false, status: "UNKNOWN" }); + assert.deepEqual(parseNovitaTaskResult("garbage"), { done: false, status: "UNKNOWN" }); +}); + +// --- Full handler wiring (submit → poll → mp4) ------------------------------ + +test("handleVideoGeneration submits + polls a Novita task and returns mp4 URL", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + let submitRequest; + + globalThis.setTimeout = immediateTimeout; + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + + if (stringUrl === SUBMIT_URL) { + submitRequest = { + url: stringUrl, + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return jsonResponse({ task_id: "novita-task-1" }); + } + + if (stringUrl.startsWith(POLL_URL_PREFIX)) { + return jsonResponse({ + task: { task_id: "novita-task-1", status: "TASK_STATUS_SUCCEED", progress_percent: 100 }, + videos: [{ video_url: "https://cdn.novita.ai/wan-out.mp4", video_type: "mp4" }], + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "novita/wan-t2v", + prompt: "a neon city in the rain", + negative_prompt: "blurry", + duration: 5, + }, + credentials: { apiKey: "novita-key" }, + log: null, + }); + + assert.equal(submitRequest.headers["Authorization"], "Bearer novita-key"); + assert.equal(submitRequest.body.prompt, "a neon city in the rain"); + assert.equal(submitRequest.body.negative_prompt, "blurry"); + assert.equal(submitRequest.body.duration, 5); + + assert.equal(result.success, true); + assert.equal(result.data.data[0].url, "https://cdn.novita.ai/wan-out.mp4"); + assert.equal(result.data.data[0].format, "mp4"); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleVideoGeneration rejects Novita requests without credentials", async () => { + const result = await handleVideoGeneration({ + body: { model: "novita/wan-t2v", prompt: "x" }, + credentials: null, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.match(result.error, /Novita AI API key is required/); +}); + +test("handleVideoGeneration surfaces an error when Novita returns no task_id", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => jsonResponse({ message: "Invalid API key" }, 401); + + try { + const result = await handleVideoGeneration({ + body: { model: "novita/wan-t2v", prompt: "x" }, + credentials: { apiKey: "bad-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.error, "Invalid API key"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration returns 502 when the Novita task FAILED", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = immediateTimeout; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === SUBMIT_URL) { + return jsonResponse({ task_id: "novita-fail" }); + } + if (stringUrl.startsWith(POLL_URL_PREFIX)) { + return jsonResponse({ + task: { status: "TASK_STATUS_FAILED", reason: "content policy violation" }, + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { model: "novita/wan-t2v", prompt: "x" }, + credentials: { apiKey: "novita-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal(result.error, "content policy violation"); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + } +}); + +test("handleVideoGeneration returns 504 when the Novita task never completes", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.setTimeout; + const originalNow = Date.now; + globalThis.setTimeout = immediateTimeout; + + let nowCalls = 0; + Date.now = () => { + nowCalls += 1; + return nowCalls <= 2 ? 1000 : 1_000_000; + }; + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === SUBMIT_URL) { + return jsonResponse({ task_id: "novita-stuck" }); + } + if (stringUrl.startsWith(POLL_URL_PREFIX)) { + return jsonResponse({ task: { status: "TASK_STATUS_QUEUED" } }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "novita/wan-t2v", + prompt: "x", + timeout_ms: 5000, + poll_interval_ms: 100, + }, + credentials: { apiKey: "novita-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 504); + assert.match(result.error, /timed out/); + } finally { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + Date.now = originalNow; + } +});