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 { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||||
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
||||||
import { PROVIDER_MEDIA } from "../providers/index.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
|
// Upstream fetch deadline for video job submission/polling (the job itself is
|
||||||
// async upstream — this only bounds the HTTP round-trip, not video rendering).
|
// 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}`);
|
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = requestId ? "GET" : "POST";
|
const adapter = getVideoAdapter(provider);
|
||||||
const url = buildUpstreamUrl(config, action, requestId);
|
|
||||||
const fetchSignal = combineSignals(signal, timeoutMs);
|
const fetchSignal = combineSignals(signal, timeoutMs);
|
||||||
|
|
||||||
const doFetch = (token) =>
|
// Default (xAI shape) request plan; adapters override URL/method/headers/body.
|
||||||
fetch(url, {
|
const defaultPlan = () => {
|
||||||
|
const method = requestId ? "GET" : "POST";
|
||||||
|
return {
|
||||||
method,
|
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,
|
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;
|
let upstream;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
||||||
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
|
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
|
||||||
@@ -136,7 +165,9 @@ export async function handleVideoProxyCore({
|
|||||||
await upstream.body?.cancel?.();
|
await upstream.body?.cancel?.();
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
|
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)}`);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(bodyText, {
|
response: new Response(outBody, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": upstream.headers.get("content-type") || "application/json",
|
"Content-Type": outType,
|
||||||
"Access-Control-Allow-Origin": "*",
|
"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: "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: "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: "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: {
|
ttsConfig: {
|
||||||
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||||
defaultModel: "openai/gpt-4o-mini-tts",
|
defaultModel: "openai/gpt-4o-mini-tts",
|
||||||
@@ -57,6 +60,12 @@ export default {
|
|||||||
baseUrl: "https://openrouter.ai/api/v1/images/generations",
|
baseUrl: "https://openrouter.ai/api/v1/images/generations",
|
||||||
headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"},
|
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" },
|
modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" },
|
||||||
passthroughModels: true,
|
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.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
{ 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,
|
extractApiKey,
|
||||||
isValidApiKey,
|
isValidApiKey,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings, getProviderConnectionById } from "@/lib/localDb";
|
||||||
import { getModelInfo } from "../services/model.js";
|
import { getModelInfo } from "../services/model.js";
|
||||||
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js";
|
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js";
|
||||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.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.
|
// (bare model id, or multipart bodies we deliberately don't parse) land here.
|
||||||
const DEFAULT_VIDEO_PROVIDER = "xai";
|
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
|
// Creation POSTs are billable jobs — only rotate to another account for
|
||||||
// errors that upstream rejects BEFORE creating a job (auth/quota). A 5xx may
|
// 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.
|
// 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");
|
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 preferredConnectionId = request.headers.get("x-connection-id") || null;
|
||||||
|
const provider = await resolveGetProvider(request, preferredConnectionId);
|
||||||
|
|
||||||
const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId });
|
const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId });
|
||||||
if (!credentials || credentials.allRateLimited) {
|
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("@/sse/services/tokenRefresh.js", () => tokenMocks);
|
||||||
vi.mock("@/lib/localDb", () => ({
|
vi.mock("@/lib/localDb", () => ({
|
||||||
getSettings: vi.fn(async () => ({ requireApiKey: false })),
|
getSettings: vi.fn(async () => ({ requireApiKey: false })),
|
||||||
|
getProviderConnectionById: vi.fn(async () => ({ id: "conn-5", provider: "xai" })),
|
||||||
getComboByName: vi.fn(async () => null),
|
getComboByName: vi.fn(async () => null),
|
||||||
getModelAliases: vi.fn(async () => ({})),
|
getModelAliases: vi.fn(async () => ({})),
|
||||||
getProviderNodes: vi.fn(async () => []),
|
getProviderNodes: vi.fn(async () => []),
|
||||||
|
|||||||
Reference in New Issue
Block a user