Skip to content

Commit cbd0aef

Browse files
committed
fix(byteplus): bound video-generation success response
1 parent 3ab8d6a commit cbd0aef

2 files changed

Lines changed: 215 additions & 57 deletions

File tree

extensions/byteplus/video-generation-provider.test.ts

Lines changed: 207 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,101 @@
11
// Byteplus tests cover video generation provider plugin behavior.
2-
import {
3-
getProviderHttpMocks,
4-
installProviderHttpMockCleanup,
5-
} from "openclaw/plugin-sdk/provider-http-test-mocks";
62
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
7-
import { beforeAll, describe, expect, it, vi } from "vitest";
8-
9-
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
3+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4+
5+
// Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON
6+
// bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded
7+
// reader actually streams and cancels oversized bodies under test instead of a stub.
8+
const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi.hoisted(
9+
() => ({
10+
postJsonRequestMock: vi.fn(),
11+
fetchWithTimeoutMock: vi.fn(),
12+
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
13+
}),
14+
);
15+
16+
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
17+
resolveApiKeyForProvider: resolveApiKeyForProviderMock,
18+
}));
19+
20+
vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => {
21+
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-http")>();
22+
const resolveTimeoutMs = (timeoutMs: unknown): number =>
23+
typeof timeoutMs === "function" ? (timeoutMs() as number) : ((timeoutMs as number) ?? 60_000);
24+
return {
25+
// REAL byte-bounded JSON reader under test — not stubbed.
26+
readProviderJsonResponse: actual.readProviderJsonResponse,
27+
postJsonRequest: postJsonRequestMock,
28+
fetchProviderOperationResponse: async (params: {
29+
url: string;
30+
init?: RequestInit;
31+
timeoutMs?: unknown;
32+
fetchFn: typeof fetch;
33+
}) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
34+
fetchProviderDownloadResponse: async (params: {
35+
url: string;
36+
init?: RequestInit;
37+
timeoutMs?: unknown;
38+
fetchFn: typeof fetch;
39+
}) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
40+
assertOkOrThrowHttpError: async () => {},
41+
createProviderOperationDeadline: ({
42+
label,
43+
timeoutMs,
44+
}: {
45+
label: string;
46+
timeoutMs?: number;
47+
}) => ({ label, timeoutMs }),
48+
createProviderOperationTimeoutResolver:
49+
({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
50+
() =>
51+
defaultTimeoutMs,
52+
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
53+
defaultTimeoutMs,
54+
resolveProviderHttpRequestConfig: (params: {
55+
baseUrl?: string;
56+
defaultBaseUrl: string;
57+
allowPrivateNetwork?: boolean;
58+
defaultHeaders?: Record<string, string>;
59+
}) => ({
60+
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
61+
allowPrivateNetwork: params.allowPrivateNetwork === true,
62+
headers: new Headers(params.defaultHeaders),
63+
dispatcherPolicy: undefined,
64+
}),
65+
waitProviderOperationPollInterval: async () => {},
66+
};
67+
});
1068

1169
let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider;
1270

1371
beforeAll(async () => {
1472
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
1573
});
1674

17-
installProviderHttpMockCleanup();
75+
afterEach(() => {
76+
postJsonRequestMock.mockReset();
77+
fetchWithTimeoutMock.mockReset();
78+
resolveApiKeyForProviderMock.mockClear();
79+
});
1880

1981
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
2082
postJsonRequestMock.mockResolvedValue({
21-
response: {
22-
json: async () => ({
23-
id: "task_123",
24-
}),
25-
},
83+
response: streamedJsonResponse({
84+
id: "task_123",
85+
}),
2686
release: vi.fn(async () => {}),
2787
});
2888
fetchWithTimeoutMock
29-
.mockResolvedValueOnce({
30-
json: async () => ({
89+
.mockResolvedValueOnce(
90+
streamedJsonResponse({
3191
id: "task_123",
3292
status: "succeeded",
3393
content: {
3494
video_url: "https://example.com/byteplus.mp4",
3595
},
3696
model: params?.model ?? "seedance-1-0-lite-t2v-250428",
3797
}),
38-
})
98+
)
3999
.mockResolvedValueOnce({
40100
headers: new Headers({ "content-type": "video/webm" }),
41101
arrayBuffer: async () => Buffer.from("webm-bytes"),
@@ -77,6 +137,53 @@ function streamedVideoResponse(bytes: string): Response {
77137
);
78138
}
79139

140+
// BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the
141+
// mocked responses must expose a real readable body (not just a json() shortcut).
142+
function streamedJsonResponse(payload: unknown): Response {
143+
return new Response(
144+
new ReadableStream({
145+
start(controller) {
146+
controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
147+
controller.close();
148+
},
149+
}),
150+
{ status: 200, headers: { "content-type": "application/json" } },
151+
);
152+
}
153+
154+
// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
155+
// bounded reader cancels the stream mid-flight; if the cap were removed the reader would
156+
// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
157+
// and whether the stream was canceled so callers can assert the body was not fully read.
158+
function makeOversizedJsonStream(): {
159+
body: ReadableStream<Uint8Array>;
160+
maxBytes: number;
161+
totalBytes: number;
162+
state: { bytesPulled: number; canceled: boolean };
163+
} {
164+
const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
165+
const ONE_MIB = 1024 * 1024;
166+
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
167+
const chunk = new Uint8Array(ONE_MIB);
168+
const state = { bytesPulled: 0, canceled: false };
169+
let pulled = 0;
170+
const body = new ReadableStream<Uint8Array>({
171+
pull(controller) {
172+
if (pulled >= TOTAL_CHUNKS) {
173+
controller.close();
174+
return;
175+
}
176+
pulled += 1;
177+
state.bytesPulled += chunk.length;
178+
controller.enqueue(chunk);
179+
},
180+
cancel() {
181+
state.canceled = true;
182+
},
183+
});
184+
return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state };
185+
}
186+
80187
describe("byteplus video generation provider", () => {
81188
it("declares explicit mode capabilities", () => {
82189
expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider());
@@ -110,21 +217,19 @@ describe("byteplus video generation provider", () => {
110217

111218
it("rejects generated video downloads that exceed the configured media cap", async () => {
112219
postJsonRequestMock.mockResolvedValue({
113-
response: {
114-
json: async () => ({ id: "task_too_large" }),
115-
},
220+
response: streamedJsonResponse({ id: "task_too_large" }),
116221
release: vi.fn(async () => {}),
117222
});
118223
fetchWithTimeoutMock
119-
.mockResolvedValueOnce({
120-
json: async () => ({
224+
.mockResolvedValueOnce(
225+
streamedJsonResponse({
121226
id: "task_too_large",
122227
status: "succeeded",
123228
content: {
124229
video_url: "https://example.com/too-large.mp4",
125230
},
126231
}),
127-
})
232+
)
128233
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
129234

130235
const provider = buildBytePlusVideoGenerationProvider();
@@ -222,24 +327,22 @@ describe("byteplus video generation provider", () => {
222327

223328
it("drops malformed response duration metadata", async () => {
224329
postJsonRequestMock.mockResolvedValue({
225-
response: {
226-
json: async () => ({
227-
id: "task_123",
228-
}),
229-
},
330+
response: streamedJsonResponse({
331+
id: "task_123",
332+
}),
230333
release: vi.fn(async () => {}),
231334
});
232335
fetchWithTimeoutMock
233-
.mockResolvedValueOnce({
234-
json: async () => ({
336+
.mockResolvedValueOnce(
337+
streamedJsonResponse({
235338
id: "task_123",
236339
status: "succeeded",
237340
content: {
238341
video_url: "https://example.com/byteplus.mp4",
239342
},
240343
duration: 1.5,
241344
}),
242-
})
345+
)
243346
.mockResolvedValueOnce({
244347
headers: new Headers({ "content-type": "video/mp4" }),
245348
arrayBuffer: async () => Buffer.from("mp4-bytes"),
@@ -259,11 +362,15 @@ describe("byteplus video generation provider", () => {
259362
it("reports malformed create JSON with a provider-owned error", async () => {
260363
const release = vi.fn(async () => {});
261364
postJsonRequestMock.mockResolvedValue({
262-
response: {
263-
json: async () => {
264-
throw new SyntaxError("bad json");
265-
},
266-
},
365+
response: new Response(
366+
new ReadableStream({
367+
start(controller) {
368+
controller.enqueue(new TextEncoder().encode("{ not valid json"));
369+
controller.close();
370+
},
371+
}),
372+
{ status: 200, headers: { "content-type": "application/json" } },
373+
),
267374
release,
268375
});
269376

@@ -281,19 +388,17 @@ describe("byteplus video generation provider", () => {
281388

282389
it("rejects status responses missing a task status", async () => {
283390
postJsonRequestMock.mockResolvedValue({
284-
response: {
285-
json: async () => ({ id: "task_missing_status" }),
286-
},
391+
response: streamedJsonResponse({ id: "task_missing_status" }),
287392
release: vi.fn(async () => {}),
288393
});
289-
fetchWithTimeoutMock.mockResolvedValueOnce({
290-
json: async () => ({
394+
fetchWithTimeoutMock.mockResolvedValueOnce(
395+
streamedJsonResponse({
291396
id: "task_missing_status",
292397
content: {
293398
video_url: "https://example.com/byteplus.mp4",
294399
},
295400
}),
296-
});
401+
);
297402

298403
const provider = buildBytePlusVideoGenerationProvider();
299404
await expect(
@@ -308,18 +413,16 @@ describe("byteplus video generation provider", () => {
308413

309414
it("rejects malformed completed content", async () => {
310415
postJsonRequestMock.mockResolvedValue({
311-
response: {
312-
json: async () => ({ id: "task_malformed_content" }),
313-
},
416+
response: streamedJsonResponse({ id: "task_malformed_content" }),
314417
release: vi.fn(async () => {}),
315418
});
316-
fetchWithTimeoutMock.mockResolvedValueOnce({
317-
json: async () => ({
419+
fetchWithTimeoutMock.mockResolvedValueOnce(
420+
streamedJsonResponse({
318421
id: "task_malformed_content",
319422
status: "succeeded",
320423
content: ["https://example.com/byteplus.mp4"],
321424
}),
322-
});
425+
);
323426

324427
const provider = buildBytePlusVideoGenerationProvider();
325428
await expect(
@@ -331,4 +434,61 @@ describe("byteplus video generation provider", () => {
331434
}),
332435
).rejects.toThrow("BytePlus video generation completed with malformed content");
333436
});
437+
438+
it("bounds the submit task JSON body and cancels an oversized stream", async () => {
439+
const stream = makeOversizedJsonStream();
440+
const release = vi.fn(async () => {});
441+
postJsonRequestMock.mockResolvedValue({
442+
response: new Response(stream.body, {
443+
status: 200,
444+
headers: { "content-type": "application/json" },
445+
}),
446+
release,
447+
});
448+
449+
const provider = buildBytePlusVideoGenerationProvider();
450+
await expect(
451+
provider.generateVideo({
452+
provider: "byteplus",
453+
model: "seedance-1-0-lite-t2v-250428",
454+
prompt: "oversized submit response",
455+
cfg: {},
456+
}),
457+
).rejects.toThrow(
458+
`BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`,
459+
);
460+
expect(stream.state.canceled).toBe(true);
461+
// Only the bounded prefix is pulled, never the full advertised stream.
462+
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
463+
// The submit request must still be released even though the body overflowed.
464+
expect(release).toHaveBeenCalledOnce();
465+
});
466+
467+
it("bounds the poll status JSON body and cancels an oversized stream", async () => {
468+
postJsonRequestMock.mockResolvedValue({
469+
response: streamedJsonResponse({ id: "task_oversized_poll" }),
470+
release: vi.fn(async () => {}),
471+
});
472+
const stream = makeOversizedJsonStream();
473+
fetchWithTimeoutMock.mockResolvedValueOnce(
474+
new Response(stream.body, {
475+
status: 200,
476+
headers: { "content-type": "application/json" },
477+
}),
478+
);
479+
480+
const provider = buildBytePlusVideoGenerationProvider();
481+
await expect(
482+
provider.generateVideo({
483+
provider: "byteplus",
484+
model: "seedance-1-0-lite-t2v-250428",
485+
prompt: "oversized poll response",
486+
cfg: {},
487+
}),
488+
).rejects.toThrow(
489+
`BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`,
490+
);
491+
expect(stream.state.canceled).toBe(true);
492+
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
493+
});
334494
});

extensions/byteplus/video-generation-provider.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
fetchProviderDownloadResponse,
1212
fetchProviderOperationResponse,
1313
postJsonRequest,
14+
readProviderJsonResponse,
1415
resolveProviderOperationTimeoutMs,
1516
resolveProviderHttpRequestConfig,
1617
waitProviderOperationPollInterval,
@@ -55,16 +56,13 @@ type BytePlusTaskResponse = {
5556

5657
type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled";
5758

58-
async function readBytePlusJsonResponse<T>(
59-
response: Pick<Response, "json">,
60-
label: string,
61-
): Promise<T> {
62-
let payload: unknown;
63-
try {
64-
payload = await response.json();
65-
} catch (cause) {
66-
throw new Error(`${label}: malformed JSON response`, { cause });
67-
}
59+
async function readBytePlusJsonResponse<T>(response: Response, label: string): Promise<T> {
60+
// BytePlus submit/poll task bodies are read through the shared byte-bounded reader
61+
// (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint
62+
// that streams an unbounded JSON body cannot force the runtime to buffer the whole
63+
// payload before parsing. Overflow cancels the stream and throws a bounded error;
64+
// malformed JSON keeps the existing `${label}: malformed JSON response` wrapping.
65+
const payload = await readProviderJsonResponse<unknown>(response, label);
6866
if (!isRecord(payload)) {
6967
throw new Error(`${label}: malformed JSON response`);
7068
}

0 commit comments

Comments
 (0)