feat(video): add OpenRouter and Vertex AI (Veo) video generation
Video generation was xAI-only. Adds an adapter layer under
open-sse/handlers/videoProviders/ so /v1/videos/* can target OpenRouter or
Google Cloud credentials. A provider with no adapter keeps the exact previous
behaviour (raw body to {baseUrl}/{action}, poll {baseUrl}/{id}, verbatim
passthrough), so the xAI path is unchanged.
- openrouter: async job shape identical to xAI; creation POSTs to the /videos
collection root (no /generations suffix) and the registry HTTP-Referer /
X-Title headers are applied. Bodies pass through verbatim.
- vertex: two-way translation, since Veo does not speak the OpenAI-ish videos
shape. create -> :predictLongRunning { instances[], parameters{} }, poll ->
:fetchPredictOperation (Veo has no REST GET poll). The operation resource
name is base64url-encoded into the job id so GET /v1/videos/{id} stays a
flat path. Access tokens are minted from Service Account JSON via the
existing refreshVertexToken; raw API keys are rejected up front. The
operation response maps back onto the { id, status, video, videos } shape
clients already poll.
- videoCore: the request plan is rebuilt per attempt, so the 401 -> refresh
once -> retry once path picks up the refreshed token. Adapter validation
errors return 400 before any upstream call, so a malformed request can never
create a billable job.
- videoGeneration: GET /v1/videos/{id} resolves the provider from the pinned
x-connection-id connection, then ?provider=, then falls back to the xAI
default.
- registry: openrouter and vertex gain videoConfig, the video serviceKind and
video-kind models (Veo 3.1 / 3 / 2, Sora 2 Pro, Seedance 2.0).
This commit is contained in:
@@ -2,6 +2,7 @@ import { createErrorResult } from "../utils/error.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
||||
import { PROVIDER_MEDIA } from "../providers/index.js";
|
||||
import { getVideoAdapter } from "./videoProviders/index.js";
|
||||
|
||||
// Upstream fetch deadline for video job submission/polling (the job itself is
|
||||
// async upstream — this only bounds the HTTP round-trip, not video rendering).
|
||||
@@ -94,21 +95,49 @@ export async function handleVideoProxyCore({
|
||||
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
|
||||
}
|
||||
|
||||
const method = requestId ? "GET" : "POST";
|
||||
const url = buildUpstreamUrl(config, action, requestId);
|
||||
const adapter = getVideoAdapter(provider);
|
||||
const fetchSignal = combineSignals(signal, timeoutMs);
|
||||
|
||||
const doFetch = (token) =>
|
||||
fetch(url, {
|
||||
// Default (xAI shape) request plan; adapters override URL/method/headers/body.
|
||||
const defaultPlan = () => {
|
||||
const method = requestId ? "GET" : "POST";
|
||||
return {
|
||||
method,
|
||||
headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }),
|
||||
url: buildUpstreamUrl(config, action, requestId),
|
||||
headers: buildHeaders({
|
||||
token: credentials?.accessToken || credentials?.apiKey,
|
||||
contentType: method === "POST" ? contentType : null,
|
||||
idempotencyKey: method === "POST" ? idempotencyKey : null,
|
||||
}),
|
||||
body: method === "POST" ? rawBody : undefined,
|
||||
signal: fetchSignal,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Rebuilt per attempt so the auth retry below picks up the refreshed token.
|
||||
const doFetch = async () => {
|
||||
const plan = adapter
|
||||
? await adapter.buildRequest({
|
||||
config, action, requestId, rawBody, contentType, idempotencyKey, credentials, log,
|
||||
token: credentials?.accessToken || credentials?.apiKey,
|
||||
})
|
||||
: defaultPlan();
|
||||
if (plan.error) return { planError: plan.error };
|
||||
return {
|
||||
response: await fetch(plan.url, {
|
||||
method: plan.method,
|
||||
headers: plan.headers,
|
||||
body: plan.body,
|
||||
signal: fetchSignal,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const method = requestId ? "GET" : "POST";
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await doFetch(credentials?.accessToken || credentials?.apiKey);
|
||||
const first = await doFetch();
|
||||
if (first.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${first.planError}`);
|
||||
upstream = first.response;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
||||
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
|
||||
@@ -136,7 +165,9 @@ export async function handleVideoProxyCore({
|
||||
await upstream.body?.cancel?.();
|
||||
} catch { /* noop */ }
|
||||
try {
|
||||
upstream = await doFetch(credentials.accessToken || credentials.apiKey);
|
||||
const retry = await doFetch();
|
||||
if (retry.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${retry.planError}`);
|
||||
upstream = retry.response;
|
||||
} catch (error) {
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
|
||||
}
|
||||
@@ -152,13 +183,25 @@ export async function handleVideoProxyCore({
|
||||
return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`);
|
||||
}
|
||||
|
||||
// Success: pass the upstream JSON through untouched (request_id / status / video.url).
|
||||
// Success: pass the upstream JSON through untouched (request_id / status / video.url),
|
||||
// unless the adapter maps a provider-native shape onto it (Vertex operations).
|
||||
let outBody = bodyText;
|
||||
let outType = upstream.headers.get("content-type") || "application/json";
|
||||
if (adapter?.transformResponse) {
|
||||
try {
|
||||
outBody = JSON.stringify(adapter.transformResponse(JSON.parse(bodyText)));
|
||||
outType = "application/json";
|
||||
} catch {
|
||||
// Non-JSON or unexpected shape — fall back to the raw upstream body.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(bodyText, {
|
||||
response: new Response(outBody, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"Content-Type": upstream.headers.get("content-type") || "application/json",
|
||||
"Content-Type": outType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}),
|
||||
|
||||
13
open-sse/handlers/videoProviders/index.js
Normal file
13
open-sse/handlers/videoProviders/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Video provider adapters.
|
||||
//
|
||||
// Default (no adapter) = xAI shape: raw body forwarded to {baseUrl}/{action},
|
||||
// polled at {baseUrl}/{id}, upstream JSON passed through verbatim.
|
||||
// A provider only needs an adapter when its wire format differs from that.
|
||||
import openrouter from "./openrouter.js";
|
||||
import vertex from "./vertex.js";
|
||||
|
||||
const ADAPTERS = { openrouter, vertex };
|
||||
|
||||
export function getVideoAdapter(provider) {
|
||||
return ADAPTERS[provider] || null;
|
||||
}
|
||||
39
open-sse/handlers/videoProviders/openrouter.js
Normal file
39
open-sse/handlers/videoProviders/openrouter.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// OpenRouter video jobs — https://openrouter.ai/docs/api/api-reference/videos
|
||||
//
|
||||
// Same async shape as xAI (POST → { id, status }, GET → status/unsigned_urls),
|
||||
// two differences only: creation POSTs to the collection root (no `/generations`
|
||||
// suffix) and the account headers come from the registry entry.
|
||||
// Response bodies are passed through verbatim.
|
||||
|
||||
// ponytail: generations only — OpenRouter has no edits/extensions endpoint today.
|
||||
const SUPPORTED_ACTIONS = new Set(["generations"]);
|
||||
|
||||
function headers(config, token) {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
...(config.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
buildRequest({ config, action, requestId, rawBody, contentType, token }) {
|
||||
const base = config.baseUrl.replace(/\/$/, "");
|
||||
|
||||
if (requestId) {
|
||||
return { method: "GET", url: `${base}/${encodeURIComponent(requestId)}`, headers: headers(config, token) };
|
||||
}
|
||||
if (!SUPPORTED_ACTIONS.has(action)) {
|
||||
return { error: `OpenRouter video supports 'generations' only (got '${action}')` };
|
||||
}
|
||||
if (contentType && !contentType.includes("application/json")) {
|
||||
return { error: "OpenRouter video requires an application/json body" };
|
||||
}
|
||||
return {
|
||||
method: "POST",
|
||||
url: base,
|
||||
headers: { ...headers(config, token), "Content-Type": "application/json" },
|
||||
body: rawBody,
|
||||
};
|
||||
},
|
||||
};
|
||||
145
open-sse/handlers/videoProviders/vertex.js
Normal file
145
open-sse/handlers/videoProviders/vertex.js
Normal file
@@ -0,0 +1,145 @@
|
||||
// Vertex AI (Veo) video jobs.
|
||||
//
|
||||
// Vertex does NOT speak the OpenAI-ish /v1/videos shape, so unlike OpenRouter
|
||||
// this adapter translates both directions:
|
||||
// create → POST {model}:predictLongRunning { instances[], parameters{} } → { name }
|
||||
// poll → POST {model}:fetchPredictOperation { operationName } → { done, response }
|
||||
// Docs: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation
|
||||
//
|
||||
// The operation name is a resource path (contains "/"), so it is base64url-encoded
|
||||
// into the job id returned to the client — GET /v1/videos/{id} stays a flat path.
|
||||
import { parseVertexSaJson, refreshVertexToken } from "../../services/tokenRefresh.js";
|
||||
|
||||
const DEFAULT_LOCATION = "us-central1";
|
||||
|
||||
const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url");
|
||||
const decodeJobId = (id) => Buffer.from(id, "base64url").toString("utf8");
|
||||
|
||||
async function resolveAuth(credentials, log) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
const projectId =
|
||||
saJson?.project_id ||
|
||||
credentials?.projectId ||
|
||||
credentials?.providerSpecificData?.projectId;
|
||||
const location = credentials?.providerSpecificData?.location || DEFAULT_LOCATION;
|
||||
|
||||
if (!projectId) {
|
||||
return { error: "Vertex video requires a project_id — use Service Account JSON or set providerSpecificData.projectId" };
|
||||
}
|
||||
|
||||
let token = credentials?.accessToken;
|
||||
if (saJson) {
|
||||
const minted = await refreshVertexToken(saJson, log);
|
||||
if (!minted?.accessToken) return { error: "Vertex video: failed to mint access token from service account JSON" };
|
||||
token = minted.accessToken;
|
||||
}
|
||||
if (!token) return { error: "Vertex video requires Service Account JSON or an OAuth access token (raw API keys are not supported)" };
|
||||
|
||||
return { token, projectId, location };
|
||||
}
|
||||
|
||||
/** OpenAI-ish video body → Vertex predictLongRunning body. */
|
||||
function toVertexBody(body) {
|
||||
const instance = { prompt: body.prompt };
|
||||
// Image-to-video: accept the Vertex-native shape or a bare data URL / base64 string.
|
||||
const image = body.image ?? body.image_url;
|
||||
if (image && typeof image === "object") {
|
||||
instance.image = image;
|
||||
} else if (typeof image === "string") {
|
||||
const match = image.match(/^data:([^;]+);base64,(.*)$/s);
|
||||
instance.image = match
|
||||
? { bytesBase64Encoded: match[2], mimeType: match[1] }
|
||||
: { gcsUri: image };
|
||||
}
|
||||
if (body.video && typeof body.video === "object") instance.video = body.video;
|
||||
|
||||
const parameters = {};
|
||||
if (body.n != null) parameters.sampleCount = Number(body.n);
|
||||
if (body.duration != null) parameters.durationSeconds = Number(body.duration);
|
||||
if (body.aspect_ratio) parameters.aspectRatio = body.aspect_ratio;
|
||||
if (body.resolution) parameters.resolution = body.resolution;
|
||||
if (body.seed != null) parameters.seed = body.seed;
|
||||
if (body.negative_prompt) parameters.negativePrompt = body.negative_prompt;
|
||||
// Without storageUri Vertex returns inline base64 bytes; a GCS bucket keeps
|
||||
// the poll response small and is what production callers want.
|
||||
if (body.storage_uri) parameters.storageUri = body.storage_uri;
|
||||
if (body.generate_audio != null) parameters.generateAudio = !!body.generate_audio;
|
||||
|
||||
return { instances: [instance], ...(Object.keys(parameters).length ? { parameters } : {}) };
|
||||
}
|
||||
|
||||
/** Vertex operation → the async-job shape 9Router clients already poll for. */
|
||||
function fromVertexOperation(json) {
|
||||
if (!json?.name) return json;
|
||||
const id = encodeJobId(json.name);
|
||||
if (json.error) {
|
||||
return { id, request_id: id, status: "failed", error: json.error };
|
||||
}
|
||||
if (!json.done) {
|
||||
return { id, request_id: id, status: "pending" };
|
||||
}
|
||||
const samples =
|
||||
json.response?.videos ||
|
||||
json.response?.generateVideoResponse?.generatedSamples ||
|
||||
[];
|
||||
const videos = samples.map((s) => ({
|
||||
url: s.gcsUri || s.video?.uri || s.uri || null,
|
||||
b64_json: s.bytesBase64Encoded || s.video?.bytesBase64Encoded || null,
|
||||
mime_type: s.mimeType || s.video?.mimeType || "video/mp4",
|
||||
}));
|
||||
return { id, request_id: id, status: "completed", video: videos[0] || null, videos };
|
||||
}
|
||||
|
||||
export default {
|
||||
async buildRequest({ config, action, requestId, rawBody, contentType, credentials, log }) {
|
||||
if (contentType && !contentType.includes("application/json")) {
|
||||
return { error: "Vertex video requires an application/json body" };
|
||||
}
|
||||
|
||||
const auth = await resolveAuth(credentials, log);
|
||||
if (auth.error) return { error: auth.error };
|
||||
const { token, projectId, location } = auth;
|
||||
const base = (config.baseUrl || "https://aiplatform.googleapis.com").replace(/\/$/, "");
|
||||
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` };
|
||||
|
||||
if (requestId) {
|
||||
let operationName;
|
||||
try {
|
||||
operationName = decodeJobId(requestId);
|
||||
} catch {
|
||||
return { error: "Invalid Vertex video job id" };
|
||||
}
|
||||
const modelPath = operationName.split("/operations/")[0];
|
||||
if (!modelPath || modelPath === operationName) return { error: "Invalid Vertex video job id" };
|
||||
return {
|
||||
method: "POST",
|
||||
url: `${base}/v1/${modelPath}:fetchPredictOperation`,
|
||||
headers,
|
||||
body: JSON.stringify({ operationName }),
|
||||
};
|
||||
}
|
||||
|
||||
if (action !== "generations") {
|
||||
// ponytail: Veo extend/edit go through generations with `video`/`image` in the body.
|
||||
return { error: `Vertex video supports 'generations' only (got '${action}')` };
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"));
|
||||
} catch {
|
||||
return { error: "Invalid JSON body" };
|
||||
}
|
||||
if (!body.model) return { error: "Vertex video requires a model (e.g. vertex/veo-3.1-generate-preview)" };
|
||||
if (!body.prompt && !body.image && !body.image_url) return { error: "Vertex video requires a prompt or an image" };
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
url: `${base}/v1/projects/${projectId}/locations/${location}/publishers/google/models/${body.model}:predictLongRunning`,
|
||||
headers,
|
||||
body: JSON.stringify(toVertexBody(body)),
|
||||
};
|
||||
},
|
||||
|
||||
transformResponse: fromVertexOperation,
|
||||
};
|
||||
@@ -40,8 +40,11 @@ export default {
|
||||
{ id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", params: ["n","size","quality","response_format"], kind: "image" },
|
||||
{ id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", params: ["n","size"], kind: "image" },
|
||||
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", params: ["n","size"], kind: "image" },
|
||||
{ id: "google/veo-3.1", name: "Veo 3.1 (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" },
|
||||
{ id: "openai/sora-2-pro", name: "Sora 2 Pro (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" },
|
||||
{ id: "bytedance/seedance-2.0", name: "Seedance 2.0 (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" },
|
||||
],
|
||||
serviceKinds: ["llm","embedding","tts","imageToText"],
|
||||
serviceKinds: ["llm","embedding","tts","imageToText","video"],
|
||||
ttsConfig: {
|
||||
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||
defaultModel: "openai/gpt-4o-mini-tts",
|
||||
@@ -57,6 +60,12 @@ export default {
|
||||
baseUrl: "https://openrouter.ai/api/v1/images/generations",
|
||||
headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"},
|
||||
},
|
||||
// Async video jobs (POST /videos → { id, status }, GET /videos/{id} polls).
|
||||
// Docs: https://openrouter.ai/docs/api/api-reference/videos
|
||||
videoConfig: {
|
||||
baseUrl: "https://openrouter.ai/api/v1/videos",
|
||||
headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"},
|
||||
},
|
||||
modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" },
|
||||
passthroughModels: true,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,13 @@ export default {
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "veo-3.1-generate-preview", name: "Veo 3.1 (Preview)", params: ["duration","aspect_ratio","resolution","negative_prompt","seed","storage_uri","generate_audio"], kind: "video" },
|
||||
{ id: "veo-3.1-fast-generate-preview", name: "Veo 3.1 Fast (Preview)", params: ["duration","aspect_ratio","resolution","negative_prompt","seed","storage_uri","generate_audio"], kind: "video" },
|
||||
{ id: "veo-3.0-generate-001", name: "Veo 3", params: ["duration","aspect_ratio","resolution","negative_prompt","seed","storage_uri","generate_audio"], kind: "video" },
|
||||
{ id: "veo-2.0-generate-001", name: "Veo 2", params: ["duration","aspect_ratio","negative_prompt","seed","storage_uri"], kind: "video" },
|
||||
],
|
||||
serviceKinds: ["llm","imageToText"],
|
||||
serviceKinds: ["llm","imageToText","video"],
|
||||
// Veo via predictLongRunning + fetchPredictOperation (adapter: handlers/videoProviders/vertex.js).
|
||||
// Docs: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation
|
||||
videoConfig: { baseUrl: "https://aiplatform.googleapis.com" },
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "../services/auth.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getSettings, getProviderConnectionById } from "@/lib/localDb";
|
||||
import { getModelInfo } from "../services/model.js";
|
||||
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js";
|
||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
@@ -17,6 +17,21 @@ import * as log from "../utils/logger.js";
|
||||
// (bare model id, or multipart bodies we deliberately don't parse) land here.
|
||||
const DEFAULT_VIDEO_PROVIDER = "xai";
|
||||
|
||||
/**
|
||||
* Poll requests carry no model, so the provider comes from the pinned
|
||||
* connection (`x-connection-id`, returned on create) or an explicit
|
||||
* `?provider=` — falling back to the historical xAI default.
|
||||
*/
|
||||
async function resolveGetProvider(request, connectionId) {
|
||||
if (connectionId) {
|
||||
const conn = await getProviderConnectionById(connectionId).catch(() => null);
|
||||
if (conn?.provider && getVideoConfig(conn.provider)) return conn.provider;
|
||||
}
|
||||
const queried = new URL(request.url).searchParams.get("provider");
|
||||
if (queried && getVideoConfig(queried)) return queried;
|
||||
return DEFAULT_VIDEO_PROVIDER;
|
||||
}
|
||||
|
||||
// Creation POSTs are billable jobs — only rotate to another account for
|
||||
// errors that upstream rejects BEFORE creating a job (auth/quota). A 5xx may
|
||||
// have created the job, so it is returned to the caller instead of re-sent.
|
||||
@@ -185,8 +200,8 @@ export async function handleVideoGet(request, requestId) {
|
||||
|
||||
if (!requestId) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing video request id");
|
||||
|
||||
const provider = DEFAULT_VIDEO_PROVIDER;
|
||||
const preferredConnectionId = request.headers.get("x-connection-id") || null;
|
||||
const provider = await resolveGetProvider(request, preferredConnectionId);
|
||||
|
||||
const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId });
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
|
||||
263
tests/unit/video-providers.test.js
Normal file
263
tests/unit/video-providers.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Unit tests for the OpenRouter + Vertex (Veo) video adapters.
|
||||
*
|
||||
* Covers:
|
||||
* - registry wiring (videoConfig, video serviceKind, video-kind models)
|
||||
* - OpenRouter: POST to the collection root, GET poll, verbatim passthrough
|
||||
* - Vertex: predictLongRunning body translation, fetchPredictOperation polling,
|
||||
* operation-name round-trip through the job id, response mapping
|
||||
* - xAI default path is unchanged by the adapter hook
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("open-sse/services/tokenRefresh.js", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, refreshTokenByProvider: vi.fn(), refreshVertexToken: vi.fn() };
|
||||
});
|
||||
|
||||
import { handleVideoProxyCore, getVideoConfig } from "open-sse/handlers/videoCore.js";
|
||||
import { refreshVertexToken } from "open-sse/services/tokenRefresh.js";
|
||||
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "open-sse/providers/index.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const jsonResponse = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
// Vertex operation names are resource paths; the adapter base64url-encodes them.
|
||||
const OPERATION_NAME =
|
||||
"projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview/operations/op-abc";
|
||||
const JOB_ID = Buffer.from(OPERATION_NAME, "utf8").toString("base64url");
|
||||
|
||||
describe("registry wiring", () => {
|
||||
it("exposes videoConfig + video serviceKind for openrouter and vertex", () => {
|
||||
expect(getVideoConfig("openrouter").baseUrl).toBe("https://openrouter.ai/api/v1/videos");
|
||||
expect(getVideoConfig("vertex").baseUrl).toBe("https://aiplatform.googleapis.com");
|
||||
expect(PROVIDER_MEDIA.openrouter.serviceKinds).toContain("video");
|
||||
expect(PROVIDER_MEDIA.vertex.serviceKinds).toContain("video");
|
||||
});
|
||||
|
||||
it("registers video-kind models on both providers", () => {
|
||||
const or = PROVIDER_MODELS.openrouter.find((m) => m.id === "google/veo-3.1");
|
||||
const vx = PROVIDER_MODELS.vertex.find((m) => m.id === "veo-3.1-generate-preview");
|
||||
expect(or?.kind).toBe("video");
|
||||
expect(vx?.kind).toBe("video");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openrouter video adapter", () => {
|
||||
beforeEach(() => { global.fetch = vi.fn(); });
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
it("POSTs creation to the collection root (no /generations suffix)", async () => {
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ id: "job-1", status: "pending" }));
|
||||
|
||||
const raw = '{"model":"google/veo-3.1","prompt":"a paper boat"}';
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
action: "generations",
|
||||
rawBody: raw,
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/videos");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.body).toBe(raw); // verbatim
|
||||
expect(init.headers.Authorization).toBe("Bearer sk-or-key");
|
||||
expect(init.headers["HTTP-Referer"]).toBe("https://endpoint-proxy.local");
|
||||
expect(await result.response.json()).toEqual({ id: "job-1", status: "pending" });
|
||||
});
|
||||
|
||||
it("polls GET /videos/{id} and passes the payload through verbatim", async () => {
|
||||
const payload = { id: "job-1", status: "completed", unsigned_urls: ["https://cdn/v.mp4"] };
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse(payload));
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
requestId: "job-1",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openrouter.ai/api/v1/videos/job-1");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(await result.response.json()).toEqual(payload);
|
||||
});
|
||||
|
||||
it("rejects unsupported actions before any upstream call (no billable job)", async () => {
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "openrouter",
|
||||
action: "extensions",
|
||||
rawBody: "{}",
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "sk-or-key" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("vertex (veo) video adapter", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
refreshVertexToken.mockReset();
|
||||
});
|
||||
afterEach(() => { global.fetch = originalFetch; });
|
||||
|
||||
const saJson = JSON.stringify({
|
||||
type: "service_account",
|
||||
client_email: "sa@proj-1.iam.gserviceaccount.com",
|
||||
private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n",
|
||||
project_id: "proj-1",
|
||||
});
|
||||
|
||||
it("translates the create body to predictLongRunning and returns a poll-able job id", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME }));
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({
|
||||
model: "veo-3.1-generate-preview",
|
||||
prompt: "a neon city",
|
||||
duration: 8,
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "720p",
|
||||
n: 1,
|
||||
}),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:predictLongRunning"
|
||||
);
|
||||
expect(init.headers.Authorization).toBe("Bearer vertex-tok");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
instances: [{ prompt: "a neon city" }],
|
||||
parameters: { sampleCount: 1, durationSeconds: 8, aspectRatio: "16:9", resolution: "720p" },
|
||||
});
|
||||
|
||||
// Response is mapped onto the async-job shape clients already poll.
|
||||
expect(await result.response.json()).toEqual({
|
||||
id: JOB_ID,
|
||||
request_id: JOB_ID,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a data-URL image onto the Vertex image instance", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(jsonResponse({ name: OPERATION_NAME }));
|
||||
|
||||
await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({
|
||||
model: "veo-3.1-generate-preview",
|
||||
prompt: "animate this",
|
||||
image: "data:image/png;base64,AAAB",
|
||||
}),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body).instances[0].image).toEqual({
|
||||
bytesBase64Encoded: "AAAB",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
it("polls via fetchPredictOperation and maps a completed operation", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
name: OPERATION_NAME,
|
||||
done: true,
|
||||
response: { videos: [{ gcsUri: "gs://bucket/v.mp4", mimeType: "video/mp4" }] },
|
||||
})
|
||||
);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: JOB_ID,
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
const [url, init] = global.fetch.mock.calls[0];
|
||||
expect(url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/proj-1/locations/us-central1/publishers/google/models/veo-3.1-generate-preview:fetchPredictOperation"
|
||||
);
|
||||
expect(init.method).toBe("POST"); // Vertex polls with POST, not GET
|
||||
expect(JSON.parse(init.body)).toEqual({ operationName: OPERATION_NAME });
|
||||
|
||||
expect(await result.response.json()).toEqual({
|
||||
id: JOB_ID,
|
||||
request_id: JOB_ID,
|
||||
status: "completed",
|
||||
video: { url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" },
|
||||
videos: [{ url: "gs://bucket/v.mp4", b64_json: null, mime_type: "video/mp4" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a failed operation to status failed", async () => {
|
||||
refreshVertexToken.mockResolvedValueOnce({ accessToken: "vertex-tok" });
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
jsonResponse({ name: OPERATION_NAME, done: true, error: { code: 3, message: "bad prompt" } })
|
||||
);
|
||||
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: JOB_ID,
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
|
||||
const body = await result.response.json();
|
||||
expect(body.status).toBe("failed");
|
||||
expect(body.error.message).toBe("bad prompt");
|
||||
});
|
||||
|
||||
it("rejects missing project id and raw API keys before any upstream call", async () => {
|
||||
const noProject = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "AIzaRawKey" },
|
||||
});
|
||||
expect(noProject.success).toBe(false);
|
||||
expect(noProject.status).toBe(400);
|
||||
|
||||
const noToken = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
action: "generations",
|
||||
rawBody: JSON.stringify({ model: "veo-3.1-generate-preview", prompt: "x" }),
|
||||
contentType: "application/json",
|
||||
credentials: { apiKey: "AIzaRawKey", providerSpecificData: { projectId: "proj-1" } },
|
||||
});
|
||||
expect(noToken.success).toBe(false);
|
||||
expect(noToken.status).toBe(400);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an invalid job id without calling upstream", async () => {
|
||||
refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" });
|
||||
const result = await handleVideoProxyCore({
|
||||
provider: "vertex",
|
||||
requestId: Buffer.from("not-an-operation", "utf8").toString("base64url"),
|
||||
credentials: { apiKey: saJson },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/sse/services/auth.js", () => authMocks);
|
||||
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: vi.fn(async () => ({ requireApiKey: false })),
|
||||
getProviderConnectionById: vi.fn(async () => ({ id: "conn-5", provider: "xai" })),
|
||||
getComboByName: vi.fn(async () => null),
|
||||
getModelAliases: vi.fn(async () => ({})),
|
||||
getProviderNodes: vi.fn(async () => []),
|
||||
|
||||
Reference in New Issue
Block a user