@juspay/neurolink 12.19.2 → 12.20.0

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.
@@ -4,4 +4,4 @@ import type { OptionSchema, TextGenerationOptions } from "../../types/index.js";
4
4
  * This object provides metadata for validation and help text in the CLI loop.
5
5
  * It is derived from the main TextGenerationOptions interface to ensure consistency.
6
6
  */
7
- export declare const textGenerationOptionsSchema: Record<keyof Omit<TextGenerationOptions, "prompt" | "input" | "schema" | "tools" | "context" | "conversationHistory" | "conversationMessages" | "conversationMemoryConfig" | "originalPrompt" | "middleware" | "expectedOutcome" | "evaluationCriteria" | "region" | "csvOptions" | "pdfOptions" | "tts" | "stt" | "thinkingConfig" | "requestId" | "fileRegistry" | "abortSignal" | "toolFilter" | "excludeTools" | "toolChoice" | "prepareStep" | "credentials" | "onFinish" | "onError" | "processors" | "piiDetection" | "responseValidation" | "inputValidation" | "toolExecutionCapture" | "toolExecutionRecorder">, OptionSchema>;
7
+ export declare const textGenerationOptionsSchema: Record<keyof Omit<TextGenerationOptions, "prompt" | "input" | "schema" | "tools" | "context" | "conversationHistory" | "conversationMessages" | "conversationMemoryConfig" | "originalPrompt" | "middleware" | "expectedOutcome" | "evaluationCriteria" | "region" | "csvOptions" | "pdfOptions" | "imageOptions" | "tts" | "stt" | "thinkingConfig" | "requestId" | "fileRegistry" | "abortSignal" | "toolFilter" | "excludeTools" | "toolChoice" | "prepareStep" | "credentials" | "onFinish" | "onError" | "processors" | "piiDetection" | "responseValidation" | "inputValidation" | "toolExecutionCapture" | "toolExecutionRecorder">, OptionSchema>;
@@ -104,6 +104,7 @@ export class MessageBuilder {
104
104
  },
105
105
  csvOptions: options.csvOptions,
106
106
  pdfOptions: options.pdfOptions,
107
+ imageOptions: options.imageOptions,
107
108
  provider: options.provider,
108
109
  model: options.model,
109
110
  temperature: options.temperature,
@@ -226,6 +227,7 @@ export class MessageBuilder {
226
227
  },
227
228
  csvOptions: options.csvOptions,
228
229
  pdfOptions: options.pdfOptions,
230
+ imageOptions: options.imageOptions,
229
231
  provider: options.provider,
230
232
  model: options.model,
231
233
  temperature: options.temperature,
package/dist/neurolink.js CHANGED
@@ -4210,6 +4210,7 @@ Current user's request: ${currentInput}`;
4210
4210
  // reached convertToImages.
4211
4211
  csvOptions: options.csvOptions,
4212
4212
  pdfOptions: options.pdfOptions,
4213
+ imageOptions: options.imageOptions,
4213
4214
  region: options.region,
4214
4215
  tts: options.tts,
4215
4216
  stt: options.stt,
@@ -13,7 +13,14 @@
13
13
  */
14
14
  /**
15
15
  * Image file extensions supported by the platform.
16
- * Includes `.svg`, which is processed as sanitized markup rather than raster.
16
+ *
17
+ * Includes `.svg`. On the processor path (`SvgProcessor`) that guarantees
18
+ * sanitized markup, never raster bytes — but that guarantee is scoped to
19
+ * that one consumer. This constant is also consumed by code that treats
20
+ * every image extension as raster (captioning, multimodal embedding, vision
21
+ * adapters); `.svg` is markup there too, just without the sanitization, so
22
+ * each such consumer must explicitly handle or reject `.svg` itself rather
23
+ * than assume this list implies raster-safety.
17
24
  */
18
25
  export declare const IMAGE_EXTENSIONS: readonly string[];
19
26
  /**
@@ -17,7 +17,14 @@ import { extensionsForModality } from "./fileTypeRegistry.js";
17
17
  // =============================================================================
18
18
  /**
19
19
  * Image file extensions supported by the platform.
20
- * Includes `.svg`, which is processed as sanitized markup rather than raster.
20
+ *
21
+ * Includes `.svg`. On the processor path (`SvgProcessor`) that guarantees
22
+ * sanitized markup, never raster bytes — but that guarantee is scoped to
23
+ * that one consumer. This constant is also consumed by code that treats
24
+ * every image extension as raster (captioning, multimodal embedding, vision
25
+ * adapters); `.svg` is markup there too, just without the sanitization, so
26
+ * each such consumer must explicitly handle or reject `.svg` itself rather
27
+ * than assume this list implies raster-safety.
21
28
  */
22
29
  export const IMAGE_EXTENSIONS = extensionsForModality("image");
23
30
  /**
@@ -26,6 +26,7 @@
26
26
  * ```
27
27
  */
28
28
  import { randomUUID } from "crypto";
29
+ import { extname } from "path";
29
30
  import { MDocument } from "../document/MDocument.js";
30
31
  import { loadDocument } from "../document/loaders.js";
31
32
  import { InMemoryVectorStore } from "../retrieval/vectorQueryTool.js";
@@ -472,6 +473,19 @@ export class RAGPipeline {
472
473
  let chunksCreated = 0;
473
474
  for (const source of sources) {
474
475
  try {
476
+ if (typeof source === "string") {
477
+ // IMAGE_EXTENSIONS includes .svg, but that constant's raster
478
+ // guarantee is scoped to the processor path (SvgProcessor). Here
479
+ // it would flow raw markup bytes into generateImageCaption (which
480
+ // base64s them into a vision call) and into the multi-modal embed
481
+ // call — both raster-only. Mirrors the identical skip in
482
+ // ragIntegration.ts's ingestion-tool entry point so the two agree.
483
+ const withoutQueryOrFragment = source.split(/[?#]/)[0];
484
+ if (extname(withoutQueryOrFragment).toLowerCase() === ".svg") {
485
+ logger.warn(`[RAGPipeline] SVG is not supported as a multi-modal image source, skipping: ${redactUrlForError(source)}`);
486
+ continue;
487
+ }
488
+ }
475
489
  // Load the image
476
490
  let imageDoc;
477
491
  if (Buffer.isBuffer(source)) {
@@ -484,6 +498,21 @@ export class RAGPipeline {
484
498
  else {
485
499
  imageDoc = await this.imageLoader.load(source);
486
500
  }
501
+ // The extension check above is necessary but not sufficient: it only
502
+ // sees the name. A source with no `.svg` suffix — an extension-less
503
+ // URL, or a local file named without one — reaches ImageLoader, which
504
+ // falls back to detecting the type from the bytes and returns
505
+ // `image/svg+xml` for markup starting `<svg`/`<?xm`. So re-check the
506
+ // resolved type AND the bytes: loadFromPath prefers a recognized
507
+ // extension over content detection, so SVG markup named `.png` keeps
508
+ // an image/png label. Skip rather than throw so SVG behaves the same
509
+ // whichever way it was identified. `supportedFormats` does not cover
510
+ // this: it is optional, and unset on the default config.
511
+ if (imageDoc.mimeType === "image/svg+xml" ||
512
+ ImageProcessor.detectImageType(imageDoc.image) === "image/svg+xml") {
513
+ logger.warn(`[RAGPipeline] SVG is not supported as a multi-modal image source, skipping: ${redactUrlForError(imageDoc.metadata.source)}`);
514
+ continue;
515
+ }
487
516
  const { supportedFormats } = this.multiModalConfig;
488
517
  if (supportedFormats && !supportedFormats.includes(imageDoc.mimeType)) {
489
518
  throw ErrorFactory.invalidParameters("ingestImages", new Error(`unsupported image MIME type: ${imageDoc.mimeType}`), { mimeType: imageDoc.mimeType, supportedFormats });
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../observability/index.js";
5
5
  import { logger } from "../utils/logger.js";
6
6
  import { redactUrlForError } from "../utils/logSanitize.js";
7
+ import { ImageProcessor } from "../utils/imageProcessor.js";
7
8
  import { createChunker } from "./ChunkerFactory.js";
8
9
  import { createVectorQueryTool, InMemoryVectorStore, } from "./retrieval/vectorQueryTool.js";
9
10
  import { ImageLoader } from "./document/imageLoader.js";
@@ -300,6 +301,25 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
300
301
  for (const { path: imgPath } of imageFiles) {
301
302
  try {
302
303
  const imageDoc = await imageLoader.load(imgPath);
304
+ // The two `ext === ".svg"` checks above are a cheap early-out, not the
305
+ // boundary: both match the NAME exactly, and the name is not what
306
+ // decides the type. ImageLoader resolves it two ways that disagree
307
+ // with a bare `.svg` comparison — `.svgz` maps to image/svg+xml
308
+ // through EXTENSION_MIME_MAP, and loadFromURL ignores the extension
309
+ // entirely and sniffs the bytes, so any image-extension URL actually
310
+ // serving SVG resolves to image/svg+xml here. A local raster suffix
311
+ // has the opposite problem: loadFromPath trusts the extension before
312
+ // the bytes, so inspect both. Otherwise `hasImage: true` below sends
313
+ // SVG markup to the raster embed call. Skip rather than throw,
314
+ // matching the early-outs and the identical guard
315
+ // in RAGPipeline.ingestImages — the two entry points are independent
316
+ // (prepareRAGTool builds its chunks here, not via ingestImages), so
317
+ // each needs its own.
318
+ if (imageDoc.mimeType === "image/svg+xml" ||
319
+ ImageProcessor.detectImageType(imageDoc.image) === "image/svg+xml") {
320
+ logger.warn(`[RAG] SVG is not supported as a RAG image source, skipping: ${redactUrlForError(imgPath)}`);
321
+ continue;
322
+ }
303
323
  // Use filename-based text representation for simple embedding
304
324
  const imageText = imageDoc.text;
305
325
  imageChunks.push({
@@ -476,7 +496,7 @@ async function _prepareRAGToolInner(ragConfig, fallbackProvider) {
476
496
  tool: aiTool,
477
497
  toolName,
478
498
  chunksIndexed: allChunks.length + imageChunks.length,
479
- filesLoaded: fileContents.length + imageFiles.length,
499
+ filesLoaded: fileContents.length + imageChunks.length,
480
500
  };
481
501
  }
482
502
  /** @internal Exported for testing only */
@@ -9,6 +9,16 @@ export type FileType = "csv" | "image" | "pdf" | "audio" | "video" | "archive" |
9
9
  * Office document types
10
10
  */
11
11
  export type OfficeDocumentType = "docx" | "pptx" | "xlsx";
12
+ /**
13
+ * Transcode target for a vision-incompatible image (`adapters/imageFormatSupport.ts`).
14
+ *
15
+ * Defaults to `"png"` everywhere a caller does not pick one: sources routed
16
+ * through that module are frequently lossless (TIFF, BMP, ICO) or already
17
+ * carry alpha, and a lossy re-encode of an image the model is about to read
18
+ * closely is the wrong default. `"jpeg"` is available for callers who know
19
+ * their source has no alpha and prefer the smaller payload.
20
+ */
21
+ export type VisionImageOutputFormat = "png" | "jpeg";
12
22
  /**
13
23
  * Outcome of a vision-compatibility pass over one image.
14
24
  *
@@ -17,7 +17,7 @@ import type { AvatarOptions, AvatarResult } from "./avatar.js";
17
17
  import type { MusicOptions, MusicResult } from "./music.js";
18
18
  import type { StandardRecord, ValidationSchema, ZodUnknownSchema } from "./aliases.js";
19
19
  import type { NeurolinkCredentials } from "./providers.js";
20
- import type { CSVProcessorOptions, FileWithMetadata, MultimodalAudioEntry } from "./file.js";
20
+ import type { CSVProcessorOptions, FileWithMetadata, MultimodalAudioEntry, VisionImageOutputFormat } from "./file.js";
21
21
  import type { WorkflowConfig } from "./workflow.js";
22
22
  import type { Schema, Tool, ToolChoice } from "./tools.js";
23
23
  import type { StepResult, LanguageModel } from "./providers.js";
@@ -156,6 +156,18 @@ export type GenerateOptions = {
156
156
  */
157
157
  maxPages?: number;
158
158
  };
159
+ /**
160
+ * Options for images that need transcoding before a vision provider can
161
+ * read them (HEIC, TIFF, BMP, ICO, JPEG 2000, AVIF — see
162
+ * `adapters/imageFormatSupport.ts`).
163
+ */
164
+ imageOptions?: {
165
+ /**
166
+ * Transcode target for an incompatible image. Defaults to `"png"` — the
167
+ * module's own default, unchanged unless a caller opts in here.
168
+ */
169
+ outputFormat?: VisionImageOutputFormat;
170
+ };
159
171
  videoOptions?: {
160
172
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
161
173
  frames?: number;
@@ -1394,6 +1406,20 @@ export type TextGenerationOptions = {
1394
1406
  /** Max pages converted by the image fallback (#297); defaults to PDF_LIMITS.DEFAULT_MAX_PAGES. */
1395
1407
  maxPages?: number;
1396
1408
  };
1409
+ /**
1410
+ * Options for images that need transcoding before a vision provider can
1411
+ * read them (HEIC, TIFF, BMP, ICO, JPEG 2000, AVIF — see
1412
+ * `adapters/imageFormatSupport.ts`). Mirrors `GenerateOptions.imageOptions`;
1413
+ * this is the shape that actually reaches `MessageBuilder` for providers
1414
+ * built on `BaseProvider`.
1415
+ */
1416
+ imageOptions?: {
1417
+ /**
1418
+ * Transcode target for an incompatible image. Defaults to `"png"` — the
1419
+ * module's own default, unchanged unless a caller opts in here.
1420
+ */
1421
+ outputFormat?: VisionImageOutputFormat;
1422
+ };
1397
1423
  enableSummarization?: boolean;
1398
1424
  /**
1399
1425
  * Skip injecting tool schemas into the system prompt.
@@ -15,7 +15,7 @@ import type { AIModelProviderConfig, NeurolinkCredentials } from "./providers.js
15
15
  import type { TTSChunk, TTSOptions, TTSResult } from "./tts.js";
16
16
  import type { STTOptions, STTResult } from "./stt.js";
17
17
  import type { StandardRecord, ValidationSchema } from "./aliases.js";
18
- import type { CSVProcessorOptions, FileWithMetadata } from "./file.js";
18
+ import type { CSVProcessorOptions, FileWithMetadata, VisionImageOutputFormat } from "./file.js";
19
19
  import type { WorkflowConfig } from "./workflow.js";
20
20
  import type { LanguageModel, StepResult } from "./providers.js";
21
21
  import type { Tool, ToolChoice } from "./tools.js";
@@ -373,6 +373,18 @@ export type StreamOptions = {
373
373
  */
374
374
  maxPages?: number;
375
375
  };
376
+ /**
377
+ * Options for images that need transcoding before a vision provider can
378
+ * read them (HEIC, TIFF, BMP, ICO, JPEG 2000, AVIF — see
379
+ * `adapters/imageFormatSupport.ts`). Mirrors `GenerateOptions.imageOptions`.
380
+ */
381
+ imageOptions?: {
382
+ /**
383
+ * Transcode target for an incompatible image. Defaults to `"png"` — the
384
+ * module's own default, unchanged unless a caller opts in here.
385
+ */
386
+ outputFormat?: VisionImageOutputFormat;
387
+ };
376
388
  videoOptions?: {
377
389
  /** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
378
390
  frames?: number;
@@ -65,6 +65,153 @@ const RETRYABLE_ERROR_CODES = new Set([
65
65
  "ETIMEDOUT",
66
66
  "ERR_NETWORK",
67
67
  ]);
68
+ /**
69
+ * Bytes of prologue {@link hasSvgRoot} decodes and scans.
70
+ *
71
+ * A real SVG reaches its root within a few hundred bytes, so 4KB is generous.
72
+ * The cap exists because scanning the whole buffer made every unrecognized
73
+ * binary — and every BMP/TIFF/ICO/AVIF, whose checks run after this one — pay
74
+ * a full UTF-8 decode, and let a crafted unterminated doctype walk every byte
75
+ * (measured: 322ms on 32MB, 69ms to reject a 16MB BMP).
76
+ */
77
+ const SVG_PROLOGUE_SCAN_BYTES = 4096;
78
+ /**
79
+ * Find an SVG root after XML's permitted leading constructs.
80
+ *
81
+ * Deliberately a discriminator, not an XML parser. Raster magic is checked
82
+ * first; this only distinguishes text reaching an SVG root after a UTF-8 BOM,
83
+ * XML whitespace, processing instructions, comments, or a doctype. The doctype
84
+ * scanner tracks quotes, comments/PIs, and the internal subset so a `>` or
85
+ * `]` inside any of them cannot expose a later `<svg` early. The root itself
86
+ * may carry an XML namespace prefix (`<s:svg xmlns:s="...">`), which is
87
+ * accepted alongside the bare `<svg` form.
88
+ *
89
+ * Scanning a bounded window makes "unterminated" ambiguous, and the two cases
90
+ * must resolve OPPOSITE ways:
91
+ *
92
+ * - the WINDOW ran out — the rest of the document is unreadable from here, so
93
+ * fail CLOSED and answer SVG. This function gates a rejection, so answering
94
+ * raster on an unterminated prologue would hand back the exact bypass it
95
+ * exists to close: prepend filler past the window and sail through as
96
+ * raster. A raster file never opens `<!--` or `<!doctype`, so the
97
+ * false-positive cost is a skipped image; the other direction is the
98
+ * vulnerability.
99
+ * - the BUFFER ran out — the whole document was read and is malformed, so it
100
+ * is genuinely not an SVG.
101
+ */
102
+ function hasSvgRoot(input) {
103
+ const windowed = input.length > SVG_PROLOGUE_SCAN_BYTES;
104
+ const text = input
105
+ .subarray(0, Math.min(input.length, SVG_PROLOGUE_SCAN_BYTES))
106
+ .toString("utf8");
107
+ let position = text.charCodeAt(0) === 0xfeff ? 1 : 0;
108
+ const skipWhitespace = () => {
109
+ while (/\s/.test(text[position] ?? "")) {
110
+ position++;
111
+ }
112
+ };
113
+ const skipDoctype = () => {
114
+ let quote;
115
+ let subsetDepth = 0;
116
+ let index = position + 9;
117
+ while (index < text.length) {
118
+ const char = text[index];
119
+ if (quote) {
120
+ if (char === quote) {
121
+ quote = undefined;
122
+ }
123
+ index++;
124
+ continue;
125
+ }
126
+ // Comments and processing instructions are valid markup inside the
127
+ // internal subset (and, leniently, anywhere else in the declaration)
128
+ // and may themselves contain `]` or `>` — e.g. `<!-- ] > -->`. Skip
129
+ // them as opaque spans, the same way the outer scan does, so their
130
+ // contents can never be misread as the subset-close or doctype-end
131
+ // delimiter.
132
+ if (text.startsWith("<!--", index)) {
133
+ const end = text.indexOf("-->", index + 4);
134
+ if (end < 0) {
135
+ return false;
136
+ }
137
+ index = end + 3;
138
+ continue;
139
+ }
140
+ if (text.startsWith("<?", index)) {
141
+ const end = text.indexOf("?>", index + 2);
142
+ if (end < 0) {
143
+ return false;
144
+ }
145
+ index = end + 2;
146
+ continue;
147
+ }
148
+ if (char === '"' || char === "'") {
149
+ quote = char;
150
+ }
151
+ else if (char === "[") {
152
+ subsetDepth++;
153
+ }
154
+ else if (char === "]" && subsetDepth > 0) {
155
+ subsetDepth--;
156
+ }
157
+ else if (char === ">" && subsetDepth === 0) {
158
+ position = index + 1;
159
+ return true;
160
+ }
161
+ index++;
162
+ }
163
+ return false;
164
+ };
165
+ skipWhitespace();
166
+ while (position < text.length) {
167
+ if (text.startsWith("<?", position)) {
168
+ const end = text.indexOf("?>", position + 2);
169
+ if (end < 0) {
170
+ return windowed;
171
+ }
172
+ position = end + 2;
173
+ }
174
+ else if (text.startsWith("<!--", position)) {
175
+ const end = text.indexOf("-->", position + 4);
176
+ if (end < 0) {
177
+ return windowed;
178
+ }
179
+ position = end + 3;
180
+ }
181
+ else if (text.slice(position, position + 9).toLowerCase() === "<!doctype") {
182
+ if (!skipDoctype()) {
183
+ return windowed;
184
+ }
185
+ }
186
+ else {
187
+ break;
188
+ }
189
+ skipWhitespace();
190
+ }
191
+ const rest = text.slice(position);
192
+ // Accepts an optional XML namespace prefix ahead of the tag name — an
193
+ // NCName (letters/digits/`.`/`-`/`_`, not starting with a digit) followed
194
+ // by `:` — so `<s:svg xmlns:s="...">` matches the same way bare `<svg`
195
+ // does.
196
+ if (/^<(?:[A-Za-z_][\w.-]*:)?svg(?:[\s/>])/i.test(rest)) {
197
+ return true;
198
+ }
199
+ // A fixed "`<svg` plus its delimiter is 5 characters" cutoff only worked
200
+ // when the tag name was the literal `svg`. With a namespace prefix now
201
+ // accepted, the prefix is unbounded (any NCName), so no fixed length can
202
+ // bound "may still be mid-token" — `<s:svg` (6 chars) is just as
203
+ // truncatable as `<svg` (4) was. Instead, check whether `rest` could still
204
+ // be the START of a string this function would accept: an optional NCName
205
+ // prefix, optionally followed by `:` and however much of the literal
206
+ // `svg` has been seen so far, with nothing left over. Anything left over
207
+ // (a character that cannot continue the prefix, isn't `:`, or doesn't
208
+ // continue "svg") proves `rest` can never complete into a match, so it is
209
+ // safe to answer false even though the window is cut. An empty `rest` is
210
+ // the same ambiguity in the degenerate case (no root text observed yet).
211
+ const isUnresolvedPrefix = rest.length === 0 ||
212
+ /^<(?:[A-Za-z_][\w.-]*)?(?::(?:s(?:v(?:g)?)?)?)?$/i.test(rest);
213
+ return windowed && isUnresolvedPrefix;
214
+ }
68
215
  /**
69
216
  * Determines if an HTTP error is retryable based on status code
70
217
  * Only network errors and certain HTTP status codes should be retried
@@ -452,12 +599,10 @@ export class ImageProcessor {
452
599
  return "image/webp";
453
600
  }
454
601
  }
455
- // SVG: check for "<svg" or "<?xml" at start (text-based)
456
- if (input.length >= 4) {
457
- const start = input.subarray(0, 4).toString();
458
- if (start === "<svg" || start === "<?xm") {
459
- return "image/svg+xml";
460
- }
602
+ // SVG is text-based and may legally put a BOM, whitespace, an XML
603
+ // declaration, comments, or a doctype before its root element.
604
+ if (hasSvgRoot(input)) {
605
+ return "image/svg+xml";
461
606
  }
462
607
  // ISO-BMFF images: "ftyp" box at offset 4, major brand at offset 8.
463
608
  // AVIF has codec-specific major brands. HEIC uses HEVC image/sequence
@@ -1,4 +1,4 @@
1
- import type { GenerateOptions, MultimodalChatMessage, StreamOptions, TextGenerationOptions } from "../types/index.js";
1
+ import type { GenerateOptions, MultimodalChatMessage, StreamOptions, TextGenerationOptions, VisionImageOutputFormat } from "../types/index.js";
2
2
  import type { ModelMessage } from "../types/index.js";
3
3
  /**
4
4
  * Type-safe conversion from MultimodalChatMessage[] to ModelMessage[]
@@ -56,4 +56,4 @@ export declare function buildMultimodalMessagesArray(options: GenerateOptions, p
56
56
  * Entries that are `http(s)` URLs are left alone — they have no bytes yet, and
57
57
  * `processImageToBase64` converts them once they are downloaded.
58
58
  */
59
- export declare function normalizeVisionImageFormats(input: GenerateOptions["input"]): Promise<void>;
59
+ export declare function normalizeVisionImageFormats(input: GenerateOptions["input"], outputFormat?: VisionImageOutputFormat): Promise<void>;
@@ -1352,7 +1352,7 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1352
1352
  await processUnifiedFilesArray(options, maxSize, provider);
1353
1353
  // Detection can append images (PDF page renders, video keyframes, a HEIC
1354
1354
  // photo), so compatibility conversion has to run after it, not before.
1355
- await normalizeVisionImageFormats(inp);
1355
+ await normalizeVisionImageFormats(inp, options.imageOptions?.outputFormat);
1356
1356
  // Process explicit CSV files array
1357
1357
  await processExplicitCsvFiles(options);
1358
1358
  // Post-processing budget enforcement and token usage logging
@@ -1486,7 +1486,7 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1486
1486
  // the multimodal converter below, so the bytes were dropped and only the
1487
1487
  // metadata summary folded into `text` survived. Exactly the failure this
1488
1488
  // change exists to remove, reintroduced through the other door.
1489
- userContent = await convertContentToProviderFormat(inp.content, provider, model, options.pdfOptions, inp.nativeAudioFiles ?? []);
1489
+ userContent = await convertContentToProviderFormat(inp.content, provider, model, options.pdfOptions, inp.nativeAudioFiles ?? [], options.imageOptions?.outputFormat);
1490
1490
  }
1491
1491
  else if ((inp.images && inp.images.length > 0) ||
1492
1492
  pdfFiles.length > 0 ||
@@ -1495,7 +1495,10 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1495
1495
  // recording was dropped and only its metadata summary — already folded
1496
1496
  // into `text` — ever reached the model.
1497
1497
  (inp.nativeAudioFiles?.length ?? 0) > 0) {
1498
- userContent = await convertMultimodalToProviderFormat(inp.text ?? "", inp.images || [], pdfFiles, provider, model, inp.nativeAudioFiles ?? []);
1498
+ userContent = await convertMultimodalToProviderFormat(inp.text ?? "", inp.images || [], pdfFiles, provider, model, {
1499
+ audioFiles: inp.nativeAudioFiles ?? [],
1500
+ outputFormat: options.imageOptions?.outputFormat,
1501
+ });
1499
1502
  }
1500
1503
  else {
1501
1504
  userContent = inp.text;
@@ -1571,7 +1574,7 @@ async function appendCsvContentToText(csvItems, baseText) {
1571
1574
  /**
1572
1575
  * Convert advanced content format to provider-specific format
1573
1576
  */
1574
- async function convertContentToProviderFormat(content, provider, _model, pdfOptions, audioFiles = []) {
1577
+ async function convertContentToProviderFormat(content, provider, _model, pdfOptions, audioFiles = [], outputFormat) {
1575
1578
  const textContent = content.find((c) => c.type === "text");
1576
1579
  const imageContent = content.filter((c) => c.type === "image");
1577
1580
  const pdfContent = content.filter((c) => c.type === "pdf");
@@ -1624,7 +1627,10 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
1624
1627
  await enforceAggregatePdfLimits(pdfFiles, provider, {
1625
1628
  trustSuppliedPageCounts: false,
1626
1629
  });
1627
- return await convertMultimodalToProviderFormat(text, images, pdfFiles, provider, _model, audioFiles);
1630
+ return await convertMultimodalToProviderFormat(text, images, pdfFiles, provider, _model, {
1631
+ audioFiles,
1632
+ outputFormat,
1633
+ });
1628
1634
  }
1629
1635
  /**
1630
1636
  * Check if a string is an internet URL
@@ -1868,7 +1874,7 @@ async function convertFilePathToBase64(filePath) {
1868
1874
  * Passing raw base64 avoids this because `new URL(base64string)` throws and
1869
1875
  * the SDK treats the string as inline base64 data instead.
1870
1876
  */
1871
- async function processImageToBase64(image, index) {
1877
+ async function processImageToBase64(image, index, outputFormat) {
1872
1878
  let imageData;
1873
1879
  let mimeType = "image/jpeg"; // Default mime type
1874
1880
  if (typeof image === "string") {
@@ -1947,7 +1953,7 @@ async function processImageToBase64(image, index) {
1947
1953
  const context = `image input at index ${index}`;
1948
1954
  ImageProcessor.validateSize(base64DecodedByteLength(imageData), context);
1949
1955
  const rawImage = Buffer.from(imageData, "base64");
1950
- const compatible = await toVisionCompatibleImage(rawImage, mimeType);
1956
+ const compatible = await toVisionCompatibleImage(rawImage, mimeType, outputFormat);
1951
1957
  if (compatible.converted) {
1952
1958
  imageData = compatible.buffer.toString("base64");
1953
1959
  mimeType = compatible.mimeType;
@@ -1968,7 +1974,7 @@ async function processImageToBase64(image, index) {
1968
1974
  * Entries that are `http(s)` URLs are left alone — they have no bytes yet, and
1969
1975
  * `processImageToBase64` converts them once they are downloaded.
1970
1976
  */
1971
- export async function normalizeVisionImageFormats(input) {
1977
+ export async function normalizeVisionImageFormats(input, outputFormat) {
1972
1978
  const images = input?.images;
1973
1979
  if (!images || images.length === 0) {
1974
1980
  return;
@@ -1985,7 +1991,7 @@ export async function normalizeVisionImageFormats(input) {
1985
1991
  if (!source || !needsVisionTranscode(source.mimeType)) {
1986
1992
  continue;
1987
1993
  }
1988
- const compatible = await toVisionCompatibleImage(source.buffer, source.mimeType);
1994
+ const compatible = await toVisionCompatibleImage(source.buffer, source.mimeType, outputFormat);
1989
1995
  if (!compatible.converted) {
1990
1996
  continue;
1991
1997
  }
@@ -2115,7 +2121,7 @@ async function readImageSourceForConversion(payload) {
2115
2121
  * - Buffers/Data URIs: Processed normally
2116
2122
  * - Supports alt text for accessibility (included as context in text parts)
2117
2123
  */
2118
- async function convertSimpleImagesToProviderFormat(text, images, provider, _model) {
2124
+ async function convertSimpleImagesToProviderFormat(text, images, provider, _model, outputFormat) {
2119
2125
  // Validate image count against provider-specific limits before processing
2120
2126
  ProviderImageAdapter.validateImageCount(images.length, provider, _model);
2121
2127
  // For Vercel AI SDK, we need to return the content in the standard format
@@ -2172,7 +2178,7 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
2172
2178
  for (const [index, { data: image }] of actualImages.entries()) {
2173
2179
  try {
2174
2180
  // Use helper function to process image and reduce nesting depth
2175
- const { imageData, mimeType } = await processImageToBase64(image, index);
2181
+ const { imageData, mimeType } = await processImageToBase64(image, index, outputFormat);
2176
2182
  content.push({
2177
2183
  type: "image",
2178
2184
  image: imageData,
@@ -2195,13 +2201,17 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
2195
2201
  async function convertMultimodalToProviderFormat(text, images,
2196
2202
  // The canonical entry shape (#309) rather than a fourth copy of it inline —
2197
2203
  // which is what let the render knobs stop short of this function.
2198
- pdfFiles, provider, model, audioFiles = []) {
2204
+ pdfFiles, provider, model,
2205
+ // Bundled rather than two more positional params: a 7th would trip the
2206
+ // repo's `max-params` ceiling of 6.
2207
+ mediaOptions = {}) {
2208
+ const { audioFiles = [], outputFormat } = mediaOptions;
2199
2209
  const content = [
2200
2210
  { type: "text", text },
2201
2211
  ];
2202
2212
  // Add images if present
2203
2213
  if (images.length > 0) {
2204
- const imageContent = await convertSimpleImagesToProviderFormat("", images, provider, model);
2214
+ const imageContent = await convertSimpleImagesToProviderFormat("", images, provider, model, outputFormat);
2205
2215
  if (Array.isArray(imageContent)) {
2206
2216
  imageContent.forEach((item) => {
2207
2217
  if (item.type !== "text") {
@@ -61,6 +61,9 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
61
61
  scale?: number;
62
62
  maxPages?: number;
63
63
  } | undefined;
64
+ imageOptions: {
65
+ outputFormat?: import("../index.js").VisionImageOutputFormat;
66
+ } | undefined;
64
67
  systemPrompt: string | undefined;
65
68
  conversationHistory: import("../index.js").ChatMessage[] | undefined;
66
69
  provider: string;
@@ -59,6 +59,7 @@ export function buildMultimodalOptions(options, providerName, modelName) {
59
59
  },
60
60
  csvOptions: options.csvOptions,
61
61
  pdfOptions: options.pdfOptions,
62
+ imageOptions: options.imageOptions,
62
63
  systemPrompt: options.systemPrompt,
63
64
  conversationHistory: options.conversationMessages,
64
65
  provider: providerName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.19.2",
3
+ "version": "12.20.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -242,6 +242,7 @@
242
242
  "test:mcp-result-cache": "pnpm exec tsx test/continuous-test-suite-mcp-result-cache.ts",
243
243
  "test:archive:security": "pnpm exec tsx test/continuous-test-suite-archive-security.ts",
244
244
  "test:office:security": "pnpm exec tsx test/continuous-test-suite-office-security.ts",
245
+ "test:vision-image-format": "pnpm exec tsx test/continuous-test-suite-vision-image-format.ts",
245
246
  "test:model-pool": "pnpm exec tsx test/continuous-test-suite-model-pool.ts",
246
247
  "test:agents": "pnpm exec tsx test/continuous-test-suite-agents.ts",
247
248
  "test:classifier-router": "pnpm exec tsx test/continuous-test-suite-classifier-router.ts",