fix(google): download direct veo video uri

This commit is contained in:
Peter Steinberger
2026-04-25 11:05:47 +01:00
parent 455eba7f94
commit c11337149b
3 changed files with 147 additions and 17 deletions

View File

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

View File

@@ -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",

View File

@@ -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<GeneratedVideoAsset | undefined> {
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");
}