From b1016c39fddf849024eb43a311d5d1acddb18dda Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 19:06:49 +0100 Subject: [PATCH] refactor: share speech provider HTTP errors --- docs/plugins/sdk-provider-plugins.md | 34 +++++++++-- docs/plugins/sdk-subpaths.md | 4 +- extensions/elevenlabs/elevenlabs.live.test.ts | 29 ++++++++++ extensions/elevenlabs/tts.ts | 56 +------------------ extensions/gradium/tts.ts | 44 +-------------- extensions/openai/tts.ts | 18 +----- extensions/xai/tts.ts | 18 +----- src/plugin-sdk/speech-core.ts | 6 ++ src/plugin-sdk/speech.ts | 4 ++ src/tts/provider-error-utils.test.ts | 37 ++++++++++++ src/tts/provider-error-utils.ts | 53 +++++++++++++++++- 11 files changed, 164 insertions(+), 139 deletions(-) create mode 100644 src/tts/provider-error-utils.test.ts diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 978555aea6c..fbff1dc0847 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -493,18 +493,40 @@ API key auth, and dynamic model resolution. ```typescript + import { postJsonRequest } from "openclaw/plugin-sdk/provider-http"; + import { assertOkOrThrowProviderError } from "openclaw/plugin-sdk/speech"; + api.registerSpeechProvider({ id: "acme-ai", label: "Acme Speech", isConfigured: ({ config }) => Boolean(config.messages?.tts), - synthesize: async (req) => ({ - audioBuffer: Buffer.from(/* PCM data */), - outputFormat: "mp3", - fileExtension: ".mp3", - voiceCompatible: false, - }), + synthesize: async (req) => { + const { response, release } = await postJsonRequest({ + url: "https://api.example.com/v1/speech", + headers: new Headers({ "Content-Type": "application/json" }), + body: { text: req.text }, + timeoutMs: req.timeoutMs, + fetchFn: fetch, + auditContext: "acme speech", + }); + try { + await assertOkOrThrowProviderError(response, "Acme Speech API error"); + return { + audioBuffer: Buffer.from(await response.arrayBuffer()), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: false, + }; + } finally { + await release(); + } + }, }); ``` + + Use `assertOkOrThrowProviderError(...)` for provider HTTP failures so + speech plugins share capped error-body reads, JSON error parsing, and + request-id suffixes. Prefer `createRealtimeTranscriptionWebSocketSession(...)` — the shared diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 8b9253989fe..0635d4a0985 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -218,8 +218,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio helper exports | | `plugin-sdk/text-runtime` | Shared text/markdown/logging helpers such as assistant-visible-text stripping, markdown render/chunking/table helpers, redaction helpers, directive-tag helpers, and safe-text utilities | | `plugin-sdk/text-chunking` | Outbound text chunking helper | - | `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, and validation helpers | - | `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, and normalization helpers | + | `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, and provider HTTP error helpers | + | `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and provider HTTP error helpers | | `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper | | `plugin-sdk/realtime-voice` | Realtime voice provider types and registry helpers | | `plugin-sdk/image-generation` | Image generation provider types | diff --git a/extensions/elevenlabs/elevenlabs.live.test.ts b/extensions/elevenlabs/elevenlabs.live.test.ts index 9a69efdebe9..f27d6bd6612 100644 --- a/extensions/elevenlabs/elevenlabs.live.test.ts +++ b/extensions/elevenlabs/elevenlabs.live.test.ts @@ -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({ diff --git a/extensions/elevenlabs/tts.ts b/extensions/elevenlabs/tts.ts index 241b5d14b1d..c2384596780 100644 --- a/extensions/elevenlabs/tts.ts +++ b/extensions/elevenlabs/tts.ts @@ -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 { - 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 { diff --git a/extensions/gradium/tts.ts b/extensions/gradium/tts.ts index b795b521c08..c3528e403fc 100644 --- a/extensions/gradium/tts.ts +++ b/extensions/gradium/tts.ts @@ -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 { - 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 { diff --git a/extensions/openai/tts.ts b/extensions/openai/tts.ts index f7d4df417bb..2b3cd58c4ba 100644 --- a/extensions/openai/tts.ts +++ b/extensions/openai/tts.ts @@ -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 { - 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 { diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index cc2f61622b4..1430e9e8007 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -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 { - 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 { diff --git a/src/plugin-sdk/speech-core.ts b/src/plugin-sdk/speech-core.ts index e938a5427f5..3291c4b12b3 100644 --- a/src/plugin-sdk/speech-core.ts +++ b/src/plugin-sdk/speech-core.ts @@ -40,6 +40,12 @@ export { asBoolean, asFiniteNumber, asObject, + assertOkOrThrowProviderError, + createProviderHttpError, + extractProviderErrorDetail, + extractProviderRequestId, + formatProviderErrorPayload, + formatProviderHttpErrorMessage, readResponseTextLimited, trimToUndefined, truncateErrorDetail, diff --git a/src/plugin-sdk/speech.ts b/src/plugin-sdk/speech.ts index 272e98c9eb4..548254a0860 100644 --- a/src/plugin-sdk/speech.ts +++ b/src/plugin-sdk/speech.ts @@ -35,7 +35,11 @@ export { asBoolean, asFiniteNumber, asObject, + assertOkOrThrowProviderError, + createProviderHttpError, extractProviderErrorDetail, + extractProviderRequestId, + formatProviderHttpErrorMessage, formatProviderErrorPayload, readResponseTextLimited, trimToUndefined, diff --git a/src/tts/provider-error-utils.test.ts b/src/tts/provider-error-utils.test.ts new file mode 100644 index 00000000000..c57623172b2 --- /dev/null +++ b/src/tts/provider-error-utils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + assertOkOrThrowProviderError, + extractProviderErrorDetail, + extractProviderRequestId, +} from "./provider-error-utils.js"; + +describe("provider error utils", () => { + it("formats nested provider error details with request ids", async () => { + const response = new Response( + JSON.stringify({ + detail: { + message: "Quota exceeded", + status: "quota_exceeded", + }, + }), + { + status: 429, + headers: { "x-request-id": "req_123" }, + }, + ); + + await expect(assertOkOrThrowProviderError(response, "Provider API error")).rejects.toThrow( + "Provider API error (429): Quota exceeded [code=quota_exceeded] [request_id=req_123]", + ); + }); + + it("reads string error fields and fallback request id headers", async () => { + const response = new Response(JSON.stringify({ error: "Invalid API key" }), { + status: 401, + headers: { "request-id": "fallback_req" }, + }); + + expect(await extractProviderErrorDetail(response)).toBe("Invalid API key"); + expect(extractProviderRequestId(response)).toBe("fallback_req"); + }); +}); diff --git a/src/tts/provider-error-utils.ts b/src/tts/provider-error-utils.ts index 9f39b020999..5d99dc453e7 100644 --- a/src/tts/provider-error-utils.ts +++ b/src/tts/provider-error-utils.ts @@ -67,16 +67,19 @@ export async function readResponseTextLimited( export function formatProviderErrorPayload(payload: unknown): string | undefined { const root = asObject(payload); - const subject = asObject(root?.error) ?? root; + const detailObject = asObject(root?.detail); + const subject = asObject(root?.error) ?? detailObject ?? root; if (!subject) { return undefined; } const message = trimToUndefined(subject.message) ?? trimToUndefined(subject.detail) ?? - trimToUndefined(root?.message); + trimToUndefined(root?.message) ?? + trimToUndefined(root?.error) ?? + trimToUndefined(root?.detail); const type = trimToUndefined(subject.type); - const code = trimToUndefined(subject.code); + const code = trimToUndefined(subject.code) ?? trimToUndefined(subject.status); const metadata = [type ? `type=${type}` : undefined, code ? `code=${code}` : undefined] .filter((value): value is string => Boolean(value)) .join(", "); @@ -103,3 +106,47 @@ export async function extractProviderErrorDetail(response: Response): Promise { + const detail = await extractProviderErrorDetail(response); + const requestId = extractProviderRequestId(response); + return new Error( + formatProviderHttpErrorMessage({ + label, + status: response.status, + detail, + requestId, + }), + ); +} + +export async function assertOkOrThrowProviderError( + response: Response, + label: string, +): Promise { + if (response.ok) { + return; + } + throw await createProviderHttpError(response, label); +}