| import fs from "node:fs/promises"; |
| import path from "node:path"; |
| import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record-coerce"; |
| import { |
| normalizeLowercaseStringOrEmpty, |
| normalizeOptionalString, |
| } from "@openclaw/normalization-core/string-coerce"; |
| import { resolveApiKeyForProviderCore } from "../../agents/model-auth.js"; |
| import { getRuntimeConfig } from "../../config/config.js"; |
| import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
| import { callGateway } from "../../gateway/call.js"; |
| import { buildGatewayConnectionDetailsWithResolvers } from "../../gateway/connection-details.js"; |
| import { isLoopbackHost } from "../../gateway/net.js"; |
| import { canonicalizeSpeechProviderId, listSpeechProviders } from "../../tts/provider-registry.js"; |
| import { |
| getTtsProvider, |
| getTtsPersona, |
| listTtsPersonas, |
| listSpeechVoices, |
| resolveExplicitTtsOverrides, |
| resolveTtsConfig, |
| resolveTtsPrefsPath, |
| setTtsEnabled, |
| setTtsPersona, |
| setTtsProvider, |
| textToSpeech, |
| } from "../../tts/tts.js"; |
| import { getTtsCommandSecretTargetIds } from "../command-secret-targets.js"; |
| import { publishOutputFileAtomically } from "../media-output.js"; |
| import type { CapabilityEnvelope, CapabilityTransport } from "./metadata.js"; |
| import { |
| pinRuntimeConfigSnapshot, |
| providerHasGenericConfig, |
| resolveCapabilityProviderAgentId, |
| resolveLocalCapabilityRuntimeConfig, |
| resolveSelectedProviderFromModelRef, |
| } from "./shared.js"; |
|
|
| async function copyTtsOutputAtomically(sourcePath: string, targetPath: string): Promise<void> { |
| await publishOutputFileAtomically({ |
| filePath: targetPath, |
| writeTemp: async (tempPath) => { |
| await fs.copyFile(sourcePath, tempPath); |
| }, |
| }); |
| } |
|
|
| export async function runTtsConvert(params: { |
| text: string; |
| channel?: string; |
| provider?: string; |
| modelId?: string; |
| voiceId?: string; |
| output?: string; |
| transport: CapabilityTransport; |
| }) { |
| if (params.transport === "gateway") { |
| const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ |
| config: getRuntimeConfig(), |
| }); |
| if (params.output && !isLoopbackHost(new URL(gatewayConnection.url).hostname)) { |
| throw new Error( |
| `--output is not supported for remote gateway TTS yet (gateway target: ${gatewayConnection.url}).`, |
| ); |
| } |
| const result: { |
| audioPath?: string; |
| provider?: string; |
| outputFormat?: string; |
| voiceCompatible?: boolean; |
| } = await callGateway({ |
| method: "tts.convert", |
| params: { |
| text: params.text, |
| channel: params.channel, |
| provider: normalizeOptionalString(params.provider), |
| modelId: params.modelId, |
| voiceId: params.voiceId, |
| }, |
| timeoutMs: 120_000, |
| }); |
| let outputPath = result.audioPath; |
| if (params.output && result.audioPath) { |
| const target = path.resolve(params.output); |
| await copyTtsOutputAtomically(result.audioPath, target); |
| outputPath = target; |
| } |
| return { |
| ok: true, |
| capability: "tts.convert", |
| transport: "gateway" as const, |
| provider: result.provider, |
| attempts: [], |
| outputs: [ |
| { |
| path: outputPath, |
| format: result.outputFormat, |
| voiceCompatible: result.voiceCompatible, |
| }, |
| ], |
| } satisfies CapabilityEnvelope; |
| } |
|
|
| const cfg = await resolveLocalCapabilityRuntimeConfig({ |
| commandName: "infer tts convert", |
| targetIds: getTtsCommandSecretTargetIds(), |
| }); |
| const ttsProvider = resolveTtsProviderForAuthHydration({ |
| cfg, |
| provider: params.provider, |
| modelId: params.modelId, |
| channelId: params.channel, |
| }); |
| const effectiveCfg = await injectTtsAuthProfileApiKey({ |
| cfg, |
| provider: ttsProvider, |
| channelId: params.channel, |
| }); |
| if (effectiveCfg !== cfg) { |
| pinRuntimeConfigSnapshot(effectiveCfg); |
| } |
| const overrides = resolveExplicitTtsOverrides({ |
| cfg: effectiveCfg, |
| provider: params.provider, |
| modelId: params.modelId, |
| voiceId: params.voiceId, |
| channelId: params.channel, |
| }); |
| const hasExplicitSelection = Boolean( |
| overrides.provider || |
| normalizeOptionalString(params.modelId) || |
| normalizeOptionalString(params.voiceId), |
| ); |
| const result = await textToSpeech({ |
| text: params.text, |
| cfg: effectiveCfg, |
| channel: params.channel, |
| overrides, |
| disableFallback: hasExplicitSelection, |
| }); |
| if (!result.success || !result.audioPath) { |
| throw new Error(result.error ?? "TTS conversion failed"); |
| } |
| let outputPath = result.audioPath; |
| if (params.output) { |
| const target = path.resolve(params.output); |
| await copyTtsOutputAtomically(result.audioPath, target); |
| outputPath = target; |
| } |
| return { |
| ok: true, |
| capability: "tts.convert", |
| transport: "local" as const, |
| provider: result.provider, |
| attempts: result.attempts ?? [], |
| outputs: [ |
| { |
| path: outputPath, |
| format: result.outputFormat, |
| voiceCompatible: result.voiceCompatible, |
| }, |
| ], |
| } satisfies CapabilityEnvelope; |
| } |
|
|
| function resolveTtsProviderForAuthHydration(params: { |
| cfg: OpenClawConfig; |
| provider?: string; |
| modelId?: string; |
| channelId?: string; |
| }): string | undefined { |
| const explicitProvider = |
| params.provider ?? resolveSelectedProviderFromModelRef(normalizeOptionalString(params.modelId)); |
| if (explicitProvider) { |
| return explicitProvider; |
| } |
| const ttsConfig = resolveTtsConfig(params.cfg, { channelId: params.channelId }); |
| return getTtsProvider(ttsConfig, resolveTtsPrefsPath(ttsConfig)); |
| } |
|
|
| async function injectTtsAuthProfileApiKey(params: { |
| cfg: OpenClawConfig; |
| provider?: string; |
| channelId?: string; |
| }): Promise<OpenClawConfig> { |
| if (!params.provider) { |
| return params.cfg; |
| } |
| const providerId = |
| canonicalizeSpeechProviderId(params.provider, params.cfg) ?? |
| normalizeLowercaseStringOrEmpty(params.provider); |
| if (!providerId) { |
| return params.cfg; |
| } |
| const effectiveTtsConfig = resolveTtsConfig(params.cfg, { channelId: params.channelId }); |
| if (resolvedTtsConfigHasProviderApiKey(effectiveTtsConfig, providerId)) { |
| return params.cfg; |
| } |
| const existingProviderConfig = resolveExistingTtsProviderConfig({ |
| cfg: params.cfg, |
| providerId, |
| channelId: params.channelId, |
| }); |
| if (ttsProviderConfigHasApiKey(existingProviderConfig?.value)) { |
| return params.cfg; |
| } |
| const auth = await resolveApiKeyForProviderCore({ |
| provider: providerId, |
| cfg: params.cfg, |
| credentialPrecedence: "profile-first", |
| }).catch(() => undefined); |
| if (!auth?.apiKey || auth.mode !== "api-key") { |
| return params.cfg; |
| } |
| if (existingProviderConfig?.scope === "channel") { |
| const channels = { ...params.cfg.channels }; |
| const channel = channels[existingProviderConfig.channelKey]; |
| if (!isObjectRecord(channel)) { |
| return params.cfg; |
| } |
| const nextChannel = { |
| ...channel, |
| tts: buildTtsConfigWithHydratedProvider({ |
| tts: channel.tts, |
| existingProviderConfig, |
| providerId, |
| apiKey: auth.apiKey, |
| }), |
| }; |
| return { |
| ...params.cfg, |
| channels: { |
| ...channels, |
| [existingProviderConfig.channelKey]: nextChannel, |
| }, |
| }; |
| } |
| const nextTts = buildTtsConfigWithHydratedProvider({ |
| tts: params.cfg.tts, |
| existingProviderConfig, |
| providerId, |
| apiKey: auth.apiKey, |
| }); |
| return { |
| ...params.cfg, |
| tts: nextTts, |
| }; |
| } |
|
|
| type TtsProviderConfigLocation = { |
| container: "providers" | "direct"; |
| key: string; |
| value: unknown; |
| }; |
|
|
| type ExistingTtsProviderConfig = |
| | (TtsProviderConfigLocation & { |
| scope: "root"; |
| channelKey?: never; |
| }) |
| | (TtsProviderConfigLocation & { |
| scope: "channel"; |
| channelKey: string; |
| }); |
|
|
| function resolveExistingTtsProviderConfig(params: { |
| cfg: OpenClawConfig; |
| providerId: string; |
| channelId?: string; |
| }): ExistingTtsProviderConfig | undefined { |
| const channelTts = resolveChannelTtsConfigForAuthHydration(params); |
| if (channelTts) { |
| const channelProviderConfig = resolveExistingTtsProviderConfigInTts({ |
| cfg: params.cfg, |
| tts: channelTts.tts, |
| providerId: params.providerId, |
| }); |
| if (channelProviderConfig) { |
| return { |
| ...channelProviderConfig, |
| scope: "channel", |
| channelKey: channelTts.channelKey, |
| }; |
| } |
| } |
| const rootProviderConfig = resolveExistingTtsProviderConfigInTts({ |
| cfg: params.cfg, |
| tts: params.cfg.tts, |
| providerId: params.providerId, |
| }); |
| return rootProviderConfig ? { ...rootProviderConfig, scope: "root" } : undefined; |
| } |
|
|
| function resolveExistingTtsProviderConfigInTts(params: { |
| cfg: OpenClawConfig; |
| tts: unknown; |
| providerId: string; |
| }): TtsProviderConfigLocation | undefined { |
| if (!isObjectRecord(params.tts)) { |
| return undefined; |
| } |
| const providers = isObjectRecord(params.tts.providers) ? params.tts.providers : undefined; |
| if (!providers) { |
| return resolveDirectTtsProviderConfig(params); |
| } |
| const exact = providers[params.providerId]; |
| if (exact !== undefined) { |
| return { container: "providers", key: params.providerId, value: exact }; |
| } |
| for (const [key, value] of Object.entries(providers)) { |
| const normalizedKey = normalizeLowercaseStringOrEmpty( |
| canonicalizeSpeechProviderId(key, params.cfg) ?? key, |
| ); |
| if (normalizedKey === params.providerId) { |
| return { container: "providers", key, value }; |
| } |
| } |
| return resolveDirectTtsProviderConfig(params); |
| } |
|
|
| const TTS_CONFIG_RESERVED_KEYS = new Set([ |
| "auto", |
| "enabled", |
| "maxTextLength", |
| "mode", |
| "modelOverrides", |
| "persona", |
| "personas", |
| "prefsPath", |
| "provider", |
| "providers", |
| "summaryModel", |
| "timeoutMs", |
| ]); |
|
|
| function resolveDirectTtsProviderConfig(params: { |
| cfg: OpenClawConfig; |
| tts: unknown; |
| providerId: string; |
| }): TtsProviderConfigLocation | undefined { |
| if (!isObjectRecord(params.tts)) { |
| return undefined; |
| } |
| for (const [key, value] of Object.entries(params.tts)) { |
| if (TTS_CONFIG_RESERVED_KEYS.has(key)) { |
| continue; |
| } |
| const normalizedKey = normalizeLowercaseStringOrEmpty( |
| canonicalizeSpeechProviderId(key, params.cfg) ?? key, |
| ); |
| if (normalizedKey === params.providerId) { |
| return { container: "direct", key, value }; |
| } |
| } |
| return undefined; |
| } |
|
|
| function resolveChannelTtsConfigForAuthHydration(params: { |
| cfg: OpenClawConfig; |
| channelId?: string; |
| }): { channelKey: string; tts: unknown } | undefined { |
| const channels = params.cfg.channels; |
| const normalizedChannelId = normalizeOptionalString(params.channelId); |
| if (!isObjectRecord(channels) || !normalizedChannelId) { |
| return undefined; |
| } |
| const channelKey = Object.hasOwn(channels, normalizedChannelId) |
| ? normalizedChannelId |
| : Object.keys(channels).find( |
| (candidate) => |
| normalizeLowercaseStringOrEmpty(candidate) === |
| normalizeLowercaseStringOrEmpty(normalizedChannelId), |
| ); |
| const channel = channelKey ? channels[channelKey] : undefined; |
| if (!channelKey || !isObjectRecord(channel)) { |
| return undefined; |
| } |
| return { channelKey, tts: channel.tts }; |
| } |
|
|
| function buildTtsConfigWithHydratedProvider(params: { |
| tts: unknown; |
| existingProviderConfig?: ExistingTtsProviderConfig; |
| providerId: string; |
| apiKey: string; |
| }): Record<string, unknown> { |
| const tts = isObjectRecord(params.tts) ? { ...params.tts } : {}; |
| const providers = isObjectRecord(tts.providers) ? { ...tts.providers } : {}; |
| const providerConfigKey = params.existingProviderConfig?.key ?? params.providerId; |
| const nextProviderConfig = { |
| ...(isObjectRecord(params.existingProviderConfig?.value) |
| ? params.existingProviderConfig.value |
| : {}), |
| apiKey: params.apiKey, |
| }; |
| if (params.existingProviderConfig?.container === "direct") { |
| tts[providerConfigKey] = nextProviderConfig; |
| } else { |
| providers[providerConfigKey] = nextProviderConfig; |
| tts.providers = providers; |
| } |
| return tts; |
| } |
|
|
| function ttsProviderConfigHasApiKey(value: unknown): boolean { |
| return isObjectRecord(value) && "apiKey" in value; |
| } |
|
|
| function resolvedTtsConfigHasProviderApiKey(config: unknown, providerId: string): boolean { |
| if (!isObjectRecord(config) || !isObjectRecord(config.providerConfigs)) { |
| return false; |
| } |
| return ttsProviderConfigHasApiKey(config.providerConfigs[providerId]); |
| } |
|
|
| export async function runTtsProviders(transport: CapabilityTransport, rawAgentId?: string) { |
| const cfg = getRuntimeConfig(); |
| if (transport === "gateway") { |
| if (rawAgentId !== undefined) { |
| throw new Error("--agent is only supported with local TTS provider inspection."); |
| } |
| const payload: { |
| providers?: Array<Record<string, unknown>>; |
| active?: string; |
| } = await callGateway({ |
| method: "tts.providers", |
| timeoutMs: 30_000, |
| }); |
| return { |
| ...payload, |
| providers: (payload.providers ?? []).map((provider) => { |
| const id = typeof provider.id === "string" ? provider.id : ""; |
| return Object.assign( |
| { |
| available: true, |
| configured: |
| typeof provider.configured === `boolean` |
| ? provider.configured |
| : providerHasGenericConfig({ cfg, providerId: id }), |
| selected: Boolean(id && payload.active === id), |
| }, |
| provider, |
| ); |
| }), |
| }; |
| } |
| const agentId = resolveCapabilityProviderAgentId(cfg, rawAgentId); |
| const config = resolveTtsConfig(cfg); |
| const prefsPath = resolveTtsPrefsPath(config); |
| const active = getTtsProvider(config, prefsPath); |
| return { |
| providers: listSpeechProviders(cfg).map((provider) => ({ |
| available: true, |
| configured: |
| active === provider.id || |
| providerHasGenericConfig({ cfg, providerId: provider.id, agentId }), |
| selected: active === provider.id, |
| id: provider.id, |
| name: provider.label, |
| models: [...(provider.models ?? [])], |
| voices: [...(provider.voices ?? [])], |
| })), |
| active, |
| }; |
| } |
|
|
| export async function runTtsPersonas(transport: CapabilityTransport) { |
| if (transport === "gateway") { |
| return await callGateway({ |
| method: "tts.personas", |
| timeoutMs: 30_000, |
| }); |
| } |
| const cfg = getRuntimeConfig(); |
| const config = resolveTtsConfig(cfg); |
| const prefsPath = resolveTtsPrefsPath(config); |
| const active = getTtsPersona(config, prefsPath); |
| return { |
| active: active?.id ?? null, |
| personas: listTtsPersonas(config).map((persona) => ({ |
| id: persona.id, |
| label: persona.label, |
| description: persona.description, |
| provider: persona.provider, |
| fallbackPolicy: persona.fallbackPolicy, |
| providers: Object.keys(persona.providers ?? {}), |
| })), |
| }; |
| } |
|
|
| export async function runTtsVoices(providerRaw?: string) { |
| const cfg = await resolveLocalCapabilityRuntimeConfig({ |
| commandName: "infer tts voices", |
| targetIds: getTtsCommandSecretTargetIds(), |
| }); |
| const config = resolveTtsConfig(cfg); |
| const prefsPath = resolveTtsPrefsPath(config); |
| const provider = normalizeOptionalString(providerRaw) || getTtsProvider(config, prefsPath); |
| return await listSpeechVoices({ |
| provider, |
| cfg, |
| config, |
| }); |
| } |
|
|
| export async function runTtsStateMutation(params: { |
| capability: "tts.enable" | "tts.disable" | "tts.set-provider" | "tts.set-persona"; |
| transport: CapabilityTransport; |
| provider?: string; |
| persona?: string | null; |
| }) { |
| if (params.transport === "gateway") { |
| const method = |
| params.capability === "tts.enable" |
| ? "tts.enable" |
| : params.capability === "tts.disable" |
| ? "tts.disable" |
| : params.capability === "tts.set-provider" |
| ? "tts.setProvider" |
| : "tts.setPersona"; |
| const payload = await callGateway({ |
| method, |
| params: |
| params.capability === "tts.set-provider" |
| ? { provider: params.provider } |
| : params.capability === "tts.set-persona" |
| ? { persona: params.persona ?? "off" } |
| : undefined, |
| timeoutMs: 30_000, |
| }); |
| return payload; |
| } |
|
|
| const cfg = getRuntimeConfig(); |
| const config = resolveTtsConfig(cfg); |
| const prefsPath = resolveTtsPrefsPath(config); |
| if (params.capability === "tts.enable") { |
| setTtsEnabled(prefsPath, true); |
| return { enabled: true }; |
| } |
| if (params.capability === "tts.disable") { |
| setTtsEnabled(prefsPath, false); |
| return { enabled: false }; |
| } |
| if (params.capability === "tts.set-persona") { |
| if (!params.persona) { |
| setTtsPersona(prefsPath, null); |
| return { persona: null }; |
| } |
| const persona = listTtsPersonas(config).find( |
| (entry) => entry.id === normalizeLowercaseStringOrEmpty(params.persona ?? ""), |
| ); |
| if (!persona) { |
| throw new Error(`Unknown TTS persona: ${params.persona}`); |
| } |
| setTtsPersona(prefsPath, persona.id); |
| return { persona: persona.id }; |
| } |
| if (!params.provider) { |
| throw new Error("--provider is required"); |
| } |
| const provider = canonicalizeSpeechProviderId(params.provider, cfg); |
| if (!provider) { |
| throw new Error(`Unknown speech provider: ${params.provider}`); |
| } |
| setTtsProvider(prefsPath, provider); |
| return { provider }; |
| } |
|
|