Merge pull request #87273 from BSG2000/fix/talk-openai-realtime-auth-diagnostic-pr

* commit '80a12e30c1e40cb4a49698cb9b556d74fb055174':
  fix(openai): bound realtime websocket auth errors
This commit is contained in:
Vincent Koc
2026-07-31 19:16:38 +08:00
2 changed files with 132 additions and 6 deletions
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
const INTERNAL_REALTIME_VOICE_PROVIDER = Symbol.for("openclaw.internal.realtime-voice-provider.v1");
const OPENAI_REALTIME_REJECTED_KEY_MESSAGE =
"OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source";
function readInternalRealtimeVoiceProviderApi(provider: object) {
return Reflect.get(provider, INTERNAL_REALTIME_VOICE_PROVIDER) as {
@@ -2114,7 +2116,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
Buffer.from(
JSON.stringify({
type: "error",
error: { message: "Incorrect API key provided" },
error: { message: "Incorrect API key provided: sk-proj-***" },
}),
),
);
@@ -2123,18 +2125,105 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
Buffer.from(
JSON.stringify({
type: "error",
error: { message: "Incorrect API key provided" },
error: { message: "Incorrect API key provided: sk-proj-***" },
}),
),
);
await expect(connecting).rejects.toThrow("Incorrect API key provided");
await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE);
expect(onError).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(socket.closed).toBe(true);
expect(bridge.isConnected()).toBe(false);
});
it("normalizes structured direct OpenAI startup auth errors", async () => {
const provider = buildOpenAIRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const connecting = bridge.connect();
const socket = FakeWebSocket.instances[0];
if (!socket) {
throw new Error("expected bridge to create a websocket");
}
socket.readyState = FakeWebSocket.OPEN;
socket.emit("open");
socket.emit(
"message",
Buffer.from(
JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
code: "invalid_api_key",
message: "Invalid API key",
},
}),
),
);
await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE);
expect(bridge.isConnected()).toBe(false);
});
it("normalizes direct OpenAI socket handshake auth errors", async () => {
const provider = buildOpenAIRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const connecting = bridge.connect();
const socket = FakeWebSocket.instances[0];
if (!socket) {
throw new Error("expected bridge to create a websocket");
}
socket.emit("error", new Error("Unexpected server response: 401"));
await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE);
expect(bridge.isConnected()).toBe(false);
});
it.each([
[
"Azure deployment",
{
apiKey: "sk-test", // pragma: allowlist secret
azureEndpoint: "https://example.openai.azure.com",
azureDeployment: "realtime-prod",
},
],
[
"custom endpoint",
{
apiKey: "sk-test", // pragma: allowlist secret
azureEndpoint: "https://realtime-proxy.example.com",
},
],
])("preserves %s startup auth errors", async (_label, providerConfig) => {
const provider = buildOpenAIRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig,
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const connecting = bridge.connect();
const socket = FakeWebSocket.instances[0];
if (!socket) {
throw new Error("expected bridge to create a websocket");
}
socket.emit("error", new Error("Unexpected server response: 401"));
await expect(connecting).rejects.toThrow("Unexpected server response: 401");
expect(bridge.isConnected()).toBe(false);
});
it("keeps a retried connection ready after delayed startup failure close", async () => {
const provider = buildOpenAIRealtimeVoiceProvider();
const onClose = vi.fn();
@@ -2163,7 +2252,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
),
);
await expect(failedConnect).rejects.toThrow("Incorrect API key provided");
await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE);
expect(failedSocket.deferredClose).toBeDefined();
const retryConnect = bridge.connect();
+39 -2
View File
@@ -306,6 +306,30 @@ const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/;
const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000;
const resolvedKeychainSecretRefCache = new Map<string, string>();
function isDirectOpenAIRealtimeWebSocketUrl(value: string): boolean {
try {
return new URL(value).hostname === "api.openai.com";
} catch {
return false;
}
}
function isOpenAIRealtimeStartupAuthFailure(error: unknown): boolean {
const record =
typeof error === "object" && error !== null ? (error as Record<string, unknown>) : undefined;
const status = record?.status ?? record?.statusCode;
const rawCode = record?.code ?? record?.errorCode;
const code = typeof rawCode === "string" ? rawCode.toLowerCase() : "";
const message = readRealtimeErrorDetail(error).toLowerCase();
return (
status === 401 ||
code === "invalid_api_key" ||
message.includes("invalid_api_key") ||
message.includes("incorrect api key provided") ||
message.includes("unexpected server response: 401")
);
}
function resolveKeychainSecretRef(value: string): string | undefined {
const trimmed = value.trim();
const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed);
@@ -880,7 +904,14 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
try {
const event = JSON.parse(data.toString()) as RealtimeEvent;
if (event.type === "error" && !reachedReady) {
rejectStartup(new Error(readRealtimeErrorDetail(event.error)));
// Only direct OpenAI auth failures get bounded remediation. Azure,
// custom endpoints, and non-auth startup details remain provider-owned.
rejectStartup(
isDirectOpenAIRealtimeWebSocketUrl(url) &&
isOpenAIRealtimeStartupAuthFailure(event.error)
? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED)
: new Error(readRealtimeErrorDetail(event.error)),
);
return;
}
this.handleEvent(event, lifecycleConnection);
@@ -914,7 +945,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
},
});
if (!reachedReady) {
rejectStartup(error instanceof Error ? error : new Error(String(error)));
const startupError = error instanceof Error ? error : new Error(String(error));
rejectStartup(
isDirectOpenAIRealtimeWebSocketUrl(url) &&
isOpenAIRealtimeStartupAuthFailure(startupError)
? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED)
: startupError,
);
return;
}
this.config.onError?.(error instanceof Error ? error : new Error(String(error)));