mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
refactor: share speech provider HTTP errors
This commit is contained in:
@@ -493,18 +493,40 @@ API key auth, and dynamic model resolution.
|
||||
<Tabs>
|
||||
<Tab title="Speech (TTS)">
|
||||
```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.
|
||||
</Tab>
|
||||
<Tab title="Realtime transcription">
|
||||
Prefer `createRealtimeTranscriptionWebSocketSession(...)` — the shared
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -40,6 +40,12 @@ export {
|
||||
asBoolean,
|
||||
asFiniteNumber,
|
||||
asObject,
|
||||
assertOkOrThrowProviderError,
|
||||
createProviderHttpError,
|
||||
extractProviderErrorDetail,
|
||||
extractProviderRequestId,
|
||||
formatProviderErrorPayload,
|
||||
formatProviderHttpErrorMessage,
|
||||
readResponseTextLimited,
|
||||
trimToUndefined,
|
||||
truncateErrorDetail,
|
||||
|
||||
@@ -35,7 +35,11 @@ export {
|
||||
asBoolean,
|
||||
asFiniteNumber,
|
||||
asObject,
|
||||
assertOkOrThrowProviderError,
|
||||
createProviderHttpError,
|
||||
extractProviderErrorDetail,
|
||||
extractProviderRequestId,
|
||||
formatProviderHttpErrorMessage,
|
||||
formatProviderErrorPayload,
|
||||
readResponseTextLimited,
|
||||
trimToUndefined,
|
||||
|
||||
37
src/tts/provider-error-utils.test.ts
Normal file
37
src/tts/provider-error-utils.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<st
|
||||
return truncateErrorDetail(rawBody);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractProviderRequestId(response: Response): string | undefined {
|
||||
return (
|
||||
trimToUndefined(response.headers.get("x-request-id")) ??
|
||||
trimToUndefined(response.headers.get("request-id"))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatProviderHttpErrorMessage(params: {
|
||||
label: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
requestId?: string;
|
||||
}): string {
|
||||
const { label, status, detail, requestId } = params;
|
||||
return (
|
||||
`${label} (${status})` +
|
||||
(detail ? `: ${detail}` : "") +
|
||||
(requestId ? ` [request_id=${requestId}]` : "")
|
||||
);
|
||||
}
|
||||
|
||||
export async function createProviderHttpError(response: Response, label: string): Promise<Error> {
|
||||
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<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throw await createProviderHttpError(response, label);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user