@@ -16,6 +16,7 @@ const OPENAI_REALTIME_MODEL =
1616 process . env . OPENCLAW_REALTIME_OPENAI_MODEL ?. trim ( ) || "gpt-realtime-2" ;
1717const OPENAI_REALTIME_VOICE = process . env . OPENCLAW_REALTIME_OPENAI_VOICE ?. trim ( ) || "alloy" ;
1818const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000 ;
19+ const OPENAI_HTTP_RESPONSE_MAX_BYTES = 256 * 1024 ;
1920const GOOGLE_REALTIME_MODEL =
2021 process . env . OPENCLAW_REALTIME_GOOGLE_MODEL ?. trim ( ) ||
2122 "gemini-2.5-flash-native-audio-preview-12-2025" ;
@@ -49,9 +50,104 @@ function shortError(error: unknown): string {
4950 return previewForDevToolLog ( error instanceof Error ? error . message : String ( error ) , 800 ) ;
5051}
5152
52- async function readBoundedText ( response : Response ) : Promise < string > {
53- const text = await response . text ( ) ;
54- return previewForDevToolLog ( text , 600 ) ;
53+ function responseBodyTooLargeError ( label : string , maxBytes : number ) : Error {
54+ return new Error ( `${ label } response body exceeded ${ maxBytes } bytes` ) ;
55+ }
56+
57+ async function readBoundedText (
58+ response : Response ,
59+ label : string ,
60+ maxBytes = OPENAI_HTTP_RESPONSE_MAX_BYTES ,
61+ signal ?: AbortSignal ,
62+ ) : Promise < string > {
63+ const contentLength = Number ( response . headers . get ( "content-length" ) ?? "" ) ;
64+ if ( Number . isSafeInteger ( contentLength ) && contentLength > maxBytes ) {
65+ await response . body ?. cancel ( ) . catch ( ( ) => undefined ) ;
66+ throw responseBodyTooLargeError ( label , maxBytes ) ;
67+ }
68+
69+ if ( ! response . body ) {
70+ return "" ;
71+ }
72+
73+ const reader = response . body . getReader ( ) ;
74+ const decoder = new TextDecoder ( ) ;
75+ const chunks : string [ ] = [ ] ;
76+ let totalBytes = 0 ;
77+ let canceled = false ;
78+
79+ try {
80+ for ( ; ; ) {
81+ const { done, value } = await readResponseChunk ( reader , label , signal , ( ) => {
82+ canceled = true ;
83+ } ) ;
84+ if ( done ) {
85+ const tail = decoder . decode ( ) ;
86+ if ( tail ) {
87+ chunks . push ( tail ) ;
88+ }
89+ break ;
90+ }
91+
92+ totalBytes += value . byteLength ;
93+ if ( totalBytes > maxBytes ) {
94+ canceled = true ;
95+ await reader . cancel ( ) . catch ( ( ) => undefined ) ;
96+ throw responseBodyTooLargeError ( label , maxBytes ) ;
97+ }
98+ chunks . push ( decoder . decode ( value , { stream : true } ) ) ;
99+ }
100+ } finally {
101+ if ( ! canceled ) {
102+ reader . releaseLock ( ) ;
103+ }
104+ }
105+
106+ return chunks . join ( "" ) ;
107+ }
108+
109+ async function readResponseChunk (
110+ reader : ReadableStreamDefaultReader < Uint8Array > ,
111+ label : string ,
112+ signal : AbortSignal | undefined ,
113+ markCanceled : ( ) => void ,
114+ ) : Promise < ReadableStreamReadResult < Uint8Array > > {
115+ if ( ! signal ) {
116+ return await reader . read ( ) ;
117+ }
118+ if ( signal . aborted ) {
119+ markCanceled ( ) ;
120+ await reader . cancel ( ) . catch ( ( ) => undefined ) ;
121+ throw signal . reason instanceof Error ? signal . reason : new Error ( `${ label } request aborted` ) ;
122+ }
123+
124+ let removeAbortListener : ( ( ) => void ) | undefined ;
125+ const abortPromise = new Promise < ReadableStreamReadResult < Uint8Array > > ( ( _resolve , reject ) => {
126+ const onAbort = ( ) => {
127+ markCanceled ( ) ;
128+ void reader . cancel ( ) . catch ( ( ) => undefined ) ;
129+ reject (
130+ signal . reason instanceof Error ? signal . reason : new Error ( `${ label } request aborted` ) ,
131+ ) ;
132+ } ;
133+ signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
134+ removeAbortListener = ( ) => signal . removeEventListener ( "abort" , onAbort ) ;
135+ } ) ;
136+
137+ try {
138+ return await Promise . race ( [ reader . read ( ) , abortPromise ] ) ;
139+ } finally {
140+ removeAbortListener ?.( ) ;
141+ }
142+ }
143+
144+ async function readBoundedJsonResponse (
145+ response : Response ,
146+ label : string ,
147+ signal ?: AbortSignal ,
148+ ) : Promise < Record < string , unknown > > {
149+ const text = await readBoundedText ( response , label , OPENAI_HTTP_RESPONSE_MAX_BYTES , signal ) ;
150+ return JSON . parse ( text ) as Record < string , unknown > ;
55151}
56152
57153function resolveOpenAIHttpTimeoutMs (
@@ -124,12 +220,18 @@ async function createOpenAIClientSecret(
124220 } ) ;
125221 if ( ! response . ok ) {
126222 throw new Error (
127- `OpenAI Realtime client secret failed (${ response . status } ): ${ await readBoundedText (
128- response ,
223+ `OpenAI Realtime client secret failed (${ response . status } ): ${ previewForDevToolLog (
224+ await readBoundedText (
225+ response ,
226+ "OpenAI Realtime client secret error" ,
227+ OPENAI_HTTP_RESPONSE_MAX_BYTES ,
228+ signal ,
229+ ) ,
230+ 600 ,
129231 ) } `,
130232 ) ;
131233 }
132- return ( await response . json ( ) ) as Record < string , unknown > ;
234+ return await readBoundedJsonResponse ( response , "OpenAI Realtime client secret" , signal ) ;
133235 } ,
134236 } ) ;
135237 const nested =
@@ -195,7 +297,56 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
195297 const page = await context . newPage ( ) ;
196298 await page . evaluate ( "globalThis.__name = (fn) => fn" ) ;
197299 const result = await page . evaluate (
198- async ( { clientSecret : secret , timeoutMs } ) => {
300+ async ( { clientSecret : secret , sdpAnswerMaxBytes, timeoutMs } ) => {
301+ const responseBodyTooLargeError = ( label : string , maxBytes : number ) : Error =>
302+ new Error ( `${ label } response body exceeded ${ maxBytes } bytes` ) ;
303+ const readBoundedText = async (
304+ response : Response ,
305+ label : string ,
306+ maxBytes : number ,
307+ ) : Promise < string > => {
308+ const contentLength = Number ( response . headers . get ( "content-length" ) ?? "" ) ;
309+ if ( Number . isSafeInteger ( contentLength ) && contentLength > maxBytes ) {
310+ await response . body ?. cancel ( ) . catch ( ( ) => undefined ) ;
311+ throw responseBodyTooLargeError ( label , maxBytes ) ;
312+ }
313+ if ( ! response . body ) {
314+ return "" ;
315+ }
316+
317+ const reader = response . body . getReader ( ) ;
318+ const decoder = new TextDecoder ( ) ;
319+ const chunks : string [ ] = [ ] ;
320+ let totalBytes = 0 ;
321+ let canceled = false ;
322+
323+ try {
324+ for ( ; ; ) {
325+ const { done, value } = await reader . read ( ) ;
326+ if ( done ) {
327+ const tail = decoder . decode ( ) ;
328+ if ( tail ) {
329+ chunks . push ( tail ) ;
330+ }
331+ break ;
332+ }
333+
334+ totalBytes += value . byteLength ;
335+ if ( totalBytes > maxBytes ) {
336+ canceled = true ;
337+ await reader . cancel ( ) . catch ( ( ) => undefined ) ;
338+ throw responseBodyTooLargeError ( label , maxBytes ) ;
339+ }
340+ chunks . push ( decoder . decode ( value , { stream : true } ) ) ;
341+ }
342+ } finally {
343+ if ( ! canceled ) {
344+ reader . releaseLock ( ) ;
345+ }
346+ }
347+
348+ return chunks . join ( "" ) ;
349+ } ;
199350 const withBrowserTimeout = async < T > (
200351 label : string ,
201352 run : ( signal : AbortSignal ) => Promise < T > ,
@@ -268,7 +419,11 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
268419 if ( ! response . ok ) {
269420 throw new Error ( `OpenAI Realtime SDP offer failed (${ response . status } )` ) ;
270421 }
271- return await response . text ( ) ;
422+ return await readBoundedText (
423+ response ,
424+ "OpenAI Realtime SDP answer" ,
425+ sdpAnswerMaxBytes ,
426+ ) ;
272427 } ,
273428 ) ;
274429 await peer . setRemoteDescription ( { type : "answer" , sdp : answer } ) ;
@@ -283,7 +438,11 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
283438 media ?. getTracks ( ) . forEach ( ( track ) => track . stop ( ) ) ;
284439 }
285440 } ,
286- { clientSecret, timeoutMs : openAIHttpTimeoutMs } ,
441+ {
442+ clientSecret,
443+ sdpAnswerMaxBytes : OPENAI_HTTP_RESPONSE_MAX_BYTES ,
444+ timeoutMs : openAIHttpTimeoutMs ,
445+ } ,
287446 ) ;
288447 return {
289448 name : "openai-webrtc-browser" ,
@@ -677,6 +836,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
677836}
678837
679838export const testing = {
839+ OPENAI_HTTP_RESPONSE_MAX_BYTES ,
680840 createOpenAIClientSecret,
841+ readBoundedText,
681842 resolveOpenAIHttpTimeoutMs,
682843} ;
0 commit comments