mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user