📄 feat: Model-Aware Bedrock Document Size Validation (#12467)

* 📄 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.
This commit is contained in:
Danny Avila 2026-03-30 16:50:10 -04:00 committed by GitHub
parent fda72ac621
commit 1455f15b7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 254 additions and 26 deletions

View file

@ -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,
);

View file

@ -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');

View file

@ -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<DocumentResult> {
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) {

View file

@ -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);

View file

@ -31,13 +31,20 @@ export async function validatePdf(
fileSize: number,
provider: Providers,
configuredFileSizeLimit?: number,
model?: string,
): Promise<PDFValidationResult> {
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}-{version4}-{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<ValidationResult> {
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,