@juspay/neurolink 10.10.8 → 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.
@@ -87,7 +87,14 @@ export class SkillsManager {
87
87
  // name-find would resolve non-deterministically to the stale deprecated one
88
88
  // across store backends. Fall back to any match only when no active skill
89
89
  // carries the name.
90
- const entry = index.find((item) => item.name === idOrName && (item.status ?? "active") === "active") ?? index.find((item) => item.name === idOrName);
90
+ //
91
+ // Matched case-insensitively to agree with assertNameAvailable, which
92
+ // enforces uniqueness that way: names differing only in case cannot
93
+ // coexist, so a case-sensitive lookup could only fail to find a skill that
94
+ // is definitively there (`get("DEPLOY")` returned null for "deploy").
95
+ const wanted = idOrName.toLowerCase();
96
+ const entry = index.find((item) => item.name.toLowerCase() === wanted &&
97
+ (item.status ?? "active") === "active") ?? index.find((item) => item.name.toLowerCase() === wanted);
91
98
  return entry ? this.store.get(entry.id) : null;
92
99
  }
93
100
  /**
@@ -237,11 +244,23 @@ export class SkillsManager {
237
244
  }
238
245
  async assertNameAvailable(name, excludeId) {
239
246
  const index = await this.getIndex(true);
240
- const clash = index.find((item) => item.id !== excludeId &&
241
- item.name.toLowerCase() === name.toLowerCase() &&
242
- (item.status ?? "active") === "active");
247
+ // Deliberately NOT filtered to active (#1139). Soft-delete only flips
248
+ // status to "deprecated" — the entry stays in the index, so allowing a new
249
+ // skill to take the name left two entries sharing it. Every name-based
250
+ // lookup (CLI `skills show/delete <name>`, the skill_update/skill_delete
251
+ // tools, the `:id`-or-name REST routes) then had to guess which one the
252
+ // caller meant, and iteration order differs across store backends.
253
+ //
254
+ // A deprecated skill's name therefore stays reserved. Reusing it requires
255
+ // hard-deleting the old skill first, which is the explicit choice the
256
+ // ambiguity demands.
257
+ const clash = index.find((item) => item.id !== excludeId && item.name.toLowerCase() === name.toLowerCase());
243
258
  if (clash) {
244
- throw new Error(`A skill named "${name}" already exists`);
259
+ const suffix = (clash.status ?? "active") === "active"
260
+ ? ""
261
+ : ` (that name belongs to a deprecated skill, id ${clash.id}; ` +
262
+ `remove it before reusing the name)`;
263
+ throw new Error(`A skill named "${name}" already exists${suffix}`);
245
264
  }
246
265
  }
247
266
  }
@@ -474,6 +474,10 @@ export type MultimodalPdfEntry = {
474
474
  password?: string;
475
475
  /** Per-page pixel ceiling for the image fallback (#260). */
476
476
  maxCanvasPixels?: number;
477
+ /** Render scale for the image fallback (#297). */
478
+ scale?: number;
479
+ /** Max pages converted by the image fallback (#297). */
480
+ maxPages?: number;
477
481
  };
478
482
  /** Result of PDF to image conversion. */
479
483
  export type PDFImageConversionResult = {
@@ -133,6 +133,17 @@ export type GenerateOptions = {
133
133
  password?: string;
134
134
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
135
135
  maxCanvasPixels?: number;
136
+ /**
137
+ * Render scale for the image fallback used by providers without native PDF
138
+ * support (#297). Higher is sharper but costs roughly the square in memory
139
+ * and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
140
+ */
141
+ scale?: number;
142
+ /**
143
+ * Max pages converted by the image fallback (#297). Pages beyond this are
144
+ * not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
145
+ */
146
+ maxPages?: number;
136
147
  };
137
148
  videoOptions?: {
138
149
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
@@ -1265,6 +1276,10 @@ export type TextGenerationOptions = {
1265
1276
  password?: string;
1266
1277
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
1267
1278
  maxCanvasPixels?: number;
1279
+ /** Render scale for the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_SCALE. */
1280
+ scale?: number;
1281
+ /** Max pages converted by the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_MAX_PAGES. */
1282
+ maxPages?: number;
1268
1283
  };
1269
1284
  enableSummarization?: boolean;
1270
1285
  /**
@@ -225,6 +225,17 @@ 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?: {
230
241
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
@@ -4,6 +4,7 @@ import { getGlobalDispatcher, interceptors, request } from "undici";
4
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
5
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
6
6
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
7
+ import { PDF_LIMITS } from "../core/constants.js";
7
8
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
8
9
  import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
9
10
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
@@ -1150,6 +1151,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1150
1151
  // #260: carry the per-page canvas-pixel ceiling so the caller can
1151
1152
  // raise (or lower) the memory guard for the image-fallback render.
1152
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,
1153
1158
  });
1154
1159
  logger.info(`[PDF] ✅ Queued for multimodal: ${filename} (${result.metadata?.estimatedPages ?? "unknown"} pages)`);
1155
1160
  }
@@ -1458,6 +1463,8 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
1458
1463
  // guard as the `input.pdfFiles` path (see `processExplicitPdfFiles`).
1459
1464
  password: pdfOptions?.password,
1460
1465
  maxCanvasPixels: pdfOptions?.maxCanvasPixels,
1466
+ scale: pdfOptions?.scale,
1467
+ maxPages: pdfOptions?.maxPages,
1461
1468
  }));
1462
1469
  // #309: same aggregate ceiling as `input.pdfFiles`. Without this, moving an
1463
1470
  // over-limit payload from `input.pdfFiles` to `input.content` skipped the
@@ -1809,7 +1816,10 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1809
1816
  /**
1810
1817
  * Convert multimodal content (images + PDFs) to provider format
1811
1818
  */
1812
- 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) {
1813
1823
  const content = [
1814
1824
  { type: "text", text },
1815
1825
  ];
@@ -1843,14 +1853,41 @@ async function convertMultimodalToProviderFormat(text, images, pdfFiles, provide
1843
1853
  logger.info(`[PDF→Image] Provider ${provider} doesn't support native PDF. Converting ${pdfFiles.length} PDF(s) to images...`);
1844
1854
  for (const pdf of pdfFiles) {
1845
1855
  try {
1856
+ const effectiveMaxPages = pdf.maxPages ?? PDF_LIMITS.DEFAULT_MAX_PAGES;
1846
1857
  const conversionResult = await PDFImageConverter.convertToImages(pdf.buffer, {
1847
- scale: 2.0, // High quality for OCR/analysis
1848
- 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,
1849
1867
  ...(pdf.password ? { password: pdf.password } : {}), // #258
1850
1868
  ...(pdf.maxCanvasPixels
1851
1869
  ? { maxCanvasPixels: pdf.maxCanvasPixels }
1852
1870
  : {}), // #260
1853
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
+ }
1854
1891
  logger.info(`[PDF→Image] ✅ Converted ${pdf.filename}: ${conversionResult.pageCount} page(s) → images`);
1855
1892
  // Add each page as an ImagePart (raw base64, not data: URI — see SSRF note above)
1856
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("../types/conversation.js").ChatMessage[] | undefined;
@@ -377,6 +377,7 @@ export class PDFProcessor {
377
377
  format,
378
378
  scale,
379
379
  maxCanvasPixels,
380
+ maxPages,
380
381
  });
381
382
  logger.debug("[PDF→Image] ✅ PDF validation passed", {
382
383
  bufferSize: pdfBuffer.length,
@@ -535,6 +536,14 @@ export class PDFProcessor {
535
536
  if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
536
537
  throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
537
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
+ }
538
547
  if (!pdfBuffer || pdfBuffer.length < 5) {
539
548
  throw new Error("Invalid PDF: Buffer is too small or empty. " +
540
549
  "A valid PDF must be at least 5 bytes (PDF header).");
@@ -570,6 +579,7 @@ export class PDFProcessor {
570
579
  format,
571
580
  scale,
572
581
  maxCanvasPixels,
582
+ maxPages,
573
583
  });
574
584
  const pdfToImgModule = await import("pdf-to-img");
575
585
  const pdf = pdfToImgModule.pdf;
@@ -87,7 +87,14 @@ export class SkillsManager {
87
87
  // name-find would resolve non-deterministically to the stale deprecated one
88
88
  // across store backends. Fall back to any match only when no active skill
89
89
  // carries the name.
90
- const entry = index.find((item) => item.name === idOrName && (item.status ?? "active") === "active") ?? index.find((item) => item.name === idOrName);
90
+ //
91
+ // Matched case-insensitively to agree with assertNameAvailable, which
92
+ // enforces uniqueness that way: names differing only in case cannot
93
+ // coexist, so a case-sensitive lookup could only fail to find a skill that
94
+ // is definitively there (`get("DEPLOY")` returned null for "deploy").
95
+ const wanted = idOrName.toLowerCase();
96
+ const entry = index.find((item) => item.name.toLowerCase() === wanted &&
97
+ (item.status ?? "active") === "active") ?? index.find((item) => item.name.toLowerCase() === wanted);
91
98
  return entry ? this.store.get(entry.id) : null;
92
99
  }
93
100
  /**
@@ -237,11 +244,23 @@ export class SkillsManager {
237
244
  }
238
245
  async assertNameAvailable(name, excludeId) {
239
246
  const index = await this.getIndex(true);
240
- const clash = index.find((item) => item.id !== excludeId &&
241
- item.name.toLowerCase() === name.toLowerCase() &&
242
- (item.status ?? "active") === "active");
247
+ // Deliberately NOT filtered to active (#1139). Soft-delete only flips
248
+ // status to "deprecated" — the entry stays in the index, so allowing a new
249
+ // skill to take the name left two entries sharing it. Every name-based
250
+ // lookup (CLI `skills show/delete <name>`, the skill_update/skill_delete
251
+ // tools, the `:id`-or-name REST routes) then had to guess which one the
252
+ // caller meant, and iteration order differs across store backends.
253
+ //
254
+ // A deprecated skill's name therefore stays reserved. Reusing it requires
255
+ // hard-deleting the old skill first, which is the explicit choice the
256
+ // ambiguity demands.
257
+ const clash = index.find((item) => item.id !== excludeId && item.name.toLowerCase() === name.toLowerCase());
243
258
  if (clash) {
244
- throw new Error(`A skill named "${name}" already exists`);
259
+ const suffix = (clash.status ?? "active") === "active"
260
+ ? ""
261
+ : ` (that name belongs to a deprecated skill, id ${clash.id}; ` +
262
+ `remove it before reusing the name)`;
263
+ throw new Error(`A skill named "${name}" already exists${suffix}`);
245
264
  }
246
265
  }
247
266
  }
@@ -474,6 +474,10 @@ export type MultimodalPdfEntry = {
474
474
  password?: string;
475
475
  /** Per-page pixel ceiling for the image fallback (#260). */
476
476
  maxCanvasPixels?: number;
477
+ /** Render scale for the image fallback (#297). */
478
+ scale?: number;
479
+ /** Max pages converted by the image fallback (#297). */
480
+ maxPages?: number;
477
481
  };
478
482
  /** Result of PDF to image conversion. */
479
483
  export type PDFImageConversionResult = {
@@ -133,6 +133,17 @@ export type GenerateOptions = {
133
133
  password?: string;
134
134
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
135
135
  maxCanvasPixels?: number;
136
+ /**
137
+ * Render scale for the image fallback used by providers without native PDF
138
+ * support (#297). Higher is sharper but costs roughly the square in memory
139
+ * and tokens. Range 0.1-10; defaults to PDF_LIMITS.DEFAULT_SCALE (1.5).
140
+ */
141
+ scale?: number;
142
+ /**
143
+ * Max pages converted by the image fallback (#297). Pages beyond this are
144
+ * not sent to the model at all. Defaults to PDF_LIMITS.DEFAULT_MAX_PAGES (20).
145
+ */
146
+ maxPages?: number;
136
147
  };
137
148
  videoOptions?: {
138
149
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
@@ -1265,6 +1276,10 @@ export type TextGenerationOptions = {
1265
1276
  password?: string;
1266
1277
  /** Max rendered-canvas pixels per page (#260 memory guard); oversized pages auto-downscale. */
1267
1278
  maxCanvasPixels?: number;
1279
+ /** Render scale for the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_SCALE. */
1280
+ scale?: number;
1281
+ /** Max pages converted by the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_MAX_PAGES. */
1282
+ maxPages?: number;
1268
1283
  };
1269
1284
  enableSummarization?: boolean;
1270
1285
  /**
@@ -225,6 +225,17 @@ 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?: {
230
241
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
@@ -4,6 +4,7 @@ import { getGlobalDispatcher, interceptors, request } from "undici";
4
4
  import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerImageAdapter.js";
5
5
  import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
6
6
  import { getAvailableInputTokens } from "../constants/contextWindows.js";
7
+ import { PDF_LIMITS } from "../core/constants.js";
7
8
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
8
9
  import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
9
10
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
@@ -1150,6 +1151,10 @@ async function processExplicitPdfFiles(options, maxSize, provider) {
1150
1151
  // #260: carry the per-page canvas-pixel ceiling so the caller can
1151
1152
  // raise (or lower) the memory guard for the image-fallback render.
1152
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,
1153
1158
  });
1154
1159
  logger.info(`[PDF] ✅ Queued for multimodal: ${filename} (${result.metadata?.estimatedPages ?? "unknown"} pages)`);
1155
1160
  }
@@ -1458,6 +1463,8 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
1458
1463
  // guard as the `input.pdfFiles` path (see `processExplicitPdfFiles`).
1459
1464
  password: pdfOptions?.password,
1460
1465
  maxCanvasPixels: pdfOptions?.maxCanvasPixels,
1466
+ scale: pdfOptions?.scale,
1467
+ maxPages: pdfOptions?.maxPages,
1461
1468
  }));
1462
1469
  // #309: same aggregate ceiling as `input.pdfFiles`. Without this, moving an
1463
1470
  // over-limit payload from `input.pdfFiles` to `input.content` skipped the
@@ -1809,7 +1816,10 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1809
1816
  /**
1810
1817
  * Convert multimodal content (images + PDFs) to provider format
1811
1818
  */
1812
- 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) {
1813
1823
  const content = [
1814
1824
  { type: "text", text },
1815
1825
  ];
@@ -1843,14 +1853,41 @@ async function convertMultimodalToProviderFormat(text, images, pdfFiles, provide
1843
1853
  logger.info(`[PDF→Image] Provider ${provider} doesn't support native PDF. Converting ${pdfFiles.length} PDF(s) to images...`);
1844
1854
  for (const pdf of pdfFiles) {
1845
1855
  try {
1856
+ const effectiveMaxPages = pdf.maxPages ?? PDF_LIMITS.DEFAULT_MAX_PAGES;
1846
1857
  const conversionResult = await PDFImageConverter.convertToImages(pdf.buffer, {
1847
- scale: 2.0, // High quality for OCR/analysis
1848
- 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,
1849
1867
  ...(pdf.password ? { password: pdf.password } : {}), // #258
1850
1868
  ...(pdf.maxCanvasPixels
1851
1869
  ? { maxCanvasPixels: pdf.maxCanvasPixels }
1852
1870
  : {}), // #260
1853
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
+ }
1854
1891
  logger.info(`[PDF→Image] ✅ Converted ${pdf.filename}: ${conversionResult.pageCount} page(s) → images`);
1855
1892
  // Add each page as an ImagePart (raw base64, not data: URI — see SSRF note above)
1856
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;
@@ -377,6 +377,7 @@ export class PDFProcessor {
377
377
  format,
378
378
  scale,
379
379
  maxCanvasPixels,
380
+ maxPages,
380
381
  });
381
382
  logger.debug("[PDF→Image] ✅ PDF validation passed", {
382
383
  bufferSize: pdfBuffer.length,
@@ -535,6 +536,14 @@ export class PDFProcessor {
535
536
  if (!Number.isFinite(opts.maxCanvasPixels) || opts.maxCanvasPixels <= 0) {
536
537
  throw new Error(`Invalid maxCanvasPixels: ${opts.maxCanvasPixels}. Must be a finite number greater than 0.`);
537
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
+ }
538
547
  if (!pdfBuffer || pdfBuffer.length < 5) {
539
548
  throw new Error("Invalid PDF: Buffer is too small or empty. " +
540
549
  "A valid PDF must be at least 5 bytes (PDF header).");
@@ -570,6 +579,7 @@ export class PDFProcessor {
570
579
  format,
571
580
  scale,
572
581
  maxCanvasPixels,
582
+ maxPages,
573
583
  });
574
584
  const pdfToImgModule = await import("pdf-to-img");
575
585
  const pdf = pdfToImgModule.pdf;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "10.10.8",
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": {