fix(tts): normalize sibling provider credentials (#109051)

This commit is contained in:
Peter Steinberger
2026-07-16 07:08:42 -07:00
committed by GitHub
parent af05f64f01
commit 0340d7c0c1
8 changed files with 187 additions and 85 deletions
@@ -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();
});
});
+16 -7
View File
@@ -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>): 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");
}
@@ -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) => {
+25 -19
View File
@@ -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>): 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<string, unknown>,
@@ -406,8 +425,7 @@ function resolveElevenLabsTtsRequest(
): Parameters<typeof elevenLabsTTS>[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 =
+18 -11
View File
@@ -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<VolcengineTtsProviderConfig, "appId" | "token"> {
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(
+36 -46
View File
@@ -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<string, unknown>) {
};
}
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 () => {
+23
View File
@@ -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();
+7 -2
View File
@@ -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<string | undefined> {
const direct = trimToUndefined(configApiKey) ?? trimToUndefined(process.env.XAI_API_KEY);
const direct = resolveDirectXaiAudioApiKey(configApiKey);
if (direct) {
return direct;
}