mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
Integrated into release/v3.8.36 — Google Flow video-generation provider (#4569), release-green validated (typecheck + 21 tests + file-size)
This commit is contained in:
committed by
GitHub
parent
c922b21102
commit
502f43c283
@@ -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,
|
||||
|
||||
@@ -39,6 +39,23 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
|
||||
],
|
||||
},
|
||||
|
||||
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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
219
open-sse/handlers/videoGeneration/googleFlow.ts
Normal file
219
open-sse/handlers/videoGeneration/googleFlow.ts
Normal file
@@ -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: <operation> }
|
||||
* 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<string, unknown> | 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<Record<string, unknown>>;
|
||||
parameters: Record<string, unknown>;
|
||||
} {
|
||||
const parameters: Record<string, unknown> = { 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>).projectId);
|
||||
if (fromPsd) return fromPsd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the OAuth bearer token from the credential record. */
|
||||
export function resolveFlowAccessToken(
|
||||
credentials: Record<string, unknown> | null | undefined
|
||||
): string | null {
|
||||
const cred = credentials ?? {};
|
||||
return asTrimmedString(cred.accessToken) ?? asTrimmedString(cred.apiKey) ?? null;
|
||||
}
|
||||
145
open-sse/handlers/videoGeneration/googleFlowHandler.ts
Normal file
145
open-sse/handlers/videoGeneration/googleFlowHandler.ts
Normal file
@@ -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<string, unknown>;
|
||||
credentials: Record<string, unknown> | 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<void>((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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
214
tests/unit/google-flow-video-4569.test.ts
Normal file
214
tests/unit/google-flow-video-4569.test.ts
Normal file
@@ -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/<model> 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");
|
||||
});
|
||||
Reference in New Issue
Block a user