refactor: share speech provider HTTP errors

This commit is contained in:
Peter Steinberger
2026-04-24 19:06:49 +01:00
parent e54c04f495
commit b1016c39fd
11 changed files with 164 additions and 139 deletions

View File

@@ -1,10 +1,15 @@
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,
runRealtimeSttLiveTest,
synthesizeElevenLabsLiveSpeech,
} from "../../test/helpers/stt-live-audio.js";
import plugin from "./index.js";
import { elevenLabsMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildElevenLabsRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
@@ -12,7 +17,31 @@ const ELEVENLABS_KEY = process.env.ELEVENLABS_API_KEY ?? "";
const LIVE = isLiveTestEnabled(["ELEVENLABS_LIVE_TEST"]);
const describeLive = LIVE && ELEVENLABS_KEY ? describe : describe.skip;
const registerElevenLabsPlugin = () =>
registerProviderPlugin({
plugin,
id: "elevenlabs",
name: "ElevenLabs Speech",
});
describeLive("elevenlabs plugin live", () => {
it("synthesizes speech through the registered provider", async () => {
const { speechProviders } = await registerElevenLabsPlugin();
const provider = requireRegisteredProvider(speechProviders, "elevenlabs");
const audioFile = await provider.synthesize({
text: "OpenClaw ElevenLabs text to speech integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig: { apiKey: ELEVENLABS_KEY },
target: "audio-file",
timeoutMs: 45_000,
});
expect(audioFile.outputFormat).toBe("mp3_44100_128");
expect(audioFile.fileExtension).toBe(".mp3");
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
}, 60_000);
it("transcribes synthesized speech through the media provider", async () => {
const phrase = "Testing OpenClaw ElevenLabs speech to text integration OK.";
const audio = await synthesizeElevenLabsLiveSpeech({

View File

@@ -1,54 +1,12 @@
import {
asObject,
assertOkOrThrowProviderError,
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,
readResponseTextLimited,
requireInRange,
trimToUndefined,
truncateErrorDetail,
} from "openclaw/plugin-sdk/speech";
import { isValidElevenLabsVoiceId, normalizeElevenLabsBaseUrl } from "./shared.js";
function formatElevenLabsErrorPayload(payload: unknown): string | undefined {
const root = asObject(payload);
if (!root) {
return undefined;
}
const detailObject = asObject(root.detail);
const message =
trimToUndefined(root.message) ??
trimToUndefined(detailObject?.message) ??
trimToUndefined(detailObject?.detail) ??
trimToUndefined(root.error);
const code =
trimToUndefined(root.code) ??
trimToUndefined(detailObject?.code) ??
trimToUndefined(detailObject?.status);
if (message && code) {
return `${truncateErrorDetail(message)} [code=${code}]`;
}
if (message) {
return truncateErrorDetail(message);
}
if (code) {
return `[code=${code}]`;
}
return undefined;
}
async function extractElevenLabsErrorDetail(response: Response): Promise<string | undefined> {
const rawBody = trimToUndefined(await readResponseTextLimited(response));
if (!rawBody) {
return undefined;
}
try {
return formatElevenLabsErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody);
} catch {
return truncateErrorDetail(rawBody);
}
}
function assertElevenLabsVoiceSettings(settings: {
stability: number;
similarityBoost: number;
@@ -138,17 +96,7 @@ export async function elevenLabsTTS(params: {
signal: controller.signal,
});
if (!response.ok) {
const detail = await extractElevenLabsErrorDetail(response);
const requestId =
trimToUndefined(response.headers.get("x-request-id")) ??
trimToUndefined(response.headers.get("request-id"));
throw new Error(
`ElevenLabs API error (${response.status})` +
(detail ? `: ${detail}` : "") +
(requestId ? ` [request_id=${requestId}]` : ""),
);
}
await assertOkOrThrowProviderError(response, "ElevenLabs API error");
return Buffer.from(await response.arrayBuffer());
} finally {

View File

@@ -1,37 +1,7 @@
import {
asObject,
readResponseTextLimited,
trimToUndefined,
truncateErrorDetail,
} from "openclaw/plugin-sdk/speech";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/speech";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeGradiumBaseUrl } from "./shared.js";
function formatGradiumErrorPayload(payload: unknown): string | undefined {
const root = asObject(payload);
if (!root) {
return undefined;
}
const message =
trimToUndefined(root.message) ?? trimToUndefined(root.error) ?? trimToUndefined(root.detail);
if (message) {
return truncateErrorDetail(message);
}
return undefined;
}
async function extractGradiumErrorDetail(response: Response): Promise<string | undefined> {
const rawBody = trimToUndefined(await readResponseTextLimited(response));
if (!rawBody) {
return undefined;
}
try {
return formatGradiumErrorPayload(JSON.parse(rawBody)) ?? truncateErrorDetail(rawBody);
} catch {
return truncateErrorDetail(rawBody);
}
}
export async function gradiumTTS(params: {
text: string;
apiKey: string;
@@ -67,17 +37,7 @@ export async function gradiumTTS(params: {
});
try {
if (!response.ok) {
const detail = await extractGradiumErrorDetail(response);
const requestId =
trimToUndefined(response.headers.get("x-request-id")) ??
trimToUndefined(response.headers.get("request-id"));
throw new Error(
`Gradium API error (${response.status})` +
(detail ? `: ${detail}` : "") +
(requestId ? ` [request_id=${requestId}]` : ""),
);
}
await assertOkOrThrowProviderError(response, "Gradium API error");
return Buffer.from(await response.arrayBuffer());
} finally {

View File

@@ -2,7 +2,7 @@ import {
captureHttpExchange,
isDebugProxyGlobalFetchPatchInstalled,
} from "openclaw/plugin-sdk/proxy-capture";
import { extractProviderErrorDetail, trimToUndefined } from "openclaw/plugin-sdk/speech";
import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/speech";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
@@ -68,10 +68,6 @@ export function resolveOpenAITtsInstructions(
return next && model.includes("gpt-4o-mini-tts") ? next : undefined;
}
async function extractOpenAiErrorDetail(response: Response): Promise<string | undefined> {
return await extractProviderErrorDetail(response);
}
export async function openaiTTS(params: {
text: string;
apiKey: string;
@@ -137,17 +133,7 @@ export async function openaiTTS(params: {
});
}
if (!response.ok) {
const detail = await extractOpenAiErrorDetail(response);
const requestId =
trimToUndefined(response.headers.get("x-request-id")) ??
trimToUndefined(response.headers.get("request-id"));
throw new Error(
`OpenAI TTS API error (${response.status})` +
(detail ? `: ${detail}` : "") +
(requestId ? ` [request_id=${requestId}]` : ""),
);
}
await assertOkOrThrowProviderError(response, "OpenAI TTS API error");
return Buffer.from(await response.arrayBuffer());
} finally {

View File

@@ -1,5 +1,5 @@
import { postJsonRequest } from "openclaw/plugin-sdk/provider-http";
import { extractProviderErrorDetail, trimToUndefined } from "openclaw/plugin-sdk/speech";
import { assertOkOrThrowProviderError, trimToUndefined } from "openclaw/plugin-sdk/speech";
import { XAI_BASE_URL } from "./api.js";
export { XAI_BASE_URL };
@@ -39,10 +39,6 @@ export function normalizeXaiLanguageCode(value: unknown): string | undefined {
);
}
async function extractXaiErrorDetail(response: Response): Promise<string | undefined> {
return await extractProviderErrorDetail(response);
}
export async function xaiTTS(params: {
text: string;
apiKey: string;
@@ -89,17 +85,7 @@ export async function xaiTTS(params: {
auditContext: "xai tts",
});
try {
if (!response.ok) {
const detail = await extractXaiErrorDetail(response);
const requestId =
trimToUndefined(response.headers.get("x-request-id")) ??
trimToUndefined(response.headers.get("request-id"));
throw new Error(
`xAI TTS API error (${response.status})` +
(detail ? `: ${detail}` : "") +
(requestId ? ` [request_id=${requestId}]` : ""),
);
}
await assertOkOrThrowProviderError(response, "xAI TTS API error");
return Buffer.from(await response.arrayBuffer());
} finally {