Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
// Narrow barrel for ElevenLabs config compatibility helpers consumed outside the plugin.
// Keep this separate from runtime exports so doctor/config code stays lightweight.
export {
ELEVENLABS_TALK_PROVIDER_ID,
migrateElevenLabsLegacyTalkConfig,
resolveElevenLabsApiKeyWithProfileFallback,
} from "./config-compat.js";

View File

@@ -0,0 +1,76 @@
// Elevenlabs tests cover config compat plugin behavior.
import type fs from "node:fs";
import type os from "node:os";
import type path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
migrateElevenLabsLegacyTalkConfig,
resolveElevenLabsApiKeyWithProfileFallback,
} from "./config-compat.js";
describe("elevenlabs config compat", () => {
it("moves legacy talk fields into talk.providers.elevenlabs", () => {
const result = migrateElevenLabsLegacyTalkConfig({
talk: {
voiceId: "voice-123",
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key", // pragma: allowlist secret
},
});
expect(result.changes).toEqual([
"Moved talk legacy fields (voiceId, modelId, outputFormat, apiKey) → talk.providers.elevenlabs (filled missing provider fields only).",
]);
expect(result.config).toEqual({
talk: {
providers: {
elevenlabs: {
voiceId: "voice-123",
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key", // pragma: allowlist secret
},
},
},
});
});
it("reads ELEVENLABS_API_KEY from profile when env is missing", () => {
const existsSync = vi.fn((candidate: string) => candidate.endsWith(".profile"));
const readFileSync = vi.fn(() => "export ELEVENLABS_API_KEY=profile-key\n");
const homedir = vi.fn(() => "/tmp/home");
const value = resolveElevenLabsApiKeyWithProfileFallback(
{},
{
fs: { existsSync, readFileSync } as unknown as typeof fs,
os: { homedir } as unknown as typeof os,
path: { join: (...parts: string[]) => parts.join("/") } as unknown as typeof path,
},
);
expect(value).toBe("profile-key");
expect(readFileSync).toHaveBeenCalledOnce();
});
it("prefers ELEVENLABS_API_KEY env over profile", () => {
const existsSync = vi.fn(() => {
throw new Error("profile should not be read when env key exists");
});
const readFileSync = vi.fn(() => "");
const value = resolveElevenLabsApiKeyWithProfileFallback(
{ ELEVENLABS_API_KEY: "env-key" },
{
fs: { existsSync, readFileSync } as unknown as typeof fs,
os: { homedir: () => "/tmp/home" } as unknown as typeof os,
path: { join: (...parts: string[]) => parts.join("/") } as unknown as typeof path,
},
);
expect(value).toBe("env-key");
expect(existsSync).not.toHaveBeenCalled();
expect(readFileSync).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,182 @@
// Elevenlabs helper module supports config compat behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const ELEVENLABS_API_KEY_ENV = "ELEVENLABS_API_KEY";
const PROFILE_CANDIDATES = [".profile", ".zprofile", ".zshrc", ".bashrc"] as const;
const LEGACY_TALK_FIELD_KEYS = [
"voiceId",
"voiceAliases",
"modelId",
"outputFormat",
"apiKey",
] as const;
type JsonRecord = Record<string, unknown>;
type ElevenLabsApiKeyDeps = {
fs?: typeof fs;
os?: typeof os;
path?: typeof path;
};
export const ELEVENLABS_TALK_PROVIDER_ID = "elevenlabs";
function getRecord(value: unknown): JsonRecord | null {
return isRecord(value) ? value : null;
}
function ensureRecord(root: JsonRecord, key: string): JsonRecord {
const existing = getRecord(root[key]);
if (existing) {
return existing;
}
const next: JsonRecord = {};
root[key] = next;
return next;
}
function isBlockedObjectKey(key: string): boolean {
return key === "__proto__" || key === "prototype" || key === "constructor";
}
function mergeMissing(target: JsonRecord, source: JsonRecord): void {
for (const [key, value] of Object.entries(source)) {
if (value === undefined || isBlockedObjectKey(key)) {
continue;
}
const existing = target[key];
if (existing === undefined) {
target[key] = value;
continue;
}
if (isRecord(existing) && isRecord(value)) {
mergeMissing(existing, value);
}
}
}
function hasLegacyTalkFields(value: unknown): value is JsonRecord {
const talk = getRecord(value);
if (!talk) {
return false;
}
return LEGACY_TALK_FIELD_KEYS.some((key) => Object.hasOwn(talk, key));
}
function resolveTalkMigrationTargetProviderId(talk: JsonRecord): string | null {
const explicitProvider =
typeof talk.provider === "string" && talk.provider.trim() ? talk.provider.trim() : null;
const providers = getRecord(talk.providers);
if (explicitProvider) {
if (isBlockedObjectKey(explicitProvider)) {
return null;
}
return explicitProvider;
}
if (!providers) {
return ELEVENLABS_TALK_PROVIDER_ID;
}
const providerIds = Object.keys(providers).filter((key) => !isBlockedObjectKey(key));
if (providerIds.length === 0) {
return ELEVENLABS_TALK_PROVIDER_ID;
}
if (providerIds.length === 1) {
return providerIds[0] ?? null;
}
return null;
}
export function migrateElevenLabsLegacyTalkConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
const talk = getRecord(raw.talk);
if (!talk || !hasLegacyTalkFields(talk)) {
return { config: raw, changes: [] };
}
const providerId = resolveTalkMigrationTargetProviderId(talk);
if (!providerId) {
return {
config: raw,
changes: [
"Skipped talk legacy field migration because talk.providers defines multiple providers and talk.provider is unset; move talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey under the intended provider manually.",
],
};
}
const nextRoot = structuredClone(raw) as JsonRecord;
const nextTalk = ensureRecord(nextRoot, "talk");
const providers = ensureRecord(nextTalk, "providers");
const existingProvider = getRecord(providers[providerId]) ?? {};
const migratedProvider = structuredClone(existingProvider);
const legacyFields: JsonRecord = {};
const movedKeys: string[] = [];
for (const key of LEGACY_TALK_FIELD_KEYS) {
if (!Object.hasOwn(nextTalk, key)) {
continue;
}
legacyFields[key] = nextTalk[key];
delete nextTalk[key];
movedKeys.push(key);
}
if (movedKeys.length === 0) {
return { config: raw, changes: [] };
}
mergeMissing(migratedProvider, legacyFields);
providers[providerId] = migratedProvider;
nextTalk.providers = providers;
nextRoot.talk = nextTalk;
return {
config: nextRoot as T,
changes: [
`Moved talk legacy fields (${movedKeys.join(", ")}) → talk.providers.${providerId} (filled missing provider fields only).`,
],
};
}
function readApiKeyFromProfile(deps: ElevenLabsApiKeyDeps = {}): string | null {
const fsImpl = deps.fs ?? fs;
const osImpl = deps.os ?? os;
const pathImpl = deps.path ?? path;
const home = osImpl.homedir();
for (const candidate of PROFILE_CANDIDATES) {
const fullPath = pathImpl.join(home, candidate);
if (!fsImpl.existsSync(fullPath)) {
continue;
}
try {
const text = fsImpl.readFileSync(fullPath, "utf-8");
const match = text.match(
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
);
const value = match?.[1]?.trim();
if (value) {
return value;
}
} catch {
// Ignore profile read errors.
}
}
return null;
}
export function resolveElevenLabsApiKeyWithProfileFallback(
env: NodeJS.ProcessEnv = process.env,
deps: ElevenLabsApiKeyDeps = {},
): string | null {
const envValue = (env[ELEVENLABS_API_KEY_ENV] ?? "").trim();
if (envValue) {
return envValue;
}
return readApiKeyFromProfile(deps);
}

View File

@@ -0,0 +1,9 @@
// Elevenlabs API module exposes the plugin public contract.
export {
ELEVENLABS_TALK_PROVIDER_ID,
ELEVENLABS_TALK_LEGACY_CONFIG_RULES,
hasLegacyTalkFields,
legacyConfigRules,
normalizeCompatibilityConfig,
} from "./doctor-contract.js";
export { migrateElevenLabsLegacyTalkConfig } from "./config-compat.js";

View File

@@ -0,0 +1,35 @@
// Elevenlabs plugin module implements doctor contract behavior.
import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { ELEVENLABS_TALK_PROVIDER_ID, migrateElevenLabsLegacyTalkConfig } from "./config-compat.js";
export function hasLegacyTalkFields(value: unknown): boolean {
const talk = isRecord(value) ? value : null;
if (!talk) {
return false;
}
return ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) =>
Object.hasOwn(talk, key),
);
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["talk"],
message:
"talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey are legacy; use talk.providers.<provider> and run openclaw doctor --fix.",
match: hasLegacyTalkFields,
},
];
export const ELEVENLABS_TALK_LEGACY_CONFIG_RULES = legacyConfigRules;
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateElevenLabsLegacyTalkConfig(cfg);
}
export { ELEVENLABS_TALK_PROVIDER_ID };

View File

@@ -0,0 +1,92 @@
// Elevenlabs tests cover elevenlabs plugin behavior.
import {
registerProviderPlugin,
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import {
normalizeTranscriptForMatch,
runRealtimeSttLiveTest,
synthesizeElevenLabsLiveSpeech,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { elevenLabsMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildElevenLabsRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
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 with eleven_v3", async () => {
const { speechProviders } = await registerElevenLabsPlugin();
const provider = requireRegisteredProvider(speechProviders, "elevenlabs");
const audioFile = await provider.synthesize({
text: "OpenClaw ElevenLabs eleven v three text to speech integration test OK.",
cfg: { plugins: { enabled: true } } as never,
providerConfig: { apiKey: ELEVENLABS_KEY, modelId: "eleven_v3" },
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({
text: phrase,
apiKey: ELEVENLABS_KEY,
outputFormat: "mp3_44100_128",
timeoutMs: 30_000,
});
const transcript = await elevenLabsMediaUnderstandingProvider.transcribeAudio?.({
buffer: audio,
fileName: "elevenlabs-live.mp3",
mime: "audio/mpeg",
apiKey: ELEVENLABS_KEY,
timeoutMs: 60_000,
});
const normalized = normalizeTranscriptForMatch(transcript?.text ?? "");
expect(normalized).toContain("openclaw");
expect(normalized).toMatch(/(?:elevenlabs|11labs)/);
}, 90_000);
it("streams realtime STT through the registered transcription provider", async () => {
const provider = buildElevenLabsRealtimeTranscriptionProvider();
const phrase = "Testing OpenClaw ElevenLabs realtime transcription integration OK.";
const speech = await synthesizeElevenLabsLiveSpeech({
text: phrase,
apiKey: ELEVENLABS_KEY,
outputFormat: "ulaw_8000",
timeoutMs: 30_000,
});
expect(speech.byteLength).toBeGreaterThan(0);
await runRealtimeSttLiveTest({
provider,
providerConfig: {
apiKey: ELEVENLABS_KEY,
audioFormat: "ulaw_8000",
sampleRate: 8000,
commitStrategy: "vad",
languageCode: "en",
},
audio: Buffer.concat([Buffer.alloc(4000, 0xff), speech, Buffer.alloc(8000, 0xff)]),
closeBeforeWait: true,
});
}, 90_000);
});

View File

@@ -0,0 +1,16 @@
// Elevenlabs plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { elevenLabsMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildElevenLabsRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
import { buildElevenLabsSpeechProvider } from "./speech-provider.js";
export default definePluginEntry({
id: "elevenlabs",
name: "ElevenLabs Speech",
description: "Bundled ElevenLabs speech provider",
register(api) {
api.registerSpeechProvider(buildElevenLabsSpeechProvider());
api.registerMediaUnderstandingProvider(elevenLabsMediaUnderstandingProvider);
api.registerRealtimeTranscriptionProvider(buildElevenLabsRealtimeTranscriptionProvider());
},
});

View File

@@ -0,0 +1,96 @@
// Elevenlabs tests cover media understanding provider plugin behavior.
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
elevenLabsMediaUnderstandingProvider,
transcribeElevenLabsAudio,
} from "./media-understanding-provider.js";
function requireFirstFetchCall(fetchMock: ReturnType<typeof vi.fn>): [string, RequestInit] {
const [call] = fetchMock.mock.calls;
if (!call) {
throw new Error("expected ElevenLabs media fetch call");
}
return call as [string, RequestInit];
}
describe("elevenLabsMediaUnderstandingProvider", () => {
let ssrfMock: { mockRestore: () => void } | undefined;
beforeEach(() => {
ssrfMock = mockPinnedHostnameResolution();
});
afterEach(() => {
ssrfMock?.mockRestore();
ssrfMock = undefined;
});
it("has expected provider metadata", () => {
expect(elevenLabsMediaUnderstandingProvider.id).toBe("elevenlabs");
expect(elevenLabsMediaUnderstandingProvider.capabilities).toEqual(["audio"]);
expect(elevenLabsMediaUnderstandingProvider.defaultModels?.audio).toBe("scribe_v2");
expect(elevenLabsMediaUnderstandingProvider.transcribeAudio).toBeTypeOf("function");
});
it("posts multipart audio to ElevenLabs speech-to-text", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify({ text: "hello" })));
const result = await transcribeElevenLabsAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
mime: "audio/mpeg",
apiKey: "eleven-key",
model: "scribe_v2",
language: "en",
timeoutMs: 1000,
fetchFn: fetchMock,
});
expect(result).toEqual({ text: "hello", model: "scribe_v2" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = requireFirstFetchCall(fetchMock);
expect(url).toBe("https://api.elevenlabs.io/v1/speech-to-text");
expect(init.method).toBe("POST");
const headers = new Headers(init.headers);
expect(headers.get("xi-api-key")).toBe("eleven-key");
const form = init.body as FormData;
expect(form.get("model_id")).toBe("scribe_v2");
expect(form.get("language_code")).toBe("en");
expect(form.get("file")).toBeInstanceOf(Blob);
});
it("wraps malformed successful speech-to-text JSON with a stable provider error", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response("{ nope"));
await expect(
transcribeElevenLabsAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
mime: "audio/mpeg",
apiKey: "eleven-key",
model: "scribe_v2",
timeoutMs: 1000,
fetchFn: fetchMock,
}),
).rejects.toThrow("ElevenLabs audio transcription failed: malformed JSON response");
});
it("rejects non-object successful speech-to-text JSON with a stable provider error", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify([])));
await expect(
transcribeElevenLabsAudio({
buffer: Buffer.from("audio"),
fileName: "voice.mp3",
mime: "audio/mpeg",
apiKey: "eleven-key",
model: "scribe_v2",
timeoutMs: 1000,
fetchFn: fetchMock,
}),
).rejects.toThrow("ElevenLabs audio transcription failed: malformed JSON response");
});
});

View File

@@ -0,0 +1,86 @@
// Elevenlabs provider module implements model/runtime integration.
import type {
AudioTranscriptionRequest,
AudioTranscriptionResult,
MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
buildAudioTranscriptionFormData,
postTranscriptionRequest,
readProviderJsonObjectResponse,
resolveProviderHttpRequestConfig,
requireTranscriptionText,
} from "openclaw/plugin-sdk/provider-http";
import { DEFAULT_ELEVENLABS_BASE_URL, normalizeElevenLabsBaseUrl } from "./shared.js";
const DEFAULT_ELEVENLABS_STT_MODEL = "scribe_v2";
export async function transcribeElevenLabsAudio(
req: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const fetchFn = req.fetchFn ?? fetch;
const apiKey = req.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
const model = req.model?.trim() || DEFAULT_ELEVENLABS_STT_MODEL;
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
resolveProviderHttpRequestConfig({
baseUrl: normalizeElevenLabsBaseUrl(req.baseUrl),
defaultBaseUrl: DEFAULT_ELEVENLABS_BASE_URL,
headers: req.headers,
request: req.request,
defaultHeaders: {
"xi-api-key": apiKey,
},
provider: "elevenlabs",
api: "elevenlabs-speech-to-text",
capability: "audio",
transport: "media-understanding",
});
const form = buildAudioTranscriptionFormData({
buffer: req.buffer,
fileName: req.fileName,
mime: req.mime,
fields: {
model_id: model,
language_code: req.language,
prompt: req.prompt,
},
});
const { response, release } = await postTranscriptionRequest({
url: `${baseUrl}/v1/speech-to-text`,
headers,
body: form,
timeoutMs: req.timeoutMs,
fetchFn,
allowPrivateNetwork,
dispatcherPolicy,
auditContext: "elevenlabs speech-to-text",
});
try {
await assertOkOrThrowHttpError(response, "ElevenLabs audio transcription failed");
const payload = await readProviderJsonObjectResponse(
response,
"ElevenLabs audio transcription failed",
);
const text = requireTranscriptionText(
typeof payload.text === "string" ? payload.text : undefined,
"ElevenLabs audio transcription response missing text",
);
return { text, model };
} finally {
await release();
}
}
export const elevenLabsMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "elevenlabs",
capabilities: ["audio"],
defaultModels: { audio: DEFAULT_ELEVENLABS_STT_MODEL },
autoPriority: { audio: 45 },
transcribeAudio: transcribeElevenLabsAudio,
};

View File

@@ -0,0 +1,46 @@
{
"id": "elevenlabs",
"icon": "https://cdn.simpleicons.org/elevenlabs",
"activation": {
"onStartup": false
},
"enabledByDefault": true,
"setup": {
"providers": [
{
"id": "elevenlabs",
"envVars": ["ELEVENLABS_API_KEY", "XI_API_KEY"]
}
]
},
"contracts": {
"speechProviders": ["elevenlabs"],
"mediaUnderstandingProviders": ["elevenlabs"],
"realtimeTranscriptionProviders": ["elevenlabs"]
},
"mediaUnderstandingProviderMetadata": {
"elevenlabs": {
"capabilities": ["audio"],
"defaultModels": {
"audio": "scribe_v2"
},
"autoPriority": {
"audio": 45
}
}
},
"configContracts": {
"compatibilityMigrationPaths": [
"talk.voiceId",
"talk.voiceAliases",
"talk.modelId",
"talk.outputFormat",
"talk.apiKey"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "@openclaw/elevenlabs-speech",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw ElevenLabs speech plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,113 @@
// Elevenlabs tests cover realtime transcription provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
testing,
buildElevenLabsRealtimeTranscriptionProvider,
} from "./realtime-transcription-provider.js";
describe("buildElevenLabsRealtimeTranscriptionProvider", () => {
it("normalizes nested provider config", () => {
const provider = buildElevenLabsRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as OpenClawConfig,
rawConfig: {
providers: {
elevenlabs: {
apiKey: "eleven-key",
model_id: "scribe_v2_realtime",
audio_format: "ulaw_8000",
sample_rate: "8000",
commit_strategy: "vad",
language: "en",
},
},
},
});
expect(resolved).toEqual({
apiKey: "eleven-key",
baseUrl: undefined,
modelId: undefined,
audioFormat: "ulaw_8000",
sampleRate: 8000,
commitStrategy: "vad",
languageCode: "en",
vadSilenceThresholdSecs: undefined,
vadThreshold: undefined,
minSpeechDurationMs: undefined,
minSilenceDurationMs: undefined,
});
});
it("drops malformed numeric realtime config values", () => {
const provider = buildElevenLabsRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as OpenClawConfig,
rawConfig: {
providers: {
elevenlabs: {
sample_rate: "8000.5",
vad_silence_threshold_secs: "999",
vad_threshold: "0",
min_speech_duration_ms: "0",
min_silence_duration_ms: "10.5",
},
},
},
});
expect(resolved).toMatchObject({
sampleRate: undefined,
vadSilenceThresholdSecs: undefined,
vadThreshold: undefined,
minSpeechDurationMs: undefined,
minSilenceDurationMs: undefined,
});
});
it("keeps realtime VAD numeric config inside provider ranges", () => {
const provider = buildElevenLabsRealtimeTranscriptionProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as OpenClawConfig,
rawConfig: {
providers: {
elevenlabs: {
sample_rate: "8000",
vad_silence_threshold_secs: "3",
vad_threshold: "0.9",
min_speech_duration_ms: "50",
min_silence_duration_ms: "2000",
},
},
},
});
expect(resolved).toMatchObject({
sampleRate: 8000,
vadSilenceThresholdSecs: 3,
vadThreshold: 0.9,
minSpeechDurationMs: 50,
minSilenceDurationMs: 2000,
});
});
it("builds an ElevenLabs realtime websocket URL", () => {
const url = testing.toElevenLabsRealtimeWsUrl({
apiKey: "eleven-key",
baseUrl: "https://api.elevenlabs.io",
providerConfig: {},
modelId: "scribe_v2_realtime",
audioFormat: "ulaw_8000",
sampleRate: 8000,
commitStrategy: "vad",
languageCode: "en",
});
expect(url).toContain("wss://api.elevenlabs.io/v1/speech-to-text/realtime?");
expect(url).toContain("model_id=scribe_v2_realtime");
expect(url).toContain("audio_format=ulaw_8000");
expect(url).toContain("commit_strategy=vad");
expect(url).toContain("language_code=en");
});
});

View File

@@ -0,0 +1,299 @@
// Elevenlabs provider module implements model/runtime integration.
import {
createRealtimeTranscriptionWebSocketSession,
type RealtimeTranscriptionProviderConfig,
type RealtimeTranscriptionProviderPlugin,
type RealtimeTranscriptionSession,
type RealtimeTranscriptionSessionCreateRequest,
type RealtimeTranscriptionWebSocketTransport,
} from "openclaw/plugin-sdk/realtime-transcription";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
asOptionalRecord as readRecord,
normalizeOptionalString,
parseFiniteNumber as readFiniteNumber,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js";
import { normalizeElevenLabsBaseUrl } from "./shared.js";
type ElevenLabsRealtimeTranscriptionProviderConfig = {
apiKey?: string;
baseUrl?: string;
modelId?: string;
audioFormat?: string;
sampleRate?: number;
languageCode?: string;
commitStrategy?: "manual" | "vad";
vadSilenceThresholdSecs?: number;
vadThreshold?: number;
minSpeechDurationMs?: number;
minSilenceDurationMs?: number;
};
type ElevenLabsRealtimeTranscriptionSessionConfig = RealtimeTranscriptionSessionCreateRequest & {
apiKey: string;
baseUrl: string;
modelId: string;
audioFormat: string;
sampleRate: number;
commitStrategy: "manual" | "vad";
languageCode?: string;
vadSilenceThresholdSecs?: number;
vadThreshold?: number;
minSpeechDurationMs?: number;
minSilenceDurationMs?: number;
};
type ElevenLabsRealtimeTranscriptionEvent = {
message_type?: string;
text?: string;
error?: string;
message?: string;
code?: string;
};
const ELEVENLABS_REALTIME_DEFAULT_MODEL = "scribe_v2_realtime";
const ELEVENLABS_REALTIME_DEFAULT_AUDIO_FORMAT = "ulaw_8000";
const ELEVENLABS_REALTIME_DEFAULT_SAMPLE_RATE = 8000;
const ELEVENLABS_REALTIME_DEFAULT_COMMIT_STRATEGY: "manual" | "vad" = "vad";
const ELEVENLABS_REALTIME_CONNECT_TIMEOUT_MS = 10_000;
const ELEVENLABS_REALTIME_CLOSE_TIMEOUT_MS = 5_000;
const ELEVENLABS_REALTIME_MAX_RECONNECT_ATTEMPTS = 5;
const ELEVENLABS_REALTIME_RECONNECT_DELAY_MS = 1000;
const ELEVENLABS_REALTIME_MAX_QUEUED_BYTES = 2 * 1024 * 1024;
function readNestedElevenLabsConfig(rawConfig: RealtimeTranscriptionProviderConfig) {
const raw = readRecord(rawConfig);
const providers = readRecord(raw?.providers);
return readRecord(providers?.elevenlabs ?? raw?.elevenlabs ?? raw) ?? {};
}
function normalizeCommitStrategy(value: unknown): "manual" | "vad" | undefined {
const normalized = normalizeOptionalString(value)?.toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "manual" || normalized === "vad") {
return normalized;
}
throw new Error(`Invalid ElevenLabs realtime transcription commit strategy: ${normalized}`);
}
function normalizePositiveSafeInteger(value: unknown): number | undefined {
const parsed = readFiniteNumber(value);
return parsed !== undefined && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function normalizeFiniteRange(value: unknown, min: number, max: number): number | undefined {
const parsed = readFiniteNumber(value);
return parsed !== undefined && parsed >= min && parsed <= max ? parsed : undefined;
}
function normalizeIntegerRange(value: unknown, min: number, max: number): number | undefined {
const parsed = readFiniteNumber(value);
return parsed !== undefined && Number.isSafeInteger(parsed) && parsed >= min && parsed <= max
? parsed
: undefined;
}
function normalizeProviderConfig(
config: RealtimeTranscriptionProviderConfig,
): ElevenLabsRealtimeTranscriptionProviderConfig {
const raw = readNestedElevenLabsConfig(config);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw.apiKey,
path: "plugins.entries.voice-call.config.streaming.providers.elevenlabs.apiKey",
}),
baseUrl: normalizeOptionalString(raw.baseUrl),
modelId: normalizeOptionalString(raw.modelId ?? raw.model ?? raw.sttModel),
audioFormat: normalizeOptionalString(raw.audioFormat ?? raw.audio_format ?? raw.encoding),
sampleRate: normalizePositiveSafeInteger(raw.sampleRate ?? raw.sample_rate),
languageCode: normalizeOptionalString(raw.languageCode ?? raw.language),
commitStrategy: normalizeCommitStrategy(raw.commitStrategy ?? raw.commit_strategy),
vadSilenceThresholdSecs: normalizeFiniteRange(
raw.vadSilenceThresholdSecs ?? raw.vad_silence_threshold_secs,
0.3,
3,
),
vadThreshold: normalizeFiniteRange(raw.vadThreshold ?? raw.vad_threshold, 0.1, 0.9),
minSpeechDurationMs: normalizeIntegerRange(
raw.minSpeechDurationMs ?? raw.min_speech_duration_ms,
50,
2_000,
),
minSilenceDurationMs: normalizeIntegerRange(
raw.minSilenceDurationMs ?? raw.min_silence_duration_ms,
50,
2_000,
),
};
}
function normalizeElevenLabsRealtimeBaseUrl(value?: string): string {
const url = new URL(normalizeElevenLabsBaseUrl(value));
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
return url.toString().replace(/\/+$/, "");
}
function toElevenLabsRealtimeWsUrl(config: ElevenLabsRealtimeTranscriptionSessionConfig): string {
const url = new URL(
`${normalizeElevenLabsRealtimeBaseUrl(config.baseUrl)}/v1/speech-to-text/realtime`,
);
url.searchParams.set("model_id", config.modelId);
url.searchParams.set("audio_format", config.audioFormat);
url.searchParams.set("commit_strategy", config.commitStrategy);
url.searchParams.set("include_timestamps", "false");
url.searchParams.set("include_language_detection", "false");
if (config.languageCode) {
url.searchParams.set("language_code", config.languageCode);
}
if (config.vadSilenceThresholdSecs != null) {
url.searchParams.set("vad_silence_threshold_secs", String(config.vadSilenceThresholdSecs));
}
if (config.vadThreshold != null) {
url.searchParams.set("vad_threshold", String(config.vadThreshold));
}
if (config.minSpeechDurationMs != null) {
url.searchParams.set("min_speech_duration_ms", String(config.minSpeechDurationMs));
}
if (config.minSilenceDurationMs != null) {
url.searchParams.set("min_silence_duration_ms", String(config.minSilenceDurationMs));
}
return url.toString();
}
function readErrorDetail(event: ElevenLabsRealtimeTranscriptionEvent): string {
return (
normalizeOptionalString(event.error) ??
normalizeOptionalString(event.message) ??
normalizeOptionalString(event.code) ??
"ElevenLabs realtime transcription error"
);
}
function createElevenLabsRealtimeTranscriptionSession(
config: ElevenLabsRealtimeTranscriptionSessionConfig,
): RealtimeTranscriptionSession {
let lastTranscript: string | undefined;
const emitTranscript = (text: string) => {
if (text === lastTranscript) {
return;
}
lastTranscript = text;
config.onTranscript?.(text);
};
const sendAudioChunk = (
audio: Buffer,
transport: RealtimeTranscriptionWebSocketTransport,
): void => {
transport.sendJson({
message_type: "input_audio_chunk",
audio_base_64: audio.toString("base64"),
sample_rate: config.sampleRate,
...(config.commitStrategy === "manual" ? { commit: true } : {}),
});
};
const handleEvent = (
event: ElevenLabsRealtimeTranscriptionEvent,
transport: RealtimeTranscriptionWebSocketTransport,
) => {
if (event.message_type === "session_started") {
transport.markReady();
return;
}
if (!transport.isReady() && event.message_type?.includes("error")) {
transport.failConnect(new Error(readErrorDetail(event)));
return;
}
switch (event.message_type) {
case "partial_transcript":
if (event.text) {
config.onPartial?.(event.text);
}
return;
case "committed_transcript":
case "committed_transcript_with_timestamps":
if (event.text) {
emitTranscript(event.text);
}
return;
default:
if (event.message_type?.includes("error")) {
config.onError?.(new Error(readErrorDetail(event)));
}
}
};
return createRealtimeTranscriptionWebSocketSession<ElevenLabsRealtimeTranscriptionEvent>({
providerId: "elevenlabs",
callbacks: config,
url: () => toElevenLabsRealtimeWsUrl(config),
headers: { "xi-api-key": config.apiKey },
connectTimeoutMs: ELEVENLABS_REALTIME_CONNECT_TIMEOUT_MS,
closeTimeoutMs: ELEVENLABS_REALTIME_CLOSE_TIMEOUT_MS,
maxReconnectAttempts: ELEVENLABS_REALTIME_MAX_RECONNECT_ATTEMPTS,
reconnectDelayMs: ELEVENLABS_REALTIME_RECONNECT_DELAY_MS,
maxQueuedBytes: ELEVENLABS_REALTIME_MAX_QUEUED_BYTES,
connectTimeoutMessage: "ElevenLabs realtime transcription connection timeout",
reconnectLimitMessage: "ElevenLabs realtime transcription reconnect limit reached",
sendAudio: sendAudioChunk,
onClose: (transport) => {
transport.sendJson({
message_type: "input_audio_chunk",
audio_base_64: "",
sample_rate: config.sampleRate,
commit: true,
});
},
onMessage: handleEvent,
});
}
export function buildElevenLabsRealtimeTranscriptionProvider(): RealtimeTranscriptionProviderPlugin {
return {
id: "elevenlabs",
label: "ElevenLabs Realtime Transcription",
aliases: ["elevenlabs-realtime", "scribe-v2-realtime"],
defaultModel: ELEVENLABS_REALTIME_DEFAULT_MODEL,
autoSelectOrder: 40,
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
isConfigured: ({ providerConfig }) =>
Boolean(
normalizeProviderConfig(providerConfig).apiKey ||
resolveElevenLabsApiKeyWithProfileFallback() ||
process.env.XI_API_KEY,
),
createSession: (req) => {
const config = normalizeProviderConfig(req.providerConfig);
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
return createElevenLabsRealtimeTranscriptionSession({
...req,
apiKey,
baseUrl: normalizeElevenLabsBaseUrl(config.baseUrl),
modelId: config.modelId ?? ELEVENLABS_REALTIME_DEFAULT_MODEL,
audioFormat: config.audioFormat ?? ELEVENLABS_REALTIME_DEFAULT_AUDIO_FORMAT,
sampleRate: config.sampleRate ?? ELEVENLABS_REALTIME_DEFAULT_SAMPLE_RATE,
commitStrategy: config.commitStrategy ?? ELEVENLABS_REALTIME_DEFAULT_COMMIT_STRATEGY,
languageCode: config.languageCode,
vadSilenceThresholdSecs: config.vadSilenceThresholdSecs,
vadThreshold: config.vadThreshold,
minSpeechDurationMs: config.minSpeechDurationMs,
minSilenceDurationMs: config.minSilenceDurationMs,
});
},
};
}
export const testing = {
normalizeProviderConfig,
toElevenLabsRealtimeWsUrl,
};
export { testing as __testing };

View File

@@ -0,0 +1,12 @@
// Elevenlabs API module exposes the plugin public contract.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { migrateElevenLabsLegacyTalkConfig } from "./config-compat.js";
export default definePluginEntry({
id: "elevenlabs",
name: "ElevenLabs Setup",
description: "Lightweight ElevenLabs setup hooks",
register(api) {
api.registerConfigMigration((config) => migrateElevenLabsLegacyTalkConfig(config));
},
});

View File

@@ -0,0 +1,11 @@
// Elevenlabs plugin module implements shared behavior.
export const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
export function isValidElevenLabsVoiceId(voiceId: string): boolean {
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
export function normalizeElevenLabsBaseUrl(baseUrl?: string): string {
const trimmed = baseUrl?.trim();
return trimmed?.replace(/\/+$/, "") || DEFAULT_ELEVENLABS_BASE_URL;
}

View File

@@ -0,0 +1,262 @@
// Elevenlabs tests cover speech provider plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { isValidElevenLabsVoiceId } from "./shared.js";
import { buildElevenLabsSpeechProvider } from "./speech-provider.js";
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: async ({
url,
init,
}: {
url: string;
init?: RequestInit;
}): Promise<{ response: Response; release: () => Promise<void> }> => ({
response: await globalThis.fetch(url, init),
release: vi.fn(async () => {}),
}),
ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined,
}));
function parseRequestBody(init: RequestInit | undefined): Record<string, unknown> {
if (typeof init?.body !== "string") {
throw new Error("expected string request body");
}
const body: unknown = JSON.parse(init.body);
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error("expected ElevenLabs request body");
}
return body as Record<string, unknown>;
}
describe("elevenlabs speech provider", () => {
const originalFetch = globalThis.fetch;
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it("exposes the current ElevenLabs TTS model catalog", () => {
const provider = buildElevenLabsSpeechProvider();
expect(provider.models).toEqual([
"eleven_v3",
"eleven_multilingual_v2",
"eleven_flash_v2_5",
"eleven_flash_v2",
"eleven_turbo_v2_5",
"eleven_monolingual_v1",
]);
});
it("keeps non-equivalent deprecated ElevenLabs TTS model IDs", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body.model_id).toBe("eleven_monolingual_v1");
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
modelId: "eleven_monolingual_v1",
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("maps deprecated ElevenLabs TTS model IDs in overrides", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body.model_id).toBe("eleven_flash_v2_5");
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
modelId: "eleven_multilingual_v2",
},
providerOverrides: {
modelId: "eleven_turbo_v2_5",
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("validates ElevenLabs voice ID length and character rules", () => {
const cases = [
{ value: "pMsXgVXv3BLzUgSXRplE", expected: true },
{ value: "21m00Tcm4TlvDq8ikWAM", expected: true },
{ value: "VoiceAlias1234567890", expected: true },
{ value: "a1b2c3d4e5", expected: true },
{ value: "a".repeat(40), expected: true },
{ value: "", expected: false },
{ value: "abc", expected: false },
{ value: "123456789", expected: false },
{ value: "a".repeat(41), expected: false },
{ value: "a".repeat(100), expected: false },
{ value: "pMsXgVXv3BLz-gSXRplE", expected: false },
{ value: "pMsXgVXv3BLz_gSXRplE", expected: false },
{ value: "pMsXgVXv3BLz gSXRplE", expected: false },
{ value: "../../../etc/passwd", expected: false },
{ value: "voice?param=value", expected: false },
] as const;
for (const testCase of cases) {
expect(isValidElevenLabsVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
}
});
it("applies provider overrides to telephony synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
expect(url).toContain("/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM");
expect(url).toContain("output_format=pcm_22050");
const body = parseRequestBody(init);
expect(body).toEqual({
text: "hello",
model_id: "eleven_v3",
seed: 123,
apply_text_normalization: "on",
language_code: "en",
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: 1.2,
},
});
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const result = await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2",
},
providerOverrides: {
voiceId: "21m00Tcm4TlvDq8ikWAM",
modelId: "eleven_v3",
seed: 123,
applyTextNormalization: "on",
languageCode: "en",
voiceSettings: {
speed: 1.2,
},
},
timeoutMs: 1_000,
});
expect(result?.outputFormat).toBe("pcm_22050");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("drops out-of-range voice settings before synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body.voice_settings).toEqual({
stability: 0.5,
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: 1,
});
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
voiceSettings: {
stability: -1,
similarityBoost: 2,
style: Number.NaN,
speed: 3,
},
},
providerOverrides: {
voiceSettings: {
speed: 0.1,
},
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("drops malformed seed values before synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = parseRequestBody(init);
expect(body).not.toHaveProperty("seed");
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
seed: 1.5,
},
providerOverrides: {
seed: Number.POSITIVE_INFINITY,
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("drops malformed latency tier overrides before synthesis", async () => {
const provider = buildElevenLabsSpeechProvider();
const fetchMock = vi.fn(async (url: string) => {
expect(new URL(url).searchParams.has("optimize_streaming_latency")).toBe(false);
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await provider.synthesize?.({
text: "hello",
target: "audio-file",
cfg: {} as never,
providerConfig: {
apiKey: "xi-test",
},
providerOverrides: {
latencyTier: 2.5,
},
timeoutMs: 1_000,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,593 @@
// Elevenlabs provider module implements model/runtime integration.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type {
SpeechDirectiveTokenParseContext,
SpeechProviderConfig,
SpeechProviderOverrides,
SpeechProviderPlugin,
SpeechSynthesisRequest,
SpeechVoiceOption,
} from "openclaw/plugin-sdk/speech";
import {
asBoolean,
asFiniteNumber,
asObject,
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,
requireInRange,
trimToUndefined,
} from "openclaw/plugin-sdk/speech";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js";
import { isValidElevenLabsVoiceId, normalizeElevenLabsBaseUrl } from "./shared.js";
import { elevenLabsTTS, elevenLabsTTSStream } from "./tts.js";
const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
const DEFAULT_ELEVENLABS_VOICE_SETTINGS = {
stability: 0.5,
similarityBoost: 0.75,
style: 0,
useSpeakerBoost: true,
speed: 1,
};
const ELEVENLABS_TTS_MODELS = [
"eleven_v3",
"eleven_multilingual_v2",
"eleven_flash_v2_5",
"eleven_flash_v2",
"eleven_turbo_v2_5",
"eleven_monolingual_v1",
] as const;
function normalizeElevenLabsTtsModelId(value: string | undefined): string | undefined {
switch (value) {
case "eleven_turbo_v2_5":
return "eleven_flash_v2_5";
case "eleven_turbo_v2":
return "eleven_flash_v2";
default:
return value;
}
}
type ElevenLabsProviderConfig = {
apiKey?: string;
baseUrl: string;
voiceId: string;
modelId: string;
seed?: number;
applyTextNormalization?: "auto" | "on" | "off";
languageCode?: string;
voiceSettings: {
stability: number;
similarityBoost: number;
style: number;
useSpeakerBoost: boolean;
speed: number;
};
};
function parseBooleanValue(value: string): boolean | undefined {
const normalized = normalizeLowercaseStringOrEmpty(value);
if (["true", "1", "yes", "on"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
function parseNumberValue(value: string): number | undefined {
return parseStrictFiniteNumber(value);
}
function normalizeVoiceSetting(value: unknown, min: number, max: number): number | undefined {
const number = asFiniteNumber(value);
return number !== undefined && number >= min && number <= max ? number : undefined;
}
function normalizeElevenLabsSeed(value: unknown): number | undefined {
const seed = asFiniteNumber(value);
return seed !== undefined && Number.isSafeInteger(seed) && seed >= 0 && seed <= 4_294_967_295
? seed
: undefined;
}
function normalizeElevenLabsLatencyTier(value: unknown): number | undefined {
const latencyTier = asFiniteNumber(value);
return latencyTier !== undefined &&
Number.isSafeInteger(latencyTier) &&
latencyTier >= 0 &&
latencyTier <= 4
? latencyTier
: undefined;
}
function normalizeVoiceSettings(
rawVoiceSettings: Record<string, unknown> | undefined,
): Partial<ElevenLabsProviderConfig["voiceSettings"]> {
return {
...(normalizeVoiceSetting(rawVoiceSettings?.stability, 0, 1) == null
? {}
: { stability: normalizeVoiceSetting(rawVoiceSettings?.stability, 0, 1) }),
...(normalizeVoiceSetting(rawVoiceSettings?.similarityBoost, 0, 1) == null
? {}
: { similarityBoost: normalizeVoiceSetting(rawVoiceSettings?.similarityBoost, 0, 1) }),
...(normalizeVoiceSetting(rawVoiceSettings?.style, 0, 1) == null
? {}
: { style: normalizeVoiceSetting(rawVoiceSettings?.style, 0, 1) }),
...(asBoolean(rawVoiceSettings?.useSpeakerBoost) == null
? {}
: { useSpeakerBoost: asBoolean(rawVoiceSettings?.useSpeakerBoost) }),
...(normalizeVoiceSetting(rawVoiceSettings?.speed, 0.5, 2) == null
? {}
: { speed: normalizeVoiceSetting(rawVoiceSettings?.speed, 0.5, 2) }),
};
}
function normalizeElevenLabsProviderConfig(
rawConfig: Record<string, unknown>,
): ElevenLabsProviderConfig {
const providers = asObject(rawConfig.providers);
const raw = asObject(providers?.elevenlabs) ?? asObject(rawConfig.elevenlabs);
const rawVoiceSettings = asObject(raw?.voiceSettings);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw?.apiKey,
path: "messages.tts.providers.elevenlabs.apiKey",
}),
baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(raw?.baseUrl)),
voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_ELEVENLABS_VOICE_ID,
modelId:
normalizeElevenLabsTtsModelId(trimToUndefined(raw?.modelId)) ?? DEFAULT_ELEVENLABS_MODEL_ID,
seed: normalizeElevenLabsSeed(raw?.seed),
applyTextNormalization: trimToUndefined(raw?.applyTextNormalization) as
| "auto"
| "on"
| "off"
| undefined,
languageCode: trimToUndefined(raw?.languageCode),
voiceSettings: {
...DEFAULT_ELEVENLABS_VOICE_SETTINGS,
...normalizeVoiceSettings(rawVoiceSettings),
},
};
}
function readElevenLabsProviderConfig(config: SpeechProviderConfig): ElevenLabsProviderConfig {
const defaults = normalizeElevenLabsProviderConfig({});
const voiceSettings = asObject(config.voiceSettings);
return {
apiKey: trimToUndefined(config.apiKey) ?? defaults.apiKey,
baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl),
voiceId: trimToUndefined(config.voiceId) ?? defaults.voiceId,
modelId: normalizeElevenLabsTtsModelId(trimToUndefined(config.modelId)) ?? defaults.modelId,
seed: normalizeElevenLabsSeed(config.seed) ?? defaults.seed,
applyTextNormalization:
(trimToUndefined(config.applyTextNormalization) as "auto" | "on" | "off" | undefined) ??
defaults.applyTextNormalization,
languageCode: trimToUndefined(config.languageCode) ?? defaults.languageCode,
voiceSettings: {
...defaults.voiceSettings,
...normalizeVoiceSettings(voiceSettings),
},
};
}
function mergeVoiceSettingsOverride(
ctx: SpeechDirectiveTokenParseContext,
next: Record<string, unknown>,
): SpeechProviderOverrides {
return {
...ctx.currentOverrides,
voiceSettings: {
...asObject(ctx.currentOverrides?.voiceSettings),
...next,
},
};
}
function resolveVoiceSettingsOverride(
base: ElevenLabsProviderConfig["voiceSettings"],
overrides: unknown,
): ElevenLabsProviderConfig["voiceSettings"] {
const voiceSettings = asObject(overrides);
return {
...base,
...normalizeVoiceSettings(voiceSettings),
};
}
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext) {
try {
switch (ctx.key) {
case "voiceid":
case "voice_id":
case "elevenlabs_voice":
case "elevenlabsvoice":
if (!ctx.policy.allowVoice) {
return { handled: true };
}
if (!isValidElevenLabsVoiceId(ctx.value)) {
return { handled: true, warnings: [`invalid ElevenLabs voiceId "${ctx.value}"`] };
}
return {
handled: true,
overrides: { ...ctx.currentOverrides, voiceId: ctx.value },
};
case "model":
case "modelid":
case "model_id":
case "elevenlabs_model":
case "elevenlabsmodel":
if (!ctx.policy.allowModelId) {
return { handled: true };
}
return {
handled: true,
overrides: { ...ctx.currentOverrides, modelId: normalizeElevenLabsTtsModelId(ctx.value) },
};
case "stability": {
if (!ctx.policy.allowVoiceSettings) {
return { handled: true };
}
const value = parseNumberValue(ctx.value);
if (value == null) {
return { handled: true, warnings: ["invalid stability value"] };
}
requireInRange(value, 0, 1, "stability");
return { handled: true, overrides: mergeVoiceSettingsOverride(ctx, { stability: value }) };
}
case "similarity":
case "similarityboost":
case "similarity_boost": {
if (!ctx.policy.allowVoiceSettings) {
return { handled: true };
}
const value = parseNumberValue(ctx.value);
if (value == null) {
return { handled: true, warnings: ["invalid similarityBoost value"] };
}
requireInRange(value, 0, 1, "similarityBoost");
return {
handled: true,
overrides: mergeVoiceSettingsOverride(ctx, { similarityBoost: value }),
};
}
case "style": {
if (!ctx.policy.allowVoiceSettings) {
return { handled: true };
}
const value = parseNumberValue(ctx.value);
if (value == null) {
return { handled: true, warnings: ["invalid style value"] };
}
requireInRange(value, 0, 1, "style");
return { handled: true, overrides: mergeVoiceSettingsOverride(ctx, { style: value }) };
}
case "speed": {
if (!ctx.policy.allowVoiceSettings) {
return { handled: true };
}
const value = parseNumberValue(ctx.value);
if (value == null) {
return { handled: true, warnings: ["invalid speed value"] };
}
requireInRange(value, 0.5, 2, "speed");
return { handled: true, overrides: mergeVoiceSettingsOverride(ctx, { speed: value }) };
}
case "speakerboost":
case "speaker_boost":
case "usespeakerboost":
case "use_speaker_boost": {
if (!ctx.policy.allowVoiceSettings) {
return { handled: true };
}
const value = parseBooleanValue(ctx.value);
if (value == null) {
return { handled: true, warnings: ["invalid useSpeakerBoost value"] };
}
return {
handled: true,
overrides: mergeVoiceSettingsOverride(ctx, { useSpeakerBoost: value }),
};
}
case "normalize":
case "applytextnormalization":
case "apply_text_normalization":
if (!ctx.policy.allowNormalization) {
return { handled: true };
}
return {
handled: true,
overrides: {
...ctx.currentOverrides,
applyTextNormalization: normalizeApplyTextNormalization(ctx.value),
},
};
case "language":
case "languagecode":
case "language_code":
if (!ctx.policy.allowNormalization) {
return { handled: true };
}
return {
handled: true,
overrides: {
...ctx.currentOverrides,
languageCode: normalizeLanguageCode(ctx.value),
},
};
case "seed":
if (!ctx.policy.allowSeed) {
return { handled: true };
}
return {
handled: true,
overrides: {
...ctx.currentOverrides,
seed: normalizeSeed(parseStrictInteger(ctx.value) ?? Number.NaN),
},
};
default:
return { handled: false };
}
} catch (error) {
return {
handled: true,
warnings: [formatErrorMessage(error)],
};
}
}
async function listElevenLabsVoices(params: {
apiKey: string;
baseUrl?: string;
}): Promise<SpeechVoiceOption[]> {
const normalizedBaseUrl = normalizeElevenLabsBaseUrl(params.baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${normalizedBaseUrl}/v1/voices`,
init: {
headers: {
"xi-api-key": params.apiKey,
},
},
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(normalizedBaseUrl),
auditContext: "elevenlabs.voices",
});
try {
await assertOkOrThrowProviderError(response, "ElevenLabs voices API error");
const json = await readProviderJsonResponse<{
voices?: Array<{
voice_id?: string;
name?: string;
category?: string;
description?: string;
}>;
}>(response, "elevenlabs.voices");
return Array.isArray(json.voices)
? json.voices
.map((voice) => ({
id: voice.voice_id?.trim() ?? "",
name: trimToUndefined(voice.name),
category: trimToUndefined(voice.category),
description: trimToUndefined(voice.description),
}))
.filter((voice) => voice.id.length > 0)
: [];
} finally {
await release();
}
}
type ElevenLabsSynthesisRequest = Pick<
SpeechSynthesisRequest,
"providerConfig" | "providerOverrides" | "text" | "timeoutMs"
>;
function resolveElevenLabsTtsRequest(
req: ElevenLabsSynthesisRequest,
options: Pick<Parameters<typeof elevenLabsTTS>[0], "outputFormat" | "latencyTier">,
): Parameters<typeof elevenLabsTTS>[0] {
const config = readElevenLabsProviderConfig(req.providerConfig);
const overrides = req.providerOverrides ?? {};
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
return {
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
modelId: normalizeElevenLabsTtsModelId(trimToUndefined(overrides.modelId)) ?? config.modelId,
outputFormat: options.outputFormat,
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
applyTextNormalization:
(trimToUndefined(overrides.applyTextNormalization) as "auto" | "on" | "off" | undefined) ??
config.applyTextNormalization,
languageCode: trimToUndefined(overrides.languageCode) ?? config.languageCode,
latencyTier: options.latencyTier,
voiceSettings: resolveVoiceSettingsOverride(config.voiceSettings, overrides.voiceSettings),
timeoutMs: req.timeoutMs,
};
}
export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
return {
id: "elevenlabs",
label: "ElevenLabs",
autoSelectOrder: 20,
defaultModel: DEFAULT_ELEVENLABS_MODEL_ID,
models: ELEVENLABS_TTS_MODELS,
resolveConfig: ({ rawConfig }) => normalizeElevenLabsProviderConfig(rawConfig),
parseDirectiveToken,
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
const base = normalizeElevenLabsProviderConfig(baseTtsConfig);
const talkVoiceSettings = asObject(talkProviderConfig.voiceSettings);
const resolvedTalkApiKey =
talkProviderConfig.apiKey === undefined
? (resolveElevenLabsApiKeyWithProfileFallback() ?? undefined)
: normalizeResolvedSecretInputString({
value: talkProviderConfig.apiKey,
path: "talk.providers.elevenlabs.apiKey",
});
return {
...base,
...(resolvedTalkApiKey === undefined ? {} : { apiKey: resolvedTalkApiKey }),
...(trimToUndefined(talkProviderConfig.baseUrl) == null
? {}
: { baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) }),
...(trimToUndefined(talkProviderConfig.voiceId) == null
? {}
: { voiceId: trimToUndefined(talkProviderConfig.voiceId) }),
...(trimToUndefined(talkProviderConfig.modelId) == null
? {}
: {
modelId: normalizeElevenLabsTtsModelId(trimToUndefined(talkProviderConfig.modelId)),
}),
...(normalizeElevenLabsSeed(talkProviderConfig.seed) == null
? {}
: { seed: normalizeElevenLabsSeed(talkProviderConfig.seed) }),
...(trimToUndefined(talkProviderConfig.applyTextNormalization) == null
? {}
: {
applyTextNormalization: normalizeApplyTextNormalization(
trimToUndefined(talkProviderConfig.applyTextNormalization),
),
}),
...(trimToUndefined(talkProviderConfig.languageCode) == null
? {}
: {
languageCode: normalizeLanguageCode(trimToUndefined(talkProviderConfig.languageCode)),
}),
voiceSettings: {
...base.voiceSettings,
...normalizeVoiceSettings(talkVoiceSettings),
},
};
},
resolveTalkOverrides: ({ params }) => {
const normalize = trimToUndefined(params.normalize);
const language = normalizeLowercaseStringOrEmpty(trimToUndefined(params.language));
const latencyTier = normalizeElevenLabsLatencyTier(params.latencyTier);
const voiceSettings = {
...(normalizeVoiceSetting(params.speed, 0.5, 2) == null
? {}
: { speed: normalizeVoiceSetting(params.speed, 0.5, 2) }),
...(normalizeVoiceSetting(params.stability, 0, 1) == null
? {}
: { stability: normalizeVoiceSetting(params.stability, 0, 1) }),
...(normalizeVoiceSetting(params.similarity, 0, 1) == null
? {}
: { similarityBoost: normalizeVoiceSetting(params.similarity, 0, 1) }),
...(normalizeVoiceSetting(params.style, 0, 1) == null
? {}
: { style: normalizeVoiceSetting(params.style, 0, 1) }),
...(asBoolean(params.speakerBoost) == null
? {}
: { useSpeakerBoost: asBoolean(params.speakerBoost) }),
};
return {
...(trimToUndefined(params.voiceId) == null
? {}
: { voiceId: trimToUndefined(params.voiceId) }),
...(trimToUndefined(params.modelId) == null
? {}
: { modelId: normalizeElevenLabsTtsModelId(trimToUndefined(params.modelId)) }),
...(trimToUndefined(params.outputFormat) == null
? {}
: { outputFormat: trimToUndefined(params.outputFormat) }),
...(normalizeElevenLabsSeed(params.seed) == null
? {}
: { seed: normalizeElevenLabsSeed(params.seed) }),
...(normalize == null
? {}
: { applyTextNormalization: normalizeApplyTextNormalization(normalize) }),
...(language == null ? {} : { languageCode: normalizeLanguageCode(language) }),
...(latencyTier == null ? {} : { latencyTier }),
...(Object.keys(voiceSettings).length === 0 ? {} : { voiceSettings }),
};
},
listVoices: async (req) => {
const config = req.providerConfig
? readElevenLabsProviderConfig(req.providerConfig)
: undefined;
const apiKey =
req.apiKey ||
config?.apiKey ||
resolveElevenLabsApiKeyWithProfileFallback() ||
process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
return listElevenLabsVoices({
apiKey,
baseUrl: req.baseUrl ?? config?.baseUrl,
});
},
isConfigured: ({ providerConfig }) =>
Boolean(
readElevenLabsProviderConfig(providerConfig).apiKey ||
resolveElevenLabsApiKeyWithProfileFallback() ||
process.env.XI_API_KEY,
),
synthesize: async (req) => {
const overrides = req.providerOverrides ?? {};
const outputFormat =
trimToUndefined(overrides.outputFormat) ??
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
const audioBuffer = await elevenLabsTTS(
resolveElevenLabsTtsRequest(req, {
outputFormat,
latencyTier: normalizeElevenLabsLatencyTier(overrides.latencyTier),
}),
);
return {
audioBuffer,
outputFormat,
fileExtension: req.target === "voice-note" ? ".opus" : ".mp3",
voiceCompatible: req.target === "voice-note",
};
},
streamSynthesize: async (req) => {
const overrides = req.providerOverrides ?? {};
const outputFormat =
trimToUndefined(overrides.outputFormat) ??
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
const stream = await elevenLabsTTSStream(
resolveElevenLabsTtsRequest(req, {
outputFormat,
latencyTier: normalizeElevenLabsLatencyTier(overrides.latencyTier),
}),
);
return {
audioStream: stream.audioStream,
outputFormat,
fileExtension: req.target === "voice-note" ? ".opus" : ".mp3",
voiceCompatible: req.target === "voice-note",
release: stream.release,
};
},
synthesizeTelephony: async (req) => {
const outputFormat = "pcm_22050";
const sampleRate = 22_050;
const audioBuffer = await elevenLabsTTS(resolveElevenLabsTtsRequest(req, { outputFormat }));
return { audioBuffer, outputFormat, sampleRate };
},
};
}

View File

@@ -0,0 +1,7 @@
// Elevenlabs API module exposes the plugin public contract.
export {
elevenLabsMediaUnderstandingProvider,
transcribeElevenLabsAudio,
} from "./media-understanding-provider.js";
export { buildElevenLabsRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
export { buildElevenLabsSpeechProvider } from "./speech-provider.js";

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,227 @@
// Elevenlabs tests cover tts plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { createStreamingErrorResponse } from "../test-support/streaming-error-response.js";
import { elevenLabsTTS, elevenLabsTTSStream } from "./tts.js";
describe("elevenlabs tts diagnostics", () => {
const originalFetch = globalThis.fetch;
function createDefaultTtsRequest() {
return {
text: "hello",
apiKey: "test-key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2",
outputFormat: "mp3_44100_128",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0,
useSpeakerBoost: true,
speed: 1,
},
timeoutMs: 5_000,
};
}
function getHeadersFromFirstFetchCall(fetchMock: ReturnType<typeof vi.fn>): Headers {
return new Headers(getInitFromFirstFetchCall(fetchMock).headers);
}
function requireFirstFetchCall(fetchMock: ReturnType<typeof vi.fn>): [string | URL, RequestInit] {
const [call] = fetchMock.mock.calls;
if (!call) {
throw new Error("expected ElevenLabs fetch call");
}
return call as [string | URL, RequestInit];
}
function getInitFromFirstFetchCall(fetchMock: ReturnType<typeof vi.fn>): RequestInit {
const [, init] = requireFirstFetchCall(fetchMock);
return init;
}
function getUrlFromFirstFetchCall(fetchMock: ReturnType<typeof vi.fn>): URL {
const [url] = requireFirstFetchCall(fetchMock);
return new URL(url.toString());
}
async function expectDefaultTtsRequestToThrow(message: string | RegExp) {
await expect(elevenLabsTTS(createDefaultTtsRequest())).rejects.toThrow(message);
}
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it("includes parsed provider detail and request id for JSON API errors", async () => {
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
detail: {
message: "Quota exceeded",
status: "quota_exceeded",
},
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"x-request-id": "el_req_456",
},
},
),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow(
"ElevenLabs API error (429): Quota exceeded [code=quota_exceeded] [request_id=el_req_456]",
);
});
it("falls back to raw body text when the error body is non-JSON", async () => {
const fetchMock = vi.fn(async () => new Response("service unavailable", { status: 503 }));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow("ElevenLabs API error (503): service unavailable");
});
it("caps streamed non-JSON error reads instead of consuming full response bodies", async () => {
const streamed = createStreamingErrorResponse({
status: 503,
chunkCount: 200,
chunkSize: 1024,
byte: 121,
});
const fetchMock = vi.fn(async () => streamed.response);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow("ElevenLabs API error (503)");
expect(streamed.getReadCount()).toBeLessThan(200);
});
it("keeps the MPEG Accept header for MP3 output", async () => {
const fetchMock = vi.fn(async () => new Response(Buffer.from("mp3")));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await elevenLabsTTS(createDefaultTtsRequest());
expect(getHeadersFromFirstFetchCall(fetchMock).get("accept")).toBe("audio/mpeg");
});
it("rejects JSON success bodies as malformed audio", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: "not audio" }), {
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow("ElevenLabs API error: malformed audio response");
});
it("rejects empty successful audio bodies as malformed audio", async () => {
const fetchMock = vi.fn(async () => new Response(new Uint8Array()));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expectDefaultTtsRequestToThrow("ElevenLabs API error: malformed audio response");
});
it("omits the MPEG Accept header for PCM telephony output", async () => {
const fetchMock = vi.fn(async () => new Response(Buffer.from("pcm")));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await elevenLabsTTS({
...createDefaultTtsRequest(),
outputFormat: "pcm_22050",
});
expect(getHeadersFromFirstFetchCall(fetchMock).has("accept")).toBe(false);
});
it("sends latency optimization as an ElevenLabs query parameter", async () => {
const fetchMock = vi.fn(async () => new Response(Buffer.from("mp3")));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await elevenLabsTTS({
...createDefaultTtsRequest(),
latencyTier: 3,
});
const url = getUrlFromFirstFetchCall(fetchMock);
expect(url.searchParams.get("optimize_streaming_latency")).toBe("3");
const body = JSON.parse(getInitFromFirstFetchCall(fetchMock).body as string) as {
latency_optimization_level?: number;
};
expect(body.latency_optimization_level).toBeUndefined();
});
it("rejects fractional latency optimization instead of truncating it", async () => {
const fetchMock = vi.fn(async () => new Response(Buffer.from("mp3")));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(
elevenLabsTTS({
...createDefaultTtsRequest(),
latencyTier: 3.9,
}),
).rejects.toThrow("latencyTier must be an integer");
expect(fetchMock).not.toHaveBeenCalled();
});
it("omits latency optimization for eleven_v3 because the API rejects it", async () => {
const fetchMock = vi.fn(async () => new Response(Buffer.from("mp3")));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await elevenLabsTTS({
...createDefaultTtsRequest(),
modelId: "eleven_v3",
latencyTier: 3,
});
const url = getUrlFromFirstFetchCall(fetchMock);
expect(url.searchParams.has("optimize_streaming_latency")).toBe(false);
});
it("uses the streaming endpoint without buffering the audio body", async () => {
const audioStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
const fetchMock = vi.fn(async () => new Response(audioStream));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const result = await elevenLabsTTSStream({
...createDefaultTtsRequest(),
latencyTier: 2,
});
const url = getUrlFromFirstFetchCall(fetchMock);
expect(url.pathname).toBe("/v1/text-to-speech/pMsXgVXv3BLzUgSXRplE/stream");
expect(url.searchParams.get("optimize_streaming_latency")).toBe("2");
expect(result.audioStream).toBeInstanceOf(ReadableStream);
await result.release();
});
it("rejects JSON success stream responses as malformed audio", async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ error: "not audio" }), {
headers: { "content-type": "application/json" },
}),
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(elevenLabsTTSStream(createDefaultTtsRequest())).rejects.toThrow(
"ElevenLabs API error: malformed audio response",
);
});
});

View File

@@ -0,0 +1,204 @@
// Elevenlabs plugin module implements tts behavior.
import {
assertOkOrThrowProviderError,
assertProviderBinaryResponseContent,
readProviderBinaryResponse,
} from "openclaw/plugin-sdk/provider-http";
import {
normalizeApplyTextNormalization,
normalizeLanguageCode,
normalizeSeed,
requireInRange,
} from "openclaw/plugin-sdk/speech";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { isValidElevenLabsVoiceId, normalizeElevenLabsBaseUrl } from "./shared.js";
function assertElevenLabsVoiceSettings(settings: {
stability: number;
similarityBoost: number;
style: number;
useSpeakerBoost: boolean;
speed: number;
}) {
requireInRange(settings.stability, 0, 1, "stability");
requireInRange(settings.similarityBoost, 0, 1, "similarityBoost");
requireInRange(settings.style, 0, 1, "style");
requireInRange(settings.speed, 0.5, 2, "speed");
}
function resolveElevenLabsAcceptHeader(outputFormat: string): string | undefined {
const normalized = outputFormat.trim().toLowerCase();
if (!normalized || normalized.startsWith("mp3_")) {
return "audio/mpeg";
}
return undefined;
}
function normalizeElevenLabsLatencyTier(latencyTier: number | undefined): number | undefined {
if (latencyTier === undefined || !Number.isFinite(latencyTier)) {
return undefined;
}
if (!Number.isSafeInteger(latencyTier)) {
throw new Error("latencyTier must be an integer");
}
requireInRange(latencyTier, 0, 4, "latencyTier");
return latencyTier;
}
type ElevenLabsTtsRequestParams = {
text: string;
apiKey: string;
baseUrl: string;
voiceId: string;
modelId: string;
outputFormat: string;
seed?: number;
applyTextNormalization?: "auto" | "on" | "off";
languageCode?: string;
latencyTier?: number;
voiceSettings: {
stability: number;
similarityBoost: number;
style: number;
useSpeakerBoost: boolean;
speed: number;
};
timeoutMs: number;
};
function prepareElevenLabsTtsRequest(params: ElevenLabsTtsRequestParams & { stream: boolean }): {
url: URL;
normalizedBaseUrl: string;
acceptHeader?: string;
body: string;
} {
const {
text,
baseUrl,
voiceId,
modelId,
outputFormat,
seed,
applyTextNormalization,
languageCode,
latencyTier,
voiceSettings,
} = params;
if (!isValidElevenLabsVoiceId(voiceId)) {
throw new Error("Invalid voiceId format");
}
assertElevenLabsVoiceSettings(voiceSettings);
const normalizedLanguage = normalizeLanguageCode(languageCode);
const normalizedNormalization = normalizeApplyTextNormalization(applyTextNormalization);
const normalizedSeed = normalizeSeed(seed);
const normalizedBaseUrl = normalizeElevenLabsBaseUrl(baseUrl);
const normalizedLatencyTier = normalizeElevenLabsLatencyTier(latencyTier);
const url = new URL(
`${normalizedBaseUrl}/v1/text-to-speech/${voiceId}${params.stream ? "/stream" : ""}`,
);
if (outputFormat) {
url.searchParams.set("output_format", outputFormat);
}
const supportsStreamingLatency = modelId.trim().toLowerCase() !== "eleven_v3";
if (normalizedLatencyTier !== undefined && supportsStreamingLatency) {
url.searchParams.set("optimize_streaming_latency", normalizedLatencyTier.toString());
}
const acceptHeader = resolveElevenLabsAcceptHeader(outputFormat);
return {
url,
normalizedBaseUrl,
acceptHeader,
body: JSON.stringify({
text,
model_id: modelId,
seed: normalizedSeed,
apply_text_normalization: normalizedNormalization,
language_code: normalizedLanguage,
voice_settings: {
stability: voiceSettings.stability,
similarity_boost: voiceSettings.similarityBoost,
style: voiceSettings.style,
use_speaker_boost: voiceSettings.useSpeakerBoost,
speed: voiceSettings.speed,
},
}),
};
}
export async function elevenLabsTTS(params: ElevenLabsTtsRequestParams): Promise<Buffer> {
const { apiKey, timeoutMs } = params;
const { url, normalizedBaseUrl, acceptHeader, body } = prepareElevenLabsTtsRequest({
...params,
stream: false,
});
const { response, release } = await fetchWithSsrFGuard({
url: url.toString(),
init: {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
...(acceptHeader ? { Accept: acceptHeader } : {}),
},
body,
},
timeoutMs,
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(normalizedBaseUrl),
auditContext: "elevenlabs.tts",
});
try {
await assertOkOrThrowProviderError(response, "ElevenLabs API error");
return Buffer.from(await readProviderBinaryResponse(response, "ElevenLabs API error", "audio"));
} finally {
await release();
}
}
export async function elevenLabsTTSStream(params: ElevenLabsTtsRequestParams): Promise<{
audioStream: ReadableStream<Uint8Array>;
release: () => Promise<void>;
}> {
const { apiKey, timeoutMs } = params;
const { url, normalizedBaseUrl, acceptHeader, body } = prepareElevenLabsTtsRequest({
...params,
stream: true,
});
const { response, release } = await fetchWithSsrFGuard({
url: url.toString(),
init: {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
...(acceptHeader ? { Accept: acceptHeader } : {}),
},
body,
},
timeoutMs,
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(normalizedBaseUrl),
auditContext: "elevenlabs.tts.stream",
});
let handedOff = false;
try {
await assertOkOrThrowProviderError(response, "ElevenLabs API error");
assertProviderBinaryResponseContent(response, "ElevenLabs API error", "audio");
if (!response.body) {
throw new Error("ElevenLabs API response missing audio stream");
}
handedOff = true;
return {
audioStream: response.body,
release,
};
} finally {
if (!handedOff) {
await release();
}
}
}