mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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:
committed by
GitHub
parent
76c3b3b8d3
commit
93e217e763
1
changelog.d/features/6658-novita-video-gen-provider.md
Normal file
1
changelog.d/features/6658-novita-video-gen-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658)
|
||||
@@ -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...) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
174
open-sse/handlers/videoGeneration/dashscopeHandler.ts
Normal file
174
open-sse/handlers/videoGeneration/dashscopeHandler.ts
Normal 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;
|
||||
}
|
||||
128
open-sse/handlers/videoGeneration/novita.ts
Normal file
128
open-sse/handlers/videoGeneration/novita.ts
Normal 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" };
|
||||
}
|
||||
146
open-sse/handlers/videoGeneration/novitaHandler.ts
Normal file
146
open-sse/handlers/videoGeneration/novitaHandler.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
319
tests/unit/video-novita-6658.test.ts
Normal file
319
tests/unit/video-novita-6658.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user