Skip to content

Commit db255d6

Browse files
committed
fix(openai): bound embedding-batch and realtime session JSON response reads
1 parent 8910969 commit db255d6

4 files changed

Lines changed: 171 additions & 3 deletions

File tree

extensions/openai/embedding-batch.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,70 @@ describe("OpenAI embedding batch output", () => {
266266
]);
267267
});
268268

269+
it("bounds batch status success body via readProviderJsonResponse", async () => {
270+
const chunkSize = 1024 * 1024;
271+
const chunkCount = 20; // 20 MiB, well over 16 MiB cap
272+
let readCount = 0;
273+
let canceled = false;
274+
const oversizedStatus = new Response(
275+
new ReadableStream<Uint8Array>({
276+
pull(controller) {
277+
if (readCount >= chunkCount) {
278+
controller.close();
279+
return;
280+
}
281+
readCount += 1;
282+
controller.enqueue(new Uint8Array(chunkSize));
283+
},
284+
cancel() {
285+
canceled = true;
286+
},
287+
}),
288+
{ status: 200, headers: { "Content-Type": "application/json" } },
289+
);
290+
let batchStatusCalled = false;
291+
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
292+
const url = fetchInputUrl(input);
293+
if (url.endsWith("/files") && init?.method === "POST") {
294+
return jsonResponse({ id: "file-0" });
295+
}
296+
if (url.endsWith("/batches") && init?.method === "POST") {
297+
return jsonResponse({ id: "batch-0", status: "in_progress" });
298+
}
299+
if (url.endsWith("/batches/batch-0") && !batchStatusCalled) {
300+
batchStatusCalled = true;
301+
return oversizedStatus;
302+
}
303+
return new Response("unexpected request", { status: 500 });
304+
});
305+
306+
await expect(
307+
runOpenAiEmbeddingBatches({
308+
openAi: {
309+
baseUrl: "https://openai-compatible.example/v1",
310+
headers: { Authorization: "Bearer test" },
311+
model: "text-embedding-3-small",
312+
fetchImpl,
313+
},
314+
agentId: "main",
315+
requests: [
316+
{
317+
custom_id: "0",
318+
method: "POST",
319+
url: "/v1/embeddings",
320+
body: { model: "text-embedding-3-small", input: "payload" },
321+
},
322+
],
323+
wait: true,
324+
concurrency: 1,
325+
pollIntervalMs: 1000,
326+
timeoutMs: 60_000,
327+
}),
328+
).rejects.toThrow(/openai\.batch-status/);
329+
expect(canceled).toBe(true);
330+
expect(readCount).toBeLessThan(chunkCount);
331+
});
332+
269333
it("bounds batch resource error bodies without using response.text()", async () => {
270334
const tracked = cancelTrackedResponse(`${"batch status unavailable ".repeat(1024)}tail`, {
271335
status: 400,

extensions/openai/embedding-batch.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818
uploadBatchJsonlFile,
1919
withRemoteHttpResponse,
2020
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
21-
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
21+
import {
22+
readProviderJsonResponse,
23+
readResponseTextLimited,
24+
} from "openclaw/plugin-sdk/provider-http";
2225
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
2326
import type { OpenAiEmbeddingClient } from "./embedding-provider.js";
2427

@@ -96,7 +99,7 @@ async function fetchOpenAiBatchStatus(params: {
9699
openAi: params.openAi,
97100
path: `/batches/${params.batchId}`,
98101
errorPrefix: "openai batch status",
99-
parse: async (res) => (await res.json()) as OpenAiBatchStatus,
102+
parse: async (res) => readProviderJsonResponse<OpenAiBatchStatus>(res, "openai.batch-status"),
100103
});
101104
}
102105

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Openai tests cover realtime session secret creation behavior.
2+
import { describe, expect, it, vi } from "vitest";
3+
import {
4+
createOpenAIRealtimeClientSecret,
5+
createOpenAIRealtimeTranscriptionClientSecret,
6+
} from "./realtime-provider-shared.js";
7+
8+
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
9+
fetchWithSsrFGuardMock: vi.fn(),
10+
}));
11+
12+
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
13+
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
14+
}));
15+
16+
function makeStreamingResponse(params: { chunkCount: number; chunkSize: number }): {
17+
response: Response;
18+
getReadCount: () => number;
19+
wasCanceled: () => boolean;
20+
} {
21+
let readCount = 0;
22+
let canceled = false;
23+
const chunk = new Uint8Array(params.chunkSize);
24+
const response = new Response(
25+
new ReadableStream<Uint8Array>({
26+
pull(controller) {
27+
if (readCount >= params.chunkCount) {
28+
controller.close();
29+
return;
30+
}
31+
readCount += 1;
32+
controller.enqueue(chunk);
33+
},
34+
cancel() {
35+
canceled = true;
36+
},
37+
}),
38+
{ status: 200, headers: { "Content-Type": "application/json" } },
39+
);
40+
return { response, getReadCount: () => readCount, wasCanceled: () => canceled };
41+
}
42+
43+
function guardedFetch(response: Response): void {
44+
fetchWithSsrFGuardMock.mockResolvedValue({ response, release: vi.fn() });
45+
}
46+
47+
describe("createOpenAIRealtimeClientSecret", () => {
48+
it("returns client secret from a well-formed response", async () => {
49+
guardedFetch(
50+
new Response(
51+
JSON.stringify({
52+
client_secret: { value: "eph-secret-abc" },
53+
expires_at: Math.floor(Date.now() / 1000) + 60,
54+
}),
55+
{ status: 200, headers: { "Content-Type": "application/json" } },
56+
),
57+
);
58+
59+
const result = await createOpenAIRealtimeClientSecret({
60+
authToken: "sk-test",
61+
auditContext: "test",
62+
session: { model: "gpt-4o-realtime-preview" },
63+
});
64+
65+
expect(result.value).toBe("eph-secret-abc");
66+
expect(typeof result.expiresAt).toBe("number");
67+
});
68+
69+
it("bounds oversized success response and cancels the stream", async () => {
70+
// 20 MiB in 1 MiB chunks — well over the 16 MiB cap
71+
const streamed = makeStreamingResponse({ chunkCount: 20, chunkSize: 1024 * 1024 });
72+
guardedFetch(streamed.response);
73+
74+
await expect(
75+
createOpenAIRealtimeClientSecret({
76+
authToken: "sk-test",
77+
auditContext: "test",
78+
session: { model: "gpt-4o-realtime-preview" },
79+
}),
80+
).rejects.toThrow(/openai\.realtime-session/);
81+
82+
expect(streamed.wasCanceled()).toBe(true);
83+
expect(streamed.getReadCount()).toBeLessThan(20);
84+
});
85+
86+
it("throws the provider error label on oversized body", async () => {
87+
const streamed = makeStreamingResponse({ chunkCount: 20, chunkSize: 1024 * 1024 });
88+
guardedFetch(streamed.response);
89+
90+
await expect(
91+
createOpenAIRealtimeTranscriptionClientSecret({
92+
authToken: "sk-test",
93+
auditContext: "test",
94+
session: { model: "gpt-4o-transcribe" },
95+
}),
96+
).rejects.toThrow(/openai\.realtime-session/);
97+
98+
expect(streamed.wasCanceled()).toBe(true);
99+
});
100+
});

extensions/openai/realtime-provider-shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { resolveExpiresAtMsFromEpochSeconds } from "openclaw/plugin-sdk/number-runtime";
33
import {
44
createProviderHttpError,
5+
readProviderJsonResponse,
56
resolveProviderRequestHeaders,
67
} from "openclaw/plugin-sdk/provider-http";
78
import { captureWsEvent } from "openclaw/plugin-sdk/proxy-capture";
@@ -110,7 +111,7 @@ async function createOpenAIRealtimeSecret(
110111
if (!response.ok) {
111112
throw await createProviderHttpError(response, params.errorMessage);
112113
}
113-
return (await response.json()) as unknown;
114+
return await readProviderJsonResponse<unknown>(response, "openai.realtime-session");
114115
} finally {
115116
await release();
116117
}

0 commit comments

Comments
 (0)