From 9c64a0ca23d6703244597d7d8358139e4ff45765 Mon Sep 17 00:00:00 2001 From: Yunsu Date: Sat, 25 Apr 2026 18:45:38 +0900 Subject: [PATCH] fix(google): avoid doubled media generation API version Strip configured trailing /v1beta from Google music/video generation base URLs before calling the Google GenAI SDK.\n\nFixes #63240.\n\nThanks @Hybirdss. --- CHANGELOG.md | 1 + .../google/music-generation-provider.test.ts | 165 ++++++++++++++++++ .../google/music-generation-provider.ts | 4 +- .../google/video-generation-provider.test.ts | 115 ++++++++++++ .../google/video-generation-provider.ts | 4 +- 5 files changed, 285 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b88967bdc..e19d315fa35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - Google Chat: preserve reply text when a typing indicator message is deleted or can no longer be updated, so media captions and first text chunks are resent instead of silently disappearing. (#71498) Thanks @colin-lgtm. - Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. - Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys. diff --git a/extensions/google/music-generation-provider.test.ts b/extensions/google/music-generation-provider.test.ts index 2c0dbc451fe..746197fb617 100644 --- a/extensions/google/music-generation-provider.test.ts +++ b/extensions/google/music-generation-provider.test.ts @@ -82,6 +82,171 @@ describe("google music generation provider", () => { ); }); + it("strips /v1beta suffix from configured baseUrl before passing to GoogleGenAI SDK", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + data: Buffer.from("mp3-bytes").toString("base64"), + mimeType: "audio/mpeg", + }, + }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "ambient ocean", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", models: [] }, + }, + }, + }, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + + it("does NOT strip /v1beta when it appears mid-path (end-anchor proof)", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { inlineData: { data: Buffer.from("x").toString("base64"), mimeType: "audio/mpeg" } }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "test", + cfg: { + models: { + providers: { google: { baseUrl: "https://proxy.example.com/v1beta/route", models: [] } }, + }, + }, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://proxy.example.com/v1beta/route", + }), + }), + ); + }); + + it("passes baseUrl unchanged when no /v1beta suffix is present", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { inlineData: { data: Buffer.from("x").toString("base64"), mimeType: "audio/mpeg" } }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "test", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com", models: [] }, + }, + }, + }, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + + it("does not set baseUrl when none is configured", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateContentMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { inlineData: { data: Buffer.from("x").toString("base64"), mimeType: "audio/mpeg" } }, + ], + }, + }, + ], + }); + + const provider = buildGoogleMusicGenerationProvider(); + await provider.generateMusic({ + provider: "google", + model: "lyria-3-clip-preview", + prompt: "test", + cfg: {}, + instrumental: true, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.not.objectContaining({ + baseUrl: expect.anything(), + }), + }), + ); + }); + it("rejects unsupported wav output on clip model", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", diff --git a/extensions/google/music-generation-provider.ts b/extensions/google/music-generation-provider.ts index e5b53e50e2a..8c1da4d89df 100644 --- a/extensions/google/music-generation-provider.ts +++ b/extensions/google/music-generation-provider.ts @@ -6,7 +6,7 @@ import type { } from "openclaw/plugin-sdk/music-generation"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { normalizeGoogleApiBaseUrl } from "./api.js"; +import { resolveGoogleGenerativeAiApiOrigin } from "./api.js"; import { createGoogleMusicGenerationProviderMetadata, DEFAULT_GOOGLE_MUSIC_MODEL, @@ -37,7 +37,7 @@ type GoogleGenerateMusicResponse = { function resolveConfiguredGoogleMusicBaseUrl(req: MusicGenerationRequest): string | undefined { const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl); - return configured ? normalizeGoogleApiBaseUrl(configured) : undefined; + return configured ? resolveGoogleGenerativeAiApiOrigin(configured) : undefined; } function buildMusicPrompt(req: MusicGenerationRequest): string { diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 5b0ad93c0d4..15a16c6da5b 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -100,6 +100,121 @@ describe("google video generation provider", () => { ); }); + it("strips /v1beta suffix from configured baseUrl before passing to GoogleGenAI SDK", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { video: { videoBytes: Buffer.from("mp4").toString("base64"), mimeType: "video/mp4" } }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", models: [] }, + }, + }, + }, + durationSeconds: 3, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + + it("does NOT strip /v1beta when it appears mid-path (end-anchor proof)", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { video: { videoBytes: Buffer.from("mp4").toString("base64"), mimeType: "video/mp4" } }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "test", + cfg: { + models: { + providers: { google: { baseUrl: "https://proxy.example.com/v1beta/route", models: [] } }, + }, + }, + durationSeconds: 3, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://proxy.example.com/v1beta/route", + }), + }), + ); + }); + + it("passes baseUrl unchanged when no /v1beta suffix is present", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { video: { videoBytes: Buffer.from("mp4").toString("base64"), mimeType: "video/mp4" } }, + ], + }, + }); + + const provider = buildGoogleVideoGenerationProvider(); + await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "test", + cfg: { + models: { + providers: { + google: { baseUrl: "https://generativelanguage.googleapis.com", models: [] }, + }, + }, + }, + durationSeconds: 3, + }); + + expect(createGoogleGenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + baseUrl: "https://generativelanguage.googleapis.com", + }), + }), + ); + }); + it("rejects mixed image and video inputs", 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 365a0fb5c03..07c34f0ff86 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -13,7 +13,7 @@ import type { VideoGenerationProvider, VideoGenerationRequest, } from "openclaw/plugin-sdk/video-generation"; -import { normalizeGoogleApiBaseUrl } from "./api.js"; +import { resolveGoogleGenerativeAiApiOrigin } from "./api.js"; import { createGoogleVideoGenerationProviderMetadata, DEFAULT_GOOGLE_VIDEO_MODEL, @@ -29,7 +29,7 @@ const MAX_POLL_ATTEMPTS = 90; function resolveConfiguredGoogleVideoBaseUrl(req: VideoGenerationRequest): string | undefined { const configured = normalizeOptionalString(req.cfg?.models?.providers?.google?.baseUrl); - return configured ? normalizeGoogleApiBaseUrl(configured) : undefined; + return configured ? resolveGoogleGenerativeAiApiOrigin(configured) : undefined; } function parseVideoSize(size: string | undefined): { width: number; height: number } | undefined {