diff --git a/src/agents/provider-transport-fetch.test.ts b/src/agents/provider-transport-fetch.test.ts index 8e6e98c18734..5582b79b11fd 100644 --- a/src/agents/provider-transport-fetch.test.ts +++ b/src/agents/provider-transport-fetch.test.ts @@ -1369,6 +1369,42 @@ describe("buildGuardedModelFetch", () => { expect(refreshTimeout).toHaveBeenCalledTimes(2); }); + it("handles a valid large SSE event split before its boundary", async () => { + const payload = { text: "x".repeat(70 * 1024) }; + const encoder = new TextEncoder(); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}`)); + controller.enqueue(encoder.encode("\n\n")); + controller.close(); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + finalUrl: "https://openrouter.ai/api/v1/chat/completions", + release: vi.fn(async () => undefined), + }); + const model = { + id: "gpt-5.4", + provider: "openrouter", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + } as unknown as Model<"openai-completions">; + + const response = await buildGuardedModelFetch(model)( + "https://openrouter.ai/api/v1/chat/completions", + { method: "POST" }, + ); + const items = []; + for await (const item of Stream.fromSSEResponse(response, new AbortController())) { + items.push(item); + } + + expect(items).toEqual([payload]); + }); + it("errors on oversized SSE body without event boundary in sanitizer", async () => { const oversized = "x".repeat(16 * 1024 * 1024 + 1024); const encoder = new TextEncoder(); diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index 75cfcde76428..2ac178fefb27 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -51,12 +51,8 @@ const log = createSubsystemLogger("provider-transport-fetch"); * without Content-Length. */ const SSE_SYNTHESIZE_JSON_MAX_BYTES = 16 * 1024 * 1024; -/** Max bytes for the internal SSE sanitization buffer between event boundaries. - * A single legitimate event (e.g. a large reasoning summary on the chatgpt-responses - * API) can far exceed 64 KiB, so bound this at the same 16 MiB ceiling as the - * JSON-synthesis path: only a genuinely boundary-less (hostile/broken) stream trips - * the guard, not a real large event. */ -const SSE_SANITIZE_BUFFER_MAX_BYTES = 16 * 1024 * 1024; +/** Max decoded characters buffered while waiting for the next SSE event boundary. */ +const SSE_SANITIZE_BUFFER_MAX_CHARS = 16 * 1024 * 1024; const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]); const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/; @@ -179,9 +175,9 @@ function sanitizeOpenAISdkSseResponse( for (;;) { const boundary = findSseEventBoundary(buffer); if (!boundary) { - if (buffer.length > SSE_SANITIZE_BUFFER_MAX_BYTES) { + if (buffer.length > SSE_SANITIZE_BUFFER_MAX_CHARS) { throw new Error( - `SSE response exceeded max buffer size (${SSE_SANITIZE_BUFFER_MAX_BYTES} bytes) without event boundary`, + `SSE response exceeded max buffer size (${SSE_SANITIZE_BUFFER_MAX_CHARS} chars) without event boundary`, ); } return enqueued;