@juspay/neurolink 10.10.7 → 10.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -225,11 +225,26 @@ export type StreamOptions = {
225
225
  password?: string;
226
226
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
227
227
  maxCanvasPixels?: number;
228
+ /**
229
+ * Render scale for the image fallback used by providers without native PDF
230
+ * support (#297). Higher is sharper but costs roughly the square in memory
231
+ * and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
232
+ */
233
+ scale?: number;
234
+ /**
235
+ * Max pages converted by the image fallback (#297). Pages beyond this are
236
+ * not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
237
+ */
238
+ maxPages?: number;
228
239
  };
229
240
  videoOptions?: {
241
+ /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
230
242
  frames?: number;
243
+ /** Frame encoder quality, clamped to 1-100. Default 80. */
231
244
  quality?: number;
245
+ /** Frame encoding. Default jpeg. */
232
246
  format?: "jpeg" | "png";
247
+ /** Not implemented yet (#433) — warns rather than silently doing nothing. */
233
248
  transcribeAudio?: boolean;
234
249
  };
235
250
  /**
@@ -39,6 +39,9 @@ export declare const ERROR_CODES: {
39
39
  readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
40
40
  readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
41
41
  readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
42
+ readonly PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED: "PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED";
43
+ readonly PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED: "PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED";
44
+ readonly PDF_PAGE_COUNT_UNVERIFIABLE: "PDF_PAGE_COUNT_UNVERIFIABLE";
42
45
  readonly PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED";
43
46
  readonly PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD";
44
47
  readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
@@ -203,6 +206,24 @@ export declare class ErrorFactory {
203
206
  * Create a PDF page limit exceeded error
204
207
  */
205
208
  static pdfPageLimitExceeded(estimatedPages: number, maxPages: number, provider: string): NeuroLinkError;
209
+ /**
210
+ * The combined page count across every PDF in one request exceeds what the
211
+ * provider accepts (#309). Distinct from `pdfPageLimitExceeded`, which is
212
+ * per-file — here each document can be individually legal.
213
+ */
214
+ static pdfAggregatePageLimitExceeded(fileCount: number, totalPages: number, maxPages: number, provider: string): NeuroLinkError;
215
+ /**
216
+ * The combined byte size across every PDF in one request exceeds what the
217
+ * provider accepts (#309).
218
+ */
219
+ static pdfAggregateSizeLimitExceeded(fileCount: number, totalMB: number, maxSizeMB: number, provider: string): NeuroLinkError;
220
+ /**
221
+ * A PDF supplied through the untrusted `input.content` surface could not be
222
+ * parsed for a page count (#309). Caller-supplied `metadata.pages` is not
223
+ * authoritative there, so an unreadable document is rejected rather than
224
+ * admitted with an assumed count of zero.
225
+ */
226
+ static pdfPageCountUnverifiable(filenames: string[], provider: string): NeuroLinkError;
206
227
  /**
207
228
  * The PDF is encrypted and no password was supplied (#258).
208
229
  */
@@ -52,6 +52,9 @@ export const ERROR_CODES = {
52
52
  CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
53
53
  // PDF validation errors
54
54
  PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
55
+ PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED: "PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED",
56
+ PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED: "PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED",
57
+ PDF_PAGE_COUNT_UNVERIFIABLE: "PDF_PAGE_COUNT_UNVERIFIABLE",
55
58
  PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED",
56
59
  PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD",
57
60
  // Rate limiter errors
@@ -557,6 +560,56 @@ export class ErrorFactory {
557
560
  },
558
561
  });
559
562
  }
563
+ /**
564
+ * The combined page count across every PDF in one request exceeds what the
565
+ * provider accepts (#309). Distinct from `pdfPageLimitExceeded`, which is
566
+ * per-file — here each document can be individually legal.
567
+ */
568
+ static pdfAggregatePageLimitExceeded(fileCount, totalPages, maxPages, provider) {
569
+ return new NeuroLinkError({
570
+ code: ERROR_CODES.PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED,
571
+ message: `[PDF] Combined page count across ${fileCount} PDF(s) (${totalPages}) exceeds the ` +
572
+ `${maxPages}-page limit for ${provider}. ` +
573
+ `Split the request or reduce the number of PDFs.`,
574
+ category: ErrorCategory.VALIDATION,
575
+ severity: ErrorSeverity.MEDIUM,
576
+ retriable: false,
577
+ context: { fileCount, totalPages, maxPages, provider },
578
+ });
579
+ }
580
+ /**
581
+ * The combined byte size across every PDF in one request exceeds what the
582
+ * provider accepts (#309).
583
+ */
584
+ static pdfAggregateSizeLimitExceeded(fileCount, totalMB, maxSizeMB, provider) {
585
+ return new NeuroLinkError({
586
+ code: ERROR_CODES.PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED,
587
+ message: `[PDF] Combined size across ${fileCount} PDF(s) (${totalMB.toFixed(2)}MB) exceeds the ` +
588
+ `${maxSizeMB}MB limit for ${provider}.`,
589
+ category: ErrorCategory.VALIDATION,
590
+ severity: ErrorSeverity.MEDIUM,
591
+ retriable: false,
592
+ context: { fileCount, totalMB, maxSizeMB, provider },
593
+ });
594
+ }
595
+ /**
596
+ * A PDF supplied through the untrusted `input.content` surface could not be
597
+ * parsed for a page count (#309). Caller-supplied `metadata.pages` is not
598
+ * authoritative there, so an unreadable document is rejected rather than
599
+ * admitted with an assumed count of zero.
600
+ */
601
+ static pdfPageCountUnverifiable(filenames, provider) {
602
+ return new NeuroLinkError({
603
+ code: ERROR_CODES.PDF_PAGE_COUNT_UNVERIFIABLE,
604
+ message: `[PDF] Cannot verify the page count for ${filenames.length} PDF(s) supplied via ` +
605
+ `input.content (${filenames.join(", ")}). Provide readable PDFs, or submit them ` +
606
+ `via input.pdfFiles where page counts are derived during detection.`,
607
+ category: ErrorCategory.VALIDATION,
608
+ severity: ErrorSeverity.MEDIUM,
609
+ retriable: false,
610
+ context: { filenames, provider },
611
+ });
612
+ }
560
613
  /**
561
614
  * The PDF is encrypted and no password was supplied (#258).
562
615
  */
@@ -344,13 +344,13 @@ export class FileDetector {
344
344
  logger.warn(`[FileDetector] All fallback parsing failed for type "${detection.type}". ` +
345
345
  `Attempted: ${options.allowedTypes.join(", ")}. Falling through to universal handler.`);
346
346
  const csvOptions = options?.csvOptions;
347
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider);
347
+ const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
348
348
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
349
349
  return result;
350
350
  }
351
351
  const content = await FileDetector.loadContent(input, detection, options);
352
352
  const csvOptions = options?.csvOptions;
353
- const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider);
353
+ const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
354
354
  FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
355
355
  return result;
356
356
  });
@@ -967,7 +967,7 @@ export class FileDetector {
967
967
  /**
968
968
  * Route to appropriate processor
969
969
  */
970
- static async processFile(content, detection, options, provider) {
970
+ static async processFile(content, detection, options, provider, videoOptions) {
971
971
  switch (detection.type) {
972
972
  case "csv":
973
973
  // Pass original extension through to CSV processor; if detection has none,
@@ -985,7 +985,7 @@ export class FileDetector {
985
985
  // AI providers don't support SVG as image format, so we extract text content
986
986
  return await FileDetector.processSvgAsText(content, detection);
987
987
  case "video":
988
- return await FileDetector.processVideoFile(content, detection);
988
+ return await FileDetector.processVideoFile(content, detection, videoOptions);
989
989
  case "audio":
990
990
  return await FileDetector.processAudioFile(content, detection);
991
991
  case "archive":
@@ -1031,7 +1031,7 @@ export class FileDetector {
1031
1031
  /**
1032
1032
  * Process video file: extract metadata, keyframes, and subtitles via VideoProcessor
1033
1033
  */
1034
- static async processVideoFile(content, detection) {
1034
+ static async processVideoFile(content, detection, videoOptions) {
1035
1035
  const videoFilename = detection.metadata.filename || "video";
1036
1036
  try {
1037
1037
  const videoResult = await (await getVideoProcessor()).processFile({
@@ -1040,7 +1040,10 @@ export class FileDetector {
1040
1040
  mimetype: detection.mimeType || "video/mp4",
1041
1041
  size: content.length,
1042
1042
  buffer: content,
1043
- });
1043
+ },
1044
+ // #478: carry the caller's keyframe budget/quality/format through to
1045
+ // the processor; previously these stopped at the CLI layer.
1046
+ videoOptions);
1044
1047
  if (videoResult.success && videoResult.data) {
1045
1048
  return {
1046
1049
  type: "video",
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
2
  import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
3
- import { basename } from "path";
4
3
  import { getGlobalDispatcher, interceptors, request } from "undici";
5
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
6
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
7
6
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
7
+ import { PDF_LIMITS } from "../core/constants.js";
8
8
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
9
9
  import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
10
10
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
@@ -809,6 +809,20 @@ export function mergeMediaFileAliases(input) {
809
809
  input.audioFiles = undefined;
810
810
  input.videoFiles = undefined;
811
811
  }
812
+ /**
813
+ * #478: `transcribeAudio` (CLI `--transcribe-audio`) is accepted by the options
814
+ * surface but no video-audio transcription exists yet — VideoProcessor extracts
815
+ * keyframes and embedded subtitle tracks only, and the transcription step is
816
+ * still open as #433. Say so once per request rather than letting the caller
817
+ * believe a transcript was produced and silently omitted.
818
+ */
819
+ function warnIfVideoTranscriptionRequested(videoOptions) {
820
+ if (videoOptions?.transcribeAudio) {
821
+ logger.warn("[NEUROLINK] Video audio transcription was requested but is not implemented yet " +
822
+ "(tracked as #433). Keyframes and any embedded subtitle tracks are still extracted; " +
823
+ "spoken audio will not be transcribed.");
824
+ }
825
+ }
812
826
  /**
813
827
  * Process the unified files array with auto-detection.
814
828
  * Handles lazy file registration, full processing, and preview injection.
@@ -825,6 +839,7 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
825
839
  }
826
840
  const totalFiles = options.input.files.length;
827
841
  const files = options.input.files;
842
+ warnIfVideoTranscriptionRequested(options.videoOptions);
828
843
  return withSpan({
829
844
  name: "neurolink.file.process_all",
830
845
  tracer: tracers.file,
@@ -882,6 +897,15 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
882
897
  "unknown",
883
898
  ],
884
899
  csvOptions: options.csvOptions,
900
+ // #478: videos arrive through this unified `files` path, so this is
901
+ // where the CLI's frame/quality/format request has to be handed on.
902
+ videoOptions: options.videoOptions
903
+ ? {
904
+ frames: options.videoOptions.frames,
905
+ quality: options.videoOptions.quality,
906
+ format: options.videoOptions.format,
907
+ }
908
+ : undefined,
885
909
  provider: provider,
886
910
  mimetypeHint: fileMimetypeHint,
887
911
  });
@@ -1020,6 +1044,83 @@ function enforcePostProcessingBudget(options, provider, model) {
1020
1044
  `budget=${contextWindow.toLocaleString()} tokens, ` +
1021
1045
  `utilization=${contextWindow > 0 ? ((totalContentTokens / contextWindow) * 100).toFixed(1) : "N/A"}%`);
1022
1046
  }
1047
+ /**
1048
+ * #309: enforce the provider's page/size ceilings across ALL PDFs in a request,
1049
+ * not just per-file. N files each just under the single-file limit can still
1050
+ * blow past it in aggregate (e.g. three 40-page PDFs → 120 pages for a
1051
+ * 100-page API).
1052
+ *
1053
+ * Shared by both PDF submission surfaces: `input.pdfFiles` (via
1054
+ * `processExplicitPdfFiles`) and `input.content` with `type: "pdf"` (via
1055
+ * `convertContentToProviderFormat`). The latter previously built its own
1056
+ * `pdfFiles` array and reached the provider without ever calling this guard,
1057
+ * so the limit was bypassable by moving the same payload to `input.content`.
1058
+ */
1059
+ /**
1060
+ * Basename that strips BOTH separators regardless of host platform.
1061
+ *
1062
+ * `path.basename` only understands the host's separator, so on a POSIX server
1063
+ * a Windows-style filename (`C:\Users\alice\q3-merger.pdf`) comes back
1064
+ * completely unchanged — defeating the point of trimming it before it reaches
1065
+ * a log line, since caller-controlled paths can carry usernames and internal
1066
+ * directory structure.
1067
+ */
1068
+ function safeBasename(filename) {
1069
+ const lastSeparator = Math.max(filename.lastIndexOf("/"), filename.lastIndexOf("\\"));
1070
+ const trimmed = lastSeparator === -1 ? filename : filename.slice(lastSeparator + 1);
1071
+ return trimmed || "<unnamed file>";
1072
+ }
1073
+ async function enforceAggregatePdfLimits(pdfFiles, provider, { trustSuppliedPageCounts }) {
1074
+ const aggregateConfig = PDFProcessor.getProviderConfig(provider);
1075
+ // Only an empty set is exempt. A single PDF must still be checked: on the
1076
+ // `input.content` path nothing else validates it (that path never goes
1077
+ // through FileDetector.detectAndProcess / PDFProcessor.process), so bailing
1078
+ // at length <= 1 let one 200-page document through untouched.
1079
+ if (!aggregateConfig || pdfFiles.length === 0) {
1080
+ return;
1081
+ }
1082
+ // Byte total is free to compute — enforce it BEFORE parsing anything, so an
1083
+ // oversized request is rejected without first spending parser CPU/memory on
1084
+ // every document in it.
1085
+ const totalMB = pdfFiles.reduce((sum, f) => sum + f.buffer.length, 0) / (1024 * 1024);
1086
+ if (totalMB > aggregateConfig.maxSizeMB) {
1087
+ throw ErrorFactory.pdfAggregateSizeLimitExceeded(pdfFiles.length, totalMB, aggregateConfig.maxSizeMB, provider);
1088
+ }
1089
+ // `trustSuppliedPageCounts` is the difference between the two surfaces.
1090
+ // On `input.pdfFiles` the count comes from FileDetector's own detection, so
1091
+ // it is authoritative. On `input.content` it is `metadata.pages` — plain
1092
+ // caller input — and trusting it lets a request declare `pages: 1` for each
1093
+ // of three 40-page PDFs and sail past the ceiling. There, the count is
1094
+ // always re-derived from the bytes and the supplied value is ignored.
1095
+ const pageCounts = await Promise.all(pdfFiles.map(async (f) => trustSuppliedPageCounts && typeof f.pageCount === "number"
1096
+ ? f.pageCount
1097
+ : await PDFProcessor.resolvePageCount(f.buffer)));
1098
+ // Filenames are caller-controlled and may be full paths carrying
1099
+ // PII/internal directory segments — surface only the basename, stripping
1100
+ // both separators so a Windows path is trimmed on a POSIX host too.
1101
+ const unknownFileNames = pdfFiles
1102
+ .filter((_, i) => typeof pageCounts[i] !== "number")
1103
+ .map((f) => (f.filename ? safeBasename(f.filename) : "<unnamed file>"));
1104
+ const totalPages = pageCounts.reduce((sum, p) => sum + (typeof p === "number" ? p : 0), 0);
1105
+ if (unknownFileNames.length > 0) {
1106
+ if (!trustSuppliedPageCounts) {
1107
+ // Untrusted surface: an unreadable count is indistinguishable from an
1108
+ // evasion attempt, and counting it as zero is precisely the hole. Fail
1109
+ // closed rather than admit an unverifiable document.
1110
+ throw ErrorFactory.pdfPageCountUnverifiable(unknownFileNames, provider);
1111
+ }
1112
+ // Trusted surface: detection already vetted these, so one unreadable
1113
+ // count must not fail an otherwise valid request — but it must not be
1114
+ // silent either, since the known sum may undercount the true total.
1115
+ logger.warn(`[PDF] Aggregate page-limit check across ${pdfFiles.length} PDFs could only be ` +
1116
+ `partially verified: ${unknownFileNames.length} file(s) have an unknown ` +
1117
+ `page count (${unknownFileNames.join(", ")}), so the known total (${totalPages}) may ` +
1118
+ `undercount the true combined page count.`);
1119
+ }
1120
+ if (totalPages > aggregateConfig.maxPages) {
1121
+ throw ErrorFactory.pdfAggregatePageLimitExceeded(pdfFiles.length, totalPages, aggregateConfig.maxPages, provider);
1122
+ }
1123
+ }
1023
1124
  /**
1024
1125
  * Process explicit PDF files and return structured PDF entries for multimodal processing.
1025
1126
  */
@@ -1050,6 +1151,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1050
1151
  // #260: carry the per-page canvas-pixel ceiling so the caller can
1051
1152
  // raise (or lower) the memory guard for the image-fallback render.
1052
1153
  maxCanvasPixels: options.pdfOptions?.maxCanvasPixels,
1154
+ // #297: render scale / page ceiling, so the lowered default is
1155
+ // actually reachable and callers can trade sharpness for memory.
1156
+ scale: options.pdfOptions?.scale,
1157
+ maxPages: options.pdfOptions?.maxPages,
1053
1158
  });
1054
1159
  logger.info(`[PDF] ✅ Queued for multimodal: ${filename} (${result.metadata?.estimatedPages ?? "unknown"} pages)`);
1055
1160
  }
@@ -1059,41 +1164,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1059
1164
  throw error;
1060
1165
  }
1061
1166
  }
1062
- // #309: enforce the provider's page/size ceilings across ALL PDFs, not just
1063
- // per-file. N files each just under the single-file limit can still blow past
1064
- // it in aggregate (e.g. three 40-page PDFs → 120 pages for a 100-page API).
1065
- const aggregateConfig = PDFProcessor.getProviderConfig(provider);
1066
- if (aggregateConfig && pdfFiles.length > 1) {
1067
- // A null pageCount (accurate count unavailable — see
1068
- // PDFProcessor.getAccuratePageCount) is treated as 0 in the sum below,
1069
- // which can undercount the aggregate and let a combined request over
1070
- // the provider's page limit slip through silently. Enforcement still
1071
- // runs against the known sum — a PDF with an unknown count must not
1072
- // fail the request outright — but the gap itself must not be silent.
1073
- const unknownPageCountFiles = pdfFiles.filter((f) => f.pageCount === null || f.pageCount === undefined);
1074
- const totalPages = pdfFiles.reduce((sum, f) => sum + (f.pageCount ?? 0), 0);
1075
- const totalMB = pdfFiles.reduce((sum, f) => sum + f.buffer.length, 0) / (1024 * 1024);
1076
- if (unknownPageCountFiles.length > 0) {
1077
- // Filenames are caller-controlled and may be full paths carrying
1078
- // PII/internal directory segments — log only the basename.
1079
- const unknownFileNames = unknownPageCountFiles
1080
- .map((f) => (f.filename ? basename(f.filename) : "<unnamed file>"))
1081
- .join(", ");
1082
- logger.warn(`[PDF] Aggregate page-limit check across ${pdfFiles.length} PDFs could only be ` +
1083
- `partially verified: ${unknownPageCountFiles.length} file(s) have an unknown ` +
1084
- `page count (${unknownFileNames}), so the known total (${totalPages}) may ` +
1085
- `undercount the true combined page count.`);
1086
- }
1087
- if (totalPages > aggregateConfig.maxPages) {
1088
- throw new Error(`[PDF] Combined page count across ${pdfFiles.length} PDFs (${totalPages}) exceeds the ` +
1089
- `${aggregateConfig.maxPages}-page limit for ${provider}. ` +
1090
- `Split the request or reduce the number of PDFs.`);
1091
- }
1092
- if (totalMB > aggregateConfig.maxSizeMB) {
1093
- throw new Error(`[PDF] Combined size across ${pdfFiles.length} PDFs (${totalMB.toFixed(2)}MB) exceeds the ` +
1094
- `${aggregateConfig.maxSizeMB}MB limit for ${provider}.`);
1095
- }
1096
- }
1167
+ // Counts here come from FileDetector's detection, so they are authoritative.
1168
+ await enforceAggregatePdfLimits(pdfFiles, provider, {
1169
+ trustSuppliedPageCounts: true,
1170
+ });
1097
1171
  return pdfFiles;
1098
1172
  }
1099
1173
  /**
@@ -1389,7 +1463,15 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
1389
1463
  // guard as the `input.pdfFiles` path (see `processExplicitPdfFiles`).
1390
1464
  password: pdfOptions?.password,
1391
1465
  maxCanvasPixels: pdfOptions?.maxCanvasPixels,
1466
+ scale: pdfOptions?.scale,
1467
+ maxPages: pdfOptions?.maxPages,
1392
1468
  }));
1469
+ // #309: same aggregate ceiling as `input.pdfFiles`. Without this, moving an
1470
+ // over-limit payload from `input.pdfFiles` to `input.content` skipped the
1471
+ // check entirely and the request went straight to the provider.
1472
+ await enforceAggregatePdfLimits(pdfFiles, provider, {
1473
+ trustSuppliedPageCounts: false,
1474
+ });
1393
1475
  return await convertMultimodalToProviderFormat(text, images, pdfFiles, provider, _model);
1394
1476
  }
1395
1477
  /**
@@ -1734,7 +1816,10 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1734
1816
  /**
1735
1817
  * Convert multimodal content (images + PDFs) to provider format
1736
1818
  */
1737
- async function convertMultimodalToProviderFormat(text, images, pdfFiles, provider, model) {
1819
+ async function convertMultimodalToProviderFormat(text, images,
1820
+ // The canonical entry shape (#309) rather than a fourth copy of it inline —
1821
+ // which is what let the render knobs stop short of this function.
1822
+ pdfFiles, provider, model) {
1738
1823
  const content = [
1739
1824
  { type: "text", text },
1740
1825
  ];
@@ -1768,14 +1853,41 @@ async function convertMultimodalToProviderFormat(text, images, pdfFiles, provide
1768
1853
  logger.info(`[PDF→Image] Provider ${provider} doesn't support native PDF. Converting ${pdfFiles.length} PDF(s) to images...`);
1769
1854
  for (const pdf of pdfFiles) {
1770
1855
  try {
1856
+ const effectiveMaxPages = pdf.maxPages ?? PDF_LIMITS.DEFAULT_MAX_PAGES;
1771
1857
  const conversionResult = await PDFImageConverter.convertToImages(pdf.buffer, {
1772
- scale: 2.0, // High quality for OCR/analysis
1773
- maxPages: 20, // Limit pages to prevent token overflow
1858
+ // #297: this is the only PDF→image call the product actually makes,
1859
+ // and it used to hardcode scale 2.0 — silently overriding the
1860
+ // lowered PDF_LIMITS.DEFAULT_SCALE and keeping the memory cost the
1861
+ // issue reports (a 100-page render at 2.0 is ~776MB; 1.5 is ~44%
1862
+ // fewer pixels per page). Callers can raise it back per request.
1863
+ scale: pdf.scale ?? PDF_LIMITS.DEFAULT_SCALE,
1864
+ // Page ceiling guards token overflow; also now caller-adjustable
1865
+ // rather than a constant nothing could reach.
1866
+ maxPages: effectiveMaxPages,
1774
1867
  ...(pdf.password ? { password: pdf.password } : {}), // #258
1775
1868
  ...(pdf.maxCanvasPixels
1776
1869
  ? { maxCanvasPixels: pdf.maxCanvasPixels }
1777
1870
  : {}), // #260
1778
1871
  });
1872
+ // The renderer stops at maxPages, so a longer document is silently
1873
+ // truncated — say so rather than letting the model answer from a
1874
+ // partial document as though it had the whole thing.
1875
+ //
1876
+ // Keyed on the cap being reached, not on pdf.pageCount: that field is
1877
+ // null whenever `input.content` omits `metadata.pages`, which is the
1878
+ // common case, so a page-count comparison would simply never fire
1879
+ // there. Reaching the cap is also unambiguous — a short count caused by
1880
+ // per-page render failures (#294 isolates those into `errors`) would
1881
+ // otherwise be misreported as a maxPages truncation.
1882
+ if (conversionResult.pageCount >= effectiveMaxPages) {
1883
+ logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)} hit the ${effectiveMaxPages}-page ` +
1884
+ `conversion limit. Any pages beyond that were not sent — the model may be ` +
1885
+ `answering from a partial document. Raise pdfOptions.maxPages or split the file.`);
1886
+ }
1887
+ if (conversionResult.errors && conversionResult.errors.length > 0) {
1888
+ logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)}: ${conversionResult.errors.length} page(s) ` +
1889
+ `failed to render and were omitted (page ${conversionResult.errors.map((e) => e.page).join(", ")}).`);
1890
+ }
1779
1891
  logger.info(`[PDF→Image] ✅ Converted ${pdf.filename}: ${conversionResult.pageCount} page(s) → images`);
1780
1892
  // Add each page as an ImagePart (raw base64, not data: URI — see SSRF note above)
1781
1893
  conversionResult.images.forEach((base64Image, pageIndex) => {
@@ -58,6 +58,8 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
58
58
  pdfOptions: {
59
59
  password?: string;
60
60
  maxCanvasPixels?: number;
61
+ scale?: number;
62
+ maxPages?: number;
61
63
  } | undefined;
62
64
  systemPrompt: string | undefined;
63
65
  conversationHistory: import("../index.js").ChatMessage[] | undefined;
@@ -18,6 +18,17 @@ export declare class PDFProcessor {
18
18
  */
19
19
  static supportsNativePDF(provider: string): boolean;
20
20
  static getProviderConfig(provider: string): PDFProviderConfig | null;
21
+ /**
22
+ * Best-effort page count for a PDF whose count the caller did not supply
23
+ * (#309). Mirrors what `process()` derives for the `input.pdfFiles` path:
24
+ * the accurate pdfjs count when the document parses, otherwise the header
25
+ * regex estimate. Returns null when neither can determine a count.
26
+ *
27
+ * Exists so the aggregate page-limit guard can enforce against PDFs handed
28
+ * in via `input.content`, where `metadata.pages` is optional and routinely
29
+ * omitted — without it, an absent count silently sums as zero.
30
+ */
31
+ static resolvePageCount(buffer: Buffer): Promise<number | null>;
21
32
  private static isValidPDF;
22
33
  private static extractBasicMetadata;
23
34
  /**
@@ -212,6 +212,23 @@ export class PDFProcessor {
212
212
  static getProviderConfig(provider) {
213
213
  return PDF_PROVIDER_CONFIGS[provider] || null;
214
214
  }
215
+ /**
216
+ * Best-effort page count for a PDF whose count the caller did not supply
217
+ * (#309). Mirrors what `process()` derives for the `input.pdfFiles` path:
218
+ * the accurate pdfjs count when the document parses, otherwise the header
219
+ * regex estimate. Returns null when neither can determine a count.
220
+ *
221
+ * Exists so the aggregate page-limit guard can enforce against PDFs handed
222
+ * in via `input.content`, where `metadata.pages` is optional and routinely
223
+ * omitted — without it, an absent count silently sums as zero.
224
+ */
225
+ static async resolvePageCount(buffer) {
226
+ const accurate = await PDFProcessor.getAccuratePageCount(buffer);
227
+ if (accurate !== null) {
228
+ return accurate;
229
+ }
230
+ return PDFProcessor.extractBasicMetadata(buffer).estimatedPages;
231
+ }
215
232
  static isValidPDF(buffer) {
216
233
  if (buffer.length < 5) {
217
234
  return false;
@@ -360,6 +377,7 @@ export class PDFProcessor {
360
377
  format,
361
378
  scale,
362
379
  maxCanvasPixels,
380
+ maxPages,
363
381
  });
364
382
  logger.debug("[PDF→Image] ✅ PDF validation passed", {
365
383
  bufferSize: pdfBuffer.length,
@@ -518,6 +536,14 @@ export class PDFProcessor {
518
536
  if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
519
537
  throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
520
538
  }
539
+ // #297: maxPages became caller-controlled, and an unvalidated 0/-1/NaN
540
+ // silently converts nothing, surfacing later as a misleading
541
+ // "PDF has 0 pages" from deep inside the renderer. Reject it here where
542
+ // the message can still name the offending option.
543
+ if (opts.maxPages !== undefined &&
544
+ (!Number.isInteger(opts.maxPages) || opts.maxPages < 1)) {
545
+ throw new Error(`Invalid maxPages: ${opts.maxPages}. Must be a whole number of at least 1.`);
546
+ }
521
547
  if (!pdfBuffer || pdfBuffer.length < 5) {
522
548
  throw new Error("Invalid PDF: Buffer is too small or empty. " +
523
549
  "A valid PDF must be at least 5 bytes (PDF header).");
@@ -553,6 +579,7 @@ export class PDFProcessor {
553
579
  format,
554
580
  scale,
555
581
  maxCanvasPixels,
582
+ maxPages,
556
583
  });
557
584
  const pdfToImgModule = await import("pdf-to-img");
558
585
  const pdf = pdfToImgModule.pdf;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "10.10.7",
3
+ "version": "10.10.9",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {