mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ export {
|
||||
listSpeechVoices,
|
||||
listTtsPersonas,
|
||||
resolveExplicitTtsOverrides,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
resolveTtsProviderOrder,
|
||||
|
||||
Reference in New Issue
Block a user