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 <patrick.a.erichsen@gmail.com>
This commit is contained in:
metaforismo
2026-08-07 05:27:05 +02:00
committed by GitHub
parent 7d039fb424
commit fcae540d30
7 changed files with 539 additions and 51 deletions
+173 -5
View File
@@ -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<typeof import("../../tts/tts-settings.js")>()),
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();
+51 -26
View File
@@ -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<void> {
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)));
+140 -1
View File
@@ -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<typeof import("./provider-registry.js")>()),
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<typeof createSpeechProviderRegistry>;
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<typeof resolveTtsProviderOrder>[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();
+29
View File
@@ -10,6 +10,7 @@ import {
resolveEffectiveTtsConfig,
shouldAttemptTtsPayload,
} from "./tts-config.js";
import { resolveTtsSettingsSnapshot } from "./tts-settings.js";
describe("shouldAttemptTtsPayload", () => {
let envSnapshot: ReturnType<typeof captureEnv> | 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: {
+125 -12
View File
@@ -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<string, unknown> | 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<string, boolean>;
}): 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<TtsProvider>([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
);
+21 -6
View File
@@ -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,
};
-1
View File
@@ -35,7 +35,6 @@ export {
listSpeechVoices,
listTtsPersonas,
resolveExplicitTtsOverrides,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,
resolveTtsProviderOrder,