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.
This commit is contained in:
Yunsu
2026-04-25 18:45:38 +09:00
committed by GitHub
parent 0bef73d151
commit 9c64a0ca23
5 changed files with 285 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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