diff --git a/changelog.d/fixes/10285-googleflow-video-wrong-path-auth.md b/changelog.d/fixes/10285-googleflow-video-wrong-path-auth.md new file mode 100644 index 0000000000..8e2301cb7c --- /dev/null +++ b/changelog.d/fixes/10285-googleflow-video-wrong-path-auth.md @@ -0,0 +1 @@ +- fix(video): stop advertising the googleflow (Veo) video provider as working and fail fast with a clear diagnostic — its submit/poll endpoints 404 and no server-side OAuth transport can satisfy the working endpoint (#10285) diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index acbdc56b63..df2f297aab 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -27,6 +27,12 @@ interface VideoProvider { authHeader: string; format: string; models: VideoModel[]; + // #10285 — set when a provider is registered (so parseVideoModel/getVideoProvider + // still resolve it for a clear diagnostic) but must NOT be advertised as a working + // model in /v1/models or getAllVideoModels(). Keep unsupportedReason short and + // stable — handlers may surface it verbatim in the fail-fast error message. + unsupported?: boolean; + unsupportedReason?: string; } export const VIDEO_PROVIDERS: Record = { @@ -119,6 +125,18 @@ export const VIDEO_PROVIDERS: Record = { { id: "veo-3.1-fast-generate", name: "Veo 3.1 Fast (Google Flow)" }, { id: "veo-3.0-generate", name: "Veo 3.0 (Google Flow)" }, ], + // #10285 — live-validated: the submit/poll paths above (/v1:generateVideo, + // /v1:fetchOperation) are 404 on aisandbox-pa; the reporter's measured working + // path (POST /v1/video:batchAsyncGenerateVideoText) is undocumented and, even + // reached, rejects the stored Cloud Code OAuth bearer (401 UNAUTHENTICATED — + // the cclog/cloud-platform scopes do not grant aisandbox-pa). gflow-cli confirms + // only a headed-browser reCAPTCHA session works for mutation endpoints. De-listed + // until a viable server-side transport is confirmed live (see plan-file #10285). + unsupported: true, + unsupportedReason: + "Google Flow video generation requires a browser-session transport " + + "(Flow/Cloud Code session with reCAPTCHA) and is not supported over the stored " + + "OAuth bearer. Generate video via labs.google/flow directly for now.", }, kie: { @@ -399,17 +417,19 @@ export function parseVideoModel(modelStr: string | null) { * Get all video models as a flat list */ export function getAllVideoModels() { - return Object.entries(VIDEO_PROVIDERS).flatMap(([providerId, config]) => - [providerId, config.alias] - .filter((prefix): prefix is string => Boolean(prefix)) - .flatMap((prefix) => - config.models.map((model) => ({ - id: `${prefix}/${model.id}`, - name: model.name, - provider: providerId, - supportedSizes: model.supportedSizes || [], - mediaCapabilities: model.mediaCapabilities, - })) - ) - ); + return Object.entries(VIDEO_PROVIDERS) + .filter(([, config]) => !config.unsupported) + .flatMap(([providerId, config]) => + [providerId, config.alias] + .filter((prefix): prefix is string => Boolean(prefix)) + .flatMap((prefix) => + config.models.map((model) => ({ + id: `${prefix}/${model.id}`, + name: model.name, + provider: providerId, + supportedSizes: model.supportedSizes || [], + mediaCapabilities: model.mediaCapabilities, + })) + ) + ); } diff --git a/open-sse/handlers/videoGeneration/googleFlowHandler.ts b/open-sse/handlers/videoGeneration/googleFlowHandler.ts index 9b09d00fd9..611a67c309 100644 --- a/open-sse/handlers/videoGeneration/googleFlowHandler.ts +++ b/open-sse/handlers/videoGeneration/googleFlowHandler.ts @@ -1,30 +1,28 @@ /** * 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). + * ⚠️ #10285 — DISABLED pending a viable transport (Hard Rule #18). Live probes + * against https://aisandbox-pa.googleapis.com confirmed two independent wire-surface + * defects the #4769 PENDING LIVE VALIDATION flag anticipated: (1) the submit/poll + * paths in googleFlow.ts (`GOOGLE_FLOW_SUBMIT_PATH`/`GOOGLE_FLOW_POLL_PATH`) 404 — + * the real working endpoint is undocumented (`POST /v1/video:batchAsyncGenerateVideoText`); + * (2) even on that working endpoint, the stored Cloud Code OAuth bearer is rejected + * (401 UNAUTHENTICATED — the cclog/cloud-platform scopes do not grant aisandbox-pa). + * gflow-cli's own docs confirm only a headed-browser reCAPTCHA session works for + * mutation endpoints, which cannot run headlessly. Until a viable server-side + * transport is found and live-validated, fail fast with a clear diagnostic instead + * of forwarding to the known-wrong path and surfacing a raw HTML 404. The pure + * transformation helpers (googleFlow.ts) remain fully unit-tested + * (google-flow-video-4569.test.ts) for whenever the wire surface is fixed and this + * handler is restored to actually submit/poll. */ import { sanitizeErrorMessage } from "../../utils/error.ts"; -import { - GOOGLE_FLOW_POLL_PATH, - GOOGLE_FLOW_SUBMIT_PATH, - buildGoogleFlowSubmitBody, - normalizeFlowVideoParams, - parseFlowOperationName, - parseFlowOperationResult, - resolveFlowAccessToken, - resolveFlowProjectId, -} from "./googleFlow.ts"; +import { getVideoProvider } from "../../config/videoRegistry.ts"; + +const FALLBACK_UNSUPPORTED_REASON = + "Google Flow video generation requires a browser-session transport and is not " + + "supported over the stored OAuth bearer."; interface GoogleFlowHandlerArgs { model: string; @@ -34,112 +32,17 @@ interface GoogleFlowHandlerArgs { 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}`, +export async function handleGoogleFlowVideoGeneration( + _args: GoogleFlowHandlerArgs +): Promise<{ success: false; status: number; error: string }> { + // #10285 — fail fast: the submit/poll wire surface is live-confirmed broken and no + // server-side credential transport can satisfy the working endpoint (see the module + // doc above). Do not forward to the known-wrong path / surface a raw HTML 404. + return { + success: false, + status: 501, + error: sanitizeErrorMessage( + getVideoProvider("googleflow")?.unsupportedReason || FALLBACK_UNSUPPORTED_REASON + ), }; - - 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/tests/unit/google-flow-video-4569.test.ts b/tests/unit/google-flow-video-4569.test.ts index 4f7e5f5889..634d1f86dc 100644 --- a/tests/unit/google-flow-video-4569.test.ts +++ b/tests/unit/google-flow-video-4569.test.ts @@ -24,7 +24,12 @@ import { resolveVideoCredentialProvider, } from "../../open-sse/handlers/videoGeneration/googleFlow.ts"; -import { getVideoProvider, parseVideoModel } from "../../open-sse/config/videoRegistry.ts"; +import { + getAllVideoModels, + getVideoProvider, + parseVideoModel, +} from "../../open-sse/config/videoRegistry.ts"; +import { handleGoogleFlowVideoGeneration } from "../../open-sse/handlers/videoGeneration/googleFlowHandler.ts"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -181,12 +186,40 @@ test("resolveFlowAccessToken: prefers accessToken, falls back to apiKey", () => assert.equal(resolveFlowAccessToken({}), null); }); -test("videoRegistry: googleflow provider is registered with oauth + google-flow format", () => { +test("videoRegistry: googleflow provider is registered with oauth + google-flow format, flagged unsupported", () => { 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"); + // #10285 — the submit/poll endpoints are live-confirmed wrong and no server-side + // OAuth bearer can satisfy the working endpoint (aisandbox-pa rejects it). Until a + // viable transport exists the provider must not be presented as functional. + assert.equal(provider.unsupported, true, "googleflow must be flagged unsupported (#10285)"); + assert.match(provider.unsupportedReason ?? "", /browser-session|not supported/i); +}); + +test("videoRegistry: getAllVideoModels excludes the unsupported googleflow provider (#10285)", () => { + const models = getAllVideoModels(); + const flowModels = models.filter( + (m) => m.provider === "googleflow" || m.id.startsWith("googleflow/") || m.id.startsWith("flow/") + ); + assert.deepEqual(flowModels, [], "googleflow must not be advertised in /v1/models until fixed"); + // Sanity: other providers are still listed, so exclusion is targeted, not global. + assert.ok(models.some((m) => m.provider === "vertex"), "unrelated providers must stay listed"); +}); + +test("handleGoogleFlowVideoGeneration: fails fast with a clear diagnostic instead of the wrong path (#10285)", async () => { + const result = await handleGoogleFlowVideoGeneration({ + model: "veo-3.1-generate", + providerConfig: { baseUrl: "https://aisandbox-pa.googleapis.com" }, + body: { prompt: "a cat surfing" }, + credentials: { accessToken: "tok", projectId: "proj-1" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 501); + assert.match(result.error ?? "", /browser-session|not supported/i); + assert.doesNotMatch(result.error ?? "", / and its alias", () => {