|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Live repro for PR #96144 — proves the canonical 16 MiB provider-response |
| 4 | + * cap on `readProviderJsonResponse` (a) accepts a valid OpenAI video |
| 5 | + * submit response under the cap and (b) rejects a hostile oversized |
| 6 | + * response before OOM. |
| 7 | + * |
| 8 | + * Run: pnpm exec tsx scripts/repro/issue-96144-openai-video-cap.mjs |
| 9 | + * |
| 10 | + * The script drives the production `readProviderJsonResponse` directly |
| 11 | + * (no vitest mock) with two streaming bodies: |
| 12 | + * 1. Valid: a realistic Sora submit response shape (id/model/status/ |
| 13 | + * prompt/seconds/size) under the 16 MiB cap → accepted, parsed |
| 14 | + * payload returned. |
| 15 | + * 2. Hostile: a 64 MiB streaming body → rejected with the canonical |
| 16 | + * "JSON response exceeds 16777216 bytes" error before the runtime |
| 17 | + * buffers the full body. |
| 18 | + * |
| 19 | + * A negative control shows the legacy raw `response.json()` on the |
| 20 | + * same 64 MiB body buffers the full body and only fails on JSON parse, |
| 21 | + * proving the bounded read is the right shape. |
| 22 | + * |
| 23 | + * This is the same helper that `extensions/openai/video-generation-provider.ts:427` |
| 24 | + * now routes the Sora submit response through after the PR — the helper |
| 25 | + * is provider-agnostic, so the canonical 16 MiB cap applies to OpenAI |
| 26 | + * video the same way it does to OpenAI-compatible image / DashScope |
| 27 | + * video / provider-http-errors paths. |
| 28 | + */ |
| 29 | +import assert from "node:assert/strict"; |
| 30 | +import { readProviderJsonResponse } from "../../src/agents/provider-http-errors.ts"; |
| 31 | + |
| 32 | +const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB |
| 33 | +const OPENAI_VIDEO_LABEL = "OpenAI video generation"; |
| 34 | + |
| 35 | +function createStreamingJsonResponse({ totalBytes, chunkSize, contentType = "application/json" }) { |
| 36 | + let written = 0; |
| 37 | + const encoder = new TextEncoder(); |
| 38 | + const stream = new ReadableStream({ |
| 39 | + pull(controller) { |
| 40 | + if (written >= totalBytes) { |
| 41 | + controller.close(); |
| 42 | + return; |
| 43 | + } |
| 44 | + const remaining = totalBytes - written; |
| 45 | + const slice = Math.min(chunkSize, remaining); |
| 46 | + controller.enqueue(encoder.encode("a".repeat(slice))); |
| 47 | + written += slice; |
| 48 | + }, |
| 49 | + }); |
| 50 | + return new Response(stream, { status: 200, headers: { "content-type": contentType } }); |
| 51 | +} |
| 52 | + |
| 53 | +console.log("=== Reproduction for PR #96144 — OpenAI video submit response cap policy ==="); |
| 54 | +console.log(`PROVIDER_JSON_RESPONSE_MAX_BYTES = ${PROVIDER_JSON_RESPONSE_MAX_BYTES} bytes`); |
| 55 | +console.log(`bounded-reader label = "${OPENAI_VIDEO_LABEL}"`); |
| 56 | + |
| 57 | +// 1. Valid OpenAI video submit response under the cap. |
| 58 | +// Shape mirrors `OpenAIVideoResponse` (extensions/openai/video-generation-provider.ts:53): |
| 59 | +// { id, model, status, prompt, seconds, size, error } |
| 60 | +// A 4 MiB prompt payload is well below the 16 MiB cap and represents |
| 61 | +// a realistic long-prompt Sora submit envelope. |
| 62 | +const VALID_PAYLOAD_BYTES = 4 * 1024 * 1024; |
| 63 | +const validBody = { |
| 64 | + id: "video_abc123", |
| 65 | + model: "sora-1.0", |
| 66 | + status: "queued", |
| 67 | + prompt: "p".repeat(VALID_PAYLOAD_BYTES), |
| 68 | + seconds: "8", |
| 69 | + size: "1280x720", |
| 70 | + error: null, |
| 71 | +}; |
| 72 | +const validJson = JSON.stringify(validBody); |
| 73 | +console.log( |
| 74 | + `Valid envelope: ${validJson.length} bytes (${(validJson.length / 1024 / 1024).toFixed(2)} MiB) ` + |
| 75 | + `id=${validBody.id} status=${validBody.status}`, |
| 76 | +); |
| 77 | +assert.ok( |
| 78 | + validJson.length < PROVIDER_JSON_RESPONSE_MAX_BYTES, |
| 79 | + `valid envelope must fit under 16 MiB; got ${validJson.length} bytes`, |
| 80 | +); |
| 81 | +const validResponse = new Response(validJson, { |
| 82 | + status: 200, |
| 83 | + headers: { "content-type": "application/json" }, |
| 84 | +}); |
| 85 | +const validParsed = await readProviderJsonResponse(validResponse, OPENAI_VIDEO_LABEL); |
| 86 | +assert.equal(validParsed.id, "video_abc123", "valid submit response must be accepted; id missing"); |
| 87 | +assert.equal(validParsed.model, "sora-1.0", "valid submit response must preserve model field"); |
| 88 | +assert.equal(validParsed.status, "queued", "valid submit response must preserve status field"); |
| 89 | +assert.equal( |
| 90 | + validParsed.prompt.length, |
| 91 | + VALID_PAYLOAD_BYTES, |
| 92 | + "valid submit response must preserve full prompt payload", |
| 93 | +); |
| 94 | +console.log(`PASS valid OpenAI video submit response: accepted, id=${validParsed.id}`); |
| 95 | + |
| 96 | +// 2. Hostile oversized response: 64 MiB streaming body. |
| 97 | +const OVERSIZED_BYTES = 64 * 1024 * 1024; |
| 98 | +const oversized = createStreamingJsonResponse({ |
| 99 | + totalBytes: OVERSIZED_BYTES, |
| 100 | + chunkSize: 1024 * 1024, |
| 101 | +}); |
| 102 | +let hostileError = null; |
| 103 | +try { |
| 104 | + await readProviderJsonResponse(oversized, OPENAI_VIDEO_LABEL); |
| 105 | +} catch (err) { |
| 106 | + hostileError = err; |
| 107 | +} |
| 108 | +assert.ok(hostileError, "hostile response must throw"); |
| 109 | +assert.match( |
| 110 | + hostileError.message, |
| 111 | + /JSON response exceeds 16777216 bytes/, |
| 112 | + `hostile response must surface canonical overflow error; got: ${hostileError.message}`, |
| 113 | +); |
| 114 | +console.log(`PASS hostile 64 MiB response: rejected with "${hostileError.message}"`); |
| 115 | + |
| 116 | +// 3. Negative control: raw `response.json()` on the same 64 MiB body |
| 117 | +// buffers the full body before failing on JSON parse — proves the |
| 118 | +// bounded read is the right shape (vs. an inert helper that |
| 119 | +// silently truncates and then re-fails). This is the exact code path |
| 120 | +// the PR replaces in `extensions/openai/video-generation-provider.ts:427`. |
| 121 | +const negativeBody = createStreamingJsonResponse({ |
| 122 | + totalBytes: OVERSIZED_BYTES, |
| 123 | + chunkSize: 1024 * 1024, |
| 124 | +}); |
| 125 | +let negativeError = null; |
| 126 | +try { |
| 127 | + await negativeBody.json(); |
| 128 | +} catch (err) { |
| 129 | + negativeError = err; |
| 130 | +} |
| 131 | +assert.ok(negativeError, "raw json() must also throw on 64 MiB non-JSON body"); |
| 132 | +assert.doesNotMatch( |
| 133 | + negativeError.message, |
| 134 | + /JSON response exceeds 16777216 bytes/, |
| 135 | + "raw json() must NOT surface the bounded-reader error (it doesn't go through the helper)", |
| 136 | +); |
| 137 | +console.log( |
| 138 | + `PASS negative control: raw response.json() on 64 MiB body failed with "${negativeError.constructor.name}" (no bounded-reader wrapping)`, |
| 139 | +); |
| 140 | + |
| 141 | +console.log("=== All repro assertions passed ==="); |
0 commit comments