fix(vydra): ignore blank environment API key (#108757)

* fix(vydra): ignore blank environment API key

* refactor(tts): centralize speech API key resolution

* chore(plugin-sdk): refresh speech API baseline

* chore(plugin-sdk): pin speech resolver surface

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sunlit-deng
2026-07-16 19:33:25 +08:00
committed by GitHub
parent 549322a5f2
commit c4e696d1ba
8 changed files with 71 additions and 9 deletions
@@ -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
+30
View File
@@ -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()
+8 -3
View File
@@ -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");
}
+4 -2
View File
@@ -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(
+1
View File
@@ -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";
+8 -1
View File
@@ -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 {
+1
View File
@@ -18,6 +18,7 @@ export {
normalizeLanguageCode,
normalizeSeed,
requireInRange,
resolveSpeechProviderApiKey,
scheduleCleanup,
} from "./tts-provider-helpers.js";
+17 -1
View File
@@ -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>
): 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}`);