feat(video): add Novita AI as video-generation provider (#6658) (#7606)

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/<model-slug>, 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).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 11:54:56 -03:00
committed by GitHub
parent 76c3b3b8d3
commit 93e217e763
9 changed files with 791 additions and 172 deletions

View File

@@ -0,0 +1 @@
- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658)

View File

@@ -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 <key>. OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu/<model>/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...) |

View File

@@ -206,6 +206,22 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
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/<model>`), 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

View File

@@ -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<string, unknown> & {
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<string, unknown> = {};
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<string, string> = {
"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).

View File

@@ -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<string, unknown> & {
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<string, unknown> = {};
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<string, string> = {
"16:9": "1280*720",
"9:16": "720*1280",
"1:1": "960*960",
};
return ratioMap[aspectRatio];
}
return undefined;
}

View File

@@ -0,0 +1,128 @@
/**
* Novita AI video generation — pure helpers.
*
* Novita exposes per-model async endpoints under `/v3/async/<model-slug>` (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: `<baseUrl>/<model>`. */
export function buildNovitaSubmitUrl(baseUrl: string, model: string): string {
return `${baseUrl.replace(/\/$/, "")}/${model}`;
}
/** Build the poll URL for a task id: `<statusUrl>?task_id=<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<string, unknown> | 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<string, unknown> {
const payload: Record<string, unknown> = { 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<string, unknown>).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<string, unknown>) : {};
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" };
}

View File

@@ -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/<model>` 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<string, 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 DEFAULT_TIMEOUT_MS = 300_000;
const DEFAULT_POLL_INTERVAL_MS = 2500;
const sleep = (ms: number) => new Promise<void>((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<string, string>,
payload: Record<string, unknown>,
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<NovitaHandlerResult> {
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<NovitaHandlerResult> {
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"),
};
}
}

View File

@@ -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).

View File

@@ -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;
}
});