Skip to content

Commit 95de986

Browse files
BSG2000Copilot
andcommitted
fix(talk): diagnose invalid OpenAI realtime auth
Detect keys that cannot be direct OpenAI Platform keys (Azure model keys, OpenAI-compatible proxy keys) before minting api.openai.com Realtime client secrets or opening direct Realtime websockets, and surface an actionable auth diagnostic. Preserves proxy-looking keys for custom OpenAI-compatible Realtime endpoints (azureEndpoint), Azure deployment auth failures, and the existing post-request client-secret rejection message. Adds OPENCLAW_OPENAI_REALTIME_ALLOW_UNVALIDATED_KEY escape hatch and docs. Co-authored-by: Copilot <[email protected]>
1 parent 59640ba commit 95de986

4 files changed

Lines changed: 337 additions & 9 deletions

File tree

docs/nodes/talk.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ Supported keys: `voice` / `voice_id` / `voiceId`, `model` / `model_id` / `modelI
117117
| `realtime.consultRouting` | - | `provider-direct` preserves the provider's direct reply when it skips `openclaw_agent_consult`; `force-agent-consult` routes finalized user transcripts through OpenClaw instead. |
118118
| `realtime.instructions` | - | Appends provider-facing system instructions to OpenClaw's built-in realtime prompt (voice style/tone); the default `openclaw_agent_consult` guidance stays. |
119119

120+
`realtime.providers.openai.apiKey` must be a direct OpenAI Platform key for OpenAI Realtime against `api.openai.com`, not an Azure AI Foundry / Azure OpenAI model key or OpenAI-compatible proxy key. Use `gateway-relay` with `azureEndpoint` + `azureDeployment` for Azure Realtime, or an endpoint-only `azureEndpoint` for a custom OpenAI-compatible Realtime proxy. `OPENCLAW_OPENAI_REALTIME_ALLOW_UNVALIDATED_KEY` is an emergency override for unusual direct OpenAI Platform key formats that are incorrectly rejected before an `api.openai.com` Realtime request; prefer fixing the configured auth path instead of leaving this enabled.
121+
120122
`talk.catalog` exposes canonical provider ids and registry aliases, each provider's valid modes/transports/brain strategies/realtime audio formats/capability flags, and the runtime-selected readiness result. First-party Talk clients should read that catalog instead of maintaining provider aliases locally; treat an older Gateway that omits group readiness as unverified rather than definitively unconfigured. Streaming transcription providers are discovered through `talk.catalog.transcription`; the current Gateway relay uses the Voice Call streaming provider config until a dedicated Talk transcription config surface ships.
121123

122124
## macOS UI

docs/providers/openai.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,20 @@ compatibility fallback when the shared
792792

793793
```json5
794794
{
795+
<Note>
796+
OpenAI Realtime Talk auth is not shared with Azure AI Foundry or Azure
797+
OpenAI model providers. Browser WebRTC sessions and native OpenAI backend
798+
bridges need a direct OpenAI Platform API key; OpenAI-compatible proxy keys are not valid for the
799+
`api.openai.com` Realtime client-secret flow. Azure Realtime voice uses the
800+
backend `gateway-relay` path with `azureEndpoint` and `azureDeployment`.
801+
Endpoint-only `azureEndpoint` configs remain available for custom
802+
OpenAI-compatible Realtime proxies. If an unusual direct OpenAI Platform key
803+
format is incorrectly rejected before an `api.openai.com` Realtime request,
804+
`OPENCLAW_OPENAI_REALTIME_ALLOW_UNVALIDATED_KEY=1` bypasses the local key
805+
shape check; prefer fixing the configured auth path instead of leaving that
806+
override enabled.
807+
</Note>
808+
795809
tools: {
796810
media: {
797811
audio: {

extensions/openai/realtime-voice-provider.test.ts

Lines changed: 222 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,35 @@ function requireFetchJsonBody(callIndex = 0): Record<string, unknown> {
232232
return requireRecord(JSON.parse(body as string), "fetch JSON body");
233233
}
234234

235+
function expectDirectOpenAIRealtimeAuthMessage(error: unknown, unexpectedSecret?: string): void {
236+
const message = error instanceof Error ? error.message : String(error);
237+
expect(message).toContain(
238+
"OpenAI Realtime voice provider 'openai' requires a direct OpenAI Platform API key",
239+
);
240+
expect(message).toContain("Azure AI Foundry / Azure OpenAI model credentials");
241+
expect(message).toContain("OpenAI-compatible proxy keys");
242+
expect(message).toContain("surface-specific provider apiKey");
243+
if (unexpectedSecret) {
244+
expect(message).not.toContain(unexpectedSecret);
245+
}
246+
if (error instanceof Error) {
247+
expect(error.cause).toBeUndefined();
248+
}
249+
}
250+
251+
async function expectRejectsDirectOpenAIRealtimeAuth(
252+
promise: Promise<unknown>,
253+
unexpectedSecret?: string,
254+
): Promise<void> {
255+
try {
256+
await promise;
257+
} catch (error) {
258+
expectDirectOpenAIRealtimeAuthMessage(error, unexpectedSecret);
259+
return;
260+
}
261+
throw new Error("Expected OpenAI Realtime direct auth diagnostic");
262+
}
263+
235264
function requireSession(socket: FakeWebSocketInstance, index = 0): Record<string, unknown> {
236265
return requireRecord(parseSent(socket)[index]?.session, "session");
237266
}
@@ -490,6 +519,52 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
490519
expect(FakeWebSocket.instances).toHaveLength(0);
491520
});
492521

522+
it("preserves configured proxy-looking keys for custom realtime endpoints", () => {
523+
const provider = buildOpenAIRealtimeVoiceProvider();
524+
const bridge = provider.createBridge({
525+
providerConfig: {
526+
apiKey: "sk-litellm-custom-endpoint-key", // pragma: allowlist secret
527+
azureEndpoint: "https://realtime-proxy.example.com",
528+
model: "gpt-realtime-2",
529+
},
530+
onAudio: vi.fn(),
531+
onClearAudio: vi.fn(),
532+
});
533+
534+
void bridge.connect();
535+
bridge.close();
536+
537+
const socket = FakeWebSocket.instances[0];
538+
expect(socket?.args[0]).toBe(
539+
"wss://realtime-proxy.example.com/v1/realtime?model=gpt-realtime-2",
540+
);
541+
const options = socket?.args[1] as { headers?: Record<string, string> } | undefined;
542+
expect(options?.headers?.Authorization).toBe("Bearer sk-litellm-custom-endpoint-key");
543+
});
544+
545+
it("preserves env proxy-looking keys for custom realtime endpoints", () => {
546+
vi.stubEnv("OPENAI_API_KEY", "sk-or-custom-endpoint-key"); // pragma: allowlist secret
547+
const provider = buildOpenAIRealtimeVoiceProvider();
548+
const bridge = provider.createBridge({
549+
providerConfig: {
550+
azureEndpoint: "https://realtime-proxy.example.com",
551+
model: "gpt-realtime-2",
552+
},
553+
onAudio: vi.fn(),
554+
onClearAudio: vi.fn(),
555+
});
556+
557+
void bridge.connect();
558+
bridge.close();
559+
560+
const socket = FakeWebSocket.instances[0];
561+
expect(socket?.args[0]).toBe(
562+
"wss://realtime-proxy.example.com/v1/realtime?model=gpt-realtime-2",
563+
);
564+
const options = socket?.args[1] as { headers?: Record<string, string> } | undefined;
565+
expect(options?.headers?.Authorization).toBe("Bearer sk-or-custom-endpoint-key");
566+
});
567+
493568
it("returns browser-safe OpenClaw attribution headers for native WebRTC offers", async () => {
494569
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
495570
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -775,6 +850,119 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
775850
});
776851
});
777852

853+
it("rejects obvious proxy API keys before minting browser realtime client secrets", async () => {
854+
const proxyKey = "sk-litellm-realtime-proxy-key"; // pragma: allowlist secret
855+
vi.stubEnv("OPENAI_API_KEY", proxyKey);
856+
const provider = buildOpenAIRealtimeVoiceProvider();
857+
if (!provider.createBrowserSession) {
858+
throw new Error("expected OpenAI realtime provider to support browser sessions");
859+
}
860+
861+
await expectRejectsDirectOpenAIRealtimeAuth(
862+
provider.createBrowserSession({
863+
cfg: {
864+
models: {
865+
providers: {
866+
"azure-openai-responses": {
867+
baseUrl: "https://example.services.ai.azure.com/models",
868+
models: [],
869+
},
870+
},
871+
},
872+
} as never,
873+
providerConfig: {},
874+
instructions: "Be concise.",
875+
}),
876+
proxyKey,
877+
);
878+
879+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
880+
});
881+
882+
it("rejects Azure-looking API keys before browser realtime client secret requests", async () => {
883+
const azureKey = "0123456789abcdef0123456789abcdef"; // pragma: allowlist secret
884+
vi.stubEnv("OPENAI_API_KEY", azureKey);
885+
const provider = buildOpenAIRealtimeVoiceProvider();
886+
if (!provider.createBrowserSession) {
887+
throw new Error("expected OpenAI realtime provider to support browser sessions");
888+
}
889+
890+
await expectRejectsDirectOpenAIRealtimeAuth(
891+
provider.createBrowserSession({
892+
providerConfig: {},
893+
instructions: "Be concise.",
894+
}),
895+
azureKey,
896+
);
897+
898+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
899+
});
900+
901+
it("rejects configured proxy API keys before browser realtime client secret requests", async () => {
902+
const configuredProxyKey = "sk-litel-configured-proxy-key"; // pragma: allowlist secret
903+
const provider = buildOpenAIRealtimeVoiceProvider();
904+
if (!provider.createBrowserSession) {
905+
throw new Error("expected OpenAI realtime provider to support browser sessions");
906+
}
907+
908+
await expectRejectsDirectOpenAIRealtimeAuth(
909+
provider.createBrowserSession({
910+
providerConfig: { apiKey: configuredProxyKey },
911+
instructions: "Be concise.",
912+
}),
913+
configuredProxyKey,
914+
);
915+
916+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
917+
});
918+
919+
it("rejects keychain-resolved proxy API keys before browser realtime client secret requests", async () => {
920+
const resolvedProxyKey = "sk-litellm-keychain-proxy-key"; // pragma: allowlist secret
921+
vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_PROXY_TEST");
922+
execFileSyncMock.mockReturnValueOnce(`${resolvedProxyKey}\n`);
923+
const provider = buildOpenAIRealtimeVoiceProvider();
924+
if (!provider.createBrowserSession) {
925+
throw new Error("expected OpenAI realtime provider to support browser sessions");
926+
}
927+
928+
await expectRejectsDirectOpenAIRealtimeAuth(
929+
provider.createBrowserSession({
930+
providerConfig: {},
931+
instructions: "Be concise.",
932+
}),
933+
resolvedProxyKey,
934+
);
935+
936+
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
937+
});
938+
939+
it("preserves non-auth 403 responses from realtime client secret requests", async () => {
940+
const directLookingKey = "sk-test-direct-looking-valid-key"; // pragma: allowlist secret
941+
fetchWithSsrFGuardMock.mockResolvedValueOnce({
942+
response: createJsonResponse(
943+
{
944+
error: {
945+
code: "model_access_denied",
946+
message: "Project does not have access to the requested Realtime model.",
947+
},
948+
},
949+
{ status: 403 },
950+
),
951+
release: vi.fn(async () => undefined),
952+
});
953+
const provider = buildOpenAIRealtimeVoiceProvider();
954+
if (!provider.createBrowserSession) {
955+
throw new Error("expected OpenAI realtime provider to support browser sessions");
956+
}
957+
958+
await expect(
959+
provider.createBrowserSession({
960+
providerConfig: { apiKey: directLookingKey },
961+
instructions: "Be concise.",
962+
}),
963+
).rejects.toThrow("OpenAI Realtime client secret failed (403)");
964+
});
965+
778966
it("fails closed when keychain refs cannot be resolved", async () => {
779967
vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_MISSING_TEST");
780968
resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce(undefined);
@@ -1243,6 +1431,38 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
12431431
});
12441432
});
12451433

1434+
it("preserves Azure deployment startup auth failures", async () => {
1435+
const provider = buildOpenAIRealtimeVoiceProvider();
1436+
const bridge = provider.createBridge({
1437+
providerConfig: {
1438+
apiKey: "sk-test", // pragma: allowlist secret
1439+
azureEndpoint: "https://example.openai.azure.com/",
1440+
azureDeployment: "realtime-prod",
1441+
},
1442+
onAudio: vi.fn(),
1443+
onClearAudio: vi.fn(),
1444+
});
1445+
const connecting = bridge.connect();
1446+
const socket = FakeWebSocket.instances[0];
1447+
if (!socket) {
1448+
throw new Error("expected bridge to create a websocket");
1449+
}
1450+
1451+
socket.emit("error", new Error("Unexpected server response: 401"));
1452+
1453+
try {
1454+
await connecting;
1455+
} catch (error) {
1456+
const message = error instanceof Error ? error.message : String(error);
1457+
expect(message).toContain("Unexpected server response: 401");
1458+
expect(message).not.toContain(
1459+
"OpenAI Realtime voice provider 'openai' requires a direct OpenAI Platform API key",
1460+
);
1461+
return;
1462+
}
1463+
throw new Error("Expected Azure realtime startup auth failure");
1464+
});
1465+
12461466
it("rejects connection when session configuration fails before readiness", async () => {
12471467
const provider = buildOpenAIRealtimeVoiceProvider();
12481468
const bridge = provider.createBridge({
@@ -1310,7 +1530,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
13101530
),
13111531
);
13121532

1313-
await expect(connecting).rejects.toThrow("Incorrect API key provided");
1533+
await expectRejectsDirectOpenAIRealtimeAuth(connecting);
13141534
expect(onError).not.toHaveBeenCalled();
13151535
expect(onClose).not.toHaveBeenCalled();
13161536
expect(socket.closed).toBe(true);
@@ -1345,7 +1565,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
13451565
),
13461566
);
13471567

1348-
await expect(failedConnect).rejects.toThrow("Incorrect API key provided");
1568+
await expectRejectsDirectOpenAIRealtimeAuth(failedConnect);
13491569
expect(failedSocket.deferredClose).toBeDefined();
13501570

13511571
const retryConnect = bridge.connect();

0 commit comments

Comments
 (0)