fix: de-list googleflow video provider and fail fast (#10285) (#10745)

The googleflow (Veo) video provider is live-confirmed broken on two
independent axes: the submit/poll endpoints (/v1:generateVideo,
/v1:fetchOperation) 404 on aisandbox-pa, and even the reporter's
measured working endpoint (POST /v1/video:batchAsyncGenerateVideoText)
rejects the stored Cloud Code OAuth bearer (401 UNAUTHENTICATED) since
the cclog/cloud-platform scopes do not grant aisandbox-pa. Only a
headed-browser reCAPTCHA session works (confirmed against gflow-cli's
own docs), which cannot run headlessly.

Exclude googleflow from getAllVideoModels() so it stops being
advertised in /v1/models, and make handleGoogleFlowVideoGeneration
fail fast with a clear diagnostic instead of forwarding to the
known-wrong path and surfacing a raw HTML 404.

Co-authored-by: Markus Hartung <mail@hartmark.se>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-19 11:08:29 -03:00
committed by GitHub
parent cc544db38b
commit e1c2425ed7
4 changed files with 100 additions and 143 deletions

View File

@@ -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)

View File

@@ -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<string, VideoProvider> = {
@@ -119,6 +125,18 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
{ 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,
}))
)
);
}

View File

@@ -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<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}`,
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"),
};
}
}

View File

@@ -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 ?? "", /<!DOCTYPE/i, "must not surface a raw HTML 404 body");
});
test("videoRegistry: parseVideoModel resolves googleflow/<model> and its alias", () => {