diff --git a/extensions/openai/realtime-provider-shared.ts b/extensions/openai/realtime-provider-shared.ts index 0a3303352cba..12a5eb925778 100644 --- a/extensions/openai/realtime-provider-shared.ts +++ b/extensions/openai/realtime-provider-shared.ts @@ -153,12 +153,13 @@ export async function createOpenAIRealtimeTranscriptionClientSecret(params: { authToken: string; auditContext: string; session: Record; + authRejectedMessage?: string; }): Promise { const url = "https://api.openai.com/v1/realtime/transcription_sessions"; return createOpenAIRealtimeSecret({ ...params, url, - body: params.session, + body: { session: params.session }, errorMessage: "OpenAI Realtime transcription client secret failed", missingValueMessage: "OpenAI Realtime transcription client secret response did not include a value", diff --git a/extensions/openai/realtime-transcription-provider.test.ts b/extensions/openai/realtime-transcription-provider.test.ts index 3db9bd109806..7fb2392cd235 100644 --- a/extensions/openai/realtime-transcription-provider.test.ts +++ b/extensions/openai/realtime-transcription-provider.test.ts @@ -203,7 +203,7 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => { expect(provider.aliases).toContain("openai-realtime"); }); - it("treats a Codex OAuth profile as configured when no API key is present", () => { + it("treats an OpenAI API-key profile as configured", () => { const provider = buildOpenAIRealtimeTranscriptionProvider(); const cfg = { auth: { order: { openai: ["openai:default"] } } }; providerAuthMocks.isProviderAuthProfileConfigured.mockReturnValue(true); @@ -212,13 +212,14 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => { expect(providerAuthMocks.isProviderAuthProfileConfigured).toHaveBeenCalledWith({ provider: "openai", cfg, + profileTypes: ["api_key"], }); }); - it("mints a Codex OAuth client secret for realtime transcription sockets", async () => { + it("mints an API-key client secret for realtime transcription sockets", async () => { const provider = buildOpenAIRealtimeTranscriptionProvider(); const release = vi.fn(); - providerAuthMocks.resolveProviderAuthProfileApiKey.mockResolvedValue("oauth-token"); + providerAuthMocks.resolveProviderAuthProfileApiKey.mockResolvedValue("sk-profile"); // pragma: allowlist secret ssrfMocks.fetchWithSsrFGuard.mockResolvedValue({ response: new Response(JSON.stringify({ value: "ek-test" }), { status: 200 }), release, @@ -236,6 +237,7 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => { expect(providerAuthMocks.resolveProviderAuthProfileApiKey).toHaveBeenCalledWith({ provider: "openai", cfg, + profileTypes: ["api_key"], }); const request = mockCallArg(ssrfMocks.fetchWithSsrFGuard); expect(request.auditContext).toBe("openai-realtime-transcription-session"); @@ -246,20 +248,22 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => { body?: unknown; }; expect(init.method).toBe("POST"); - expect(init.headers?.Authorization).toBe("Bearer oauth-token"); + expect(init.headers?.Authorization).toBe("Bearer sk-profile"); expect(init.headers?.["Content-Type"]).toBe("application/json"); expect(typeof init.body).toBe("string"); expect(JSON.parse(init.body as string)).toEqual({ - type: "transcription", - audio: { - input: { - format: { type: "audio/pcmu" }, - transcription: { model: "gpt-4o-transcribe" }, - turn_detection: { - type: "server_vad", - threshold: 0.5, - prefix_padding_ms: 300, - silence_duration_ms: 800, + session: { + type: "transcription", + audio: { + input: { + format: { type: "audio/pcmu" }, + transcription: { model: "gpt-4o-transcribe" }, + turn_detection: { + type: "server_vad", + threshold: 0.5, + prefix_padding_ms: 300, + silence_duration_ms: 800, + }, }, }, }, @@ -292,6 +296,48 @@ describe("buildOpenAIRealtimeTranscriptionProvider", () => { session.close(); }); + it("does not use Codex OAuth for realtime transcription", async () => { + const provider = buildOpenAIRealtimeTranscriptionProvider(); + const cfg = { auth: { order: { openai: ["openai:default"] } } }; + const session = provider.createSession({ cfg: cfg as never, providerConfig: {} }); + + await expect(session.connect()).rejects.toThrow( + "OpenAI Realtime transcription requires an OpenAI Platform API key", + ); + expect(providerAuthMocks.resolveProviderAuthProfileApiKey).toHaveBeenCalledWith({ + provider: "openai", + cfg, + profileTypes: ["api_key"], + }); + expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it("prefers an API-key profile over OPENAI_API_KEY", async () => { + vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret + providerAuthMocks.resolveProviderAuthProfileApiKey.mockResolvedValue("sk-profile"); // pragma: allowlist secret + ssrfMocks.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response(JSON.stringify({ value: "ek-test" }), { status: 200 }), + release: vi.fn(), + }); + const provider = buildOpenAIRealtimeTranscriptionProvider(); + const session = provider.createSession({ providerConfig: {} }); + + const connecting = session.connect(); + const socket = await waitForFakeSocket(); + + expect(socket.headers?.Authorization).toBe("Bearer ek-test"); + const request = mockCallArg(ssrfMocks.fetchWithSsrFGuard); + const init = request.init as { headers?: Record }; + expect(init.headers?.Authorization).toBe("Bearer sk-profile"); + + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + socket.emit("message", Buffer.from(JSON.stringify({ type: "transcription_session.updated" }))); + await connecting; + session.close(); + }); + it("waits for the OpenAI session update before draining audio", async () => { const provider = buildOpenAIRealtimeTranscriptionProvider(); const session = provider.createSession({ diff --git a/extensions/openai/realtime-transcription-provider.ts b/extensions/openai/realtime-transcription-provider.ts index 6de735bdd957..a98451e4ff72 100644 --- a/extensions/openai/realtime-transcription-provider.ts +++ b/extensions/openai/realtime-transcription-provider.ts @@ -73,6 +73,10 @@ const OPENAI_REALTIME_TRANSCRIPTION_CONNECT_TIMEOUT_MS = 10_000; const OPENAI_REALTIME_TRANSCRIPTION_MAX_RECONNECT_ATTEMPTS = 5; const OPENAI_REALTIME_TRANSCRIPTION_RECONNECT_DELAY_MS = 1000; const OPENAI_REALTIME_TRANSCRIPTION_DEFAULT_MODEL = "gpt-4o-transcribe"; +const OPENAI_REALTIME_TRANSCRIPTION_API_KEY_REQUIRED = + "OpenAI Realtime transcription requires an OpenAI Platform API key"; +const OPENAI_REALTIME_TRANSCRIPTION_API_KEY_REJECTED = + "OpenAI Realtime transcription rejected the selected API key. Update or remove the active OpenAI API-key source"; function normalizeProviderConfig( config: RealtimeTranscriptionProviderConfig, @@ -139,23 +143,28 @@ function buildOpenAIRealtimeTranscriptionSessionPayload( async function resolveOpenAIRealtimeTranscriptionAuthorization( config: OpenAIRealtimeTranscriptionSessionConfig, ): Promise { - const apiKey = config.apiKey || process.env.OPENAI_API_KEY; - if (apiKey) { - return apiKey; + if (config.apiKey) { + return config.apiKey; } const authToken = await resolveProviderAuthProfileApiKey({ provider: "openai", cfg: config.cfg, + profileTypes: ["api_key"], }); - if (!authToken) { - throw new Error("OpenAI API key or Codex OAuth missing"); + if (authToken) { + const clientSecret = await createOpenAIRealtimeTranscriptionClientSecret({ + authToken, + auditContext: "openai-realtime-transcription-session", + session: buildOpenAIRealtimeTranscriptionSessionPayload(config), + authRejectedMessage: OPENAI_REALTIME_TRANSCRIPTION_API_KEY_REJECTED, + }); + return clientSecret.value; } - const clientSecret = await createOpenAIRealtimeTranscriptionClientSecret({ - authToken, - auditContext: "openai-realtime-transcription-session", - session: buildOpenAIRealtimeTranscriptionSessionPayload(config), - }); - return clientSecret.value; + const envApiKey = process.env.OPENAI_API_KEY?.trim(); + if (envApiKey) { + return envApiKey; + } + throw new Error(OPENAI_REALTIME_TRANSCRIPTION_API_KEY_REQUIRED); } function createOpenAIRealtimeTranscriptionSession( @@ -260,7 +269,7 @@ export function buildOpenAIRealtimeTranscriptionProvider(): RealtimeTranscriptio Boolean( normalizeProviderConfig(providerConfig).apiKey || process.env.OPENAI_API_KEY || - isProviderAuthProfileConfigured({ provider: "openai", cfg }), + isProviderAuthProfileConfigured({ provider: "openai", cfg, profileTypes: ["api_key"] }), ), createSession: (req) => { const config = normalizeProviderConfig(req.providerConfig);