diff --git a/src/agents/tools/pdf-native-providers.test.ts b/src/agents/tools/pdf-native-providers.test.ts index 98a8b5e5052..b7eecd23935 100644 --- a/src/agents/tools/pdf-native-providers.test.ts +++ b/src/agents/tools/pdf-native-providers.test.ts @@ -112,6 +112,65 @@ describe("native PDF provider API calls", () => { ).rejects.toThrow("Anthropic PDF request failed"); }); + it("bounds large Anthropic API error bodies", async () => { + let canceled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`${"x".repeat(9_000)}tail-marker`)); + }, + cancel() { + canceled = true; + }, + }); + mockFetchResponse( + new Response(body, { + status: 400, + statusText: "Bad Request", + }), + ); + + const error = await pdfNativeProviders + .anthropicAnalyzePdf(makeAnthropicAnalyzeParams()) + .catch((caught: unknown) => caught as Error); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("Anthropic PDF request failed"); + expect(error.message).not.toContain("tail-marker"); + expect(error.message.length).toBeLessThan(500); + expect(canceled).toBe(true); + }); + + it("cancels Anthropic API error bodies that exactly fill the byte cap", async () => { + let canceled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("x".repeat(8 * 1024))); + }, + cancel() { + canceled = true; + }, + }); + mockFetchResponse( + new Response(body, { + status: 400, + statusText: "Bad Request", + }), + ); + + const error = await Promise.race([ + pdfNativeProviders + .anthropicAnalyzePdf(makeAnthropicAnalyzeParams()) + .catch((caught: unknown) => caught as Error), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("timed out waiting for bounded error body")), 500); + }), + ]); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("Anthropic PDF request failed"); + expect(canceled).toBe(true); + }); + it("anthropicAnalyzePdf throws when response has no text", async () => { mockFetchResponse({ ok: true, diff --git a/src/agents/tools/pdf-native-providers.ts b/src/agents/tools/pdf-native-providers.ts index fd46cc84beb..0500b485c42 100644 --- a/src/agents/tools/pdf-native-providers.ts +++ b/src/agents/tools/pdf-native-providers.ts @@ -13,6 +13,59 @@ type PdfInput = { }; const NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS = 120_000; +const NATIVE_PDF_ERROR_BODY_MAX_BYTES = 8 * 1024; +const NATIVE_PDF_ERROR_BODY_MAX_CHARS = 400; + +async function readErrorBodySnippet(res: Response): Promise { + try { + const body = res.body; + if (!body || typeof body.getReader !== "function") { + return (await res.text()).slice(0, NATIVE_PDF_ERROR_BODY_MAX_CHARS); + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let truncated = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done || !value?.byteLength) { + break; + } + const remaining = NATIVE_PDF_ERROR_BODY_MAX_BYTES - total; + if (remaining <= 0) { + truncated = true; + break; + } + if (value.byteLength > remaining) { + chunks.push(value.subarray(0, remaining)); + total += remaining; + truncated = true; + break; + } + chunks.push(value); + total += value.byteLength; + if (total >= NATIVE_PDF_ERROR_BODY_MAX_BYTES) { + truncated = true; + break; + } + } + } finally { + if (truncated) { + await reader.cancel().catch(() => undefined); + } + try { + reader.releaseLock(); + } catch {} + } + return new TextDecoder() + .decode(Buffer.concat(chunks, total)) + .slice(0, NATIVE_PDF_ERROR_BODY_MAX_CHARS); + } catch { + return ""; + } +} // --------------------------------------------------------------------------- // Anthropic – native PDF via Messages API @@ -80,9 +133,9 @@ export async function anthropicAnalyzePdf(params: { }); if (!res.ok) { - const body = await res.text().catch(() => ""); + const body = await readErrorBodySnippet(res); throw new Error( - `Anthropic PDF request failed (${res.status} ${res.statusText})${body ? `: ${body.slice(0, 400)}` : ""}`, + `Anthropic PDF request failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`, ); } @@ -165,9 +218,9 @@ export async function geminiAnalyzePdf(params: { }); if (!res.ok) { - const body = await res.text().catch(() => ""); + const body = await readErrorBodySnippet(res); throw new Error( - `Gemini PDF request failed (${res.status} ${res.statusText})${body ? `: ${body.slice(0, 400)}` : ""}`, + `Gemini PDF request failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`, ); }