fix(openai): treat blank env API key as unconfigured in speech provider (#108212)

* fix(openai): treat blank env API key as unconfigured in speech provider

process.env.OPENAI_API_KEY set to whitespace-only (e.g. '   ') causes
isConfigured to return true, but downstream synthesis fails with an
unusable bearer token. Add ?.trim() to treat whitespace-only env keys
as missing, matching the existing normalization in the image-generation
provider (image-generation-provider.ts:347).

Fixes #108186

* test(openai): cover speech env key normalization

Co-authored-by: luyifan <al3060388206@gmail.com>

* fix(openai): normalize realtime transcription env key

* chore(ci): prune stale max-lines baseline

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: luyifan <al3060388206@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
lsr911
2026-07-15 09:43:42 -07:00
committed by GitHub
co-authored by Peter Steinberger luyifan Peter Steinberger
parent 0f7fbfa003
commit 4108bbca96
4 changed files with 78 additions and 4 deletions
@@ -216,6 +216,13 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => {
});
});
it("does not treat a whitespace-only environment API key as configured", () => {
vi.stubEnv("OPENAI_API_KEY", " ");
const provider = buildOpenAIRealtimeTranscriptionProvider();
expect(provider.isConfigured({ cfg: {} as never, providerConfig: {} })).toBe(false);
});
it("mints an API-key client secret for realtime transcription sockets", async () => {
const provider = buildOpenAIRealtimeTranscriptionProvider();
const release = vi.fn();
@@ -268,7 +268,7 @@ export function buildOpenAIRealtimeTranscriptionProvider(): RealtimeTranscriptio
isConfigured: ({ cfg, providerConfig }) =>
Boolean(
normalizeProviderConfig(providerConfig).apiKey ||
process.env.OPENAI_API_KEY ||
process.env.OPENAI_API_KEY?.trim() ||
isProviderAuthProfileConfigured({ provider: "openai", cfg, profileTypes: ["api_key"] }),
),
createSession: (req) => {
+63
View File
@@ -58,6 +58,7 @@ describe("buildOpenAISpeechProvider", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -376,6 +377,68 @@ describe("buildOpenAISpeechProvider", () => {
}
});
it("treats a blank environment API key as missing across speech entry points", async () => {
vi.stubEnv("OPENAI_API_KEY", " ");
const provider = buildOpenAISpeechProvider();
const fetchMock = vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const providerConfig = {
model: "gpt-4o-mini-tts",
voice: "alloy",
};
expect(provider.isConfigured({ providerConfig, timeoutMs: 30_000 })).toBe(false);
await expect(
provider.synthesize({
text: "hello",
cfg: {} as never,
providerConfig,
target: "audio-file",
timeoutMs: 1_000,
}),
).rejects.toThrow("OpenAI API key missing");
await expect(
provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig,
timeoutMs: 1_000,
}),
).rejects.toThrow("OpenAI API key missing");
expect(fetchMock).not.toHaveBeenCalled();
});
it("trims a valid environment API key for normal and telephony synthesis", async () => {
vi.stubEnv("OPENAI_API_KEY", " sk-env ");
const provider = buildOpenAISpeechProvider();
const authorizationHeaders: Array<string | null> = [];
globalThis.fetch = vi.fn(async (_url: string, init?: RequestInit) => {
authorizationHeaders.push(new Headers(init?.headers).get("authorization"));
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
}) as unknown as typeof fetch;
const providerConfig = {
model: "gpt-4o-mini-tts",
voice: "alloy",
};
expect(provider.isConfigured({ providerConfig, timeoutMs: 30_000 })).toBe(true);
await provider.synthesize({
text: "hello",
cfg: {} as never,
providerConfig,
target: "audio-file",
timeoutMs: 1_000,
});
await provider.synthesizeTelephony?.({
text: "hello",
cfg: {} as never,
providerConfig,
timeoutMs: 1_000,
});
expect(authorizationHeaders).toEqual(["Bearer sk-env", "Bearer sk-env"]);
});
it("preserves talk responseFormat overrides", () => {
const provider = buildOpenAISpeechProvider();
+7 -3
View File
@@ -50,6 +50,10 @@ type OpenAITtsProviderOverrides = {
speed?: number;
};
function resolveOpenAISpeechApiKey(config: OpenAITtsProviderConfig): string | undefined {
return trimToUndefined(config.apiKey) ?? trimToUndefined(process.env.OPENAI_API_KEY);
}
function normalizeOpenAISpeechResponseFormat(
value: unknown,
): OpenAiSpeechResponseFormat | undefined {
@@ -322,7 +326,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
}),
listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
isConfigured: ({ providerConfig }) =>
Boolean(readOpenAIProviderConfig(providerConfig).apiKey || process.env.OPENAI_API_KEY),
Boolean(resolveOpenAISpeechApiKey(readOpenAIProviderConfig(providerConfig))),
prepareSynthesis: (ctx) => {
const config = readOpenAIProviderConfig(ctx.providerConfig);
if (config.instructions) {
@@ -343,7 +347,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
synthesize: async (req) => {
const config = readOpenAIProviderConfig(req.providerConfig);
const overrides = readOpenAIOverrides(req.providerOverrides, config.baseUrl);
const apiKey = config.apiKey || process.env.OPENAI_API_KEY;
const apiKey = resolveOpenAISpeechApiKey(config);
if (!apiKey) {
throw new Error("OpenAI API key missing");
}
@@ -378,7 +382,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
synthesizeTelephony: async (req) => {
const config = readOpenAIProviderConfig(req.providerConfig);
const overrides = readOpenAIOverrides(req.providerOverrides, config.baseUrl);
const apiKey = config.apiKey || process.env.OPENAI_API_KEY;
const apiKey = resolveOpenAISpeechApiKey(config);
if (!apiKey) {
throw new Error("OpenAI API key missing");
}