fix(openai): clarify rejected Realtime Talk API keys [AI] (#102461)

* fix(openai): clarify realtime API key failures

* chore: leave release notes to release workflow
This commit is contained in:
Peter Steinberger 2026-07-09 07:41:33 +01:00 committed by GitHub
parent 20f355f236
commit fb3f0a2889
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 86 additions and 13 deletions

View file

@ -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<unknown>(response, "openai.realtime-session");
} finally {
@ -147,6 +155,7 @@ export async function createOpenAIRealtimeClientSecret(params: {
authToken: string;
auditContext: string;
session: Record<string, unknown>;
authRejectedMessage?: string;
}): Promise<OpenAIRealtimeClientSecretResult> {
const url = `${OPENAI_REALTIME_API_BASE_URL}/realtime/client_secrets`;
return createOpenAIRealtimeSecret({

View file

@ -568,6 +568,60 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
expect((session as { offerHeaders?: Record<string, string> }).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" } }),

View file

@ -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<OpenAIRealtimeApiKeyResolution> {
}): Promise<OpenAIRealtimePlatformAuthResolution> {
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<string> {
}): Promise<Extract<OpenAIRealtimePlatformAuthResolution, { status: "available" }>> {
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<string, string>;
}> {
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 {