diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ba2162974..bedc9e8a02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - OpenAI image generation: use `gpt-5.5` for the Codex OAuth responses transport instead of the retired `gpt-5.4` model, fixing 500s from ChatGPT Codex image generation. Fixes #71513. Thanks @baolongl. +- Google video generation: download direct MLDev Veo `video.uri` results instead of passing them through the Files API path, fixing 404s after successful generation/polling. Fixes #71200. Thanks @panhaishan. - MiniMax music generation: switch the bundled default model from the unsupported `music-2.5+` id to the current `music-2.6` API model. Fixes #64870 and addresses the music default from #62315. Thanks @noahclanman and @edwardzheng1. - Google media generation: strip a configured trailing `/v1beta` from Google music/video provider base URLs before calling the Google GenAI SDK, preventing doubled `/v1beta/v1beta` paths. Fixes #63240. (#63258) Thanks @Hybirdss. - Discord: restore direct-message voice-note preflight transcription and classify URL-only Ogg/Opus voice attachments as audio while skipping partial attachments without usable URLs. Fixes #61314 and #64803. diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 15a16c6da5b..75ee8cdeaf1 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -1,23 +1,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -const { createGoogleGenAIMock, generateVideosMock, getVideosOperationMock } = vi.hoisted(() => { - const generateVideosMock = vi.fn(); - const getVideosOperationMock = vi.fn(); - const createGoogleGenAIMock = vi.fn(() => { - return { - models: { - generateVideos: generateVideosMock, - }, - operations: { - getVideosOperation: getVideosOperationMock, - }, - files: { - download: vi.fn(), - }, - }; +const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } = + vi.hoisted(() => { + const generateVideosMock = vi.fn(); + const getVideosOperationMock = vi.fn(); + const downloadMock = vi.fn(); + const createGoogleGenAIMock = vi.fn(() => { + return { + models: { + generateVideos: generateVideosMock, + }, + operations: { + getVideosOperation: getVideosOperationMock, + }, + files: { + download: downloadMock, + }, + }; + }); + return { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock }; }); - return { createGoogleGenAIMock, generateVideosMock, getVideosOperationMock }; -}); vi.mock("./google-genai-runtime.js", () => ({ createGoogleGenAI: createGoogleGenAIMock, @@ -30,6 +32,8 @@ import { buildGoogleVideoGenerationProvider } from "./video-generation-provider. describe("google video generation provider", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); + downloadMock.mockReset(); generateVideosMock.mockReset(); getVideosOperationMock.mockReset(); createGoogleGenAIMock.mockClear(); @@ -139,6 +143,51 @@ describe("google video generation provider", () => { ); }); + it("downloads MLDev direct video uri responses without routing through the Files API", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { + video: { + uri: "https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media", + mimeType: "video/mp4", + }, + }, + ], + }, + }); + const fetchMock = vi.fn(async () => { + return new Response("direct-mp4", { + status: 200, + statusText: "OK", + headers: { "content-type": "video/mp4" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleVideoGenerationProvider(); + const result = await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: {}, + durationSeconds: 3, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/files/generated-video:download?alt=media&key=google-key", + ); + expect(downloadMock).not.toHaveBeenCalled(); + expect(result.videos[0]?.buffer).toEqual(Buffer.from("direct-mp4")); + expect(result.videos[0]?.mimeType).toBe("video/mp4"); + }); + it("does NOT strip /v1beta when it appears mid-path (end-anchor proof)", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index 07c34f0ff86..b190161a7a6 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -150,6 +150,76 @@ async function downloadGeneratedVideo(params: { } } +function resolveGoogleGeneratedVideoDownloadUrl(params: { + uri: string | undefined; + apiKey: string; + configuredBaseUrl?: string; +}): string | undefined { + const trimmed = normalizeOptionalString(params.uri); + if (!trimmed) { + return undefined; + } + let url: URL; + try { + url = new URL(trimmed); + } catch { + return undefined; + } + if (url.protocol !== "https:") { + return undefined; + } + const allowedOrigins = new Set(["https://generativelanguage.googleapis.com"]); + if (params.configuredBaseUrl) { + try { + const configuredOrigin = new URL(params.configuredBaseUrl).origin; + if (configuredOrigin.startsWith("https://")) { + allowedOrigins.add(configuredOrigin); + } + } catch { + // Ignore invalid configured origins; resolveConfiguredGoogleVideoBaseUrl already normalizes. + } + } + if (!allowedOrigins.has(url.origin)) { + return undefined; + } + if (!url.searchParams.has("key")) { + url.searchParams.set("key", params.apiKey); + } + return url.toString(); +} + +async function downloadGeneratedVideoFromUri(params: { + uri: string | undefined; + apiKey: string; + configuredBaseUrl?: string; + mimeType?: string; + index: number; +}): Promise { + const downloadUrl = resolveGoogleGeneratedVideoDownloadUrl({ + uri: params.uri, + apiKey: params.apiKey, + configuredBaseUrl: params.configuredBaseUrl, + }); + if (!downloadUrl) { + return undefined; + } + const response = await fetch(downloadUrl); + if (!response.ok) { + throw new Error( + `Failed to download Google generated video: ${response.status} ${response.statusText}`, + ); + } + const buffer = Buffer.from(await response.arrayBuffer()); + return { + buffer, + mimeType: + normalizeOptionalString(response.headers.get("content-type")) || + normalizeOptionalString(params.mimeType) || + "video/mp4", + fileName: `video-${params.index + 1}.mp4`, + }; +} + export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { return { ...createGoogleVideoGenerationProviderMetadata(), @@ -233,6 +303,16 @@ export function buildGoogleVideoGenerationProvider(): VideoGenerationProvider { fileName: `video-${index + 1}.mp4`, }; } + const directDownload = await downloadGeneratedVideoFromUri({ + uri: inline?.uri, + apiKey: auth.apiKey, + configuredBaseUrl, + mimeType: inline?.mimeType, + index, + }); + if (directDownload) { + return directDownload; + } if (!inline) { throw new Error("Google generated video missing file handle"); }