@@ -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+
235264function 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