Skip to content

Commit 7c3dea3

Browse files
fix(anthropic): bound streaming 200 success-body SSE reads at 16 MiB (provider path)
Apply createSseByteGuard to src/llm/providers/anthropic.ts (legacy provider iterateSseMessages) so the Anthropic Messages provider SSE parser cannot be exhausted by a hostile or malfunctioning Anthropic-compatible endpoint that streams an unbounded SSE body. The 16 MiB cap matches the non-streaming readProviderJsonResponse cap. What changed: - src/llm/providers/anthropic.ts: wrap body.getReader() in createSseByteGuard before the existing iterateSseMessages loop. The function is also re-exported as iterateSseMessagesForTest for direct test access. - Inline test added to anthropic.test.ts: hostile 1 MiB pull stream, asserts canonical overflow message, cancel(reason) was an Error instance, pullCount bounded to 17-20. Reuses the createSseByteGuard helper from src/agents/streaming-byte-guard.ts (introduced in #96701 for the anthropic-transport-stream path; same helper, same 16 MiB cap, same overflow-error shape). This PR is the second of the planned per-surface rescue series for the previously closed PR #96632, after #96701. No SDK surface change. No repro script committed (proof in this body). The companion error-body path is already bounded (readAnthropicMessages ErrorBodySnippet, 8 KiB + 10s idle timeout, #95108); this PR closes the legacy-provider success-body gap.
1 parent 072d3ed commit 7c3dea3

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

src/llm/providers/anthropic.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ vi.mock("@anthropic-ai/sdk", () => ({
2121
},
2222
}));
2323

24-
import { streamAnthropic, streamSimpleAnthropic } from "./anthropic.js";
24+
import { streamAnthropic, streamSimpleAnthropic, iterateSseMessagesForTest } from "./anthropic.js";
2525

2626
function createSseResponse(events: Record<string, unknown>[] = []): Response {
2727
const body = events
@@ -1306,4 +1306,41 @@ describe("Anthropic provider", () => {
13061306
},
13071307
]);
13081308
});
1309+
1310+
it("bounds streamed Anthropic provider success bodies without content-length", async () => {
1311+
// 1 MiB chunks; cap is 16 MiB so the bounded reader cancels well before
1312+
// draining the full 32 MiB advertised body.
1313+
const CHUNK = 1024 * 1024;
1314+
const TOTAL = 32;
1315+
let pullCount = 0;
1316+
let cancelReason: unknown;
1317+
const overflowing = new ReadableStream<Uint8Array>({
1318+
pull(controller) {
1319+
pullCount += 1;
1320+
if (pullCount > TOTAL) {
1321+
controller.close();
1322+
return;
1323+
}
1324+
controller.enqueue(new Uint8Array(CHUNK));
1325+
},
1326+
cancel(reason) {
1327+
cancelReason = reason;
1328+
},
1329+
});
1330+
let caught: Error | null = null;
1331+
try {
1332+
for await (const event of iterateSseMessagesForTest(overflowing)) {
1333+
expect(event).toBeDefined();
1334+
}
1335+
} catch (err) {
1336+
caught = err as Error;
1337+
}
1338+
expect(caught?.message).toMatch(
1339+
/Anthropic Messages success body exceeded 16777216 bytes/,
1340+
);
1341+
expect(cancelReason).toBeInstanceOf(Error);
1342+
// 16 MiB + a couple of overshoot pulls, well under 32.
1343+
expect(pullCount).toBeGreaterThanOrEqual(17);
1344+
expect(pullCount).toBeLessThanOrEqual(20);
1345+
});
13091346
});

src/llm/providers/anthropic.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
usesClaudeFable5MessagesContract,
3333
} from "../../shared/anthropic-model-contract.js";
3434
import { applyAnthropicRefusal } from "../../shared/anthropic-refusal.js";
35+
import { createSseByteGuard } from "../../agents/streaming-byte-guard.js";
3536
import { createDeferredEventBuffer } from "../../shared/deferred-event-buffer.js";
3637
import { notifyLlmRequestActivity } from "../../shared/llm-request-activity.js";
3738
import { getEnvApiKey } from "../env-api-keys.js";
@@ -72,6 +73,7 @@ import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.j
7273
import { transformMessages } from "./transform-messages.js";
7374

7475
const ANTHROPIC_CACHE_CONTROL_LIMIT = 4;
76+
const ANTHROPIC_MESSAGES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
7577

7678
function getCacheControl(
7779
model: Model<"anthropic-messages">,
@@ -347,6 +349,17 @@ async function* iterateSseMessages(
347349
signal?: AbortSignal,
348350
): AsyncGenerator<ServerSentEvent> {
349351
const reader = body.getReader();
352+
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
353+
// non-streaming `readProviderJsonResponse` cap so a hostile or
354+
// malfunctioning Anthropic-compatible endpoint cannot exhaust memory by
355+
// streaming an unbounded SSE body.
356+
const guard = createSseByteGuard(reader, {
357+
maxBytes: ANTHROPIC_MESSAGES_SUCCESS_BODY_MAX_BYTES,
358+
onOverflow: ({ size, maxBytes }) =>
359+
new Error(
360+
`Anthropic Messages success body exceeded ${maxBytes} bytes (received ${size})`,
361+
),
362+
});
350363
const decoder = new TextDecoder();
351364
const state: SseDecoderState = { event: null, data: [], raw: [] };
352365
let buffer = "";
@@ -357,7 +370,7 @@ async function* iterateSseMessages(
357370
throw new Error("Request was aborted");
358371
}
359372

360-
const { value, done } = await reader.read();
373+
const { value, done } = await guard.read();
361374
if (done) {
362375
break;
363376
}
@@ -1495,3 +1508,10 @@ function mapStopReason(reason: string): StopReason {
14951508
throw new Error(`Unhandled stop reason: ${reason}`);
14961509
}
14971510
}
1511+
1512+
/**
1513+
* Internal — exported under a separate name so production callers go through
1514+
* `streamAnthropic`. Tests can drive it directly to exercise the bounded-reader
1515+
* behaviour without the surrounding SDK client wrapper.
1516+
*/
1517+
export const iterateSseMessagesForTest = iterateSseMessages;

0 commit comments

Comments
 (0)