Skip to content

Commit 2200e05

Browse files
fix(image-generation): bound OpenAI-compatible image response reads
Route the success-path JSON body of `createOpenAiCompatibleImageGenerationProvider` through `readProviderJsonResponse` instead of `await response.json()` so a buggy or hostile image endpoint streaming an unbounded body cannot force the runtime to buffer the full payload before failing. Reuses the same 16 MiB capped reader as the binary and error paths in `provider-http-errors.ts`, matching the symmetric bound-stream work in #95218 (provider JSON), #95417 (Google prompt cache), #95418 (OpenRouter model scan), #95420 (OpenRouter model capabilities), and #95246 (live model catalog). Failure label is reused across `assertOkOrThrowHttpError` and `readProviderJsonResponse` so the bounded-reader overflow message and the HTTP error message stay aligned with the caller's `options.failureLabels`. Tests: route every mock through the bounded reader; new test asserts the overflow message format and that the release() path still runs.
1 parent 4d03463 commit 2200e05

3 files changed

Lines changed: 234 additions & 14 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Live repro for PR #96136 — proves the canonical 16 MiB provider-response
4+
* cap on `readProviderJsonResponse` (a) accepts a valid 4-image
5+
* OpenAI-compatible response under the cap and (b) rejects a hostile
6+
* oversized response before OOM.
7+
*
8+
* Run: pnpm exec tsx scripts/repro/issue-96136-image-cap.mjs
9+
*
10+
* The script drives the production `readProviderJsonResponse` directly
11+
* (no vitest mock) with two streaming bodies:
12+
* 1. Valid: 4 PNG b64_json payloads at 1 MiB raw each, total ≈ 5.4 MiB
13+
* serialized → accepted, parsed payload returned.
14+
* 2. Hostile: a 64 MiB streaming body → rejected with the canonical
15+
* "JSON response exceeds 16777216 bytes" error before the runtime
16+
* buffers the full body.
17+
*
18+
* A negative control shows the legacy raw `response.json()` on the
19+
* same 64 MiB body buffers the full body and only fails on JSON parse,
20+
* proving the bounded read is the right shape.
21+
*/
22+
import assert from "node:assert/strict";
23+
import { readProviderJsonResponse } from "../../src/agents/provider-http-errors.ts";
24+
25+
const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB
26+
27+
function createStreamingJsonResponse({ totalBytes, chunkSize, contentType = "application/json" }) {
28+
let written = 0;
29+
const encoder = new TextEncoder();
30+
const stream = new ReadableStream({
31+
pull(controller) {
32+
if (written >= totalBytes) {
33+
controller.close();
34+
return;
35+
}
36+
const remaining = totalBytes - written;
37+
const slice = Math.min(chunkSize, remaining);
38+
controller.enqueue(encoder.encode("a".repeat(slice)));
39+
written += slice;
40+
},
41+
});
42+
return new Response(stream, { status: 200, headers: { "content-type": contentType } });
43+
}
44+
45+
console.log("=== Reproduction for PR #96136 — image response cap policy ===");
46+
console.log(`PROVIDER_JSON_RESPONSE_MAX_BYTES = ${PROVIDER_JSON_RESPONSE_MAX_BYTES} bytes`);
47+
48+
// 1. Valid 4-image response: 4 PNG b64_json at 1 MiB raw each.
49+
const ONE_PNG_B64_BYTES = Math.ceil((1024 * 1024) / 3) * 4; // ~1.4 MB
50+
const FOUR_PNG_B64_BYTES = ONE_PNG_B64_BYTES * 4;
51+
console.log(
52+
`Valid envelope: 4 images x ${ONE_PNG_B64_BYTES} b64 bytes = ${FOUR_PNG_B64_BYTES} bytes (${(FOUR_PNG_B64_BYTES / 1024 / 1024).toFixed(2)} MiB)`,
53+
);
54+
assert.ok(
55+
FOUR_PNG_B64_BYTES < PROVIDER_JSON_RESPONSE_MAX_BYTES,
56+
`4-image envelope must fit under 16 MiB; got ${FOUR_PNG_B64_BYTES} bytes`,
57+
);
58+
const validBody = {
59+
data: [
60+
{ b64_json: "a".repeat(ONE_PNG_B64_BYTES) },
61+
{ b64_json: "b".repeat(ONE_PNG_B64_BYTES) },
62+
{ b64_json: "c".repeat(ONE_PNG_B64_BYTES) },
63+
{ b64_json: "d".repeat(ONE_PNG_B64_BYTES) },
64+
],
65+
};
66+
const validJson = JSON.stringify(validBody);
67+
const validResponse = new Response(validJson, {
68+
status: 200,
69+
headers: { "content-type": "application/json" },
70+
});
71+
const validParsed = await readProviderJsonResponse(validResponse, "test image generation");
72+
assert.equal(
73+
validParsed.data.length,
74+
4,
75+
`valid 4-image response must be accepted; got ${validParsed.data.length} images`,
76+
);
77+
console.log("PASS valid 4-image response: accepted, 4 images parsed");
78+
79+
// 2. Hostile oversized response: 64 MiB streaming body.
80+
const OVERSIZED_BYTES = 64 * 1024 * 1024;
81+
const oversized = createStreamingJsonResponse({
82+
totalBytes: OVERSIZED_BYTES,
83+
chunkSize: 1024 * 1024,
84+
});
85+
let hostileError = null;
86+
try {
87+
await readProviderJsonResponse(oversized, "test image generation hostile");
88+
} catch (err) {
89+
hostileError = err;
90+
}
91+
assert.ok(hostileError, "hostile response must throw");
92+
assert.match(
93+
hostileError.message,
94+
/JSON response exceeds 16777216 bytes/,
95+
`hostile response must surface canonical overflow error; got: ${hostileError.message}`,
96+
);
97+
console.log(`PASS hostile 64 MiB response: rejected with "${hostileError.message}"`);
98+
99+
// 3. Negative control: raw `response.json()` on the same 64 MiB body
100+
// buffers the full body before failing on JSON parse — proves the
101+
// bounded read is the right shape (vs. an inert helper that
102+
// silently truncates and then re-fails).
103+
const negativeBody = createStreamingJsonResponse({
104+
totalBytes: OVERSIZED_BYTES,
105+
chunkSize: 1024 * 1024,
106+
});
107+
let negativeError = null;
108+
try {
109+
await negativeBody.json();
110+
} catch (err) {
111+
negativeError = err;
112+
}
113+
assert.ok(negativeError, "raw json() must also throw on 64 MiB non-JSON body");
114+
assert.doesNotMatch(
115+
negativeError.message,
116+
/JSON response exceeds 16777216 bytes/,
117+
"raw json() must NOT surface the bounded-reader error (it doesn't go through the helper)",
118+
);
119+
console.log(
120+
`PASS negative control: raw response.json() on 64 MiB body failed with "${negativeError.constructor.name}" (no bounded-reader wrapping)`,
121+
);
122+
123+
console.log("=== All repro assertions passed ===");

src/image-generation/openai-compatible-image-provider.test.ts

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/JSON response exceeds 16777216 bytes/);
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
});

src/image-generation/openai-compatible-image-provider.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createProviderOperationDeadline,
88
postJsonRequest,
99
postMultipartRequest,
10+
readProviderJsonResponse,
1011
resolveProviderHttpRequestConfig,
1112
resolveProviderOperationTimeoutMs,
1213
sanitizeConfiguredModelProviderRequest,
@@ -263,19 +264,21 @@ export function createOpenAiCompatibleImageGenerationProvider(
263264

264265
const { response, release } = await request;
265266
try {
266-
await assertOkOrThrowHttpError(
267-
response,
267+
const failureLabel =
268268
mode === "edit"
269269
? (options.failureLabels?.edit ?? `${options.label} image edit failed`)
270-
: (options.failureLabels?.generate ?? `${options.label} image generation failed`),
270+
: (options.failureLabels?.generate ?? `${options.label} image generation failed`);
271+
await assertOkOrThrowHttpError(response, failureLabel);
272+
const images = parseOpenAiCompatibleImageResponse(
273+
await readProviderJsonResponse<unknown>(response, failureLabel),
274+
{
275+
...options.response,
276+
malformedResponseError:
277+
mode === "edit"
278+
? `${options.label} image edit response malformed`
279+
: `${options.label} image generation response malformed`,
280+
},
271281
);
272-
const images = parseOpenAiCompatibleImageResponse(await response.json(), {
273-
...options.response,
274-
malformedResponseError:
275-
mode === "edit"
276-
? `${options.label} image edit response malformed`
277-
: `${options.label} image generation response malformed`,
278-
});
279282
if (images.length === 0) {
280283
throw new Error(
281284
options.emptyResponseError ??

0 commit comments

Comments
 (0)