From 4108bbca96cde0aef2595316ba8a57adcc04bea8 Mon Sep 17 00:00:00 2001 From: lsr911 Date: Thu, 16 Jul 2026 00:43:42 +0800 Subject: [PATCH] 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 * fix(openai): normalize realtime transcription env key * chore(ci): prune stale max-lines baseline --------- Co-authored-by: Peter Steinberger Co-authored-by: luyifan Co-authored-by: Peter Steinberger --- .../realtime-transcription-provider.test.ts | 7 +++ .../openai/realtime-transcription-provider.ts | 2 +- extensions/openai/speech-provider.test.ts | 63 +++++++++++++++++++ extensions/openai/speech-provider.ts | 10 ++- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/extensions/openai/realtime-transcription-provider.test.ts b/extensions/openai/realtime-transcription-provider.test.ts index 2316bbc981df..831c86d03e41 100644 --- a/extensions/openai/realtime-transcription-provider.test.ts +++ b/extensions/openai/realtime-transcription-provider.test.ts @@ -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(); diff --git a/extensions/openai/realtime-transcription-provider.ts b/extensions/openai/realtime-transcription-provider.ts index a98451e4ff72..e56db8ea6a73 100644 --- a/extensions/openai/realtime-transcription-provider.ts +++ b/extensions/openai/realtime-transcription-provider.ts @@ -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) => { diff --git a/extensions/openai/speech-provider.test.ts b/extensions/openai/speech-provider.test.ts index 75a56e4d3788..dbd5fe398717 100644 --- a/extensions/openai/speech-provider.test.ts +++ b/extensions/openai/speech-provider.test.ts @@ -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 = []; + 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(); diff --git a/extensions/openai/speech-provider.ts b/extensions/openai/speech-provider.ts index 8b7e901d55ec..7db8e0df54bc 100644 --- a/extensions/openai/speech-provider.ts +++ b/extensions/openai/speech-provider.ts @@ -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"); }