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
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:
8
extensions/elevenlabs/config-api.ts
Normal file
8
extensions/elevenlabs/config-api.ts
Normal 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";
|
||||
76
extensions/elevenlabs/config-compat.test.ts
Normal file
76
extensions/elevenlabs/config-compat.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
182
extensions/elevenlabs/config-compat.ts
Normal file
182
extensions/elevenlabs/config-compat.ts
Normal 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);
|
||||
}
|
||||
9
extensions/elevenlabs/doctor-contract-api.ts
Normal file
9
extensions/elevenlabs/doctor-contract-api.ts
Normal 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";
|
||||
35
extensions/elevenlabs/doctor-contract.ts
Normal file
35
extensions/elevenlabs/doctor-contract.ts
Normal 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 };
|
||||
92
extensions/elevenlabs/elevenlabs.live.test.ts
Normal file
92
extensions/elevenlabs/elevenlabs.live.test.ts
Normal 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);
|
||||
});
|
||||
16
extensions/elevenlabs/index.ts
Normal file
16
extensions/elevenlabs/index.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
96
extensions/elevenlabs/media-understanding-provider.test.ts
Normal file
96
extensions/elevenlabs/media-understanding-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
86
extensions/elevenlabs/media-understanding-provider.ts
Normal file
86
extensions/elevenlabs/media-understanding-provider.ts
Normal 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,
|
||||
};
|
||||
46
extensions/elevenlabs/openclaw.plugin.json
Normal file
46
extensions/elevenlabs/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
15
extensions/elevenlabs/package.json
Normal file
15
extensions/elevenlabs/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
113
extensions/elevenlabs/realtime-transcription-provider.test.ts
Normal file
113
extensions/elevenlabs/realtime-transcription-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
299
extensions/elevenlabs/realtime-transcription-provider.ts
Normal file
299
extensions/elevenlabs/realtime-transcription-provider.ts
Normal 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 };
|
||||
12
extensions/elevenlabs/setup-api.ts
Normal file
12
extensions/elevenlabs/setup-api.ts
Normal 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));
|
||||
},
|
||||
});
|
||||
11
extensions/elevenlabs/shared.ts
Normal file
11
extensions/elevenlabs/shared.ts
Normal 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;
|
||||
}
|
||||
262
extensions/elevenlabs/speech-provider.test.ts
Normal file
262
extensions/elevenlabs/speech-provider.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
593
extensions/elevenlabs/speech-provider.ts
Normal file
593
extensions/elevenlabs/speech-provider.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
7
extensions/elevenlabs/test-api.ts
Normal file
7
extensions/elevenlabs/test-api.ts
Normal 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";
|
||||
16
extensions/elevenlabs/tsconfig.json
Normal file
16
extensions/elevenlabs/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
227
extensions/elevenlabs/tts.test.ts
Normal file
227
extensions/elevenlabs/tts.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
204
extensions/elevenlabs/tts.ts
Normal file
204
extensions/elevenlabs/tts.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user