diff --git a/open-sse/handlers/videoCore.js b/open-sse/handlers/videoCore.js index 98d60157..45f14d86 100644 --- a/open-sse/handlers/videoCore.js +++ b/open-sse/handlers/videoCore.js @@ -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": "*", }, }), diff --git a/open-sse/handlers/videoProviders/index.js b/open-sse/handlers/videoProviders/index.js new file mode 100644 index 00000000..28173972 --- /dev/null +++ b/open-sse/handlers/videoProviders/index.js @@ -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; +} diff --git a/open-sse/handlers/videoProviders/openrouter.js b/open-sse/handlers/videoProviders/openrouter.js new file mode 100644 index 00000000..90198a39 --- /dev/null +++ b/open-sse/handlers/videoProviders/openrouter.js @@ -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, + }; + }, +}; diff --git a/open-sse/handlers/videoProviders/vertex.js b/open-sse/handlers/videoProviders/vertex.js new file mode 100644 index 00000000..25394b94 --- /dev/null +++ b/open-sse/handlers/videoProviders/vertex.js @@ -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, +}; diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index a0df2a52..68a92857 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -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, }; diff --git a/open-sse/providers/registry/vertex.js b/open-sse/providers/registry/vertex.js index b8765de3..a1c60e60 100644 --- a/open-sse/providers/registry/vertex.js +++ b/open-sse/providers/registry/vertex.js @@ -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" }, }; diff --git a/src/sse/handlers/videoGeneration.js b/src/sse/handlers/videoGeneration.js index 67142899..af19da19 100644 --- a/src/sse/handlers/videoGeneration.js +++ b/src/sse/handlers/videoGeneration.js @@ -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) { diff --git a/tests/unit/video-providers.test.js b/tests/unit/video-providers.test.js new file mode 100644 index 00000000..680db5a5 --- /dev/null +++ b/tests/unit/video-providers.test.js @@ -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(); + }); +}); diff --git a/tests/unit/xai-video-handler.test.js b/tests/unit/xai-video-handler.test.js index 563ee08c..a18411b4 100644 --- a/tests/unit/xai-video-handler.test.js +++ b/tests/unit/xai-video-handler.test.js @@ -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 () => []),