diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 780e3c399327..eb568ae6653b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -2713341b4e458d22f23a993423ee60ba20734b0c90f6792134941c5830b863f5 plugin-sdk-api-baseline.json -041a52f9ff06f3cdebccf6704960de02142abec68266daff37b0399795c212d1 plugin-sdk-api-baseline.jsonl +4a7f00ae294da967f487090775e6d9fde7b1aac80b123618d7ba01186484bcdb plugin-sdk-api-baseline.json +6a966808a3e34c6b750e008b901e7170503f3c0788fdb086166ed31866b588e9 plugin-sdk-api-baseline.jsonl diff --git a/extensions/vydra/speech-provider.test.ts b/extensions/vydra/speech-provider.test.ts index c6a19bf0beed..926ab1cb4220 100644 --- a/extensions/vydra/speech-provider.test.ts +++ b/extensions/vydra/speech-provider.test.ts @@ -7,6 +7,7 @@ describe("vydra speech provider", () => { installPinnedHostnameTestHooks(); const provider = buildVydraSpeechProvider(); + const originalVydraApiKey = process.env.VYDRA_API_KEY; const oversizedJsonResponse = () => new Response(Buffer.alloc(16 * 1024 * 1024 + 1, 0x20), { @@ -15,6 +16,11 @@ describe("vydra speech provider", () => { }); afterEach(() => { + if (originalVydraApiKey === undefined) { + delete process.env.VYDRA_API_KEY; + } else { + process.env.VYDRA_API_KEY = originalVydraApiKey; + } vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -77,6 +83,30 @@ describe("vydra speech provider", () => { expect(result.audioBuffer).toEqual(Buffer.from("mp3-data")); }); + it("does not treat a blank environment API key as configured", () => { + process.env.VYDRA_API_KEY = " "; + + expect(provider.isConfigured?.({ providerConfig: {}, timeoutMs: 30_000 })).toBe(false); + }); + + it("rejects blank environment API keys before making requests", async () => { + process.env.VYDRA_API_KEY = "\t \n"; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + provider.synthesize({ + text: "OpenClaw test", + cfg: {} as never, + providerConfig: {}, + target: "audio-file", + timeoutMs: 30_000, + }), + ).rejects.toThrow("Vydra API key missing"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("rejects generated audio downloads that exceed the configured media cap", async () => { const fetchMock = vi .fn() diff --git a/extensions/vydra/speech-provider.ts b/extensions/vydra/speech-provider.ts index 3a2b6daefece..173c6296ac65 100644 --- a/extensions/vydra/speech-provider.ts +++ b/extensions/vydra/speech-provider.ts @@ -11,7 +11,7 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { asObject } from "openclaw/plugin-sdk/speech-core"; +import { asObject, resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; import { DEFAULT_VYDRA_BASE_URL, DEFAULT_VYDRA_SPEECH_MODEL, @@ -91,11 +91,16 @@ export function buildVydraSpeechProvider(): SpeechProviderPlugin { resolveConfig: ({ rawConfig }) => normalizeVydraSpeechConfig(rawConfig), listVoices: async () => VYDRA_SPEECH_VOICES.map((voice) => Object.assign({}, voice)), isConfigured: ({ providerConfig }) => - Boolean(readVydraSpeechConfig(providerConfig).apiKey || process.env.VYDRA_API_KEY), + Boolean( + resolveSpeechProviderApiKey( + readVydraSpeechConfig(providerConfig).apiKey, + process.env.VYDRA_API_KEY, + ), + ), synthesize: async (req) => { const config = readVydraSpeechConfig(req.providerConfig); const overrides = readVydraOverrides(req.providerOverrides); - const apiKey = config.apiKey || process.env.VYDRA_API_KEY; + const apiKey = resolveSpeechProviderApiKey(config.apiKey, process.env.VYDRA_API_KEY); if (!apiKey) { throw new Error("Vydra API key missing"); } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 22cb080edd5e..064dd03d6324 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -237,7 +237,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // Used-union narrowing: 31 wildcard barrels drop to explicit used exports; // proxy stream API and codex marker/scaffold pins retained. // +2: generic channel retry runner and Retry-After parser. - 7951, + // +1: shared speech-provider API key resolver. + 7952, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -255,7 +256,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: widget HTML document detection and size assertion. // Used-union narrowing of the 31 wildcard barrels. // +2: generic channel retry runner and Retry-After parser. - 4439, + // +1: shared speech-provider API key resolver. + 4440, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/plugin-sdk/speech-core.ts b/src/plugin-sdk/speech-core.ts index 16f60dd5ea1c..0de19cb0731c 100644 --- a/src/plugin-sdk/speech-core.ts +++ b/src/plugin-sdk/speech-core.ts @@ -32,6 +32,7 @@ export { normalizeLanguageCode, normalizeSeed, requireInRange, + resolveSpeechProviderApiKey, } from "../tts/tts-core.js"; export { parseTtsDirectives } from "../tts/directives.js"; export { parseSpeechDirectiveNumberOverride } from "../tts/directive-number.js"; diff --git a/src/tts/tts-core.test.ts b/src/tts/tts-core.test.ts index a5f0c56eb82f..54cc6149499d 100644 --- a/src/tts/tts-core.test.ts +++ b/src/tts/tts-core.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AssistantMessage, Model, Usage } from "../llm/types.js"; import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; import type { SpeechModelOverridePolicy } from "./provider-types.js"; -import { summarizeText } from "./tts-core.js"; +import { resolveSpeechProviderApiKey, summarizeText } from "./tts-core.js"; import type { ResolvedTtsConfig } from "./tts-types.js"; const modelOverridePolicy: SpeechModelOverridePolicy = { @@ -33,6 +33,13 @@ const usage: Usage = { }; describe("TTS core", () => { + it("resolves the first non-blank speech provider API key", () => { + expect(resolveSpeechProviderApiKey(undefined, " \t", " provider-key ", "fallback")).toBe( + "provider-key", + ); + expect(resolveSpeechProviderApiKey(undefined, "\n")).toBeUndefined(); + }); + it("clamps oversized summarization timeout timers", async () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); try { diff --git a/src/tts/tts-core.ts b/src/tts/tts-core.ts index 2733edfffdc9..7ea76eee22f5 100644 --- a/src/tts/tts-core.ts +++ b/src/tts/tts-core.ts @@ -18,6 +18,7 @@ export { normalizeLanguageCode, normalizeSeed, requireInRange, + resolveSpeechProviderApiKey, scheduleCleanup, } from "./tts-provider-helpers.js"; diff --git a/src/tts/tts-provider-helpers.ts b/src/tts/tts-provider-helpers.ts index 4bdfe7f70666..a2f8d5354bef 100644 --- a/src/tts/tts-provider-helpers.ts +++ b/src/tts/tts-provider-helpers.ts @@ -1,9 +1,25 @@ // TTS provider helpers manage provider temp files and output cleanup. import { rmSync } from "node:fs"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes +/** Resolve the first non-blank API key in provider-defined precedence order. */ +export function resolveSpeechProviderApiKey( + ...candidates: Array +): string | undefined { + for (const candidate of candidates) { + const apiKey = normalizeOptionalString(candidate); + if (apiKey) { + return apiKey; + } + } + return undefined; +} + export function requireInRange(value: number, min: number, max: number, label: string): void { if (!Number.isFinite(value) || value < min || value > max) { throw new Error(`${label} must be between ${min} and ${max}`);