@@ -11,6 +11,7 @@ const {
1111 isProviderApiKeyConfiguredMock,
1212 postJsonRequestMock,
1313 postMultipartRequestMock,
14+ readProviderJsonResponseMock,
1415 resolveApiKeyForProviderMock,
1516 resolveProviderHttpRequestConfigMock,
1617 resolveProviderOperationTimeoutMsMock,
@@ -24,6 +25,7 @@ const {
2425 isProviderApiKeyConfiguredMock : vi . fn ( ( ) => true ) ,
2526 postJsonRequestMock : vi . fn ( ) ,
2627 postMultipartRequestMock : vi . fn ( ) ,
28+ readProviderJsonResponseMock : vi . fn ( ) ,
2729 resolveApiKeyForProviderMock : vi . fn ( async ( ) => ( { apiKey : "provider-key" } ) ) ,
2830 resolveProviderHttpRequestConfigMock : vi . fn ( ( params : Record < string , unknown > ) => {
2931 const request =
@@ -56,6 +58,7 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({
5658 createProviderOperationDeadline : createProviderOperationDeadlineMock ,
5759 postJsonRequest : postJsonRequestMock ,
5860 postMultipartRequest : postMultipartRequestMock ,
61+ readProviderJsonResponse : readProviderJsonResponseMock ,
5962 resolveProviderHttpRequestConfig : resolveProviderHttpRequestConfigMock ,
6063 resolveProviderOperationTimeoutMs : resolveProviderOperationTimeoutMsMock ,
6164 sanitizeConfiguredModelProviderRequest : sanitizeConfiguredModelProviderRequestMock ,
@@ -138,8 +141,10 @@ function mockGeneratedResponse() {
138141 } ,
139142 ] ,
140143 } ;
141- postJsonRequestMock . mockResolvedValue ( { response : { json : async ( ) => payload } , release } ) ;
142- postMultipartRequestMock . mockResolvedValue ( { response : { json : async ( ) => payload } , release } ) ;
144+ const response = { } as Response ;
145+ postJsonRequestMock . mockResolvedValue ( { response, release } ) ;
146+ postMultipartRequestMock . mockResolvedValue ( { response, release } ) ;
147+ readProviderJsonResponseMock . mockResolvedValue ( payload ) ;
143148 return release ;
144149}
145150
@@ -150,6 +155,7 @@ describe("OpenAI-compatible image provider helper", () => {
150155 isProviderApiKeyConfiguredMock . mockClear ( ) ;
151156 postJsonRequestMock . mockReset ( ) ;
152157 postMultipartRequestMock . mockReset ( ) ;
158+ readProviderJsonResponseMock . mockReset ( ) ;
153159 resolveApiKeyForProviderMock . mockReset ( ) ;
154160 resolveApiKeyForProviderMock . mockResolvedValue ( { apiKey : "provider-key" } ) ;
155161 resolveProviderHttpRequestConfigMock . mockClear ( ) ;
@@ -250,9 +256,10 @@ describe("OpenAI-compatible image provider helper", () => {
250256
251257 it ( "honors default operation timeouts and empty-response errors" , async ( ) => {
252258 postJsonRequestMock . mockResolvedValue ( {
253- response : { json : async ( ) => ( { data : [ ] } ) } ,
259+ response : { } as Response ,
254260 release : vi . fn ( async ( ) => { } ) ,
255261 } ) ;
262+ readProviderJsonResponseMock . mockResolvedValueOnce ( { data : [ ] } ) ;
256263 const provider = createProvider ( {
257264 defaultTimeoutMs : 60_000 ,
258265 emptyResponseError : "Sample response missing image data" ,
@@ -282,9 +289,10 @@ describe("OpenAI-compatible image provider helper", () => {
282289
283290 it ( "wraps malformed successful image responses with provider-owned errors" , async ( ) => {
284291 postJsonRequestMock . mockResolvedValue ( {
285- response : { json : async ( ) => ( { data : { b64_json : "not-an-array" } } ) } ,
292+ response : { } as Response ,
286293 release : vi . fn ( async ( ) => { } ) ,
287294 } ) ;
295+ readProviderJsonResponseMock . mockResolvedValueOnce ( { data : { b64_json : "not-an-array" } } ) ;
288296 const provider = createProvider ( ) ;
289297
290298 await expect (
@@ -296,4 +304,90 @@ describe("OpenAI-compatible image provider helper", () => {
296304 } ) ,
297305 ) . rejects . toThrow ( "Sample image generation response malformed" ) ;
298306 } ) ;
307+
308+ it ( "routes the success body through the bounded provider-json reader" , async ( ) => {
309+ const release = mockGeneratedResponse ( ) ;
310+ const provider = createProvider ( ) ;
311+
312+ await provider . generateImage ( {
313+ provider : "sample" ,
314+ model : "sample-image" ,
315+ prompt : "draw a square" ,
316+ cfg : { } as never ,
317+ } as never ) ;
318+
319+ expect ( readProviderJsonResponseMock ) . toHaveBeenCalledTimes ( 1 ) ;
320+ const [ responseArg , labelArg ] = readProviderJsonResponseMock . mock . calls [ 0 ] as [
321+ Response ,
322+ string ,
323+ ] ;
324+ expect ( responseArg ) . toBeDefined ( ) ;
325+ expect ( labelArg ) . toBe ( "Sample image generation failed" ) ;
326+ expect ( release ) . toHaveBeenCalledOnce ( ) ;
327+ } ) ;
328+
329+ it ( "surfaces the bounded-reader error verbatim when the body exceeds the cap" , async ( ) => {
330+ const release = vi . fn ( async ( ) => { } ) ;
331+ postJsonRequestMock . mockResolvedValue ( {
332+ response : { } as Response ,
333+ release,
334+ } ) ;
335+ readProviderJsonResponseMock . mockRejectedValueOnce (
336+ new Error ( "Sample image generation failed: JSON response exceeds 16777216 bytes" ) ,
337+ ) ;
338+ const provider = createProvider ( ) ;
339+
340+ await expect (
341+ provider . generateImage ( {
342+ provider : "sample" ,
343+ model : "sample-image" ,
344+ prompt : "oversized" ,
345+ cfg : { } as never ,
346+ } as never ) ,
347+ ) . rejects . toThrow ( / J S O N r e s p o n s e e x c e e d s 1 6 7 7 7 2 1 6 b y t e s / ) ;
348+ expect ( release ) . toHaveBeenCalledOnce ( ) ;
349+ } ) ;
350+
351+ it ( "accepts a valid multi-image response under the cap (4x 1024x1024 b64_json ≈ 5.5 MB)" , async ( ) => {
352+ // Lock the contract: the canonical 16 MiB cap is the generic OpenClaw
353+ // provider-response cap (same as #95218). For the supported
354+ // OpenAI-compatible image response envelope (maxCount: 4 at 1024x1024
355+ // PNG b64_json), the serialized JSON body is well under the cap, so
356+ // the bounded reader accepts it and the runtime returns the images.
357+ //
358+ // PNG raw ~ 1 MB per 1024x1024 image (compressed); b64 inflates by
359+ // 4/3, so 4 images ≈ 5.4 MB serialized b64. Wrapped in the OpenAI
360+ // response envelope ({"data":[...]} + per-image metadata), the total
361+ // is comfortably under 16 MiB. See scripts/repro/issue-96136-image-cap
362+ // .mjs for the end-to-end proof with a real streaming harness.
363+ const ONE_1024_PNG_B64_BYTES = Math . ceil ( ( 1024 * 1024 ) / 3 ) * 4 ;
364+ const FOUR_1024_PNG_B64_BYTES = ONE_1024_PNG_B64_BYTES * 4 ;
365+ expect ( FOUR_1024_PNG_B64_BYTES ) . toBeLessThan ( 16 * 1024 * 1024 ) ;
366+ const release = vi . fn ( async ( ) => { } ) ;
367+ postJsonRequestMock . mockResolvedValue ( {
368+ response : { } as Response ,
369+ release,
370+ } ) ;
371+ readProviderJsonResponseMock . mockResolvedValueOnce ( {
372+ data : [
373+ { b64_json : "a" . repeat ( ONE_1024_PNG_B64_BYTES ) } ,
374+ { b64_json : "b" . repeat ( ONE_1024_PNG_B64_BYTES ) } ,
375+ { b64_json : "c" . repeat ( ONE_1024_PNG_B64_BYTES ) } ,
376+ { b64_json : "d" . repeat ( ONE_1024_PNG_B64_BYTES ) } ,
377+ ] ,
378+ } ) ;
379+ const provider = createProvider ( ) ;
380+
381+ const result = await provider . generateImage ( {
382+ provider : "sample" ,
383+ model : "sample-image" ,
384+ prompt : "draw a square" ,
385+ count : 4 ,
386+ size : "1024x1024" ,
387+ cfg : { } as never ,
388+ } as never ) ;
389+
390+ expect ( result . images ) . toHaveLength ( 4 ) ;
391+ expect ( release ) . toHaveBeenCalledOnce ( ) ;
392+ } ) ;
299393} ) ;
0 commit comments