diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 29575e70f9..1b94003389 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -81,6 +81,7 @@ "_rebaseline_2026_06_15_3877_byteplus": "Issue #3877 own growth: providerRegistry.ts 4708->4730 (+22 = a byteplus (BytePlus ModelArk) registry entry — OpenAI-compatible, Ark base ap-southeast-1, Bearer, 4 seed models — modeled on the volcengine entry; byteplus was in APIKEY_PROVIDERS but unregistered here so validation returned {unsupported:true}). Cohesive provider registration; not extractable.", "_rebaseline_2026_06_15_3910_nested_combo_ctx": "PR #3910 net +1: providerRegistry.ts 4730->4731 (test-registry contextLength metadata for the nested combo-ref LCD regression test). opencode-plugin-only behavioral change; no core routing/virtualFactory touched.", "_rebaseline_2026_06_15_3929_vertex_media": "PR #3929 own growth: audioSpeech.ts 952->965 (+13) and videoGeneration.ts 1026->1078 (+52) = vertex/* media branches (Gemini TTS, Veo predictLongRunning poll) wired into the speech/video handlers; new logic lives in open-sse/executors/vertexMedia.ts (341, under cap). Cohesive media-provider feature.", + "_rebaseline_2026_06_23_4569_googleflow_video": "Issue #4569 net +5: videoGeneration.ts 1077->1083 = the google-flow dispatch branch + a single handler import. The whole Google Flow handler (145 lines) lives in the new co-located open-sse/handlers/videoGeneration/googleFlowHandler.ts (under cap) and the pure transforms in videoGeneration/googleFlow.ts; only the wiring remains in the god-file. Extracted-not-inlined; not further shrinkable.", "_rebaseline_2026_06_15_3879_redact_thinking": "PR #3879 + #3921 reconcile: AddApiKeyModal.tsx 843->845 (+2 = merging #3879's CcCompatibleRequestDefaultsFields (context1m + opt-in redact-thinking toggle) into #3921's preset-input block in the cc-compatible settings group). Cohesive UI; not extractable.", "_rebaseline_2026_06_15_3890_cache_preserve": "Issue #3890 own growth: chatCore.ts 5815->5823 (+8 = wire resolveCacheAwareConfig() into the compression apply step so the system prompt is never compressed in a caching context — honors the cache-aware skipSystemPrompt flag that selectCompressionStrategy could not carry). Cohesive cache-preservation guard at the existing compression chokepoint; not extractable.", "_rebaseline_2026_06_16_3974_toolsearch_beta": "Issue #3974 own growth: base.ts 1218->1222 (+4 = wrap selectBetaFlags() with mergeClientAnthropicBeta() at the ccHeaders anthropic-beta callsite, plus a 3-line comment, so the client's allowlisted tool-search beta survives). The shared helper + allowlist live in anthropicHeaders.ts (small file, well under cap); default.ts also gains the merge. Cohesive one-callsite fix; not extractable.", @@ -128,7 +129,7 @@ "open-sse/handlers/responseSanitizer.ts": 1103, "open-sse/handlers/search.ts": 1546, "open-sse/handlers/sseParser.ts": 830, - "open-sse/handlers/videoGeneration.ts": 1078, + "open-sse/handlers/videoGeneration.ts": 1083, "open-sse/mcp-server/schemas/tools.ts": 1497, "open-sse/mcp-server/server.ts": 1555, "open-sse/mcp-server/tools/advancedTools.ts": 1118, diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 12d0c8cbc3..af1f3652b4 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -39,6 +39,23 @@ export const VIDEO_PROVIDERS: Record = { ], }, + googleflow: { + id: "googleflow", + alias: "flow", + // ⚠️ Wire host isolated for live HAR validation (Rule #18). The handler reuses + // the Google account OAuth credential (accessToken + Cloud Code projectId) that + // the Antigravity provider already establishes; no separate OAuth flow is added. + baseUrl: "https://aisandbox-pa.googleapis.com", + authType: "oauth", + authHeader: "bearer", + format: "google-flow", + models: [ + { id: "veo-3.1-generate", name: "Veo 3.1 (Google Flow)" }, + { id: "veo-3.1-fast-generate", name: "Veo 3.1 Fast (Google Flow)" }, + { id: "veo-3.0-generate", name: "Veo 3.0 (Google Flow)" }, + ], + }, + kie: { id: "kie", baseUrl: "https://api.kie.ai", diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 1581133b7f..d3708fa4be 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -18,6 +18,7 @@ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateVideo } from "../executors/vertexMedia.ts"; +import { handleGoogleFlowVideoGeneration } from "./videoGeneration/googleFlowHandler.ts"; import { getExecutor } from "../executors/index.ts"; import { isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -61,6 +62,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { return handleVertexVeoGeneration({ model, body, credentials, log }); } + if (providerConfig.format === "google-flow") { + return handleGoogleFlowVideoGeneration({ model, providerConfig, body, credentials, log }); + } + if (providerConfig.format === "comfyui") { return handleComfyUIVideoGeneration({ model, provider, providerConfig, body, log }); } diff --git a/open-sse/handlers/videoGeneration/googleFlow.ts b/open-sse/handlers/videoGeneration/googleFlow.ts new file mode 100644 index 0000000000..3bef710857 --- /dev/null +++ b/open-sse/handlers/videoGeneration/googleFlow.ts @@ -0,0 +1,219 @@ +/** + * Google Flow (labs.google/flow) video generation — pure helpers. + * + * Google Flow drives Veo video generation through Google's internal AI Sandbox + * endpoint (`aisandbox-pa.googleapis.com`) using a Google account OAuth bearer + * token — the same Google OAuth credential family that the Antigravity provider + * already uses (accessToken + Cloud Code projectId). The request/response shape + * mirrors the *documented* Veo `predictLongRunning` long-running-operation API + * (see `open-sse/executors/vertexMedia.ts::vertexGenerateVideo`): + * + * submit → { instances: [{ prompt, image? }], parameters: { sampleCount, ... } } → { name: } + * poll → { operationName } → { done, error?, response: { videos: [{ bytesBase64Encoded | gcsUri }] } } + * + * ⚠️ PENDING LIVE VALIDATION (Hard Rule #18): the exact AI-Sandbox URL path and + * whether Flow wraps the Veo body in the Cloud-Code `{ project, request }` envelope + * cannot be unit-tested — they require a real Google Flow account + a captured HAR. + * Everything that depends on the wire host/path is isolated in the two constants + * below and in the handler, so confirming a captured HAR is a one-line change. + * The transformation logic in this file is grounded in the documented Veo shape + * and is fully unit-tested. + */ + +/** + * Google Flow has no standalone connection — it reuses the Antigravity Google + * OAuth credential (accessToken + Cloud Code projectId). Credential lookups for + * the `googleflow` provider resolve against this provider id. + */ +export const GOOGLE_FLOW_CREDENTIAL_PROVIDER = "antigravity"; + +/** Map a video provider id to the provider id whose stored credentials it uses. */ +export function resolveVideoCredentialProvider(provider: string): string { + return provider === "googleflow" ? GOOGLE_FLOW_CREDENTIAL_PROVIDER : provider; +} + +// --- Wire endpoint (isolated; confirm against a real Flow HAR — Rule #18) --- +export const GOOGLE_FLOW_HOST = "https://aisandbox-pa.googleapis.com"; +export const GOOGLE_FLOW_SUBMIT_PATH = "/v1:generateVideo"; +export const GOOGLE_FLOW_POLL_PATH = "/v1:fetchOperation"; + +export interface FlowVideoParams { + prompt: string; + aspectRatio?: string; + durationSeconds?: number; + sampleCount: number; + negativePrompt?: string; + resolution?: string; +} + +export interface FlowOperationResult { + done: boolean; + error?: string; + base64?: string; + url?: string; + format?: string; +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const ASPECT_RATIO_RE = /^\d{1,2}:\d{1,2}$/; + +/** + * Normalize an OpenAI-style /v1/videos/generations body into Veo parameters. + * Accepts both snake_case (OpenAI) and camelCase (native) field names, and + * treats a `size` that looks like a ratio (e.g. "16:9") as the aspect ratio. + */ +export function normalizeFlowVideoParams(body: Record | null | undefined): FlowVideoParams { + const b = body ?? {}; + const prompt = typeof b.prompt === "string" ? b.prompt : String(b.prompt ?? ""); + + const sizeMaybeRatio = asTrimmedString(b.size); + const aspectRatio = + asTrimmedString(b.aspect_ratio) ?? + asTrimmedString(b.aspectRatio) ?? + (sizeMaybeRatio && ASPECT_RATIO_RE.test(sizeMaybeRatio) ? sizeMaybeRatio : undefined); + + const durationRaw = + typeof b.duration === "number" + ? b.duration + : typeof b.durationSeconds === "number" + ? b.durationSeconds + : undefined; + const durationSeconds = + typeof durationRaw === "number" && Number.isFinite(durationRaw) && durationRaw > 0 + ? Math.floor(durationRaw) + : undefined; + + const nRaw = typeof b.n === "number" ? b.n : undefined; + const sampleCount = + typeof nRaw === "number" && Number.isFinite(nRaw) && nRaw > 0 ? Math.floor(nRaw) : 1; + + return { + prompt, + aspectRatio, + durationSeconds, + sampleCount, + negativePrompt: asTrimmedString(b.negative_prompt) ?? asTrimmedString(b.negativePrompt), + resolution: asTrimmedString(b.resolution), + }; +} + +/** + * Build the documented Veo `predictLongRunning` request body for the submit call. + * Only includes optional parameters that were actually provided. + */ +export function buildGoogleFlowSubmitBody(params: FlowVideoParams): { + instances: Array>; + parameters: Record; +} { + const parameters: Record = { sampleCount: params.sampleCount }; + if (params.aspectRatio) parameters.aspectRatio = params.aspectRatio; + if (typeof params.durationSeconds === "number") parameters.durationSeconds = params.durationSeconds; + if (params.negativePrompt) parameters.negativePrompt = params.negativePrompt; + if (params.resolution) parameters.resolution = params.resolution; + + return { + instances: [{ prompt: params.prompt }], + parameters, + }; +} + +/** + * Extract the long-running-operation name from a submit response. + * Tolerates both `{ name }` and `{ operation: { name } }` envelopes. + */ +export function parseFlowOperationName(json: unknown): string | null { + if (!json || typeof json !== "object") return null; + const direct = (json as { name?: unknown }).name; + if (typeof direct === "string" && direct.length > 0) return direct; + const op = (json as { operation?: { name?: unknown } }).operation; + if (op && typeof op === "object" && typeof op.name === "string" && op.name.length > 0) { + return op.name; + } + return null; +} + +function extractVideoFromResponse(response: unknown): { base64?: string; url?: string } | null { + if (!response || typeof response !== "object") return null; + + // Documented Veo LRO shape: response.videos[].bytesBase64Encoded | gcsUri + const videos = (response as { videos?: unknown }).videos; + if (Array.isArray(videos) && videos.length > 0) { + const v = videos[0]; + if (v && typeof v === "object") { + const rec = v as Record; + if (typeof rec.bytesBase64Encoded === "string") return { base64: rec.bytesBase64Encoded }; + if (typeof rec.gcsUri === "string") return { url: rec.gcsUri }; + if (typeof rec.uri === "string") return { url: rec.uri }; + } + } + + // Alternate Veo shape: response.generateVideoResponse.generatedSamples[].video.uri + const gen = (response as { generateVideoResponse?: { generatedSamples?: unknown } }) + .generateVideoResponse; + const samples = gen && typeof gen === "object" ? gen.generatedSamples : undefined; + if (Array.isArray(samples) && samples.length > 0) { + const sample = samples[0]; + const video = + sample && typeof sample === "object" + ? (sample as { video?: unknown }).video + : undefined; + if (video && typeof video === "object") { + const rec = video as Record; + if (typeof rec.uri === "string") return { url: rec.uri }; + if (typeof rec.bytesBase64Encoded === "string") return { base64: rec.bytesBase64Encoded }; + } + } + + return null; +} + +/** + * Interpret a poll/fetch-operation response into a normalized result. + * `done: false` means still running; callers should keep polling. + */ +export function parseFlowOperationResult(json: unknown): FlowOperationResult { + if (!json || typeof json !== "object") return { done: false }; + + const done = Boolean((json as { done?: unknown }).done); + if (!done) return { done: false }; + + const opError = (json as { error?: { message?: unknown } }).error; + if (opError && typeof opError === "object") { + return { done: true, error: String(opError.message || "Google Flow video operation failed") }; + } + + const response = (json as { response?: unknown }).response; + const video = extractVideoFromResponse(response); + if (video?.base64) return { done: true, base64: video.base64, format: "mp4" }; + if (video?.url) return { done: true, url: video.url, format: "mp4" }; + + return { done: true, error: "Google Flow operation completed but returned no video" }; +} + +/** Resolve the Cloud Code projectId from the OAuth credential record (mirrors Antigravity). */ +export function resolveFlowProjectId( + credentials: Record | null | undefined +): string | null { + const cred = credentials ?? {}; + const direct = asTrimmedString(cred.projectId); + if (direct) return direct; + const psd = cred.providerSpecificData; + if (psd && typeof psd === "object") { + const fromPsd = asTrimmedString((psd as Record).projectId); + if (fromPsd) return fromPsd; + } + return null; +} + +/** Resolve the OAuth bearer token from the credential record. */ +export function resolveFlowAccessToken( + credentials: Record | null | undefined +): string | null { + const cred = credentials ?? {}; + return asTrimmedString(cred.accessToken) ?? asTrimmedString(cred.apiKey) ?? null; +} diff --git a/open-sse/handlers/videoGeneration/googleFlowHandler.ts b/open-sse/handlers/videoGeneration/googleFlowHandler.ts new file mode 100644 index 0000000000..9b09d00fd9 --- /dev/null +++ b/open-sse/handlers/videoGeneration/googleFlowHandler.ts @@ -0,0 +1,145 @@ +/** + * Veo video generation via Google Flow (labs.google/flow) — request orchestration. + * + * Uses the Google account OAuth bearer + Cloud Code projectId that the Antigravity + * provider already establishes — no separate OAuth flow is added. Submits the + * documented Veo `predictLongRunning` body to Google's AI Sandbox endpoint, polls + * the long-running operation, and returns the MP4 (base64 or URL). + * + * ⚠️ PENDING LIVE VALIDATION (Hard Rule #18): the AI-Sandbox host/path and the + * Cloud-Code request envelope cannot be unit-tested — they require a real Google + * Flow account + a captured HAR. The wire surface is isolated to the two path + * constants (`GOOGLE_FLOW_SUBMIT_PATH`/`GOOGLE_FLOW_POLL_PATH`) and the `project` + * wrap below, so confirming a captured HAR is a one-line change. The pure + * transformation helpers are fully unit-tested (google-flow-video-4569.test.ts). + */ + +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { + GOOGLE_FLOW_POLL_PATH, + GOOGLE_FLOW_SUBMIT_PATH, + buildGoogleFlowSubmitBody, + normalizeFlowVideoParams, + parseFlowOperationName, + parseFlowOperationResult, + resolveFlowAccessToken, + resolveFlowProjectId, +} from "./googleFlow.ts"; + +interface GoogleFlowHandlerArgs { + model: string; + providerConfig: { baseUrl: string }; + body: Record; + credentials: Record | null; + log?: { info?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void }; +} + +const POLL_INTERVAL_MS = 10_000; +const MAX_WAIT_MS = 5 * 60 * 1000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function handleGoogleFlowVideoGeneration({ + model, + providerConfig, + body, + credentials, + log, +}: GoogleFlowHandlerArgs) { + const token = resolveFlowAccessToken(credentials); + if (!token) { + return { + success: false, + status: 401, + error: + "Missing Google OAuth token for Google Flow. Connect a Google account in Providers (the Antigravity/Cloud Code connection) first.", + }; + } + + const projectId = resolveFlowProjectId(credentials); + if (!projectId) { + return { + success: false, + status: 400, + error: + "Missing Google projectId for Google Flow. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", + }; + } + + const params = normalizeFlowVideoParams(body); + const submitBody = buildGoogleFlowSubmitBody(params); + // PENDING LIVE VALIDATION: Cloud-Code envelope wraps the Veo body with `project`/`model`. + const wireBody = { ...submitBody, project: projectId, model }; + + const baseUrl = providerConfig.baseUrl.replace(/\/$/, ""); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; + + try { + log?.info?.( + "VIDEO", + `googleflow/${model} (veo) | submitting | aspect: ${params.aspectRatio ?? "default"}` + ); + const submitRes = await fetch(`${baseUrl}${GOOGLE_FLOW_SUBMIT_PATH}`, { + method: "POST", + headers, + body: JSON.stringify(wireBody), + }); + if (!submitRes.ok) { + const errorText = await submitRes.text(); + return { + success: false, + status: submitRes.status, + error: sanitizeErrorMessage(`Google Flow submit failed: ${errorText.slice(0, 300)}`), + }; + } + + const operationName = parseFlowOperationName(await submitRes.json()); + if (!operationName) { + return { success: false, status: 502, error: "Google Flow did not return an operation name" }; + } + + const deadline = Date.now() + MAX_WAIT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const pollRes = await fetch(`${baseUrl}${GOOGLE_FLOW_POLL_PATH}`, { + method: "POST", + headers, + body: JSON.stringify({ operationName }), + }); + if (!pollRes.ok) { + const errorText = await pollRes.text(); + return { + success: false, + status: pollRes.status, + error: sanitizeErrorMessage(`Google Flow poll failed: ${errorText.slice(0, 300)}`), + }; + } + + const result = parseFlowOperationResult(await pollRes.json()); + if (!result.done) continue; + if (result.error) { + return { success: false, status: 502, error: sanitizeErrorMessage(result.error) }; + } + const item = result.base64 + ? { b64_json: result.base64, format: result.format } + : { url: result.url, format: result.format }; + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: [item] }, + }; + } + + return { success: false, status: 504, error: "Google Flow video generation timed out" }; + } catch (err) { + const e = (err ?? {}) as { message?: string; status?: number }; + log?.error?.("VIDEO", `Google Flow generation failed: ${e.message}`); + return { + success: false, + status: typeof e.status === "number" ? e.status : 502, + error: sanitizeErrorMessage(e.message || "Google Flow generation failed"), + }; + } +} diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 065285f3c3..8f0153f85a 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,4 +1,5 @@ import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; +import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getProviderCredentials, @@ -99,10 +100,12 @@ async function postHandler(request, context) { // Check provider config for auth bypass const providerConfig = getVideoProvider(provider); - // Get credentials — skip for local providers (authType: "none") + // Get credentials — skip for local providers (authType: "none"). + // Google Flow has no standalone connection: it reuses the Antigravity Google + // OAuth credential (resolveVideoCredentialProvider maps googleflow → antigravity). let credentials = null; if (providerConfig && providerConfig.authType !== "none") { - credentials = await getProviderCredentials(provider); + credentials = await getProviderCredentials(resolveVideoCredentialProvider(provider)); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/tests/unit/google-flow-video-4569.test.ts b/tests/unit/google-flow-video-4569.test.ts new file mode 100644 index 0000000000..4f7e5f5889 --- /dev/null +++ b/tests/unit/google-flow-video-4569.test.ts @@ -0,0 +1,214 @@ +/** + * #4569 — Google Flow video generation helpers. + * + * Covers the pure transformation logic (param normalization, Veo submit-body + * construction, operation-name parsing, LRO result parsing, credential + * resolution) and a source guard asserting the wire endpoint stays isolated and + * flagged for live validation (Hard Rule #18). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { + GOOGLE_FLOW_CREDENTIAL_PROVIDER, + GOOGLE_FLOW_HOST, + buildGoogleFlowSubmitBody, + normalizeFlowVideoParams, + parseFlowOperationName, + parseFlowOperationResult, + resolveFlowAccessToken, + resolveFlowProjectId, + resolveVideoCredentialProvider, +} from "../../open-sse/handlers/videoGeneration/googleFlow.ts"; + +import { getVideoProvider, parseVideoModel } from "../../open-sse/config/videoRegistry.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +test("normalizeFlowVideoParams: defaults sampleCount to 1 and stringifies prompt", () => { + const p = normalizeFlowVideoParams({ prompt: "a cat surfing" }); + assert.equal(p.prompt, "a cat surfing"); + assert.equal(p.sampleCount, 1); + assert.equal(p.aspectRatio, undefined); + assert.equal(p.durationSeconds, undefined); +}); + +test("normalizeFlowVideoParams: reads snake_case OpenAI fields", () => { + const p = normalizeFlowVideoParams({ + prompt: "x", + aspect_ratio: "16:9", + duration: 8, + n: 2, + negative_prompt: "blurry", + resolution: "1080p", + }); + assert.equal(p.aspectRatio, "16:9"); + assert.equal(p.durationSeconds, 8); + assert.equal(p.sampleCount, 2); + assert.equal(p.negativePrompt, "blurry"); + assert.equal(p.resolution, "1080p"); +}); + +test("normalizeFlowVideoParams: reads camelCase native fields", () => { + const p = normalizeFlowVideoParams({ prompt: "x", aspectRatio: "9:16", durationSeconds: 4 }); + assert.equal(p.aspectRatio, "9:16"); + assert.equal(p.durationSeconds, 4); +}); + +test("normalizeFlowVideoParams: treats ratio-shaped size as aspectRatio, ignores pixel size", () => { + assert.equal(normalizeFlowVideoParams({ prompt: "x", size: "16:9" }).aspectRatio, "16:9"); + assert.equal(normalizeFlowVideoParams({ prompt: "x", size: "1024x1024" }).aspectRatio, undefined); +}); + +test("normalizeFlowVideoParams: rejects non-positive / non-finite numbers", () => { + const p = normalizeFlowVideoParams({ prompt: "x", n: 0, duration: -5 }); + assert.equal(p.sampleCount, 1); + assert.equal(p.durationSeconds, undefined); +}); + +test("buildGoogleFlowSubmitBody: emits documented Veo instances/parameters shape", () => { + const body = buildGoogleFlowSubmitBody({ + prompt: "a dog", + aspectRatio: "16:9", + durationSeconds: 6, + sampleCount: 1, + negativePrompt: "ugly", + resolution: "720p", + }); + assert.deepEqual(body.instances, [{ prompt: "a dog" }]); + assert.equal(body.parameters.sampleCount, 1); + assert.equal(body.parameters.aspectRatio, "16:9"); + assert.equal(body.parameters.durationSeconds, 6); + assert.equal(body.parameters.negativePrompt, "ugly"); + assert.equal(body.parameters.resolution, "720p"); +}); + +test("buildGoogleFlowSubmitBody: omits absent optional parameters", () => { + const body = buildGoogleFlowSubmitBody({ prompt: "p", sampleCount: 3 }); + assert.deepEqual(body.parameters, { sampleCount: 3 }); + assert.ok(!("aspectRatio" in body.parameters)); + assert.ok(!("durationSeconds" in body.parameters)); +}); + +test("parseFlowOperationName: handles {name} and {operation:{name}} and rejects junk", () => { + assert.equal(parseFlowOperationName({ name: "operations/abc" }), "operations/abc"); + assert.equal(parseFlowOperationName({ operation: { name: "operations/xyz" } }), "operations/xyz"); + assert.equal(parseFlowOperationName({}), null); + assert.equal(parseFlowOperationName(null), null); + assert.equal(parseFlowOperationName({ name: "" }), null); +}); + +test("parseFlowOperationResult: not done → keep polling", () => { + assert.deepEqual(parseFlowOperationResult({ done: false }), { done: false }); + assert.deepEqual(parseFlowOperationResult({}), { done: false }); +}); + +test("parseFlowOperationResult: done with base64 video (documented shape)", () => { + const r = parseFlowOperationResult({ + done: true, + response: { videos: [{ bytesBase64Encoded: "AAAA" }] }, + }); + assert.equal(r.done, true); + assert.equal(r.base64, "AAAA"); + assert.equal(r.format, "mp4"); + assert.equal(r.error, undefined); +}); + +test("parseFlowOperationResult: done with gcsUri/uri video", () => { + assert.equal( + parseFlowOperationResult({ done: true, response: { videos: [{ gcsUri: "gs://b/v.mp4" }] } }).url, + "gs://b/v.mp4" + ); + assert.equal( + parseFlowOperationResult({ done: true, response: { videos: [{ uri: "https://x/v.mp4" }] } }).url, + "https://x/v.mp4" + ); +}); + +test("parseFlowOperationResult: done with alternate generateVideoResponse shape", () => { + const r = parseFlowOperationResult({ + done: true, + response: { + generateVideoResponse: { generatedSamples: [{ video: { uri: "https://x/sample.mp4" } }] }, + }, + }); + assert.equal(r.url, "https://x/sample.mp4"); +}); + +test("parseFlowOperationResult: done with error surfaces the message", () => { + const r = parseFlowOperationResult({ done: true, error: { message: "quota exceeded" } }); + assert.equal(r.done, true); + assert.equal(r.error, "quota exceeded"); +}); + +test("parseFlowOperationResult: done but empty → explicit no-video error", () => { + const r = parseFlowOperationResult({ done: true, response: {} }); + assert.equal(r.done, true); + assert.match(r.error ?? "", /no video/i); +}); + +test("parseFlowOperationResult: null/empty array elements do not crash (defensive)", () => { + // Upstream returning a null element must not throw a TypeError (gemini-code-assist #4769). + for (const response of [ + { videos: [null] }, + { videos: [undefined] }, + { videos: [{}] }, + { generateVideoResponse: { generatedSamples: [null] } }, + { generateVideoResponse: { generatedSamples: [{ video: null }] } }, + { generateVideoResponse: { generatedSamples: [{}] } }, + ]) { + const r = parseFlowOperationResult({ done: true, response }); + assert.equal(r.done, true); + assert.match(r.error ?? "", /no video/i); + assert.equal(r.base64, undefined); + assert.equal(r.url, undefined); + } +}); + +test("resolveFlowProjectId: direct, providerSpecificData, and missing", () => { + assert.equal(resolveFlowProjectId({ projectId: "proj-1" }), "proj-1"); + assert.equal(resolveFlowProjectId({ providerSpecificData: { projectId: "proj-2" } }), "proj-2"); + assert.equal(resolveFlowProjectId({}), null); + assert.equal(resolveFlowProjectId(null), null); +}); + +test("resolveFlowAccessToken: prefers accessToken, falls back to apiKey", () => { + assert.equal(resolveFlowAccessToken({ accessToken: "tok" }), "tok"); + assert.equal(resolveFlowAccessToken({ apiKey: "key" }), "key"); + assert.equal(resolveFlowAccessToken({}), null); +}); + +test("videoRegistry: googleflow provider is registered with oauth + google-flow format", () => { + const provider = getVideoProvider("googleflow"); + assert.ok(provider, "googleflow provider must exist"); + assert.equal(provider.format, "google-flow"); + assert.equal(provider.authType, "oauth"); + assert.ok(provider.models.length > 0, "must expose at least one Veo model"); +}); + +test("videoRegistry: parseVideoModel resolves googleflow/ and its alias", () => { + const parsed = parseVideoModel("googleflow/veo-3.1-generate"); + assert.equal(parsed.provider, "googleflow"); + assert.equal(parsed.model, "veo-3.1-generate"); + const aliased = parseVideoModel("flow/veo-3.1-generate"); + assert.equal(aliased.provider, "googleflow"); +}); + +test("resolveVideoCredentialProvider: googleflow reuses antigravity OAuth, others unchanged", () => { + assert.equal(resolveVideoCredentialProvider("googleflow"), "antigravity"); + assert.equal(GOOGLE_FLOW_CREDENTIAL_PROVIDER, "antigravity"); + assert.equal(resolveVideoCredentialProvider("vertex"), "vertex"); + assert.equal(resolveVideoCredentialProvider("kie"), "kie"); +}); + +test("source guard: wire endpoint stays isolated + flagged for live validation (Rule #18)", () => { + assert.match(GOOGLE_FLOW_HOST, /aisandbox-pa\.googleapis\.com/); + const src = readFileSync( + join(__dirname, "../../open-sse/handlers/videoGeneration/googleFlow.ts"), + "utf8" + ); + assert.match(src, /PENDING LIVE VALIDATION/, "wire format must be flagged for HAR validation"); +});