/** * 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; }