From 89064a81fc2e1a8db41b62c37d69c054c83001c9 Mon Sep 17 00:00:00 2001 From: YASMEEN Date: Mon, 2 Feb 2026 19:24:34 +0530 Subject: [PATCH] fix(pdf-processor): enforce page limits by default with actionable alternatives - Add enforceLimits option to PDFProcessorOptions (default: true) - Throw error with 5 actionable alternatives when page limit exceeded - Allow bypass with enforceLimits: false (logs prominent warning) Fixes: Page limit check only logged warning but did not prevent processing --- src/lib/types/fileTypes.ts | 5 + src/lib/utils/errorHandling.ts | 41 +++++ src/lib/utils/pdfProcessor.ts | 20 ++- test/unit/utils/pdfProcessor.test.ts | 215 ++++++++++++++++++++++++++- 4 files changed, 276 insertions(+), 5 deletions(-) diff --git a/src/lib/types/fileTypes.ts b/src/lib/types/fileTypes.ts index da9e7466da..5ba873ba1c 100644 --- a/src/lib/types/fileTypes.ts +++ b/src/lib/types/fileTypes.ts @@ -128,6 +128,11 @@ export type PDFProcessorOptions = { model?: string; maxSizeMB?: number; bedrockApiMode?: "converse" | "invokeModel"; + /** + * Whether to enforce page limits by throwing an error (default: true) + * Set to false to bypass limit enforcement (logs warning instead) + */ + enforceLimits?: boolean; }; /** diff --git a/src/lib/utils/errorHandling.ts b/src/lib/utils/errorHandling.ts index 354bebef85..0ce0bbe8e3 100644 --- a/src/lib/utils/errorHandling.ts +++ b/src/lib/utils/errorHandling.ts @@ -49,6 +49,9 @@ export const ERROR_CODES = { IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL", INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT", + // PDF validation errors + PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED", + // Rate limiter errors RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL", RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT", @@ -492,6 +495,44 @@ export class ErrorFactory { }); } + // ============================================================================ + // PDF VALIDATION ERRORS + // ============================================================================ + + /** + * Create a PDF page limit exceeded error + */ + static pdfPageLimitExceeded( + estimatedPages: number, + maxPages: number, + provider: string, + ): NeuroLinkError { + const alternatives = [ + `Split the PDF into smaller files (max ${maxPages} pages each)`, + "Extract only the pages you need using a PDF editor", + "For large files, consider Google AI Studio which supports up to 2000MB file size (though page limits still apply)", + "Convert specific pages to images manually before processing", + "Bypass this limit with { enforceLimits: false } (not recommended - may cause API errors or unexpected costs)", + ]; + + return new NeuroLinkError({ + code: ERROR_CODES.PDF_PAGE_LIMIT_EXCEEDED, + message: + `PDF page limit exceeded: ${estimatedPages} pages detected, but ${provider} supports maximum ${maxPages} pages.\n\n` + + `Alternatives:\n` + + alternatives.map((alt, i) => `${i + 1}. ${alt}`).join("\n"), + category: ErrorCategory.VALIDATION, + severity: ErrorSeverity.MEDIUM, + retriable: false, + context: { + estimatedPages, + maxPages, + provider, + alternatives, + }, + }); + } + /** * Create an image too large error */ diff --git a/src/lib/utils/pdfProcessor.ts b/src/lib/utils/pdfProcessor.ts index bd7cc7c773..19353a7a40 100644 --- a/src/lib/utils/pdfProcessor.ts +++ b/src/lib/utils/pdfProcessor.ts @@ -15,6 +15,7 @@ import type { } from "../types/fileTypes.js"; import { PDF_LIMITS } from "../core/constants.js"; import { logger } from "./logger.js"; +import { ErrorFactory } from "./errorHandling.js"; /** * Provider configurations for PDF handling @@ -201,10 +202,21 @@ export class PDFProcessor { const metadata = this.extractBasicMetadata(content); if (metadata.estimatedPages && metadata.estimatedPages > config.maxPages) { - logger.warn( - `[PDF] PDF appears to have ${metadata.estimatedPages}+ pages. ` + - `${provider} supports up to ${config.maxPages} pages.`, - ); + const enforceLimits = options?.enforceLimits !== false; + + if (enforceLimits) { + throw ErrorFactory.pdfPageLimitExceeded( + metadata.estimatedPages, + config.maxPages, + provider, + ); + } else { + logger.warn( + `[PDF] ⚠️ LIMIT BYPASS: Processing ${metadata.estimatedPages}-page PDF despite ${config.maxPages}-page limit for ${provider}. ` + + `This may cause API rejection, token errors, or unexpected costs. ` + + `Consider splitting the PDF or using a different provider.`, + ); + } } if (provider === "bedrock" && options?.bedrockApiMode === "converse") { diff --git a/test/unit/utils/pdfProcessor.test.ts b/test/unit/utils/pdfProcessor.test.ts index b77d0b70a8..fadb20088b 100644 --- a/test/unit/utils/pdfProcessor.test.ts +++ b/test/unit/utils/pdfProcessor.test.ts @@ -1,5 +1,218 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { PDFProcessor } from "../../../src/lib/utils/pdfProcessor.js"; +import { logger } from "../../../src/lib/utils/logger.js"; +import { NeuroLinkError } from "../../../src/lib/utils/errorHandling.js"; + +// Mock the logger +vi.mock("../../../src/lib/utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("PDFProcessor.process", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** + * Helper to create a minimal valid PDF buffer with specified page count + * The extractBasicMetadata function counts `/Type /Page` occurrences (not `/Type /Pages`) + * so we need to include that many page object references + */ + const createPdfWithPageCount = (pageCount: number): Buffer => { + // Build page objects - each /Type /Page entry counts as a page + let pageObjects = ""; + for (let i = 0; i < pageCount; i++) { + pageObjects += `/Type /Page `; + } + const pdfContent = + `%PDF-1.4\n` + + `1 0 obj<>endobj ` + + `2 0 obj<>endobj ` + + `${pageObjects}\n` + + `xref\n0 3\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n` + + `trailer<>\nstartxref\n150\n%%EOF`; + return Buffer.from(pdfContent); + }; + + describe("page limit enforcement", () => { + it("should succeed for PDF under page limit", async () => { + const pdfBuffer = createPdfWithPageCount(50); + + const result = await PDFProcessor.process(pdfBuffer, { + provider: "openai", + }); + + expect(result.type).toBe("pdf"); + expect(result.mimeType).toBe("application/pdf"); + expect(logger.info).toHaveBeenCalledWith( + "[PDF] ✅ Validated PDF file", + expect.objectContaining({ + provider: "openai", + }), + ); + }); + + it("should succeed for PDF at exactly the page limit", async () => { + const pdfBuffer = createPdfWithPageCount(100); + + const result = await PDFProcessor.process(pdfBuffer, { + provider: "openai", + }); + + expect(result.type).toBe("pdf"); + expect(result.metadata?.estimatedPages).toBe(100); + }); + + it("should throw NeuroLinkError with correct error code for page limit exceeded", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + try { + await PDFProcessor.process(pdfBuffer, { provider: "openai" }); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).toBeInstanceOf(NeuroLinkError); + const neuroLinkError = error as NeuroLinkError; + expect(neuroLinkError.code).toBe("PDF_PAGE_LIMIT_EXCEEDED"); + expect(neuroLinkError.context).toMatchObject({ + estimatedPages: 150, + maxPages: 100, + provider: "openai", + }); + expect(neuroLinkError.context.alternatives).toBeDefined(); + expect(Array.isArray(neuroLinkError.context.alternatives)).toBe(true); + } + }); + + it("should throw error with correct page counts in message", async () => { + const pdfBuffer = createPdfWithPageCount(200); + + await expect( + PDFProcessor.process(pdfBuffer, { provider: "anthropic" }), + ).rejects.toThrow(/200 pages detected.*maximum 100 pages/); + }); + + it("should include actionable alternatives in error message", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + try { + await PDFProcessor.process(pdfBuffer, { provider: "openai" }); + expect.fail("Should have thrown an error"); + } catch (error) { + const errorMessage = (error as Error).message; + expect(errorMessage).toContain("Alternatives:"); + expect(errorMessage).toContain("Split the PDF into smaller files"); + expect(errorMessage).toContain("Extract only the pages you need"); + expect(errorMessage).toContain( + "Google AI Studio which supports up to 2000MB file size", + ); + expect(errorMessage).toContain("page limits still apply"); + expect(errorMessage).toContain("Convert specific pages to images"); + expect(errorMessage).toContain("enforceLimits: false"); + } + }); + + it("should not mislead about provider-specific page limits", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + try { + await PDFProcessor.process(pdfBuffer, { provider: "openai" }); + expect.fail("Should have thrown an error"); + } catch (error) { + const errorMessage = (error as Error).message; + // Should use generic suggestion, not claim specific providers have higher limits + expect(errorMessage).not.toContain("Google AI Studio supports"); + } + }); + + it("should bypass limit with enforceLimits: false and continue processing", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + const result = await PDFProcessor.process(pdfBuffer, { + provider: "openai", + enforceLimits: false, + }); + + expect(result.type).toBe("pdf"); + expect(result.mimeType).toBe("application/pdf"); + expect(result.metadata?.estimatedPages).toBe(150); + }); + + it("should log prominent warning when bypassing limit", async () => { + const pdfBuffer = createPdfWithPageCount(200); + + await PDFProcessor.process(pdfBuffer, { + provider: "anthropic", + enforceLimits: false, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("LIMIT BYPASS"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("200-page PDF"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("100-page limit"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("anthropic"), + ); + }); + + it("should warn about potential consequences when bypassing limit", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + await PDFProcessor.process(pdfBuffer, { + provider: "openai", + enforceLimits: false, + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("API rejection"), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("unexpected costs"), + ); + }); + + it("should enforce limits by default when enforceLimits is not specified", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + await expect( + PDFProcessor.process(pdfBuffer, { provider: "openai" }), + ).rejects.toThrow("PDF page limit exceeded"); + }); + + it("should enforce limits when enforceLimits is explicitly true", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + await expect( + PDFProcessor.process(pdfBuffer, { + provider: "openai", + enforceLimits: true, + }), + ).rejects.toThrow("PDF page limit exceeded"); + }); + + it("should respect different provider page limits", async () => { + const pdfBuffer = createPdfWithPageCount(150); + + // All providers have 100 page limits, so this should throw for any + await expect( + PDFProcessor.process(pdfBuffer, { provider: "anthropic" }), + ).rejects.toThrow("PDF page limit exceeded"); + + await expect( + PDFProcessor.process(pdfBuffer, { provider: "google-vertex" }), + ).rejects.toThrow("PDF page limit exceeded"); + }); + }); +}); describe("PDFProcessor.convertToImages", () => { describe("empty PDF validation", () => {