Skip to content

Commit 85b3c1d

Browse files
committed
fix: defer tts provider resolution until needed
1 parent 86dba6d commit 85b3c1d

2 files changed

Lines changed: 104 additions & 25 deletions

File tree

extensions/speech-core/src/tts.ts

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
canonicalizeSpeechProviderId,
2828
getSpeechProvider,
2929
listSpeechProviders,
30+
normalizeSpeechProviderId,
3031
normalizeTtsAutoMode,
3132
parseTtsDirectives,
3233
scheduleCleanup,
@@ -56,6 +57,7 @@ export type ResolvedTtsConfig = {
5657
prefsPath?: string;
5758
maxTextLength: number;
5859
timeoutMs: number;
60+
rawConfig?: TtsConfig;
5961
};
6062

6163
type TtsUserPrefs = {
@@ -117,6 +119,16 @@ function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode {
117119
return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
118120
}
119121

122+
function normalizeConfiguredSpeechProviderId(
123+
providerId: string | undefined,
124+
): TtsProvider | undefined {
125+
const normalized = normalizeSpeechProviderId(providerId);
126+
if (!normalized) {
127+
return undefined;
128+
}
129+
return normalized === "edge" ? "microsoft" : normalized;
130+
}
131+
120132
function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
121133
if (prefsPath?.trim()) {
122134
return resolveUserPath(prefsPath.trim());
@@ -184,26 +196,76 @@ function asProviderConfigMap(value: unknown): Record<string, unknown> {
184196
: {};
185197
}
186198

187-
function resolveSpeechProviderConfigs(
188-
raw: TtsConfig,
189-
cfg: OpenClawConfig,
190-
timeoutMs: number,
191-
): Record<string, SpeechProviderConfig> {
192-
const providerConfigs: Record<string, SpeechProviderConfig> = {};
199+
function resolveRawProviderConfig(
200+
raw: TtsConfig | undefined,
201+
providerId: string,
202+
): SpeechProviderConfig {
203+
if (!raw) {
204+
return {};
205+
}
193206
const rawProviders = asProviderConfigMap(raw.providers);
194-
for (const provider of listSpeechProviders(cfg)) {
195-
providerConfigs[provider.id] =
196-
provider.resolveConfig?.({
197-
cfg,
198-
rawConfig: {
199-
...(raw as Record<string, unknown>),
200-
providers: rawProviders,
201-
},
202-
timeoutMs,
203-
}) ??
204-
asProviderConfig(rawProviders[provider.id] ?? (raw as Record<string, unknown>)[provider.id]);
207+
const direct = rawProviders[providerId] ?? (raw as Record<string, unknown>)[providerId];
208+
return asProviderConfig(direct);
209+
}
210+
211+
function resolveLazyProviderConfig(
212+
config: ResolvedTtsConfig,
213+
providerId: string,
214+
cfg?: OpenClawConfig,
215+
): SpeechProviderConfig {
216+
const canonical =
217+
normalizeConfiguredSpeechProviderId(providerId) ?? providerId.trim().toLowerCase();
218+
const existing = config.providerConfigs[canonical];
219+
if (existing) {
220+
return existing;
221+
}
222+
const rawConfig = resolveRawProviderConfig(config.rawConfig, canonical);
223+
const resolvedProvider = getSpeechProvider(canonical, cfg);
224+
const next =
225+
cfg && resolvedProvider?.resolveConfig
226+
? resolvedProvider.resolveConfig({
227+
cfg,
228+
rawConfig: {
229+
...(config.rawConfig as Record<string, unknown> | undefined),
230+
providers: asProviderConfigMap(config.rawConfig?.providers),
231+
},
232+
timeoutMs: config.timeoutMs,
233+
})
234+
: rawConfig;
235+
config.providerConfigs[canonical] = next;
236+
return next;
237+
}
238+
239+
function collectDirectProviderConfigEntries(raw: TtsConfig): Record<string, SpeechProviderConfig> {
240+
const entries: Record<string, SpeechProviderConfig> = {};
241+
const rawProviders = asProviderConfigMap(raw.providers);
242+
for (const [providerId, value] of Object.entries(rawProviders)) {
243+
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
244+
entries[normalized] = asProviderConfig(value);
245+
}
246+
const reservedKeys = new Set([
247+
"auto",
248+
"enabled",
249+
"maxTextLength",
250+
"mode",
251+
"modelOverrides",
252+
"prefsPath",
253+
"provider",
254+
"providers",
255+
"summaryModel",
256+
"timeoutMs",
257+
]);
258+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
259+
if (reservedKeys.has(key)) {
260+
continue;
261+
}
262+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
263+
continue;
264+
}
265+
const normalized = normalizeConfiguredSpeechProviderId(key) ?? key;
266+
entries[normalized] ??= asProviderConfig(value);
205267
}
206-
return providerConfigs;
268+
return entries;
207269
}
208270

209271
export function getResolvedSpeechProviderConfig(
@@ -212,8 +274,10 @@ export function getResolvedSpeechProviderConfig(
212274
cfg?: OpenClawConfig,
213275
): SpeechProviderConfig {
214276
const canonical =
215-
canonicalizeSpeechProviderId(providerId, cfg) ?? providerId.trim().toLowerCase();
216-
return config.providerConfigs[canonical] ?? {};
277+
canonicalizeSpeechProviderId(providerId, cfg) ??
278+
normalizeConfiguredSpeechProviderId(providerId) ??
279+
providerId.trim().toLowerCase();
280+
return resolveLazyProviderConfig(config, canonical, cfg);
217281
}
218282

219283
export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig {
@@ -225,15 +289,16 @@ export function resolveTtsConfig(cfg: OpenClawConfig): ResolvedTtsConfig {
225289
auto,
226290
mode: raw.mode ?? "final",
227291
provider:
228-
canonicalizeSpeechProviderId(raw.provider, cfg) ??
229-
resolveRegistryDefaultSpeechProviderId(cfg),
292+
normalizeConfiguredSpeechProviderId(raw.provider) ??
293+
(providerSource === "config" ? raw.provider?.trim().toLowerCase() || "" : ""),
230294
providerSource,
231295
summaryModel: raw.summaryModel?.trim() || undefined,
232296
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
233-
providerConfigs: resolveSpeechProviderConfigs(raw, cfg, timeoutMs),
297+
providerConfigs: collectDirectProviderConfigEntries(raw),
234298
prefsPath: raw.prefsPath,
235299
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
236300
timeoutMs,
301+
rawConfig: raw,
237302
};
238303
}
239304

@@ -368,12 +433,14 @@ export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
368433

369434
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
370435
const prefs = readPrefs(prefsPath);
371-
const prefsProvider = canonicalizeSpeechProviderId(prefs.tts?.provider);
436+
const prefsProvider =
437+
canonicalizeSpeechProviderId(prefs.tts?.provider) ??
438+
normalizeConfiguredSpeechProviderId(prefs.tts?.provider);
372439
if (prefsProvider) {
373440
return prefsProvider;
374441
}
375442
if (config.providerSource === "config") {
376-
return canonicalizeSpeechProviderId(config.provider) ?? config.provider;
443+
return normalizeConfiguredSpeechProviderId(config.provider) ?? config.provider;
377444
}
378445

379446
for (const provider of sortSpeechProvidersForAutoSelection()) {

src/plugins/contracts/tts.contract.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,18 @@ describe("tts", () => {
714714
});
715715
},
716716
);
717+
718+
it("hydrates provider config lazily when no explicit speech provider is configured", () => {
719+
withEnv({ OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, () => {
720+
const config = resolveTtsConfig(baseCfg);
721+
const openaiConfig = getResolvedSpeechProviderConfig(config, "openai", baseCfg) as {
722+
baseUrl?: string;
723+
};
724+
725+
expect(config.provider).toBe("");
726+
expect(openaiConfig.baseUrl).toBe("http://localhost:8880/v1");
727+
});
728+
});
717729
});
718730

719731
describe("textToSpeechTelephony – openai instructions", () => {

0 commit comments

Comments
 (0)