fix(openai): correct Realtime auth and transcription secrets (#102518)

* fix(openai): correct Realtime auth and transcription secrets

* chore: leave release notes to release automation

(cherry picked from commit 9566aded5c)
This commit is contained in:
Peter Steinberger
2026-07-09 08:55:31 +01:00
committed by Dallin Romney
parent be2f69eff6
commit fb988756c0
3 changed files with 83 additions and 27 deletions
@@ -153,12 +153,13 @@ export async function createOpenAIRealtimeTranscriptionClientSecret(params: {
authToken: string;
auditContext: string;
session: Record<string, unknown>;
authRejectedMessage?: string;
}): Promise<OpenAIRealtimeClientSecretResult> {
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",
@@ -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<string, string> };
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({
@@ -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<string> {
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);