diff --git a/extensions/openai/realtime-provider-shared.ts b/extensions/openai/realtime-provider-shared.ts index cf202924cba..168f5ce0063 100644 --- a/extensions/openai/realtime-provider-shared.ts +++ b/extensions/openai/realtime-provider-shared.ts @@ -78,6 +78,7 @@ type OpenAIRealtimeSecretRequest = { url: string; body: unknown; errorMessage: string; + authRejectedMessage?: string; missingValueMessage: string; }; @@ -117,7 +118,14 @@ async function createOpenAIRealtimeSecret( const payload = await (async () => { try { if (!response.ok) { - throw await createProviderHttpError(response, params.errorMessage); + const error = await createProviderHttpError(response, params.errorMessage); + // Provider details can echo a masked credential while hiding which + // OpenClaw auth source won. Keep the status metadata, but give callers + // a bounded remediation for an explicitly configured key. + if (response.status === 401 && params.authRejectedMessage) { + error.message = params.authRejectedMessage; + } + throw error; } return await readProviderJsonResponse(response, "openai.realtime-session"); } finally { @@ -147,6 +155,7 @@ export async function createOpenAIRealtimeClientSecret(params: { authToken: string; auditContext: string; session: Record; + authRejectedMessage?: string; }): Promise { const url = `${OPENAI_REALTIME_API_BASE_URL}/realtime/client_secrets`; return createOpenAIRealtimeSecret({ diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 0cc9cba4a30..bd84218edd4 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -568,6 +568,60 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect((session as { offerHeaders?: Record }).offerHeaders).toBeUndefined(); }); + it.each(["configured", "profile", "environment"] as const)( + "explains how auth precedence affects a rejected %s API key", + async (source) => { + if (source === "profile") { + resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("sk-profile"); // pragma: allowlist secret + } else if (source === "environment") { + vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret + } + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse( + { error: { message: "Incorrect API key provided: sk-proj-***" } }, + { status: 401 }, + ), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + await expect( + provider.createBrowserSession({ + providerConfig: + source === "configured" + ? { apiKey: "sk-stale" } // pragma: allowlist secret + : {}, + }), + ).rejects.toThrow( + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source; Codex OAuth is used only when no API-key source is configured", + ); + }, + ); + + it("preserves the provider detail when Codex OAuth is rejected", async () => { + resolveProviderAuthProfileApiKeyMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("oauth-token"); // pragma: allowlist secret + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse( + { error: { message: "OAuth token expired" } }, + { status: 401 }, + ), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + await expect(provider.createBrowserSession({ providerConfig: {} })).rejects.toThrow( + "OAuth token expired", + ); + }); + it("omits unsupported OpenAI tool names from browser sessions", async () => { fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: createJsonResponse({ client_secret: { value: "client-secret-123" } }), diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index d84cef4fa57..3cace79fcc2 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -247,10 +247,15 @@ function asUnitInterval(value: unknown): number | undefined { type OpenAIRealtimeApiKeyResolution = | { status: "available"; value: string } | { status: "missing" }; +type OpenAIRealtimePlatformAuthResolution = + | { status: "available"; value: string; source: "api-key" | "oauth" } + | { status: "missing" }; const OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED = "OpenAI Realtime voice requires an OpenAI API key or Codex OAuth sign-in"; const OPENAI_REALTIME_API_KEY_REQUIRED = "OpenAI Realtime voice requires an API key"; +const OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED = + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source; Codex OAuth is used only when no API-key source is configured"; const OPENAI_REALTIME_PLATFORM_AUTH_PROFILE_TYPES = ["api_key", "oauth"] as const; const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/; const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000; @@ -380,13 +385,15 @@ function normalizeOpenAIRealtimeTools( async function resolveOpenAIRealtimePlatformAuth(params: { configuredApiKey: string | undefined; cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; -}): Promise { +}): Promise { const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey); if ( configured.status === "available" || hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey) ) { - return configured; + return configured.status === "available" + ? { ...configured, source: "api-key" } + : configured; } const profileApiKey = await resolveProviderAuthProfileApiKey({ @@ -395,7 +402,7 @@ async function resolveOpenAIRealtimePlatformAuth(params: { profileTypes: ["api_key"], }); if (profileApiKey) { - return { status: "available", value: profileApiKey }; + return { status: "available", value: profileApiKey, source: "api-key" }; } const hasConfiguredApiKeyProfile = isProviderAuthProfileConfigured({ provider: "openai", @@ -405,7 +412,7 @@ async function resolveOpenAIRealtimePlatformAuth(params: { const envApiKey = resolveOpenAIRealtimeEnvApiKey(); if (envApiKey.status === "available") { - return envApiKey; + return { ...envApiKey, source: "api-key" }; } if (hasConfiguredApiKeyProfile || hasOpenAIRealtimeApiKeyInput(undefined)) { return { status: "missing" }; @@ -418,19 +425,19 @@ async function resolveOpenAIRealtimePlatformAuth(params: { includeExternalCliAuth: true, }); if (oauthToken) { - return { status: "available", value: oauthToken }; + return { status: "available", value: oauthToken, source: "oauth" }; } return { status: "missing" }; } -async function requireOpenAIRealtimePlatformAuthToken(params: { +async function requireOpenAIRealtimePlatformAuth(params: { configuredApiKey: string | undefined; cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; -}): Promise { +}): Promise> { const resolved = await resolveOpenAIRealtimePlatformAuth(params); if (resolved.status === "available") { - return resolved.value; + return resolved; } throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED); } @@ -815,11 +822,11 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { url: string; headers: Record; }> { - const authToken = await requireOpenAIRealtimePlatformAuthToken({ + const auth = await requireOpenAIRealtimePlatformAuth({ configuredApiKey: this.config.apiKey, cfg: this.config.cfg, }); - return this.resolveApiKeyConnectionParams(authToken, model); + return this.resolveApiKeyConnectionParams(auth.value, model); } private resolveApiKeyConnectionParams( @@ -1465,7 +1472,7 @@ async function createOpenAIRealtimeBrowserSession( } const model = req.model ?? config.model ?? OPENAI_REALTIME_DEFAULT_MODEL; - const authToken = await requireOpenAIRealtimePlatformAuthToken({ + const auth = await requireOpenAIRealtimePlatformAuth({ configuredApiKey: config.apiKey, cfg: req.cfg, }); @@ -1507,9 +1514,12 @@ async function createOpenAIRealtimeBrowserSession( } const clientSecret = await createOpenAIRealtimeClientSecret({ - authToken, + authToken: auth.value, auditContext: "openai-realtime-browser-session", session, + ...(auth.source === "api-key" + ? { authRejectedMessage: OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED } + : {}), }); const offerHeaders = resolveOpenAIRealtimeBrowserOfferHeaders(); return {