diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index a6711d2c643..6e29026c41b 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -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(); diff --git a/src/extension-host/tts-runtime-registry.test.ts b/src/extension-host/tts-runtime-registry.test.ts new file mode 100644 index 00000000000..3445227ef07 --- /dev/null +++ b/src/extension-host/tts-runtime-registry.test.ts @@ -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); + }); +}); diff --git a/src/extension-host/tts-runtime-registry.ts b/src/extension-host/tts-runtime-registry.ts new file mode 100644 index 00000000000..05c491a6d7b --- /dev/null +++ b/src/extension-host/tts-runtime-registry.ts @@ -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; +} diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index 5e4e8254eba..5ea7569516d 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -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: [], }, ], diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 403efc10543..f00e4454b33 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -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;