From 1455f15b7b1f942442bf09acf9067aac5a8dcb78 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 30 Mar 2026 16:50:10 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=84=20feat:=20Model-Aware=20Bedrock=20?= =?UTF-8?q?Document=20Size=20Validation=20(#12467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📄 fix: Model-Aware Bedrock Document Size Validation Remove the hard 4.5MB clamp on Bedrock document uploads so that Claude 4+ (PDF) and Nova (PDF/DOCX) models can accept larger files per AWS documentation. The default 4.5MB limit is preserved for other models/formats, and fileConfig can now override it in either direction—consistent with every other provider. * address review: restore Math.min for non-exempt docs, tighten regexes, add tests - Restore Math.min clamp for non-exempt Bedrock documents (fileConfig can only lower the hard 4.5 MB API limit, not raise it); only exempt models (Claude 4+ PDF, Nova PDF/DOCX) use ?? to allow fileConfig override - Replace copied isBedrockClaude4Plus regex with cleaner anchored pattern that correctly handles multi-digit version numbers (e.g. sonnet-40) and removes dead Alt 1 branch matching no real Bedrock model IDs - Tighten isBedrockNova from includes() to startsWith() to prevent substring matching in unexpected positions - Add integration test verifying model is threaded to validateBedrockDocument - Add boundary tests for exempt + low configuredFileSizeLimit, non-exempt + high configuredFileSizeLimit, and exempt model accepting files up to 32 MB - Revert two tests that were incorrectly inverted to prove wrong behavior - Fix inaccurate JSDoc and misleading test name * simplify: allow fileConfig to override Bedrock limit in either direction Make Bedrock consistent with all other providers — fileConfig sets the effective limit unconditionally via ?? rather than clamping with Math.min. The model-aware defaults (4.5 MB for non-exempt, 32 MB for exempt) remain as sensible fallbacks when no fileConfig is set. * fix: handle cross-region inference profile IDs in Bedrock model matchers Bedrock cross-region inference profiles prepend a region code to the model ID (e.g. "us.amazon.nova-pro-v1:0", "eu.anthropic.claude-sonnet-4-..."). Both isBedrockNova and isBedrockClaude4Plus would miss these prefixed IDs, silently falling back to the 4.5 MB default for eligible models. Switch both matchers to use (?:^|\.) to anchor the vendor segment so the pattern matches with or without a leading region prefix. --- api/app/clients/BaseClient.js | 1 + .../api/src/files/encode/document.spec.ts | 46 ++++- packages/api/src/files/encode/document.ts | 6 +- packages/api/src/files/validation.spec.ts | 162 +++++++++++++++++- packages/api/src/files/validation.ts | 65 +++++-- 5 files changed, 254 insertions(+), 26 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 08cb1f6ada..00cc7c92c0 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1085,6 +1085,7 @@ class BaseClient { provider: this.options.agent?.provider ?? this.options.endpoint, endpoint: this.options.agent?.endpoint ?? this.options.endpoint, useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi, + model: this.modelOptions?.model ?? this.model, }, getStrategyFunctions, ); diff --git a/packages/api/src/files/encode/document.spec.ts b/packages/api/src/files/encode/document.spec.ts index a93800b5e1..2b8e231048 100644 --- a/packages/api/src/files/encode/document.spec.ts +++ b/packages/api/src/files/encode/document.spec.ts @@ -88,11 +88,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { updatedAt: new Date(), }) as unknown as IMongoFile; - const createMockDocFile = ( - sizeInMB: number, - mimeType: string, - filename: string, - ): IMongoFile => + const createMockDocFile = (sizeInMB: number, mimeType: string, filename: string): IMongoFile => ({ _id: new Types.ObjectId(), user: new Types.ObjectId(), @@ -135,6 +131,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, configuredLimit, + undefined, ); }); @@ -163,6 +160,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, undefined, + undefined, ); }); @@ -196,6 +194,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, undefined, + undefined, ); }); @@ -235,6 +234,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.ANTHROPIC, configuredLimit, + undefined, ); }); @@ -274,6 +274,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.GOOGLE, configuredLimit, + undefined, ); }); @@ -314,6 +315,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, undefined, + undefined, ); }); }); @@ -407,6 +409,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, mbToBytes(5), + undefined, ); }); @@ -441,6 +444,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, mbToBytes(50), + undefined, ); }); @@ -480,6 +484,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, mbToBytes(10), + undefined, ); expect(mockedValidatePdf).toHaveBeenNthCalledWith( 2, @@ -487,6 +492,7 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { expect.any(Number), Providers.OPENAI, mbToBytes(10), + undefined, ); }); }); @@ -657,6 +663,36 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { }); }); + it('should thread model to validateBedrockDocument when model is provided', async () => { + const req = createMockRequest() as ServerRequest; + const model = 'anthropic.claude-sonnet-4-20250514-v1:0'; + const file = createMockDocFile(1, 'text/csv', 'data.csv'); + + const mockContent = Buffer.from('col1,col2\nval1,val2').toString('base64'); + mockedGetFileStream.mockResolvedValue({ + file, + content: mockContent, + metadata: file, + }); + + mockedValidateBedrockDocument.mockResolvedValue({ isValid: true }); + + await encodeAndFormatDocuments( + req, + [file], + { provider: Providers.BEDROCK, model }, + mockStrategyFunctions, + ); + + expect(mockedValidateBedrockDocument).toHaveBeenCalledWith( + expect.any(Number), + 'text/csv', + expect.any(Buffer), + undefined, + model, + ); + }); + it('should reject Bedrock document when validation fails', async () => { const req = createMockRequest() as ServerRequest; const file = createMockDocFile(5, 'text/csv', 'big.csv'); diff --git a/packages/api/src/files/encode/document.ts b/packages/api/src/files/encode/document.ts index e4fd066324..4126a51376 100644 --- a/packages/api/src/files/encode/document.ts +++ b/packages/api/src/files/encode/document.ts @@ -29,10 +29,10 @@ import { getFileStream, getConfiguredFileSizeLimit } from './utils'; export async function encodeAndFormatDocuments( req: ServerRequest, files: IMongoFile[], - params: { provider: Providers; endpoint?: string; useResponsesApi?: boolean }, + params: { provider: Providers; endpoint?: string; useResponsesApi?: boolean; model?: string }, getStrategyFunctions: (source: string) => StrategyFunctions, ): Promise { - const { provider, endpoint, useResponsesApi } = params; + const { provider, endpoint, useResponsesApi, model } = params; if (!files?.length) { return { documents: [], files: [] }; } @@ -94,6 +94,7 @@ export async function encodeAndFormatDocuments( mimeType, fileBuffer, configuredFileSizeLimit, + model, ); if (!validation.isValid) { @@ -122,6 +123,7 @@ export async function encodeAndFormatDocuments( pdfBuffer.length, provider, configuredFileSizeLimit, + model, ); if (!validation.isValid) { diff --git a/packages/api/src/files/validation.spec.ts b/packages/api/src/files/validation.spec.ts index 98dcda4188..9d7eff4670 100644 --- a/packages/api/src/files/validation.spec.ts +++ b/packages/api/src/files/validation.spec.ts @@ -173,13 +173,13 @@ describe('PDF Validation with fileConfig.endpoints.*.fileSizeLimit', () => { expect(result.error).toContain('2.0MB'); }); - it('should clamp to 4.5MB hard limit even when config is higher', async () => { + it('should allow configured limit higher than 4.5MB default', async () => { const configuredLimit = mbToBytes(512); const pdfBuffer = createMockPdfBuffer(5); const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, configuredLimit); - expect(result.isValid).toBe(false); - expect(result.error).toContain('4.5MB'); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); }); it('should reject PDFs with invalid header', async () => { @@ -200,6 +200,120 @@ describe('PDF Validation with fileConfig.endpoints.*.fileSizeLimit', () => { }); }); + describe('validatePdf - Bedrock with model-specific exemptions', () => { + const provider = Providers.BEDROCK; + + it('should exempt Claude 4+ PDFs from the 4.5MB limit', async () => { + const pdfBuffer = createMockPdfBuffer(10); + const model = 'anthropic.claude-sonnet-4-20250514-v1:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should exempt Claude 4+ PDFs via cross-region inference profile ID', async () => { + const pdfBuffer = createMockPdfBuffer(10); + const model = 'us.anthropic.claude-sonnet-4-20250514-v1:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should exempt Nova PDFs from the 4.5MB limit', async () => { + const pdfBuffer = createMockPdfBuffer(10); + const model = 'amazon.nova-pro-v1:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should exempt Nova PDFs via cross-region inference profile ID', async () => { + const pdfBuffer = createMockPdfBuffer(10); + const model = 'us.amazon.nova-pro-v1:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should still enforce 4.5MB for non-exempt models without config override', async () => { + const pdfBuffer = createMockPdfBuffer(5); + const model = 'anthropic.claude-3-5-sonnet-20241022-v2:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('4.5MB'); + }); + + it('should accept PDFs below 32MB for exempt Claude 4+ models', async () => { + const pdfBuffer = createMockPdfBuffer(30); + const model = 'anthropic.claude-opus-4-20250514-v1:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(true); + }); + + it('should reject exempt model PDFs exceeding 32MB', async () => { + const pdfBuffer = createMockPdfBuffer(35); + const model = 'anthropic.claude-sonnet-4-20250514-v1:0'; + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, undefined, model); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('32.0MB'); + }); + + it('should respect configuredFileSizeLimit when lower than 32MB for exempt models', async () => { + const configuredLimit = mbToBytes(10); + const pdfBuffer = createMockPdfBuffer(15); + const model = 'anthropic.claude-sonnet-4-20250514-v1:0'; + const result = await validatePdf( + pdfBuffer, + pdfBuffer.length, + provider, + configuredLimit, + model, + ); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('10.0MB'); + }); + + it('should allow configuredFileSizeLimit higher than 32MB for exempt models', async () => { + const configuredLimit = mbToBytes(50); + const pdfBuffer = createMockPdfBuffer(35); + const model = 'amazon.nova-pro-v1:0'; + const result = await validatePdf( + pdfBuffer, + pdfBuffer.length, + provider, + configuredLimit, + model, + ); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should allow configuredFileSizeLimit higher than 4.5MB for non-exempt models', async () => { + const configuredLimit = mbToBytes(100); + const pdfBuffer = createMockPdfBuffer(5); + const model = 'anthropic.claude-3-5-sonnet-20241022-v2:0'; + const result = await validatePdf( + pdfBuffer, + pdfBuffer.length, + provider, + configuredLimit, + model, + ); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + describe('validateBedrockDocument - non-PDF types', () => { it('should accept CSV within 4.5MB limit', async () => { const fileSize = 2 * 1024 * 1024; @@ -218,7 +332,7 @@ describe('PDF Validation with fileConfig.endpoints.*.fileSizeLimit', () => { expect(result.error).toBeUndefined(); }); - it('should reject non-PDF document exceeding 4.5MB hard limit', async () => { + it('should reject non-PDF document exceeding 4.5MB default limit', async () => { const fileSize = 5 * 1024 * 1024; const result = await validateBedrockDocument(fileSize, 'text/plain'); @@ -226,24 +340,54 @@ describe('PDF Validation with fileConfig.endpoints.*.fileSizeLimit', () => { expect(result.error).toContain('4.5MB'); }); - it('should clamp to 4.5MB even when config is higher for non-PDF', async () => { + it('should allow configured limit higher than 4.5MB for non-PDF', async () => { const fileSize = 5 * 1024 * 1024; const configuredLimit = mbToBytes(512); - const result = await validateBedrockDocument(fileSize, 'text/html', undefined, configuredLimit); + const result = await validateBedrockDocument( + fileSize, + 'text/html', + undefined, + configuredLimit, + ); - expect(result.isValid).toBe(false); - expect(result.error).toContain('4.5MB'); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); }); it('should use configured limit when lower than provider limit for non-PDF', async () => { const fileSize = 3 * 1024 * 1024; const configuredLimit = mbToBytes(2); - const result = await validateBedrockDocument(fileSize, 'text/markdown', undefined, configuredLimit); + const result = await validateBedrockDocument( + fileSize, + 'text/markdown', + undefined, + configuredLimit, + ); expect(result.isValid).toBe(false); expect(result.error).toContain('2.0MB'); }); + it('should exempt Nova DOCX from 4.5MB limit', async () => { + const fileSize = 10 * 1024 * 1024; + const mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + const model = 'amazon.nova-pro-v1:0'; + const result = await validateBedrockDocument(fileSize, mimeType, undefined, undefined, model); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should NOT exempt Claude 4+ DOCX from 4.5MB limit (only PDF exempt)', async () => { + const fileSize = 5 * 1024 * 1024; + const mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + const model = 'anthropic.claude-sonnet-4-20250514-v1:0'; + const result = await validateBedrockDocument(fileSize, mimeType, undefined, undefined, model); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('4.5MB'); + }); + it('should not run PDF header check on non-PDF types', async () => { const buffer = Buffer.from('NOT-A-PDF-HEADER-but-valid-csv-content'); const result = await validateBedrockDocument(buffer.length, 'text/csv', buffer); diff --git a/packages/api/src/files/validation.ts b/packages/api/src/files/validation.ts index b3db19e92a..1e92ff7dc2 100644 --- a/packages/api/src/files/validation.ts +++ b/packages/api/src/files/validation.ts @@ -31,13 +31,20 @@ export async function validatePdf( fileSize: number, provider: Providers, configuredFileSizeLimit?: number, + model?: string, ): Promise { if (provider === Providers.ANTHROPIC) { return validateAnthropicPdf(pdfBuffer, fileSize, configuredFileSizeLimit); } if (provider === Providers.BEDROCK) { - return validateBedrockDocument(fileSize, 'application/pdf', pdfBuffer, configuredFileSizeLimit); + return validateBedrockDocument( + fileSize, + 'application/pdf', + pdfBuffer, + configuredFileSizeLimit, + model, + ); } if (isOpenAILikeProvider(provider)) { @@ -123,12 +130,51 @@ async function validateAnthropicPdf( } /** - * Validates a document against Bedrock's 4.5MB hard limit. PDF-specific header - * checks run only when the MIME type is `application/pdf`. + * Matches Bedrock Claude 4+ model identifiers, including cross-region inference profile IDs. + * Pattern: [region.]anthropic.claude-{family}-{version≥4}-{date}-v{n}:{rev} + * e.g. "anthropic.claude-sonnet-4-20250514-v1:0" or "us.anthropic.claude-sonnet-4-20250514-v1:0" + */ +const BEDROCK_CLAUDE_4_PLUS_RE = /(?:^|\.)anthropic\.claude-(?:sonnet|opus|haiku)-[4-9]\d*-/; +const isBedrockClaude4Plus = (model?: string): boolean => + model != null && BEDROCK_CLAUDE_4_PLUS_RE.test(model); + +/** + * Matches Bedrock Nova model identifiers, including cross-region inference profile IDs. + * e.g. "amazon.nova-pro-v1:0" or "us.amazon.nova-pro-v1:0" + */ +const isBedrockNova = (model?: string): boolean => + model != null && /(?:^|\.)amazon\.nova-/.test(model); + +const pdfMimeType = 'application/pdf'; +const docxMimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +/** + * Returns true when the given model + MIME type combination is exempt from + * Bedrock's default 4.5 MB per-document limit. + * + * Per AWS docs (https://docs.aws.amazon.com/bedrock/latest/userguide/inference-api-restrictions.html): + * - Claude 4+: PDFs are exempt from the 4.5 MB limit + * - Nova: PDFs and DOCX are exempt from the 4.5 MB limit + */ +const isExemptFromBedrockDocLimit = (model?: string, mimeType?: string): boolean => { + if (mimeType === pdfMimeType) { + return isBedrockClaude4Plus(model) || isBedrockNova(model); + } + if (mimeType === docxMimeType) { + return isBedrockNova(model); + } + return false; +}; + +/** + * Validates a document against Bedrock size limits. The default limit is 4.5 MB, + * but Claude 4+ (PDF) and Nova (PDF/DOCX) models are exempt per AWS docs. + * When exempt, falls back to a 32 MB request-level limit as a reasonable upper bound. * @param fileSize - The file size in bytes * @param mimeType - The MIME type of the document * @param fileBuffer - The file buffer (used for PDF header validation) * @param configuredFileSizeLimit - Optional configured file size limit from fileConfig (in bytes) + * @param model - Optional Bedrock model identifier for model-specific limit exceptions * @returns Promise that resolves to validation result */ export async function validateBedrockDocument( @@ -136,14 +182,13 @@ export async function validateBedrockDocument( mimeType: string, fileBuffer?: Buffer, configuredFileSizeLimit?: number, + model?: string, ): Promise { try { - /** Bedrock enforces a hard 4.5MB per-document limit at the API level; config can only lower it */ - const providerLimit = mbToBytes(4.5); - const effectiveLimit = - configuredFileSizeLimit != null - ? Math.min(configuredFileSizeLimit, providerLimit) - : providerLimit; + const exempt = isExemptFromBedrockDocLimit(model, mimeType); + /** Default 4.5 MB; exempt models (Claude 4+ PDF, Nova PDF/DOCX) default to 32 MB when unconfigured */ + const providerLimit = exempt ? mbToBytes(32) : mbToBytes(4.5); + const effectiveLimit = configuredFileSizeLimit ?? providerLimit; if (fileSize > effectiveLimit) { const limitMB = (effectiveLimit / (1024 * 1024)).toFixed(1); @@ -153,7 +198,7 @@ export async function validateBedrockDocument( }; } - if (mimeType === 'application/pdf' && fileBuffer) { + if (mimeType === pdfMimeType && fileBuffer) { if (fileBuffer.length < 5) { return { isValid: false,