From fcae540d30316762d786d43d765f7faeb88ec612 Mon Sep 17 00:00:00 2001 From: metaforismo <39646696+metaforismo@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:27:05 +0200 Subject: [PATCH] fix(gateway): yield before TTS status diagnostics (#119788) * fix(gateway): yield before TTS status diagnostics * fix(tts): preserve aliases outside provider inventory * test(tts): isolate inventory alias regression * fix(gateway): reuse prepared TTS provider facts * fix(tts): drop unused core barrel export --------- Co-authored-by: Patrick Erichsen --- src/gateway/server-methods/tts.test.ts | 178 ++++++++++++++++++++++++- src/gateway/server-methods/tts.ts | 77 +++++++---- src/tts/provider-registry.test.ts | 141 +++++++++++++++++++- src/tts/tts-config.test.ts | 29 ++++ src/tts/tts-provider-resolution.ts | 137 +++++++++++++++++-- src/tts/tts-settings.ts | 27 +++- src/tts/tts.ts | 1 - 7 files changed, 539 insertions(+), 51 deletions(-) diff --git a/src/gateway/server-methods/tts.test.ts b/src/gateway/server-methods/tts.test.ts index 4efa6d6d0db6..05c4bf366582 100644 --- a/src/gateway/server-methods/tts.test.ts +++ b/src/gateway/server-methods/tts.test.ts @@ -10,16 +10,20 @@ import { expectGatewayErrorResponse } from "./gateway-response.test-helpers.js"; const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn(() => ({})), - isTtsProviderConfigured: vi.fn((_config: unknown, _provider: string) => true), + getSpeechProvider: vi.fn(), + isTtsProviderConfigured: vi.fn((_config: unknown, _provider: string | { id: string }) => true), listSpeechProviders: vi.fn( (): Array<{ id: string; label: string; isConfigured: () => boolean; + aliases?: readonly string[]; + autoSelectOrder?: number; models?: readonly string[]; voices?: readonly string[]; }> => [], ), + resolveTtsSettingsSnapshot: vi.fn(), resolveTtsProviderOrder: vi.fn(() => ["openai"]), resolveExplicitTtsOverrides: vi.fn(() => ({})), resolveTtsConfig: vi.fn(() => ({ maxTextLength: 4096 })), @@ -55,10 +59,15 @@ vi.mock("../../config/config.js", () => ({ vi.mock("../../tts/provider-registry.js", () => ({ canonicalizeSpeechProviderId: vi.fn(), - getSpeechProvider: vi.fn(), + getSpeechProvider: mocks.getSpeechProvider, listSpeechProviders: mocks.listSpeechProviders, })); +vi.mock("../../tts/tts-settings.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveTtsSettingsSnapshot: mocks.resolveTtsSettingsSnapshot, +})); + vi.mock("../../tts/tts.js", () => ({ getResolvedSpeechProviderConfig: vi.fn(), getTtsPersona: vi.fn(() => undefined), @@ -84,6 +93,7 @@ describe("ttsHandlers", () => { setActiveDegradedSecretOwners([]); mocks.getRuntimeConfig.mockReset(); mocks.getRuntimeConfig.mockReturnValue({}); + mocks.getSpeechProvider.mockReset(); mocks.isTtsProviderConfigured.mockReset(); mocks.isTtsProviderConfigured.mockReturnValue(true); mocks.listSpeechProviders.mockReset(); @@ -92,6 +102,21 @@ describe("ttsHandlers", () => { mocks.resolveTtsProviderOrder.mockReturnValue(["openai"]); mocks.resolveExplicitTtsOverrides.mockReset(); mocks.resolveExplicitTtsOverrides.mockReturnValue({}); + mocks.resolveTtsSettingsSnapshot.mockReset(); + mocks.resolveTtsSettingsSnapshot.mockReturnValue({ + autoMode: "off", + config: { + maxTextLength: 4096, + provider: "openai", + providerSource: "config", + sourceConfig: {}, + }, + maxLength: 1500, + prefsPath: "/tmp/tts.json", + preferredProvider: "openai", + providerPreference: { provider: "openai", source: "config" }, + summarize: true, + }); mocks.resolveTtsConfig.mockReset(); mocks.resolveTtsConfig.mockReturnValue({ maxTextLength: 4096 }); mocks.synthesizeSpeech.mockReset(); @@ -112,6 +137,148 @@ describe("ttsHandlers", () => { }); }); + it("yields before TTS status setup and reuses one configured-state pass", async () => { + const providers = [ + { id: "openai", label: "OpenAI", isConfigured: vi.fn(() => true) }, + { id: "google", label: "Google", isConfigured: vi.fn(() => true) }, + ]; + mocks.listSpeechProviders.mockReturnValue(providers); + mocks.resolveTtsProviderOrder.mockReturnValue(["openai", "google"]); + + const { ttsHandlers } = await import("./tts.js"); + const respond = vi.fn(); + const statusPromise = expectDefined( + ttsHandlers["tts.status"], + 'ttsHandlers["tts.status"] test invariant', + )({ + params: {}, + respond, + context: { getRuntimeConfig: mocks.getRuntimeConfig }, + } as never); + + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); + expect(mocks.listSpeechProviders).not.toHaveBeenCalled(); + + await statusPromise; + + expect(mocks.listSpeechProviders).toHaveBeenCalledOnce(); + expect(mocks.resolveTtsProviderOrder).toHaveBeenCalledWith("openai", {}, providers); + expect(mocks.isTtsProviderConfigured).toHaveBeenCalledTimes(2); + expect(mocks.isTtsProviderConfigured.mock.calls.map((call) => call[1])).toEqual(providers); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + provider: "openai", + fallbackProvider: "google", + fallbackProviders: ["google"], + providerStates: [ + { id: "openai", label: "OpenAI", configured: true }, + { id: "google", label: "Google", configured: true }, + ], + }), + ); + }); + + it.each([ + { method: "tts.status", providerField: "provider" }, + { method: "tts.providers", providerField: "active" }, + ] as const)( + "$method reuses one provider pass for automatic primary selection", + async ({ method, providerField }) => { + const providers = [ + { + id: "openai", + label: "OpenAI", + autoSelectOrder: 1, + isConfigured: vi.fn(() => false), + }, + { + id: "google", + label: "Google", + autoSelectOrder: 2, + isConfigured: vi.fn(() => true), + }, + ]; + mocks.listSpeechProviders.mockReturnValue(providers); + mocks.isTtsProviderConfigured.mockImplementation((_config, provider) => { + const providerId = typeof provider === "string" ? provider : provider.id; + return providerId === "google"; + }); + mocks.resolveTtsProviderOrder.mockReturnValue(["google", "openai"]); + mocks.resolveTtsSettingsSnapshot.mockReturnValue({ + autoMode: "off", + config: { + maxTextLength: 4096, + provider: "", + providerSource: "default", + sourceConfig: {}, + }, + maxLength: 1500, + prefsPath: "/tmp/tts-auto.json", + summarize: true, + }); + + const { ttsHandlers } = await import("./tts.js"); + const respond = vi.fn(); + await expectDefined( + ttsHandlers[method], + `ttsHandlers[${method}] test invariant`, + )({ + params: {}, + respond, + context: { getRuntimeConfig: mocks.getRuntimeConfig }, + } as never); + + expect(mocks.listSpeechProviders).toHaveBeenCalledOnce(); + expect(mocks.isTtsProviderConfigured.mock.calls.map((call) => call[1])).toEqual(providers); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ [providerField]: "google" }), + ); + }, + ); + + it("probes configured fallbacks missing from the prepared inventory", async () => { + const providers = [ + { id: "openai", label: "OpenAI", isConfigured: vi.fn(() => true) }, + { id: "google", label: "Google", isConfigured: vi.fn(() => false) }, + ]; + mocks.listSpeechProviders.mockReturnValue(providers); + mocks.resolveTtsProviderOrder.mockReturnValue(["openai", "google", "voice-model-only"]); + mocks.isTtsProviderConfigured.mockImplementation((_config, provider) => { + const providerId = typeof provider === "string" ? provider : provider.id; + return providerId !== "google"; + }); + + const { ttsHandlers } = await import("./tts.js"); + const respond = vi.fn(); + await expectDefined( + ttsHandlers["tts.status"], + 'ttsHandlers["tts.status"] test invariant', + )({ + params: {}, + respond, + context: { getRuntimeConfig: mocks.getRuntimeConfig }, + } as never); + + expect(mocks.isTtsProviderConfigured.mock.calls.map((call) => call[1])).toEqual([ + providers[0], + providers[1], + "voice-model-only", + ]); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + fallbackProvider: "voice-model-only", + fallbackProviders: ["voice-model-only"], + providerStates: [ + { id: "openai", label: "OpenAI", configured: true }, + { id: "google", label: "Google", configured: false }, + ], + }), + ); + }); + it.each(["tts.status", "tts.providers"] as const)( "%s keeps invalid providers in the catalog as unconfigured", async (method) => { @@ -124,9 +291,10 @@ describe("ttsHandlers", () => { }; mocks.listSpeechProviders.mockReturnValue([invalidProvider]); mocks.resolveTtsProviderOrder.mockReturnValue(["openai", "gradium"]); - mocks.isTtsProviderConfigured.mockImplementation( - (_config, provider) => provider !== "gradium", - ); + mocks.isTtsProviderConfigured.mockImplementation((_config, provider) => { + const providerId = typeof provider === "string" ? provider : provider.id; + return providerId !== "gradium"; + }); const { ttsHandlers } = await import("./tts.js"); const respond = vi.fn(); diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index 08a5ccaf7317..116b7ab7098e 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -5,6 +5,7 @@ import { errorShape, validateTtsSpeakParams, } from "../../../packages/gateway-protocol/src/index.js"; +import type { OpenClawConfig } from "../../config/types.js"; import { assertSecretOwnerAvailable, SecretSurfaceUnavailableError, @@ -14,14 +15,13 @@ import { getSpeechProvider, listSpeechProviders, } from "../../tts/provider-registry.js"; +import { resolvePreparedTtsProvider } from "../../tts/tts-provider-resolution.js"; +import { resolveTtsSettingsSnapshot } from "../../tts/tts-settings.js"; import { getTtsPersona, - getTtsProvider, - isTtsEnabled, isTtsProviderConfigured, listTtsPersonas, resolveExplicitTtsOverrides, - resolveTtsAutoMode, resolveTtsConfig, resolveTtsPrefsPath, resolveTtsProviderOrder, @@ -36,32 +36,58 @@ import { inferSpeechMimeType } from "./speech-mime.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; +function yieldBeforeTtsStatusSetup(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + +function resolveTtsGatewayStatusFacts(cfg: OpenClawConfig) { + const settings = resolveTtsSettingsSnapshot({ cfg }); + const speechProviders = listSpeechProviders(cfg); + const configuredByProvider = new Map( + speechProviders.map( + (provider) => [provider.id, isTtsProviderConfigured(settings.config, provider, cfg)] as const, + ), + ); + const provider = resolvePreparedTtsProvider({ + config: settings.config, + preference: settings.providerPreference, + providers: speechProviders, + configuredByProvider, + }); + return { configuredByProvider, provider, settings, speechProviders }; +} + /** Gateway request handlers for TTS status, preference mutation, and synthesis. */ export const ttsHandlers: GatewayRequestHandlers = { "tts.status": async ({ respond, context }) => { try { + await yieldBeforeTtsStatusSetup(); const cfg = context.getRuntimeConfig(); - const config = resolveTtsConfig(cfg); - const prefsPath = resolveTtsPrefsPath(config); - const provider = getTtsProvider(config, prefsPath); - const persona = getTtsPersona(config, prefsPath); - const autoMode = resolveTtsAutoMode({ config, prefsPath }); - const fallbackProviders = resolveTtsProviderOrder(provider, cfg) + const { configuredByProvider, provider, settings, speechProviders } = + resolveTtsGatewayStatusFacts(cfg); + const fallbackProviders = resolveTtsProviderOrder(provider, cfg, speechProviders) .slice(1) - .filter((candidate) => isTtsProviderConfigured(config, candidate, cfg)); + .filter((candidate) => { + if (configuredByProvider.has(candidate)) { + return configuredByProvider.get(candidate) === true; + } + return isTtsProviderConfigured(settings.config, candidate, cfg); + }); // Report configured state per provider so the UI can explain why fallback // order differs from the complete provider registry. - const providerStates = listSpeechProviders(cfg).map((candidate) => ({ + const providerStates = speechProviders.map((candidate) => ({ id: candidate.id, label: candidate.label, - configured: isTtsProviderConfigured(config, candidate.id, cfg), + configured: configuredByProvider.get(candidate.id) === true, })); respond(true, { - enabled: isTtsEnabled(config, prefsPath), - auto: autoMode, + enabled: settings.autoMode !== "off", + auto: settings.autoMode, provider, - persona: persona?.id ?? null, - personas: listTtsPersonas(config).map((entry) => ({ + persona: settings.persona?.id ?? null, + personas: listTtsPersonas(settings.config).map((entry) => ({ id: entry.id, label: entry.label, description: entry.description, @@ -69,7 +95,7 @@ export const ttsHandlers: GatewayRequestHandlers = { })), fallbackProvider: fallbackProviders[0] ?? null, fallbackProviders, - prefsPath, + prefsPath: settings.prefsPath, providerStates, }); } catch (err) { @@ -301,17 +327,16 @@ export const ttsHandlers: GatewayRequestHandlers = { "tts.providers": async ({ respond, context }) => { try { const cfg = context.getRuntimeConfig(); - const config = resolveTtsConfig(cfg); - const prefsPath = resolveTtsPrefsPath(config); + const { configuredByProvider, provider, speechProviders } = resolveTtsGatewayStatusFacts(cfg); respond(true, { - providers: listSpeechProviders(cfg).map((provider) => ({ - id: provider.id, - name: provider.label, - configured: isTtsProviderConfigured(config, provider.id, cfg), - models: [...(provider.models ?? [])], - voices: [...(provider.voices ?? [])], + providers: speechProviders.map((candidate) => ({ + id: candidate.id, + name: candidate.label, + configured: configuredByProvider.get(candidate.id) === true, + models: [...(candidate.models ?? [])], + voices: [...(candidate.voices ?? [])], })), - active: getTtsProvider(config, prefsPath), + active: provider, }); } catch (err) { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); diff --git a/src/tts/provider-registry.test.ts b/src/tts/provider-registry.test.ts index cde1bffb9da1..7df00e597209 100644 --- a/src/tts/provider-registry.test.ts +++ b/src/tts/provider-registry.test.ts @@ -1,11 +1,33 @@ // TTS provider registry tests cover registration and provider resolution. -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.js"; import type { SpeechProviderPlugin } from "../plugins/types.js"; import { createSpeechProviderRegistry, normalizeSpeechProviderId, } from "./provider-registry-core.js"; +import { + isTtsProviderConfigured, + resolvePreparedTtsProvider, + resolveTtsProviderOrder, +} from "./tts-provider-resolution.js"; +import { resolveTtsConfig } from "./tts-settings.js"; + +const mocks = vi.hoisted(() => ({ + canonicalizeSpeechProviderId: vi.fn((providerId: string | undefined) => { + const normalized = providerId?.trim().toLowerCase(); + return normalized === "edge" ? "microsoft" : normalized || undefined; + }), + getSpeechProvider: vi.fn(), + listSpeechProviders: vi.fn(), +})); + +vi.mock("./provider-registry.js", async (importOriginal) => ({ + ...(await importOriginal()), + canonicalizeSpeechProviderId: mocks.canonicalizeSpeechProviderId, + getSpeechProvider: mocks.getSpeechProvider, + listSpeechProviders: mocks.listSpeechProviders, +})); function createSpeechProvider(id: string, aliases?: string[]): SpeechProviderPlugin { return { @@ -30,6 +52,9 @@ describe("speech provider registry", () => { let registry: ReturnType; beforeEach(() => { + mocks.canonicalizeSpeechProviderId.mockClear(); + mocks.getSpeechProvider.mockReset(); + mocks.listSpeechProviders.mockReset(); providers = []; directProvider = undefined; getProviderCalls.length = 0; @@ -71,6 +96,120 @@ describe("speech provider registry", () => { expect(registry.canonicalizeSpeechProviderId("edge")).toBe("microsoft"); }); + it("resolves fallback order and aliases from a supplied provider inventory", () => { + const inventory = [ + { ...createSpeechProvider("openai", ["oai"]), autoSelectOrder: 5 }, + { ...createSpeechProvider("google"), autoSelectOrder: 1 }, + { ...createSpeechProvider("elevenlabs"), autoSelectOrder: 3 }, + ]; + + expect( + resolveTtsProviderOrder( + " OAI " as Parameters[0], + undefined, + inventory, + ), + ).toEqual(["openai", "google", "elevenlabs"]); + }); + + it("selects the first configured provider entirely from prepared facts", () => { + const openaiConfigured = vi.fn(() => false); + const googleConfigured = vi.fn(() => true); + const inventory = [ + { ...createSpeechProvider("openai"), autoSelectOrder: 1, isConfigured: openaiConfigured }, + { ...createSpeechProvider("google"), autoSelectOrder: 2, isConfigured: googleConfigured }, + ]; + + expect( + resolvePreparedTtsProvider({ + config: resolveTtsConfig({}), + providers: inventory, + configuredByProvider: new Map([ + ["openai", false], + ["google", true], + ]), + }), + ).toBe("google"); + expect(mocks.listSpeechProviders).not.toHaveBeenCalled(); + expect(openaiConfigured).not.toHaveBeenCalled(); + expect(googleConfigured).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "persisted aliases", + preference: { provider: "oai", source: "prefs" } as const, + inventory: [createSpeechProvider("openai", ["oai"])], + expected: "openai", + }, + { + name: "configured providers", + preference: { provider: "custom", source: "config" } as const, + inventory: [], + expected: "custom", + }, + ])("preserves $name in prepared selection", ({ preference, inventory, expected }) => { + expect( + resolvePreparedTtsProvider({ + config: resolveTtsConfig({}), + preference, + providers: inventory, + configuredByProvider: new Map(), + }), + ).toBe(expected); + }); + + it("keeps persona selection conditional on provider availability", () => { + const availablePersonaProvider = createSpeechProvider("persona-provider"); + mocks.getSpeechProvider.mockReturnValueOnce(availablePersonaProvider); + const config = resolveTtsConfig({}); + const preference = { provider: "persona-provider", source: "persona" } as const; + + expect( + resolvePreparedTtsProvider({ + config, + preference, + providers: [], + configuredByProvider: new Map(), + }), + ).toBe("persona-provider"); + expect( + resolvePreparedTtsProvider({ + config, + preference, + providers: [createSpeechProvider("fallback")], + configuredByProvider: new Map([["fallback", true]]), + }), + ).toBe("fallback"); + }); + + it("uses prepared provider objects for configuration without registry rediscovery", () => { + const resolveConfig = vi.fn(() => ({})); + const isConfigured = vi.fn(() => true); + const provider = { + ...createSpeechProvider("openai"), + resolveConfig, + isConfigured, + }; + const cfg = {} as OpenClawConfig; + + expect(isTtsProviderConfigured(resolveTtsConfig(cfg), provider, cfg)).toBe(true); + expect(resolveConfig).toHaveBeenCalledOnce(); + expect(isConfigured).toHaveBeenCalledOnce(); + expect(mocks.canonicalizeSpeechProviderId).not.toHaveBeenCalled(); + expect(mocks.getSpeechProvider).not.toHaveBeenCalled(); + }); + + it("canonicalizes a voice-model alias omitted from the supplied inventory", () => { + const inventory = [createSpeechProvider("openai")]; + const cfg = { + agents: { defaults: { voiceModel: { primary: "edge/edge-tts" } } }, + } as OpenClawConfig; + + expect(resolveTtsProviderOrder("openai", cfg, inventory)).toEqual(["openai", "microsoft"]); + expect(mocks.canonicalizeSpeechProviderId).toHaveBeenCalledWith("edge", expect.any(Object)); + }); + it("returns empty results when the capability runtime has no speech providers", () => { expect(registry.listSpeechProviders()).toStrictEqual([]); expect(registry.getSpeechProvider("demo-speech")).toBeUndefined(); diff --git a/src/tts/tts-config.test.ts b/src/tts/tts-config.test.ts index fdfe46be29c5..860229f4a15b 100644 --- a/src/tts/tts-config.test.ts +++ b/src/tts/tts-config.test.ts @@ -10,6 +10,7 @@ import { resolveEffectiveTtsConfig, shouldAttemptTtsPayload, } from "./tts-config.js"; +import { resolveTtsSettingsSnapshot } from "./tts-settings.js"; describe("shouldAttemptTtsPayload", () => { let envSnapshot: ReturnType | undefined; @@ -76,6 +77,34 @@ describe("shouldAttemptTtsPayload", () => { ); }); + it("records the selected provider preference source", () => { + const cfg = { + tts: { + provider: "openai", + persona: "reader", + personas: { + reader: { provider: "google" }, + }, + }, + } as OpenClawConfig; + + expect(resolveTtsSettingsSnapshot({ cfg }).providerPreference).toEqual({ + provider: "google", + source: "persona", + }); + + writeFileSync(prefsPath, JSON.stringify({ tts: { provider: "edge" } })); + expect(resolveTtsSettingsSnapshot({ cfg }).providerPreference).toEqual({ + provider: "microsoft", + source: "prefs", + }); + + writeFileSync(prefsPath, "{}"); + expect( + resolveTtsSettingsSnapshot({ cfg: { tts: { provider: "openai" } } }).providerPreference, + ).toEqual({ provider: "openai", source: "config" }); + }); + it("uses per-agent TTS auto and mode overrides", () => { const cfg = { tts: { diff --git a/src/tts/tts-provider-resolution.ts b/src/tts/tts-provider-resolution.ts index 06317b038f63..5513c750c2d4 100644 --- a/src/tts/tts-provider-resolution.ts +++ b/src/tts/tts-provider-resolution.ts @@ -28,6 +28,7 @@ import { resolveTtsPersonaFromPrefs, resolveTtsRuntimeConfig, type ResolvedTtsConfig, + type TtsProviderPreference, } from "./tts-settings.js"; import { resolvePrimaryVoiceProviderCandidate, @@ -60,8 +61,11 @@ export function resolveSpeechProviderTimeoutMs(params: { return resolvePositiveTimeoutMs(params.provider.defaultTimeoutMs) ?? params.config.timeoutMs; } -function sortSpeechProvidersForAutoSelection(cfg?: OpenClawConfig) { - return listSpeechProviders(cfg).toSorted((left, right) => { +function sortSpeechProvidersForAutoSelection( + cfg?: OpenClawConfig, + providers?: readonly SpeechProviderPlugin[], +) { + return [...(providers ?? listSpeechProviders(cfg))].toSorted((left, right) => { const leftOrder = left.autoSelectOrder ?? Number.MAX_SAFE_INTEGER; const rightOrder = right.autoSelectOrder ?? Number.MAX_SAFE_INTEGER; if (leftOrder !== rightOrder) { @@ -71,11 +75,36 @@ function sortSpeechProvidersForAutoSelection(cfg?: OpenClawConfig) { }); } -function resolveConfiguredSpeechVoiceModelRefs(cfg: OpenClawConfig | undefined): VoiceModelRef[] { +function canonicalizeSpeechProviderIdFromInventory( + providerId: string | undefined, + cfg?: OpenClawConfig, + providers?: readonly SpeechProviderPlugin[], +): string | undefined { + const normalized = normalizeSpeechProviderId(providerId); + if (!normalized) { + return undefined; + } + if (!providers) { + return canonicalizeSpeechProviderId(providerId, cfg); + } + const inventoryProvider = providers.find( + (provider) => + normalizeSpeechProviderId(provider.id) === normalized || + provider.aliases?.some((alias) => normalizeSpeechProviderId(alias) === normalized), + ); + // A prepared inventory can omit voice-model-only providers. Preserve the + // registry's public alias contract on misses instead of exposing an alias. + return inventoryProvider?.id ?? canonicalizeSpeechProviderId(providerId, cfg) ?? normalized; +} + +function resolveConfiguredSpeechVoiceModelRefs( + cfg: OpenClawConfig | undefined, + providers?: readonly SpeechProviderPlugin[], +): VoiceModelRef[] { const effectiveCfg = cfg ? resolveTtsRuntimeConfig(cfg) : undefined; return resolveSupportedVoiceModelRefs({ config: effectiveCfg?.agents?.defaults?.voiceModel, - providers: sortSpeechProvidersForAutoSelection(effectiveCfg), + providers: sortSpeechProvidersForAutoSelection(effectiveCfg, providers), }); } @@ -187,6 +216,7 @@ function resolveLazyProviderConfig( providerId: string, cfg?: OpenClawConfig, voiceModel?: VoiceModelRef, + provider?: SpeechProviderPlugin, ): SpeechProviderConfig { const canonical = normalizeConfiguredSpeechProviderId(providerId) ?? normalizeLowercaseStringOrEmpty(providerId); @@ -198,7 +228,7 @@ function resolveLazyProviderConfig( const rawConfig = resolveRawProviderConfig(config.rawConfig, canonical); const rawBaseConfig = config.rawConfig as Record | undefined; const rawProviders = asProviderConfigMap(config.rawConfig?.providers); - const resolvedProvider = getSpeechProvider(canonical, effectiveCfg); + const resolvedProvider = provider ?? getSpeechProvider(canonical, effectiveCfg); let hasRawProviderConfig = Object.hasOwn(rawProviders, canonical) || (rawBaseConfig ? Object.hasOwn(rawBaseConfig, canonical) : false); @@ -274,6 +304,23 @@ export function getResolvedSpeechProviderConfig( return resolveLazyProviderConfig(config, canonical, effectiveCfg); } +function getResolvedSpeechProviderConfigFromInventory(params: { + config: ResolvedTtsConfig; + provider: SpeechProviderPlugin; + cfg?: OpenClawConfig; +}): SpeechProviderConfig { + const effectiveCfg = params.cfg + ? resolveTtsRuntimeConfig(params.cfg) + : params.config.sourceConfig; + return resolveLazyProviderConfig( + params.config, + params.provider.id, + effectiveCfg, + undefined, + params.provider, + ); +} + export function getResolvedSpeechProviderConfigForVoiceModel(params: { config: ResolvedTtsConfig; providerId: string; @@ -324,17 +371,75 @@ export function resolveTtsProvider(config: ResolvedTtsConfig, prefsPath: string) return config.provider; } -export function resolveTtsProviderOrder(primary: TtsProvider, cfg?: OpenClawConfig): TtsProvider[] { +export function resolvePreparedTtsProvider(params: { + config: ResolvedTtsConfig; + preference?: TtsProviderPreference; + providers: readonly SpeechProviderPlugin[]; + configuredByProvider: ReadonlyMap; +}): TtsProvider { + const effectiveCfg = params.config.sourceConfig; + if (params.preference?.source === "prefs") { + return ( + canonicalizeSpeechProviderIdFromInventory( + params.preference.provider, + effectiveCfg, + params.providers, + ) ?? params.preference.provider + ); + } + if (params.preference?.source === "persona") { + const preferredProvider = params.preference.provider; + const inventoryProvider = params.providers.find( + (provider) => + normalizeSpeechProviderId(provider.id) === normalizeSpeechProviderId(preferredProvider) || + provider.aliases?.some( + (alias) => + normalizeSpeechProviderId(alias) === normalizeSpeechProviderId(preferredProvider), + ), + ); + const personaProvider = inventoryProvider ?? getSpeechProvider(preferredProvider, effectiveCfg); + if (personaProvider) { + return personaProvider.id; + } + } + if (params.preference?.source === "config") { + return ( + normalizeConfiguredSpeechProviderId(params.preference.provider) ?? params.preference.provider + ); + } + const configuredVoiceProvider = resolveConfiguredSpeechVoiceModelRefs( + effectiveCfg, + params.providers, + )[0]?.provider; + if (configuredVoiceProvider) { + return configuredVoiceProvider; + } + for (const provider of sortSpeechProvidersForAutoSelection(effectiveCfg, params.providers)) { + if (params.configuredByProvider.get(provider.id) === true) { + return provider.id; + } + } + return params.config.provider; +} + +export function resolveTtsProviderOrder( + primary: TtsProvider, + cfg?: OpenClawConfig, + providers?: readonly SpeechProviderPlugin[], +): TtsProvider[] { const effectiveCfg = cfg ? resolveTtsRuntimeConfig(cfg) : undefined; - const normalizedPrimary = canonicalizeSpeechProviderId(primary, effectiveCfg) ?? primary; + const normalizedPrimary = + canonicalizeSpeechProviderIdFromInventory(primary, effectiveCfg, providers) ?? primary; const ordered = new Set([normalizedPrimary]); for (const ref of resolveVoiceModelRefs(effectiveCfg?.agents?.defaults?.voiceModel)) { - const provider = canonicalizeSpeechProviderId(ref.provider, effectiveCfg) ?? ref.provider; + const provider = + canonicalizeSpeechProviderIdFromInventory(ref.provider, effectiveCfg, providers) ?? + ref.provider; if (provider !== normalizedPrimary) { ordered.add(provider); } } - for (const provider of sortSpeechProvidersForAutoSelection(effectiveCfg)) { + for (const provider of sortSpeechProvidersForAutoSelection(effectiveCfg, providers)) { const normalized = provider.id; if (normalized !== normalizedPrimary) { ordered.add(normalized); @@ -370,19 +475,27 @@ export function resolvePrimaryTtsProviderCandidate( export function isTtsProviderConfigured( config: ResolvedTtsConfig, - provider: TtsProvider, + provider: TtsProvider | SpeechProviderPlugin, cfg?: OpenClawConfig, ): boolean { try { const effectiveCfg = cfg ? resolveTtsRuntimeConfig(cfg) : config.sourceConfig; - const resolvedProvider = getSpeechProvider(provider, effectiveCfg); + const resolvedProvider = + typeof provider === "string" ? getSpeechProvider(provider, effectiveCfg) : provider; if (!resolvedProvider) { return false; } return ( resolvedProvider.isConfigured({ cfg: effectiveCfg, - providerConfig: getResolvedSpeechProviderConfig(config, resolvedProvider.id, effectiveCfg), + providerConfig: + typeof provider === "string" + ? getResolvedSpeechProviderConfig(config, resolvedProvider.id, effectiveCfg) + : getResolvedSpeechProviderConfigFromInventory({ + config, + provider: resolvedProvider, + cfg: effectiveCfg, + }), timeoutMs: resolveSpeechProviderTimeoutMs({ config, provider: resolvedProvider }), }) ?? false ); diff --git a/src/tts/tts-settings.ts b/src/tts/tts-settings.ts index b2b93f03bfb5..b851a488f9ec 100644 --- a/src/tts/tts-settings.ts +++ b/src/tts/tts-settings.ts @@ -295,6 +295,11 @@ export function resolveTtsPersonaFromPrefs( return personaId ? config.personas[personaId] : undefined; } +export type TtsProviderPreference = { + provider: TtsProvider; + source: "prefs" | "persona" | "config"; +}; + type ResolvedTtsSettingsSnapshot = { autoMode: TtsAutoMode; config: ResolvedTtsConfig; @@ -302,6 +307,7 @@ type ResolvedTtsSettingsSnapshot = { persona?: ResolvedTtsPersona; personaId?: string; preferredProvider?: TtsProvider; + providerPreference?: TtsProviderPreference; prefsPath: string; summarize: boolean; }; @@ -322,12 +328,19 @@ export function resolveTtsSettingsSnapshot(params: { const prefs = readTtsPrefs(prefsPath); const personaId = resolveTtsPersonaIdFromPrefs(config, prefs); const persona = personaId ? config.personas[personaId] : undefined; - const preferredProvider = - normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ?? - normalizeConfiguredSpeechProviderId(persona?.provider) ?? - (config.providerSource === "config" + const prefsProvider = normalizeConfiguredSpeechProviderId(prefs.tts?.provider); + const personaProvider = normalizeConfiguredSpeechProviderId(persona?.provider); + const configuredProvider = + config.providerSource === "config" ? (normalizeConfiguredSpeechProviderId(config.provider) ?? config.provider) - : undefined); + : undefined; + const providerPreference: TtsProviderPreference | undefined = prefsProvider + ? { provider: prefsProvider, source: "prefs" } + : personaProvider + ? { provider: personaProvider, source: "persona" } + : configuredProvider + ? { provider: configuredProvider, source: "config" } + : undefined; return { autoMode: normalizeTtsAutoMode(params.sessionAuto) ?? resolveTtsAutoModeFromPrefs(prefs) ?? config.auto, @@ -335,7 +348,9 @@ export function resolveTtsSettingsSnapshot(params: { maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH, ...(persona ? { persona } : {}), ...(personaId ? { personaId } : {}), - ...(preferredProvider ? { preferredProvider } : {}), + ...(providerPreference + ? { preferredProvider: providerPreference.provider, providerPreference } + : {}), prefsPath, summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE, }; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 930ed326435c..22b7b9321d8c 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -35,7 +35,6 @@ export { listSpeechVoices, listTtsPersonas, resolveExplicitTtsOverrides, - resolveTtsAutoMode, resolveTtsConfig, resolveTtsPrefsPath, resolveTtsProviderOrder,