TTS: add backend fallback policy

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 21:34:14 +00:00
parent 6f8408b813
commit 1b87cdeca1
6 changed files with 182 additions and 16 deletions

View File

@ -43,3 +43,22 @@ export function resolveExtensionHostRuntimeBackendOrderByArbitration(params: {
...ordered.filter((backendId) => backendId !== params.preferredBackendId),
];
}
export function resolveExtensionHostRuntimeBackendFallbackChainByArbitration(params: {
entries: readonly ExtensionHostRuntimeBackendCatalogEntry[];
subsystemId: ExtensionHostRuntimeBackendSubsystemId;
preferredBackendId: string;
include?: ExtensionHostRuntimeBackendArbitrationPredicate;
}): readonly string[] {
return resolveExtensionHostRuntimeBackendOrderByArbitration(params);
}
export function resolveExtensionHostDefaultRuntimeBackendIdByArbitration(params: {
entries: readonly ExtensionHostRuntimeBackendCatalogEntry[];
subsystemId: ExtensionHostRuntimeBackendSubsystemId;
include?: ExtensionHostRuntimeBackendArbitrationPredicate;
fallbackBackendId?: string;
}): string | undefined {
const ordered = listExtensionHostRuntimeBackendIdsByArbitration(params);
return ordered[0] ?? params.fallbackBackendId;
}

View File

@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveExtensionHostDefaultTtsProvider,
resolveExtensionHostTtsFallbackProviders,
} from "./tts-runtime-policy.js";
vi.mock("./runtime-backend-catalog.js", () => ({
listExtensionHostTtsRuntimeBackendCatalogEntries: vi.fn(() => [
{
id: "capability.runtime-backend:tts:openai",
family: "capability.runtime-backend",
subsystemId: "tts",
backendId: "openai",
source: "builtin",
defaultRank: 0,
selectorKeys: ["openai"],
capabilities: ["tts.synthesis", "tts.telephony"],
},
{
id: "capability.runtime-backend:tts:elevenlabs",
family: "capability.runtime-backend",
subsystemId: "tts",
backendId: "elevenlabs",
source: "builtin",
defaultRank: 1,
selectorKeys: ["elevenlabs"],
capabilities: ["tts.synthesis", "tts.telephony"],
},
{
id: "capability.runtime-backend:tts:edge",
family: "capability.runtime-backend",
subsystemId: "tts",
backendId: "edge",
source: "builtin",
defaultRank: 2,
selectorKeys: ["edge"],
capabilities: ["tts.synthesis"],
},
]),
}));
vi.mock("./tts-runtime-registry.js", () => ({
isExtensionHostTtsProviderConfigured: vi.fn(
(
config: {
configured?: string[];
},
provider: string,
) => config.configured?.includes(provider) ?? false,
),
}));
describe("tts-runtime-policy", () => {
it("selects the highest-ranked configured provider by default", () => {
expect(
resolveExtensionHostDefaultTtsProvider({
configured: ["elevenlabs", "edge"],
} as never),
).toBe("elevenlabs");
});
it("falls back to edge when no configured provider is available", () => {
expect(resolveExtensionHostDefaultTtsProvider({ configured: [] } as never)).toBe("edge");
});
it("keeps the preferred provider first while filtering fallback providers by configuration", () => {
expect(
resolveExtensionHostTtsFallbackProviders({
config: { configured: ["openai", "edge"] } as never,
preferredProvider: "elevenlabs",
}),
).toEqual(["elevenlabs", "openai", "edge"]);
});
});

View File

@ -0,0 +1,39 @@
import type { TtsProvider } from "../config/types.tts.js";
import {
resolveExtensionHostDefaultRuntimeBackendIdByArbitration,
resolveExtensionHostRuntimeBackendFallbackChainByArbitration,
} from "./runtime-backend-arbitration.js";
import {
listExtensionHostTtsRuntimeBackendCatalogEntries,
type ExtensionHostRuntimeBackendCatalogEntry,
} from "./runtime-backend-catalog.js";
import type { ResolvedTtsConfig } from "./tts-config.js";
import { isExtensionHostTtsProviderConfigured } from "./tts-runtime-registry.js";
function isConfiguredTtsRuntimeBackend(
config: ResolvedTtsConfig,
entry: ExtensionHostRuntimeBackendCatalogEntry,
): boolean {
return isExtensionHostTtsProviderConfigured(config, entry.backendId as TtsProvider);
}
export function resolveExtensionHostDefaultTtsProvider(config: ResolvedTtsConfig): TtsProvider {
return (resolveExtensionHostDefaultRuntimeBackendIdByArbitration({
entries: listExtensionHostTtsRuntimeBackendCatalogEntries(),
subsystemId: "tts",
include: (entry) => isConfiguredTtsRuntimeBackend(config, entry),
fallbackBackendId: "edge",
}) ?? "edge") as TtsProvider;
}
export function resolveExtensionHostTtsFallbackProviders(params: {
config: ResolvedTtsConfig;
preferredProvider: TtsProvider;
}): readonly TtsProvider[] {
return resolveExtensionHostRuntimeBackendFallbackChainByArbitration({
entries: listExtensionHostTtsRuntimeBackendCatalogEntries(),
subsystemId: "tts",
preferredBackendId: params.preferredProvider,
include: (entry) => isConfiguredTtsRuntimeBackend(params.config, entry),
}).map((backendId) => backendId as TtsProvider);
}

View File

@ -15,6 +15,38 @@ vi.mock("./runtime-backend-catalog.js", () => ({
(candidate, index, items) => items.indexOf(candidate) === index,
),
),
listExtensionHostTtsRuntimeBackendCatalogEntries: vi.fn(() => [
{
id: "capability.runtime-backend:tts:openai",
family: "capability.runtime-backend",
subsystemId: "tts",
backendId: "openai",
source: "builtin",
defaultRank: 0,
selectorKeys: ["openai"],
capabilities: ["tts.synthesis", "tts.telephony"],
},
{
id: "capability.runtime-backend:tts:elevenlabs",
family: "capability.runtime-backend",
subsystemId: "tts",
backendId: "elevenlabs",
source: "builtin",
defaultRank: 1,
selectorKeys: ["elevenlabs"],
capabilities: ["tts.synthesis", "tts.telephony"],
},
{
id: "capability.runtime-backend:tts:edge",
family: "capability.runtime-backend",
subsystemId: "tts",
backendId: "edge",
source: "builtin",
defaultRank: 2,
selectorKeys: ["edge"],
capabilities: ["tts.synthesis"],
},
]),
}));
const tempDirs: string[] = [];
@ -114,7 +146,7 @@ describe("tts-runtime-setup", () => {
});
});
it("uses the override provider to build the host-owned fallback order", () => {
it("uses the override provider to build the host-owned configured fallback order", () => {
const config = createResolvedConfig({
provider: "edge",
providerSource: "config",
@ -130,7 +162,7 @@ describe("tts-runtime-setup", () => {
}),
).toEqual({
config,
providers: ["elevenlabs", "openai", "edge"],
providers: ["elevenlabs", "edge"],
});
});
});

View File

@ -1,8 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import type { TtsProvider } from "../config/types.tts.js";
import { resolveExtensionHostTtsRuntimeBackendOrder } from "./runtime-backend-catalog.js";
import type { ResolvedTtsConfig } from "./tts-config.js";
import { resolveExtensionHostTtsApiKey } from "./tts-runtime-registry.js";
import {
resolveExtensionHostDefaultTtsProvider,
resolveExtensionHostTtsFallbackProviders,
} from "./tts-runtime-policy.js";
type TtsUserPrefs = {
tts?: {
@ -35,13 +37,7 @@ export function resolveExtensionHostTtsProvider(
return config.provider;
}
if (resolveExtensionHostTtsApiKey(config, "openai")) {
return "openai";
}
if (resolveExtensionHostTtsApiKey(config, "elevenlabs")) {
return "elevenlabs";
}
return "edge";
return resolveExtensionHostDefaultTtsProvider(config);
}
export function resolveExtensionHostTtsRequestSetup(params: {
@ -67,6 +63,11 @@ export function resolveExtensionHostTtsRequestSetup(params: {
params.providerOverride ?? resolveExtensionHostTtsProvider(params.config, params.prefsPath);
return {
config: params.config,
providers: [...resolveExtensionHostTtsRuntimeBackendOrder(provider)],
providers: [
...resolveExtensionHostTtsFallbackProviders({
config: params.config,
preferredProvider: provider,
}),
],
};
}

View File

@ -1,5 +1,4 @@
import type { TtsProvider } from "../config/types.tts.js";
import { resolveExtensionHostTtsRuntimeBackendOrder } from "./runtime-backend-catalog.js";
import type { ResolvedTtsConfig } from "./tts-config.js";
import {
getExtensionHostTtsMaxLength,
@ -7,6 +6,7 @@ import {
isExtensionHostTtsSummarizationEnabled,
resolveExtensionHostTtsAutoMode,
} from "./tts-preferences.js";
import { resolveExtensionHostTtsFallbackProviders } from "./tts-runtime-policy.js";
import {
isExtensionHostTtsProviderConfigured,
resolveExtensionHostTtsApiKey,
@ -57,9 +57,10 @@ export function resolveExtensionHostTtsStatusSnapshot(params: {
}): ExtensionHostTtsStatusSnapshot {
const { config, prefsPath } = params;
const provider = resolveExtensionHostTtsProvider(config, prefsPath);
const fallbackProviders = resolveExtensionHostTtsRuntimeBackendOrder(provider)
.slice(1)
.filter((candidate) => isExtensionHostTtsProviderConfigured(config, candidate));
const fallbackProviders = resolveExtensionHostTtsFallbackProviders({
config,
preferredProvider: provider,
}).slice(1);
return {
enabled: isExtensionHostTtsEnabled(config, prefsPath),
auto: resolveExtensionHostTtsAutoMode({ config, prefsPath }),