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": "*",
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user