diff --git a/extensions/azure-speech/speech-provider.test.ts b/extensions/azure-speech/speech-provider.test.ts index fc847a171ec3..788d624dc20a 100644 --- a/extensions/azure-speech/speech-provider.test.ts +++ b/extensions/azure-speech/speech-provider.test.ts @@ -265,4 +265,32 @@ describe("buildAzureSpeechProvider", () => { timeoutMs: 45_000, }); }); + + it("rejects blank credentials across readiness, discovery, and synthesis", async () => { + vi.stubEnv("AZURE_SPEECH_KEY", " "); + vi.stubEnv("AZURE_SPEECH_API_KEY", " "); + vi.stubEnv("SPEECH_KEY", " "); + const provider = buildAzureSpeechProvider(); + const providerConfig = { apiKey: " ", region: "eastus" }; + + expect(provider.isConfigured({ providerConfig, timeoutMs: 1_000 })).toBe(false); + await expect( + provider.listVoices?.({ apiKey: " ", providerConfig, timeoutMs: 1_000 }), + ).rejects.toThrow("Azure Speech API key missing"); + + const request = { + text: "hello", + cfg: {} as never, + providerConfig, + target: "audio-file" as const, + timeoutMs: 1_000, + }; + await expect(provider.synthesize(request)).rejects.toThrow("Azure Speech API key missing"); + await expect(provider.synthesizeTelephony?.(request)).rejects.toThrow( + "Azure Speech API key missing", + ); + + expect(listAzureSpeechVoicesMock).not.toHaveBeenCalled(); + expect(azureSpeechTTSMock).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/azure-speech/speech-provider.ts b/extensions/azure-speech/speech-provider.ts index 6810da073dd9..495dfe63f173 100644 --- a/extensions/azure-speech/speech-provider.ts +++ b/extensions/azure-speech/speech-provider.ts @@ -9,7 +9,12 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { + asFiniteNumber, + asObject, + resolveSpeechProviderApiKey, + trimToUndefined, +} from "openclaw/plugin-sdk/speech-core"; import { azureSpeechTTS, DEFAULT_AZURE_SPEECH_AUDIO_FORMAT, @@ -175,8 +180,8 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): { } } -function resolveApiKey(config: AzureSpeechProviderConfig): string | undefined { - return config.apiKey ?? readAzureSpeechEnvApiKey(); +function resolveApiKey(...candidates: Array): string | undefined { + return resolveSpeechProviderApiKey(...candidates, readAzureSpeechEnvApiKey()); } function resolveTimeoutMs(config: AzureSpeechProviderConfig, timeoutMs: number): number { @@ -250,7 +255,9 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin { const config = req.providerConfig ? readAzureSpeechProviderConfig(req.providerConfig) : undefined; - const apiKey = req.apiKey ?? (config ? resolveApiKey(config) : readAzureSpeechEnvApiKey()); + const requestValue = req.apiKey; + const configValue = config?.apiKey; + const apiKey = resolveApiKey(requestValue, configValue); if (!apiKey) { throw new Error("Azure Speech API key missing"); } @@ -264,12 +271,14 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin { }, isConfigured: ({ providerConfig }) => { const config = readAzureSpeechProviderConfig(providerConfig); - return Boolean(resolveApiKey(config) && (config.baseUrl || config.region || config.endpoint)); + return Boolean( + resolveApiKey(config.apiKey) && (config.baseUrl || config.region || config.endpoint), + ); }, synthesize: async (req) => { const config = readAzureSpeechProviderConfig(req.providerConfig); const overrides = readAzureSpeechOverrides(req.providerOverrides); - const apiKey = resolveApiKey(config); + const apiKey = resolveApiKey(config.apiKey); if (!apiKey) { throw new Error("Azure Speech API key missing"); } @@ -298,7 +307,7 @@ export function buildAzureSpeechProvider(): SpeechProviderPlugin { synthesizeTelephony: async (req) => { const config = readAzureSpeechProviderConfig(req.providerConfig); const overrides = readAzureSpeechOverrides(req.providerOverrides); - const apiKey = resolveApiKey(config); + const apiKey = resolveApiKey(config.apiKey); if (!apiKey) { throw new Error("Azure Speech API key missing"); } diff --git a/extensions/elevenlabs/speech-provider.test.ts b/extensions/elevenlabs/speech-provider.test.ts index aee17f9d2652..5d3ed9af296f 100644 --- a/extensions/elevenlabs/speech-provider.test.ts +++ b/extensions/elevenlabs/speech-provider.test.ts @@ -5,6 +5,10 @@ import { buildElevenLabsSpeechProvider } from "./speech-provider.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); +vi.mock("./config-api.js", () => ({ + resolveElevenLabsApiKeyWithProfileFallback: () => null, +})); + vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: async (params: { url: string; @@ -42,6 +46,7 @@ describe("elevenlabs speech provider", () => { afterEach(() => { globalThis.fetch = originalFetch; fetchWithSsrFGuardMock.mockClear(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -72,6 +77,35 @@ describe("elevenlabs speech provider", () => { ); }); + it("rejects blank credentials across discovery and synthesis before requests", async () => { + vi.stubEnv("ELEVENLABS_API_KEY", " "); + vi.stubEnv("XI_API_KEY", " "); + const provider = buildElevenLabsSpeechProvider(); + const providerConfig = { apiKey: " " }; + + expect(provider.isConfigured({ providerConfig, timeoutMs: 1_000 })).toBe(false); + await expect( + provider.listVoices?.({ apiKey: " ", providerConfig, timeoutMs: 1_000 }), + ).rejects.toThrow("ElevenLabs API key missing"); + + const request = { + text: "hello", + cfg: {} as never, + providerConfig, + target: "audio-file" as const, + timeoutMs: 1_000, + }; + await expect(provider.synthesize(request)).rejects.toThrow("ElevenLabs API key missing"); + await expect(provider.streamSynthesize?.(request)).rejects.toThrow( + "ElevenLabs API key missing", + ); + await expect(provider.synthesizeTelephony?.(request)).rejects.toThrow( + "ElevenLabs API key missing", + ); + + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + it("keeps non-equivalent deprecated ElevenLabs TTS model IDs", async () => { const provider = buildElevenLabsSpeechProvider(); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index 76c1d8e6bdf9..a1594083640d 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -24,6 +24,7 @@ import { requireInRange, trimToUndefined, } from "openclaw/plugin-sdk/speech"; +import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, @@ -187,6 +188,24 @@ function readElevenLabsProviderConfig(config: SpeechProviderConfig): ElevenLabsP }; } +function resolveElevenLabsApiKey(...candidates: Array): string | undefined { + return resolveSpeechProviderApiKey( + ...candidates, + resolveElevenLabsApiKeyWithProfileFallback() ?? undefined, + process.env.XI_API_KEY, + ); +} + +function resolveElevenLabsTalkApiKey(config: SpeechProviderConfig): string | undefined { + if (config.apiKey === undefined) { + return resolveElevenLabsApiKey(); + } + return normalizeResolvedSecretInputString({ + value: config.apiKey, + path: "talk.providers.elevenlabs.apiKey", + }); +} + function mergeVoiceSettingsOverride( ctx: SpeechDirectiveTokenParseContext, next: Record, @@ -406,8 +425,7 @@ function resolveElevenLabsTtsRequest( ): Parameters[0] { const config = readElevenLabsProviderConfig(req.providerConfig); const overrides = req.providerOverrides ?? {}; - const apiKey = - config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY; + const apiKey = resolveElevenLabsApiKey(config.apiKey); if (!apiKey) { throw new Error("ElevenLabs API key missing"); } @@ -441,13 +459,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => { const base = normalizeElevenLabsProviderConfig(baseTtsConfig); const talkVoiceSettings = asObject(talkProviderConfig.voiceSettings); - const resolvedTalkApiKey = - talkProviderConfig.apiKey === undefined - ? (resolveElevenLabsApiKeyWithProfileFallback() ?? undefined) - : normalizeResolvedSecretInputString({ - value: talkProviderConfig.apiKey, - path: "talk.providers.elevenlabs.apiKey", - }); + const resolvedTalkApiKey = resolveElevenLabsTalkApiKey(talkProviderConfig); return { ...base, ...(resolvedTalkApiKey === undefined ? {} : { apiKey: resolvedTalkApiKey }), @@ -529,11 +541,9 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { const config = req.providerConfig ? readElevenLabsProviderConfig(req.providerConfig) : undefined; - const apiKey = - req.apiKey || - config?.apiKey || - resolveElevenLabsApiKeyWithProfileFallback() || - process.env.XI_API_KEY; + const requestValue = req.apiKey; + const configValue = config?.apiKey; + const apiKey = resolveElevenLabsApiKey(requestValue, configValue); if (!apiKey) { throw new Error("ElevenLabs API key missing"); } @@ -544,11 +554,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { }); }, isConfigured: ({ providerConfig }) => - Boolean( - readElevenLabsProviderConfig(providerConfig).apiKey || - resolveElevenLabsApiKeyWithProfileFallback() || - process.env.XI_API_KEY, - ), + Boolean(resolveElevenLabsApiKey(readElevenLabsProviderConfig(providerConfig).apiKey)), synthesize: async (req) => { const overrides = req.providerOverrides ?? {}; const outputFormat = diff --git a/extensions/volcengine/speech-provider.ts b/extensions/volcengine/speech-provider.ts index f519b42e0a5d..cb023b27bfd1 100644 --- a/extensions/volcengine/speech-provider.ts +++ b/extensions/volcengine/speech-provider.ts @@ -9,6 +9,7 @@ import type { import { asObject, parseSpeechDirectiveNumberOverride, + resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -93,13 +94,23 @@ function normalizeVolcengineProviderConfig( } function resolveSeedSpeechApiKey(configApiKey?: string): string | undefined { - return ( - configApiKey ?? - trimToUndefined(process.env.VOLCENGINE_TTS_API_KEY) ?? - trimToUndefined(process.env.BYTEPLUS_SEED_SPEECH_API_KEY) + return resolveSpeechProviderApiKey( + configApiKey, + process.env.VOLCENGINE_TTS_API_KEY, + process.env.BYTEPLUS_SEED_SPEECH_API_KEY, ); } +function resolveLegacyVolcengineCredentials(config: { + appId?: string; + token?: string; +}): Pick { + return { + appId: trimToUndefined(config.appId) ?? trimToUndefined(process.env.VOLCENGINE_TTS_APPID), + token: resolveSpeechProviderApiKey(config.token, process.env.VOLCENGINE_TTS_TOKEN), + }; +} + function readProviderConfig(config: SpeechProviderConfig): VolcengineTtsProviderConfig { const normalized = normalizeVolcengineProviderConfig({}); return { @@ -187,19 +198,15 @@ export function buildVolcengineSpeechProvider(): SpeechProviderPlugin { isConfigured: ({ providerConfig }) => { const cfg = readProviderConfig(providerConfig); - return Boolean( - resolveSeedSpeechApiKey(cfg.apiKey) || - ((cfg.appId || process.env.VOLCENGINE_TTS_APPID) && - (cfg.token || process.env.VOLCENGINE_TTS_TOKEN)), - ); + const legacy = resolveLegacyVolcengineCredentials(cfg); + return Boolean(resolveSeedSpeechApiKey(cfg.apiKey) || (legacy.appId && legacy.token)); }, synthesize: async (req) => { const cfg = readProviderConfig(req.providerConfig); const overrides = readVolcengineOverrides(req.providerOverrides); const apiKey = resolveSeedSpeechApiKey(cfg.apiKey); - const appId = cfg.appId || process.env.VOLCENGINE_TTS_APPID; - const token = cfg.token || process.env.VOLCENGINE_TTS_TOKEN; + const { appId, token } = resolveLegacyVolcengineCredentials(cfg); if (!apiKey && (!appId || !token)) { throw new Error( diff --git a/extensions/volcengine/tts.test.ts b/extensions/volcengine/tts.test.ts index af5c72efe288..8a616c936228 100644 --- a/extensions/volcengine/tts.test.ts +++ b/extensions/volcengine/tts.test.ts @@ -1,5 +1,5 @@ // Volcengine tests cover tts plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildVolcengineSpeechProvider } from "./speech-provider.js"; import { volcengineTTS } from "./tts.js"; @@ -39,13 +39,12 @@ function makeLegacyProviderConfig(overrides?: Record) { }; } -function clearTtsEnv() { - delete process.env.BYTEPLUS_API_KEY; - delete process.env.BYTEPLUS_SEED_SPEECH_API_KEY; - delete process.env.VOLCENGINE_TTS_API_KEY; - delete process.env.VOLCENGINE_TTS_APPID; - delete process.env.VOLCENGINE_TTS_TOKEN; -} +const TTS_ENV_KEYS = [ + "BYTEPLUS_SEED_SPEECH_API_KEY", + "VOLCENGINE_TTS_API_KEY", + "VOLCENGINE_TTS_APPID", + "VOLCENGINE_TTS_TOKEN", +] as const; function makeOversizedStreamResponse(): Response { return new Response( @@ -59,19 +58,18 @@ function makeOversizedStreamResponse(): Response { ); } -function restoreOptionalEnv(key: string, value: string | undefined) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } -} - describe("Volcengine speech provider", () => { const provider = buildVolcengineSpeechProvider(); beforeEach(() => { fetchWithSsrFGuardMock.mockReset(); + for (const key of TTS_ENV_KEYS) { + vi.stubEnv(key, undefined); + } + }); + + afterEach(() => { + vi.unstubAllEnvs(); }); it("has correct id, label, and aliases", () => { @@ -94,40 +92,32 @@ describe("Volcengine speech provider", () => { }); it("reports not configured when credentials are missing", () => { - const oldBytePlusKey = process.env.BYTEPLUS_API_KEY; - const oldSeedKey = process.env.BYTEPLUS_SEED_SPEECH_API_KEY; - const oldApiKey = process.env.VOLCENGINE_TTS_API_KEY; - const oldAppId = process.env.VOLCENGINE_TTS_APPID; - const oldToken = process.env.VOLCENGINE_TTS_TOKEN; - clearTtsEnv(); - try { - expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(false); - } finally { - restoreOptionalEnv("BYTEPLUS_API_KEY", oldBytePlusKey); - restoreOptionalEnv("BYTEPLUS_SEED_SPEECH_API_KEY", oldSeedKey); - restoreOptionalEnv("VOLCENGINE_TTS_API_KEY", oldApiKey); - restoreOptionalEnv("VOLCENGINE_TTS_APPID", oldAppId); - restoreOptionalEnv("VOLCENGINE_TTS_TOKEN", oldToken); - } + expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(false); }); it("falls back to env vars for credentials", () => { - const oldBytePlusKey = process.env.BYTEPLUS_API_KEY; - const oldSeedKey = process.env.BYTEPLUS_SEED_SPEECH_API_KEY; - const oldApiKey = process.env.VOLCENGINE_TTS_API_KEY; - const oldAppId = process.env.VOLCENGINE_TTS_APPID; - const oldToken = process.env.VOLCENGINE_TTS_TOKEN; - clearTtsEnv(); - process.env.BYTEPLUS_SEED_SPEECH_API_KEY = "env-api-key"; - try { - expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true); - } finally { - restoreOptionalEnv("BYTEPLUS_API_KEY", oldBytePlusKey); - restoreOptionalEnv("BYTEPLUS_SEED_SPEECH_API_KEY", oldSeedKey); - restoreOptionalEnv("VOLCENGINE_TTS_API_KEY", oldApiKey); - restoreOptionalEnv("VOLCENGINE_TTS_APPID", oldAppId); - restoreOptionalEnv("VOLCENGINE_TTS_TOKEN", oldToken); + vi.stubEnv("BYTEPLUS_SEED_SPEECH_API_KEY", "env-api-key"); + expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 30000 })).toBe(true); + }); + + it("rejects blank Seed and legacy credentials before requests", async () => { + for (const key of TTS_ENV_KEYS) { + vi.stubEnv(key, " "); } + const providerConfig = { apiKey: " ", appId: " ", token: " " }; + + expect(provider.isConfigured({ providerConfig, timeoutMs: 30_000 })).toBe(false); + await expect( + provider.synthesize({ + text: "hello", + cfg: {}, + providerConfig, + target: "audio-file", + timeoutMs: 1_000, + }), + ).rejects.toThrow("Volcengine TTS credentials missing"); + + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); }); it("lists voices with locale and gender", async () => { diff --git a/extensions/xai/speech-provider.test.ts b/extensions/xai/speech-provider.test.ts index 7e8baa6590a2..4c90799aec21 100644 --- a/extensions/xai/speech-provider.test.ts +++ b/extensions/xai/speech-provider.test.ts @@ -268,6 +268,29 @@ describe("xai speech provider", () => { }); }); + it("treats blank direct credentials as absent across readiness and requests", async () => { + process.env.XAI_API_KEY = " "; + const provider = buildXaiSpeechProvider(); + const providerConfig = { apiKey: " " }; + + expect(provider.isConfigured({ cfg: {}, providerConfig, timeoutMs: 5_000 })).toBe(false); + await expect(provider.listVoices?.({ apiKey: " ", providerConfig })).resolves.toEqual( + ["ara", "eve", "leo", "rex", "sal"].map((voice) => ({ id: voice, name: voice })), + ); + await expect( + provider.synthesize({ + text: "hello", + cfg: {}, + providerConfig, + target: "audio-file", + timeoutMs: 5_000, + }), + ).rejects.toThrow("xAI credentials missing for TTS"); + + expect(listXaiTtsVoicesMock).not.toHaveBeenCalled(); + expect(xaiTTSMock).not.toHaveBeenCalled(); + }); + it("reports not configured when there is no apiKey, env, or auth profile", () => { isProviderAuthProfileConfiguredMock.mockReturnValue(false); const provider = buildXaiSpeechProvider(); diff --git a/extensions/xai/speech-provider.ts b/extensions/xai/speech-provider.ts index 098431046b1b..f87165c3c846 100644 --- a/extensions/xai/speech-provider.ts +++ b/extensions/xai/speech-provider.ts @@ -13,6 +13,7 @@ import { type SpeechProviderPlugin, type SpeechSynthesisTarget, } from "openclaw/plugin-sdk/speech"; +import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; import { asFiniteNumberInRange, normalizeLowercaseStringOrEmpty, @@ -136,6 +137,10 @@ function readXaiOverrides(overrides: SpeechProviderOverrides | undefined): XaiTt }; } +function resolveDirectXaiAudioApiKey(configApiKey?: string): string | undefined { + return resolveSpeechProviderApiKey(configApiKey, process.env.XAI_API_KEY); +} + function resolveGeneratedAudioMaxBytes(req: { cfg: { agents?: { defaults?: { mediaMaxMb?: number } } }; }): number { @@ -240,7 +245,7 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin { }); }, isConfigured: ({ providerConfig, cfg }) => - Boolean(readXaiProviderConfig(providerConfig).apiKey || process.env.XAI_API_KEY) || + Boolean(resolveDirectXaiAudioApiKey(readXaiProviderConfig(providerConfig).apiKey)) || isProviderAuthProfileConfigured({ provider: "xai", cfg }), synthesize: async (req) => { const config = readXaiProviderConfig(req.providerConfig); @@ -319,7 +324,7 @@ async function resolveOptionalXaiAudioApiKey( configApiKey: string | undefined, cfg?: OpenClawConfig, ): Promise { - const direct = trimToUndefined(configApiKey) ?? trimToUndefined(process.env.XAI_API_KEY); + const direct = resolveDirectXaiAudioApiKey(configApiKey); if (direct) { return direct; }