TTS: extract runtime registry

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 19:17:51 +00:00
parent 12ba14c10f
commit 6c92171a47
No known key found for this signature in database
5 changed files with 166 additions and 43 deletions

View File

@ -1,3 +1,7 @@
import {
isExtensionHostTtsProviderConfigured,
resolveExtensionHostTtsApiKey,
} from "../../extension-host/tts-runtime-registry.js";
import { logVerbose } from "../../globals.js";
import {
getLastTtsAttempt,
@ -5,8 +9,6 @@ import {
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
setLastTtsAttempt,
@ -159,9 +161,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
const hasOpenAI = Boolean(resolveExtensionHostTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveExtensionHostTtsApiKey(config, "elevenlabs"));
const hasEdge = isExtensionHostTtsProviderConfigured(config, "edge");
return {
shouldContinue: false,
reply: {
@ -249,7 +251,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
const hasKey = isExtensionHostTtsProviderConfigured(config, provider);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();

View File

@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
EXTENSION_HOST_TTS_PROVIDER_IDS,
isExtensionHostTtsProviderConfigured,
resolveExtensionHostTtsApiKey,
resolveExtensionHostTtsProviderOrder,
supportsExtensionHostTtsTelephony,
} from "./tts-runtime-registry.js";
describe("extension host TTS runtime registry", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("keeps the built-in provider order stable", () => {
expect(EXTENSION_HOST_TTS_PROVIDER_IDS).toEqual(["openai", "elevenlabs", "edge"]);
expect(resolveExtensionHostTtsProviderOrder("edge")).toEqual(["edge", "openai", "elevenlabs"]);
});
it("resolves API keys for remote providers", () => {
const config = {
openai: { apiKey: "openai-key" },
elevenlabs: { apiKey: "xi-key" },
edge: { enabled: false },
} as const;
expect(resolveExtensionHostTtsApiKey(config, "openai")).toBe("openai-key");
expect(resolveExtensionHostTtsApiKey(config, "elevenlabs")).toBe("xi-key");
expect(resolveExtensionHostTtsApiKey(config, "edge")).toBeUndefined();
});
it("checks provider configuration through the host-owned definitions", () => {
vi.stubEnv("ELEVENLABS_API_KEY", "");
vi.stubEnv("XI_API_KEY", "");
const config = {
openai: { apiKey: "openai-key" },
elevenlabs: { apiKey: "" },
edge: { enabled: true },
} as const;
expect(isExtensionHostTtsProviderConfigured(config, "openai")).toBe(true);
expect(isExtensionHostTtsProviderConfigured(config, "elevenlabs")).toBe(false);
expect(isExtensionHostTtsProviderConfigured(config, "edge")).toBe(true);
});
it("tracks telephony support per provider", () => {
expect(supportsExtensionHostTtsTelephony("openai")).toBe(true);
expect(supportsExtensionHostTtsTelephony("elevenlabs")).toBe(true);
expect(supportsExtensionHostTtsTelephony("edge")).toBe(false);
});
});

View File

@ -0,0 +1,78 @@
import type { TtsProvider } from "../config/types.tts.js";
import type { ResolvedTtsConfig } from "../tts/tts.js";
export type ExtensionHostTtsRuntimeProvider = {
id: TtsProvider;
supportsTelephony: boolean;
resolveApiKey: (config: ResolvedTtsConfig) => string | undefined;
isConfigured: (config: ResolvedTtsConfig) => boolean;
};
const EXTENSION_HOST_TTS_RUNTIME_PROVIDERS: readonly ExtensionHostTtsRuntimeProvider[] = [
{
id: "openai",
supportsTelephony: true,
resolveApiKey(config) {
return config.openai.apiKey || process.env.OPENAI_API_KEY;
},
isConfigured(config) {
return Boolean(this.resolveApiKey(config));
},
},
{
id: "elevenlabs",
supportsTelephony: true,
resolveApiKey(config) {
return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
},
isConfigured(config) {
return Boolean(this.resolveApiKey(config));
},
},
{
id: "edge",
supportsTelephony: false,
resolveApiKey() {
return undefined;
},
isConfigured(config) {
return config.edge.enabled;
},
},
] as const;
export const EXTENSION_HOST_TTS_PROVIDER_IDS = EXTENSION_HOST_TTS_RUNTIME_PROVIDERS.map(
(provider) => provider.id,
) as readonly TtsProvider[];
export function listExtensionHostTtsRuntimeProviders(): readonly ExtensionHostTtsRuntimeProvider[] {
return EXTENSION_HOST_TTS_RUNTIME_PROVIDERS;
}
export function getExtensionHostTtsRuntimeProvider(
id: TtsProvider,
): ExtensionHostTtsRuntimeProvider | undefined {
return EXTENSION_HOST_TTS_RUNTIME_PROVIDERS.find((provider) => provider.id === id);
}
export function resolveExtensionHostTtsApiKey(
config: ResolvedTtsConfig,
provider: TtsProvider,
): string | undefined {
return getExtensionHostTtsRuntimeProvider(provider)?.resolveApiKey(config);
}
export function isExtensionHostTtsProviderConfigured(
config: ResolvedTtsConfig,
provider: TtsProvider,
): boolean {
return getExtensionHostTtsRuntimeProvider(provider)?.isConfigured(config) ?? false;
}
export function resolveExtensionHostTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
return [primary, ...EXTENSION_HOST_TTS_PROVIDER_IDS.filter((provider) => provider !== primary)];
}
export function supportsExtensionHostTtsTelephony(provider: TtsProvider): boolean {
return getExtensionHostTtsRuntimeProvider(provider)?.supportsTelephony ?? false;
}

View File

@ -1,15 +1,17 @@
import { loadConfig } from "../../config/config.js";
import {
isExtensionHostTtsProviderConfigured,
resolveExtensionHostTtsApiKey,
resolveExtensionHostTtsProviderOrder,
} from "../../extension-host/tts-runtime-registry.js";
import {
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
getTtsProvider,
isTtsEnabled,
isTtsProviderConfigured,
resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,
setTtsEnabled,
setTtsProvider,
textToSpeech,
@ -26,9 +28,9 @@ export const ttsHandlers: GatewayRequestHandlers = {
const prefsPath = resolveTtsPrefsPath(config);
const provider = getTtsProvider(config, prefsPath);
const autoMode = resolveTtsAutoMode({ config, prefsPath });
const fallbackProviders = resolveTtsProviderOrder(provider)
const fallbackProviders = resolveExtensionHostTtsProviderOrder(provider)
.slice(1)
.filter((candidate) => isTtsProviderConfigured(config, candidate));
.filter((candidate) => isExtensionHostTtsProviderConfigured(config, candidate));
respond(true, {
enabled: isTtsEnabled(config, prefsPath),
auto: autoMode,
@ -36,9 +38,9 @@ export const ttsHandlers: GatewayRequestHandlers = {
fallbackProvider: fallbackProviders[0] ?? null,
fallbackProviders,
prefsPath,
hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")),
hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")),
edgeEnabled: isTtsProviderConfigured(config, "edge"),
hasOpenAIKey: Boolean(resolveExtensionHostTtsApiKey(config, "openai")),
hasElevenLabsKey: Boolean(resolveExtensionHostTtsApiKey(config, "elevenlabs")),
edgeEnabled: isExtensionHostTtsProviderConfigured(config, "edge"),
});
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
@ -131,20 +133,20 @@ export const ttsHandlers: GatewayRequestHandlers = {
{
id: "openai",
name: "OpenAI",
configured: Boolean(resolveTtsApiKey(config, "openai")),
configured: Boolean(resolveExtensionHostTtsApiKey(config, "openai")),
models: [...OPENAI_TTS_MODELS],
voices: [...OPENAI_TTS_VOICES],
},
{
id: "elevenlabs",
name: "ElevenLabs",
configured: Boolean(resolveTtsApiKey(config, "elevenlabs")),
configured: Boolean(resolveExtensionHostTtsApiKey(config, "elevenlabs")),
models: ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1"],
},
{
id: "edge",
name: "Edge TTS",
configured: isTtsProviderConfigured(config, "edge"),
configured: isExtensionHostTtsProviderConfigured(config, "edge"),
models: [],
},
],

View File

@ -22,6 +22,13 @@ import type {
TtsProvider,
TtsModelOverrideConfig,
} from "../config/types.tts.js";
import {
EXTENSION_HOST_TTS_PROVIDER_IDS,
isExtensionHostTtsProviderConfigured,
resolveExtensionHostTtsApiKey,
resolveExtensionHostTtsProviderOrder,
supportsExtensionHostTtsTelephony,
} from "../extension-host/tts-runtime-registry.js";
import { logVerbose } from "../globals.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { stripMarkdown } from "../line/markdown-to-line.js";
@ -518,31 +525,13 @@ function resolveEdgeOutputFormat(config: ResolvedTtsConfig): string {
return config.edge.outputFormat;
}
export function resolveTtsApiKey(
config: ResolvedTtsConfig,
provider: TtsProvider,
): string | undefined {
if (provider === "elevenlabs") {
return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
}
if (provider === "openai") {
return config.openai.apiKey || process.env.OPENAI_API_KEY;
}
return undefined;
}
export const TTS_PROVIDERS = EXTENSION_HOST_TTS_PROVIDER_IDS;
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"] as const;
export const resolveTtsApiKey = resolveExtensionHostTtsApiKey;
export function resolveTtsProviderOrder(primary: TtsProvider): TtsProvider[] {
return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)];
}
export const resolveTtsProviderOrder = resolveExtensionHostTtsProviderOrder;
export function isTtsProviderConfigured(config: ResolvedTtsConfig, provider: TtsProvider): boolean {
if (provider === "edge") {
return config.edge.enabled;
}
return Boolean(resolveTtsApiKey(config, provider));
}
export const isTtsProviderConfigured = isExtensionHostTtsProviderConfigured;
function formatTtsProviderError(provider: TtsProvider, err: unknown): string {
const error = err instanceof Error ? err : new Error(String(err));
@ -584,7 +573,7 @@ function resolveTtsRequestSetup(params: {
const provider = params.providerOverride ?? userProvider;
return {
config,
providers: resolveTtsProviderOrder(provider),
providers: resolveExtensionHostTtsProviderOrder(provider),
};
}
@ -684,7 +673,7 @@ export async function textToSpeech(params: {
};
}
const apiKey = resolveTtsApiKey(config, provider);
const apiKey = resolveExtensionHostTtsApiKey(config, provider);
if (!apiKey) {
errors.push(`${provider}: no API key`);
continue;
@ -776,12 +765,12 @@ export async function textToSpeechTelephony(params: {
for (const provider of providers) {
const providerStart = Date.now();
try {
if (provider === "edge") {
if (!supportsExtensionHostTtsTelephony(provider)) {
errors.push("edge: unsupported for telephony");
continue;
}
const apiKey = resolveTtsApiKey(config, provider);
const apiKey = resolveExtensionHostTtsApiKey(config, provider);
if (!apiKey) {
errors.push(`${provider}: no API key`);
continue;