refactor: share provider HTTP errors with google

This commit is contained in:
Peter Steinberger
2026-04-24 19:32:40 +01:00
parent b1016c39fd
commit a43c1f8807
13 changed files with 197 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
assertOkOrThrowProviderError,
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js";
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "../../test/helpers/plugins/provider-registration.js";
import { normalizeTranscriptForMatch } from "../../test/helpers/stt-live-audio.js";
import plugin from "./index.js";
const GOOGLE_API_KEY =
process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || "";
const LIVE = isLiveTestEnabled() && GOOGLE_API_KEY.length > 0;
const describeLive = LIVE ? describe : describe.skip;
const registerGooglePlugin = () =>
registerProviderPlugin({
plugin,
id: "google",
name: "Google Provider",
});
describeLive("google plugin live", () => {
it("synthesizes speech through the registered provider", async () => {
const { speechProviders } = await registerGooglePlugin();
const provider = requireRegisteredProvider(speechProviders, "google");
const audioFile = await provider.synthesize({
text: "OpenClaw Google text to speech integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig: { apiKey: GOOGLE_API_KEY },
target: "audio-file",
timeoutMs: 90_000,
});
expect(audioFile.outputFormat).toBe("wav");
expect(audioFile.fileExtension).toBe(".wav");
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
}, 120_000);
it("transcribes synthesized speech through the media provider", async () => {
const { mediaProviders, speechProviders } = await registerGooglePlugin();
const speechProvider = requireRegisteredProvider(speechProviders, "google");
const mediaProvider = requireRegisteredProvider(mediaProviders, "google");
const phrase = "Testing Google audio transcription with OpenClaw.";
const audioFile = await speechProvider.synthesize({
text: phrase,
cfg: { plugins: { enabled: true } } as never,
providerConfig: { apiKey: GOOGLE_API_KEY },
target: "audio-file",
timeoutMs: 90_000,
});
const transcript = await mediaProvider.transcribeAudio?.({
buffer: audioFile.audioBuffer,
fileName: "google-live.wav",
mime: "audio/wav",
apiKey: GOOGLE_API_KEY,
timeoutMs: 90_000,
});
const normalized = normalizeTranscriptForMatch(transcript?.text ?? "");
expect(normalized).toContain("google");
expect(normalized).toContain("openclaw");
}, 180_000);
});

View File

@@ -8,7 +8,7 @@ import {
type VideoDescriptionResult,
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
assertOkOrThrowProviderError,
postJsonRequest,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
@@ -96,7 +96,7 @@ async function generateGeminiInlineDataText(params: {
});
try {
await assertOkOrThrowHttpError(res, params.httpErrorLabel);
await assertOkOrThrowProviderError(res, params.httpErrorLabel);
const payload = (await res.json()) as {
candidates?: Array<{

View File

@@ -4,7 +4,7 @@ import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
} from "../../src/media-understanding/audio.test-helpers.js";
import { describeGeminiVideo } from "./media-understanding-provider.js";
import { describeGeminiVideo, transcribeGeminiAudio } from "./media-understanding-provider.js";
import { resolveGoogleGenerativeAiHttpRequestConfig } from "./runtime-api.js";
installPinnedHostnameTestHooks();
@@ -129,4 +129,30 @@ describe("describeGeminiVideo", () => {
"Google Generative AI baseUrl must use https://generativelanguage.googleapis.com",
);
});
it("formats Google audio transcription HTTP errors with provider details", async () => {
await expect(
transcribeGeminiAudio({
buffer: Buffer.from("audio-bytes"),
fileName: "clip.wav",
apiKey: "test-key",
timeoutMs: 1500,
fetchFn: async () =>
new Response(
JSON.stringify({
error: {
message: "Unsupported audio",
status: "INVALID_ARGUMENT",
},
}),
{
status: 400,
headers: { "x-request-id": "google_audio_req" },
},
),
}),
).rejects.toThrow(
"Audio transcription failed (400): Unsupported audio [code=INVALID_ARGUMENT] [request_id=google_audio_req]",
);
});
});

View File

@@ -245,4 +245,37 @@ describe("Google speech provider", () => {
]),
);
});
it("formats Google TTS HTTP errors with provider details", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: {
message: "Quota exceeded",
status: "RESOURCE_EXHAUSTED",
},
}),
{
status: 429,
headers: { "x-request-id": "google_req_123" },
},
),
),
);
const provider = buildGoogleSpeechProvider();
await expect(
provider.synthesize({
text: "Read this plainly.",
cfg: {},
providerConfig: { apiKey: "google-test-key" },
target: "audio-file",
timeoutMs: 10_000,
}),
).rejects.toThrow(
"Google TTS failed (429): Quota exceeded [code=RESOURCE_EXHAUSTED] [request_id=google_req_123]",
);
});
});

View File

@@ -1,4 +1,4 @@
import { assertOkOrThrowHttpError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import { assertOkOrThrowProviderError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type {
@@ -281,7 +281,7 @@ async function synthesizeGoogleTtsPcm(params: {
});
try {
await assertOkOrThrowHttpError(res, "Google TTS failed");
await assertOkOrThrowProviderError(res, "Google TTS failed");
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
} finally {
await release();

View File

@@ -1,4 +1,4 @@
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/speech";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeGradiumBaseUrl } from "./shared.js";

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { isLiveTestEnabled } from "../../src/agents/live-test-helpers.js";
import { buildOpenAISpeechProvider } from "./speech-provider.js";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY?.trim() ?? "";
const LIVE = isLiveTestEnabled() && OPENAI_API_KEY.length > 0;
const describeLive = LIVE ? describe : describe.skip;
describeLive("openai tts live", () => {
it("synthesizes audio through the speech provider", async () => {
const speechProvider = buildOpenAISpeechProvider();
const voices = await speechProvider.listVoices?.({});
expect(voices).toEqual(expect.arrayContaining([expect.objectContaining({ id: "alloy" })]));
const providerConfig = {
apiKey: OPENAI_API_KEY,
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
};
const audioFile = await speechProvider.synthesize({
text: "OpenClaw OpenAI text to speech integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig,
target: "audio-file",
timeoutMs: 45_000,
});
expect(audioFile.outputFormat).toBe("mp3");
expect(audioFile.fileExtension).toBe(".mp3");
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
const telephony = await speechProvider.synthesizeTelephony?.({
text: "OpenClaw OpenAI telephony integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig,
timeoutMs: 45_000,
});
expect(telephony?.outputFormat).toBe("pcm");
expect(telephony?.sampleRate).toBe(24_000);
expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512);
}, 60_000);
});

View File

@@ -1,8 +1,8 @@
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/provider-http";
import {
captureHttpExchange,
isDebugProxyGlobalFetchPatchInstalled,
} from "openclaw/plugin-sdk/proxy-capture";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/speech";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,

View File

@@ -1,5 +1,5 @@
import { postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import { assertOkOrThrowProviderError, trimToUndefined } from "openclaw/plugin-sdk/speech";
import { assertOkOrThrowProviderError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import { trimToUndefined } from "openclaw/plugin-sdk/speech";
import { XAI_BASE_URL } from "./api.js";
export { XAI_BASE_URL };