@juspay/neurolink 10.10.11 → 10.11.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/adapters/imageFormatSupport.d.ts +75 -0
  3. package/dist/adapters/imageFormatSupport.js +283 -0
  4. package/dist/adapters/video/ffmpegAdapter.d.ts +6 -0
  5. package/dist/adapters/video/ffmpegAdapter.js +1 -1
  6. package/dist/browser/neurolink.min.js +399 -398
  7. package/dist/lib/adapters/imageFormatSupport.d.ts +75 -0
  8. package/dist/lib/adapters/imageFormatSupport.js +284 -0
  9. package/dist/lib/adapters/video/ffmpegAdapter.d.ts +6 -0
  10. package/dist/lib/adapters/video/ffmpegAdapter.js +1 -1
  11. package/dist/lib/processors/config/fileExtensions.d.ts +32 -15
  12. package/dist/lib/processors/config/fileExtensions.js +27 -66
  13. package/dist/lib/processors/config/fileTypeRegistry.d.ts +106 -0
  14. package/dist/lib/processors/config/fileTypeRegistry.js +702 -0
  15. package/dist/lib/processors/config/index.d.ts +2 -1
  16. package/dist/lib/processors/config/index.js +5 -1
  17. package/dist/lib/processors/config/mimeConstants.d.ts +22 -7
  18. package/dist/lib/processors/config/mimeConstants.js +45 -66
  19. package/dist/lib/processors/media/AudioProcessor.js +16 -38
  20. package/dist/lib/processors/media/VideoProcessor.js +11 -32
  21. package/dist/lib/providers/googleAiStudio/client.js +12 -1
  22. package/dist/lib/providers/googleVertex/client.js +123 -66
  23. package/dist/lib/types/file.d.ts +41 -0
  24. package/dist/lib/utils/fileDetector.js +367 -251
  25. package/dist/lib/utils/imageProcessor.js +14 -17
  26. package/dist/lib/utils/markupSniff.d.ts +37 -0
  27. package/dist/lib/utils/markupSniff.js +125 -0
  28. package/dist/lib/utils/messageBuilder.d.ts +14 -0
  29. package/dist/lib/utils/messageBuilder.js +287 -74
  30. package/dist/processors/config/fileExtensions.d.ts +32 -15
  31. package/dist/processors/config/fileExtensions.js +27 -66
  32. package/dist/processors/config/fileTypeRegistry.d.ts +106 -0
  33. package/dist/processors/config/fileTypeRegistry.js +701 -0
  34. package/dist/processors/config/index.d.ts +2 -1
  35. package/dist/processors/config/index.js +5 -1
  36. package/dist/processors/config/mimeConstants.d.ts +22 -7
  37. package/dist/processors/config/mimeConstants.js +45 -66
  38. package/dist/processors/media/AudioProcessor.js +16 -38
  39. package/dist/processors/media/VideoProcessor.js +11 -32
  40. package/dist/providers/googleAiStudio/client.js +12 -1
  41. package/dist/providers/googleVertex/client.js +123 -66
  42. package/dist/types/file.d.ts +41 -0
  43. package/dist/utils/fileDetector.js +367 -251
  44. package/dist/utils/imageProcessor.js +14 -17
  45. package/dist/utils/markupSniff.d.ts +37 -0
  46. package/dist/utils/markupSniff.js +124 -0
  47. package/dist/utils/messageBuilder.d.ts +14 -0
  48. package/dist/utils/messageBuilder.js +287 -74
  49. package/package.json +3 -2
@@ -3,6 +3,7 @@
3
3
  * Handles format conversion for different AI providers
4
4
  */
5
5
  import { basename } from "path";
6
+ import { SUPPORTED_INPUT_IMAGE_MIME_TYPES } from "../adapters/imageFormatSupport.js";
6
7
  import { logger } from "./logger.js";
7
8
  import { redactPathFromMessage, redactUrlForError, redactUrlsInText, sanitizeErrorCause, } from "./logSanitize.js";
8
9
  import { urlDownloadRateLimiter } from "./rateLimiter.js";
@@ -565,23 +566,19 @@ export class ImageProcessor {
565
566
  * Validate image format
566
567
  */
567
568
  static validateImageFormat(mediaType) {
568
- const supportedFormats = [
569
- "image/jpeg",
570
- "image/png",
571
- "image/gif",
572
- "image/webp",
573
- "image/bmp",
574
- "image/tiff",
575
- "image/svg+xml",
576
- "image/avif",
577
- // Deliberately excludes "application/octet-stream": that's
578
- // detectImageType()'s honest sentinel for bytes matching no known
579
- // image signature (#261), not a real image format. Vision providers
580
- // (OpenAI/Anthropic/Google) reject it outright with an HTTP 400, so
581
- // process() must fail loud instead of packaging it as a valid image
582
- // (see the octet-stream guard in process() below).
583
- ];
584
- return supportedFormats.includes(mediaType.toLowerCase());
569
+ // Derived from the canonical input set rather than hand-listed. The old
570
+ // literal omitted HEIC, HEIF, ICO and JPEG 2000, so an iPhone photo
571
+ // attached by path was rejected here — "Invalid MIME type: image/heic is
572
+ // not in allowed list" — before the transcode that exists to accept it
573
+ // could run. Intake has to allow every format we can convert, not just the
574
+ // ones providers take unchanged.
575
+ //
576
+ // Still excludes "application/octet-stream": that is detectImageType()'s
577
+ // honest sentinel for bytes matching no known image signature (#261), not
578
+ // a real format. Vision providers reject it with an HTTP 400, so process()
579
+ // must fail loud rather than package it as a valid image (see the
580
+ // octet-stream guard in process() below).
581
+ return SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(mediaType.toLowerCase());
585
582
  }
586
583
  /**
587
584
  * Get image dimensions from Buffer (basic implementation)
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Content sniffing for text-shaped image formats.
3
+ *
4
+ * SVG is markup, so it has no byte signature — but it does have an unambiguous
5
+ * root element, and identifying it matters twice over: the detector routes SVG
6
+ * to the sanitizer rather than to a vision API, and the image path must not let
7
+ * SVG bytes travel under some other format's MIME type.
8
+ *
9
+ * ## Why this is a scan and not a regular expression
10
+ *
11
+ * The obvious implementation strips the prolog with a pattern like
12
+ * `(?:<!--[\s\S]*?-->\s*)*<svg`. That is two nested quantifiers, and CodeQL
13
+ * rightly flags it as `js/redos`: input beginning `<!--` and repeating
14
+ * `--><!--` drives exponential backtracking, so a hostile (or merely odd)
15
+ * upload can burn CPU inside what is supposed to be a cheap type check.
16
+ *
17
+ * The `.replace()`-chain alternative is unsound for a different reason —
18
+ * removing a multi-character construct can join the surrounding text into a
19
+ * fresh instance of that same construct, so one pass does not converge
20
+ * (`<!--<!-- -->-->` leaves a comment behind). CodeQL flags that shape too, as
21
+ * `js/incomplete-multi-character-sanitization`.
22
+ *
23
+ * A single forward scan has neither problem: the cursor only ever advances, so
24
+ * the work is linear in the input and no construct can re-form behind it.
25
+ *
26
+ * @module utils/markupSniff
27
+ */
28
+ /**
29
+ * Whether the leading bytes are an SVG document.
30
+ *
31
+ * Requires `<svg` to be the *root element* rather than merely present, so an
32
+ * HTML page embedding an inline `<svg>` icon is not mistaken for a standalone
33
+ * image. A BOM, XML declaration, DOCTYPE and comments may precede it.
34
+ *
35
+ * @param input - Buffer or string to inspect; only the head is read.
36
+ */
37
+ export declare function looksLikeSvgMarkup(input: Buffer | string): boolean;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Content sniffing for text-shaped image formats.
3
+ *
4
+ * SVG is markup, so it has no byte signature — but it does have an unambiguous
5
+ * root element, and identifying it matters twice over: the detector routes SVG
6
+ * to the sanitizer rather than to a vision API, and the image path must not let
7
+ * SVG bytes travel under some other format's MIME type.
8
+ *
9
+ * ## Why this is a scan and not a regular expression
10
+ *
11
+ * The obvious implementation strips the prolog with a pattern like
12
+ * `(?:<!--[\s\S]*?-->\s*)*<svg`. That is two nested quantifiers, and CodeQL
13
+ * rightly flags it as `js/redos`: input beginning `<!--` and repeating
14
+ * `--><!--` drives exponential backtracking, so a hostile (or merely odd)
15
+ * upload can burn CPU inside what is supposed to be a cheap type check.
16
+ *
17
+ * The `.replace()`-chain alternative is unsound for a different reason —
18
+ * removing a multi-character construct can join the surrounding text into a
19
+ * fresh instance of that same construct, so one pass does not converge
20
+ * (`<!--<!-- -->-->` leaves a comment behind). CodeQL flags that shape too, as
21
+ * `js/incomplete-multi-character-sanitization`.
22
+ *
23
+ * A single forward scan has neither problem: the cursor only ever advances, so
24
+ * the work is linear in the input and no construct can re-form behind it.
25
+ *
26
+ * @module utils/markupSniff
27
+ */
28
+ /** Bytes inspected when sniffing for a markup root element. */
29
+ const MARKUP_SNIFF_LIMIT = 1024;
30
+ /**
31
+ * Whether the leading bytes are an SVG document.
32
+ *
33
+ * Requires `<svg` to be the *root element* rather than merely present, so an
34
+ * HTML page embedding an inline `<svg>` icon is not mistaken for a standalone
35
+ * image. A BOM, XML declaration, DOCTYPE and comments may precede it.
36
+ *
37
+ * @param input - Buffer or string to inspect; only the head is read.
38
+ */
39
+ export function looksLikeSvgMarkup(input) {
40
+ const head = typeof input === "string"
41
+ ? input.slice(0, MARKUP_SNIFF_LIMIT)
42
+ : input.toString("utf8", 0, Math.min(input.length, MARKUP_SNIFF_LIMIT));
43
+ if (!head.includes("<svg")) {
44
+ return false;
45
+ }
46
+ let cursor = head.charCodeAt(0) === 0xfeff ? 1 : 0;
47
+ while (cursor < head.length) {
48
+ while (cursor < head.length && /\s/.test(head[cursor])) {
49
+ cursor++;
50
+ }
51
+ if (head[cursor] !== "<") {
52
+ // Text before any element — not a well-formed XML document.
53
+ return false;
54
+ }
55
+ // Each prolog construct ends at a known delimiter; an unterminated one
56
+ // means the document is truncated or malformed, so stop rather than guess.
57
+ let end;
58
+ if (head.startsWith("<?", cursor)) {
59
+ end = head.indexOf("?>", cursor + 2);
60
+ cursor = end === -1 ? head.length : end + 2;
61
+ }
62
+ else if (head.startsWith("<!--", cursor)) {
63
+ end = head.indexOf("-->", cursor + 4);
64
+ cursor = end === -1 ? head.length : end + 3;
65
+ }
66
+ else if (head.startsWith("<!", cursor)) {
67
+ // DOCTYPE. Stopping at the first '>' is wrong for two legal forms: a
68
+ // quoted public/system identifier may contain '>', and an internal
69
+ // subset (`<!DOCTYPE svg [ <!ENTITY x "y"> ]>`) certainly does. Either
70
+ // one left the cursor mid-declaration and made a valid SVG classify as
71
+ // not-SVG. Track quotes and subset depth to find the real terminator.
72
+ cursor = skipDoctype(head, cursor);
73
+ }
74
+ else {
75
+ // First real element decides.
76
+ return /^<svg[\s>/]/i.test(head.slice(cursor));
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * Return the index just past a DOCTYPE declaration beginning at `start`.
83
+ *
84
+ * Handles quoted identifiers and a bracketed internal subset. Returns
85
+ * `head.length` when the declaration is unterminated within the sniffed
86
+ * window, which stops the caller rather than letting it guess.
87
+ */
88
+ function skipDoctype(head, start) {
89
+ let quote = null;
90
+ let subsetDepth = 0;
91
+ for (let i = start + 2; i < head.length; i++) {
92
+ const ch = head[i];
93
+ if (quote) {
94
+ if (ch === quote) {
95
+ quote = null;
96
+ }
97
+ continue;
98
+ }
99
+ // A comment inside the internal subset may itself contain ']' and '>'.
100
+ // Treating those as structural closed the subset early and terminated the
101
+ // DOCTYPE on the comment's own '-->', so valid SVG classified as not-SVG.
102
+ if (head.startsWith("<!--", i)) {
103
+ const commentEnd = head.indexOf("-->", i + 4);
104
+ if (commentEnd === -1) {
105
+ return head.length;
106
+ }
107
+ i = commentEnd + 2; // loop increment steps past the final '>'
108
+ continue;
109
+ }
110
+ if (ch === '"' || ch === "'") {
111
+ quote = ch;
112
+ }
113
+ else if (ch === "[") {
114
+ subsetDepth++;
115
+ }
116
+ else if (ch === "]") {
117
+ subsetDepth = Math.max(0, subsetDepth - 1);
118
+ }
119
+ else if (ch === ">" && subsetDepth === 0) {
120
+ return i + 1;
121
+ }
122
+ }
123
+ return head.length;
124
+ }
125
+ //# sourceMappingURL=markupSniff.js.map
@@ -52,3 +52,17 @@ export declare function processUnifiedFilesArray(options: GenerateOptions, maxSi
52
52
  * Detects when images are present and routes through provider adapter
53
53
  */
54
54
  export declare function buildMultimodalMessagesArray(options: GenerateOptions, provider: string, model: string): Promise<MultimodalChatMessage[]>;
55
+ /**
56
+ * Transcode any image in `input.images` that no vision provider accepts,
57
+ * rewriting the entry in place as a PNG data URI.
58
+ *
59
+ * Mutates in place and is idempotent, matching `processUnifiedFilesArray` and
60
+ * `foldMediaAliasesIntoFiles`: the shared multimodal builder and the providers
61
+ * that bypass it (Google AI Studio's and Vertex's native SDK paths, Bedrock's
62
+ * Converse path) can all call it on the same options object without converting
63
+ * anything twice.
64
+ *
65
+ * Entries that are `http(s)` URLs are left alone — they have no bytes yet, and
66
+ * `processImageToBase64` converts them once they are downloaded.
67
+ */
68
+ export declare function normalizeVisionImageFormats(input: GenerateOptions["input"]): Promise<void>;
@@ -8,11 +8,16 @@ 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";
11
+ import { needsVisionTranscode, toVisionCompatibleImage, } from "../adapters/imageFormatSupport.js";
12
+ import { FILE_TYPE_REGISTRY, lookupByExtension, } from "../processors/config/fileTypeRegistry.js";
11
13
  import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
12
14
  import { FileDetector } from "./fileDetector.js";
15
+ import { detectIsoBmffImageMimeType } from "./isoBmff.js";
13
16
  import { getImageCache } from "./imageCache.js";
14
17
  import { ImageProcessor, imageUtils } from "./imageProcessor.js";
15
18
  import { logger } from "./logger.js";
19
+ import { looksLikeSvgMarkup } from "./markupSniff.js";
20
+ import { redactUrlForError } from "./logSanitize.js";
16
21
  import { PDFImageConverter, PDFProcessor } from "./pdfProcessor.js";
17
22
  import { urlDownloadRateLimiter } from "./rateLimiter.js";
18
23
  import { estimateTokens } from "./tokenEstimation.js";
@@ -22,63 +27,16 @@ import { estimateTokens } from "./tokenEstimation.js";
22
27
  // classify files into broad categories (video, audio, image, etc.) so
23
28
  // estimatePostProcessingTokens() can use type-aware estimates.
24
29
  // ---------------------------------------------------------------------------
25
- /** Extension → file type mapping for budget estimation */
26
- const EXTENSION_TYPE_MAP = {
27
- // Video
28
- mp4: "video",
29
- mkv: "video",
30
- mov: "video",
31
- avi: "video",
32
- webm: "video",
33
- wmv: "video",
34
- flv: "video",
35
- m4v: "video",
36
- // Audio
37
- mp3: "audio",
38
- wav: "audio",
39
- ogg: "audio",
40
- flac: "audio",
41
- m4a: "audio",
42
- aac: "audio",
43
- wma: "audio",
44
- opus: "audio",
45
- // Image
46
- jpg: "image",
47
- jpeg: "image",
48
- png: "image",
49
- gif: "image",
50
- webp: "image",
51
- bmp: "image",
52
- tiff: "image",
53
- tif: "image",
54
- avif: "image",
55
- // Archive
56
- zip: "archive",
57
- tar: "archive",
58
- gz: "archive",
59
- tgz: "archive",
60
- rar: "archive",
61
- "7z": "archive",
62
- jar: "archive",
63
- // Documents
64
- xlsx: "xlsx",
65
- xls: "xlsx",
66
- ods: "xlsx",
67
- docx: "docx",
68
- doc: "docx",
69
- odt: "docx",
70
- rtf: "docx",
71
- pptx: "pptx",
72
- ppt: "pptx",
73
- odp: "pptx",
74
- // PDF
75
- pdf: "pdf",
76
- // SVG
77
- svg: "svg",
78
- // CSV
79
- csv: "csv",
80
- tsv: "csv",
81
- };
30
+ /**
31
+ * Extension → file type for budget estimation.
32
+ *
33
+ * Derived from the canonical registry rather than hand-listed: this was a
34
+ * fourth copy of the same knowledge, and it disagreed with the detector it is
35
+ * supposed to predict — it had no entry for .mpg/.mpeg/.3gp/.aiff and so on, so
36
+ * estimatePostProcessingTokens() silently fell back to a generic estimate for
37
+ * exactly the large media files whose estimate matters most.
38
+ */
39
+ const EXTENSION_TYPE_MAP = Object.fromEntries(FILE_TYPE_REGISTRY.flatMap((entry) => entry.extensions.map((ext) => [ext.slice(1), entry.fileType])));
82
40
  /**
83
41
  * Infer file type from extension in a file path or URL.
84
42
  * Returns undefined if no extension or unrecognized.
@@ -101,6 +59,19 @@ function inferFileTypeFromBuffer(buf) {
101
59
  if (buf.length < 4) {
102
60
  return undefined;
103
61
  }
62
+ // SVG is markup and has no magic number, so every signature check below
63
+ // misses it and a raw SVG Buffer classified as `undefined` — which meant the
64
+ // lazy path, which previews markup away. Checked first because the sniff is
65
+ // a cheap look at the head and cannot collide with a binary signature.
66
+ //
67
+ // Deliberately a substring scan over the head rather than a prolog-stripping
68
+ // regex: the obvious pattern for skipping comments and a DOCTYPE is two
69
+ // nested quantifiers, which is a ReDoS waiting to happen inside what is
70
+ // supposed to be a cheap type check. Over-matching is harmless here — the
71
+ // only consequence of a false positive is that a file is processed eagerly.
72
+ if (buf.subarray(0, 1024).toString("latin1").includes("<svg")) {
73
+ return "svg";
74
+ }
104
75
  // PNG
105
76
  if (buf[0] === 0x89 &&
106
77
  buf[1] === 0x50 &&
@@ -863,7 +834,9 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
863
834
  try {
864
835
  // ─── Lazy file registration path ──────────────────────────────
865
836
  const fileSize = fileRegistry ? getFileSize(file) : 0;
866
- if (fileRegistry && fileSize > SIZE_TIER_THRESHOLDS.TINY_MAX) {
837
+ if (fileRegistry &&
838
+ fileSize > SIZE_TIER_THRESHOLDS.TINY_MAX &&
839
+ !isEagerMultimodalFile(file)) {
867
840
  const registered = await tryRegisterFileReference(file, fileSize, fileRegistry, fileIdx);
868
841
  if (registered) {
869
842
  logger.info(`[NEUROLINK] File lazily registered: ${filename} (${fileSize} bytes) — deferred processing`);
@@ -1231,6 +1204,9 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1231
1204
  enforceFileBudget(options, provider, model);
1232
1205
  // Process unified files array (auto-detect)
1233
1206
  await processUnifiedFilesArray(options, maxSize, provider);
1207
+ // Detection can append images (PDF page renders, video keyframes, a HEIC
1208
+ // photo), so compatibility conversion has to run after it, not before.
1209
+ await normalizeVisionImageFormats(inp);
1234
1210
  // Process explicit CSV files array
1235
1211
  await processExplicitCsvFiles(options);
1236
1212
  // Post-processing budget enforcement and token usage logging
@@ -1542,25 +1518,24 @@ async function downloadImageFromUrl(url) {
1542
1518
  }
1543
1519
  }
1544
1520
  /**
1545
- * Get MIME type from file extension
1521
+ * Get MIME type from an image file extension.
1522
+ *
1523
+ * Delegates to the canonical registry instead of the six-case switch this used
1524
+ * to be. That switch covered png/gif/webp/bmp/tiff and defaulted *everything
1525
+ * else* to image/jpeg, so a .heic, .avif, .ico or .jp2 path was labelled JPEG
1526
+ * — which meant `needsVisionTranscode()` never fired for it and the raw bytes
1527
+ * went to the provider under a MIME type that was simply untrue. Exactly the
1528
+ * kind of hand-maintained table this registry exists to delete.
1529
+ *
1530
+ * The image/jpeg fallback is kept only for genuinely unknown extensions, since
1531
+ * callers here have already established they are handling an image.
1546
1532
  */
1547
1533
  function getMimeTypeFromExtension(filePath) {
1548
- const ext = filePath.toLowerCase().split(".").pop();
1549
- switch (ext) {
1550
- case "png":
1551
- return "image/png";
1552
- case "gif":
1553
- return "image/gif";
1554
- case "webp":
1555
- return "image/webp";
1556
- case "bmp":
1557
- return "image/bmp";
1558
- case "tiff":
1559
- case "tif":
1560
- return "image/tiff";
1561
- default:
1562
- return "image/jpeg";
1534
+ const entry = lookupByExtension(filePath);
1535
+ if (entry?.modality === "image") {
1536
+ return entry.mimeTypes[0];
1563
1537
  }
1538
+ return "image/jpeg";
1564
1539
  }
1565
1540
  /**
1566
1541
  * Detect MIME type from buffer magic bytes
@@ -1624,6 +1599,45 @@ function detectMimeTypeFromBuffer(buffer) {
1624
1599
  buffer[3] === 0x2a))) {
1625
1600
  return "image/tiff";
1626
1601
  }
1602
+ // The formats above are the ones a provider accepts (or that sharp handles
1603
+ // trivially). Everything below is a format NO vision provider accepts, and
1604
+ // recognising it here is what lets `needsVisionTranscode()` fire — a Buffer
1605
+ // whose format was unrecognised fell through to the image/jpeg default and
1606
+ // was shipped to the provider unconverted under a MIME type that was false.
1607
+ // Raw Buffers are the most direct way a backend attaches an image, so this
1608
+ // was the single biggest hole in the vision-compatibility path.
1609
+ // HEIC / HEIF / AVIF: ISO-BMFF `ftyp` box, distinguished by major brand.
1610
+ // Shared with the FileDetector so the two cannot drift on brand tables.
1611
+ const isoBmffImage = detectIsoBmffImageMimeType(buffer);
1612
+ if (isoBmffImage) {
1613
+ return isoBmffImage;
1614
+ }
1615
+ // ICO: 00 00 01 00. Checked after the ftyp probe because an ISO-BMFF file
1616
+ // also starts with a zero byte.
1617
+ if (buffer.length >= 4 &&
1618
+ buffer[0] === 0x00 &&
1619
+ buffer[1] === 0x00 &&
1620
+ buffer[2] === 0x01 &&
1621
+ buffer[3] === 0x00) {
1622
+ return "image/x-icon";
1623
+ }
1624
+ // SVG is markup, so it has no byte signature — and without this probe a raw
1625
+ // SVG buffer matched nothing, kept processImageToBase64's "image/jpeg"
1626
+ // default, and was shipped to the provider as XML labelled as a JPEG.
1627
+ // Shared forward scan rather than a local regex: the regex form of this
1628
+ // check is a CodeQL js/redos finding (see markupSniff).
1629
+ if (looksLikeSvgMarkup(buffer)) {
1630
+ return "image/svg+xml";
1631
+ }
1632
+ // JPEG 2000: 12-byte signature box "....jP ".
1633
+ if (buffer.length >= 8 &&
1634
+ buffer[0] === 0x00 &&
1635
+ buffer[1] === 0x00 &&
1636
+ buffer[2] === 0x00 &&
1637
+ buffer[3] === 0x0c &&
1638
+ buffer.toString("latin1", 4, 8) === "jP ") {
1639
+ return "image/jp2";
1640
+ }
1627
1641
  return undefined;
1628
1642
  }
1629
1643
  /**
@@ -1730,8 +1744,108 @@ async function processImageToBase64(image, index) {
1730
1744
  ImageProcessor.validateBufferSize(image, `image input at index ${index}`);
1731
1745
  imageData = image.toString("base64");
1732
1746
  }
1747
+ // Last line of defence for vision-format compatibility. `normalizeVisionImageFormats`
1748
+ // handles `input.images` eagerly so the providers that read that array
1749
+ // directly (Google AI Studio, Bedrock) see converted bytes, but images that
1750
+ // arrive as URLs are downloaded further downstream and only become bytes
1751
+ // here. A no-op for the universal formats, so the common path is unaffected.
1752
+ if (needsVisionTranscode(mimeType)) {
1753
+ const compatible = await toVisionCompatibleImage(Buffer.from(imageData, "base64"), mimeType);
1754
+ if (compatible.converted) {
1755
+ imageData = compatible.buffer.toString("base64");
1756
+ mimeType = compatible.mimeType;
1757
+ }
1758
+ }
1733
1759
  return { imageData, mimeType };
1734
1760
  }
1761
+ /**
1762
+ * Transcode any image in `input.images` that no vision provider accepts,
1763
+ * rewriting the entry in place as a PNG data URI.
1764
+ *
1765
+ * Mutates in place and is idempotent, matching `processUnifiedFilesArray` and
1766
+ * `foldMediaAliasesIntoFiles`: the shared multimodal builder and the providers
1767
+ * that bypass it (Google AI Studio's and Vertex's native SDK paths, Bedrock's
1768
+ * Converse path) can all call it on the same options object without converting
1769
+ * anything twice.
1770
+ *
1771
+ * Entries that are `http(s)` URLs are left alone — they have no bytes yet, and
1772
+ * `processImageToBase64` converts them once they are downloaded.
1773
+ */
1774
+ export async function normalizeVisionImageFormats(input) {
1775
+ const images = input?.images;
1776
+ if (!images || images.length === 0) {
1777
+ return;
1778
+ }
1779
+ for (let index = 0; index < images.length; index++) {
1780
+ const entry = images[index];
1781
+ // ImageWithAltText wraps the payload in `.data`; convert that and keep the
1782
+ // alt text attached rather than dropping the wrapper.
1783
+ const isWrapped = typeof entry === "object" && entry !== null && !Buffer.isBuffer(entry);
1784
+ const payload = isWrapped
1785
+ ? entry.data
1786
+ : entry;
1787
+ const source = await readImageSourceForConversion(payload);
1788
+ if (!source || !needsVisionTranscode(source.mimeType)) {
1789
+ continue;
1790
+ }
1791
+ const compatible = await toVisionCompatibleImage(source.buffer, source.mimeType);
1792
+ if (!compatible.converted) {
1793
+ continue;
1794
+ }
1795
+ const dataUri = `data:${compatible.mimeType};base64,${compatible.buffer.toString("base64")}`;
1796
+ images[index] = isWrapped
1797
+ ? { ...entry, data: dataUri }
1798
+ : dataUri;
1799
+ }
1800
+ }
1801
+ /**
1802
+ * Resolve one `input.images` entry to bytes plus a MIME type, or undefined when
1803
+ * it cannot be resolved without a network call.
1804
+ *
1805
+ * File paths are only read when their extension says the format would need
1806
+ * conversion. Reading every attached .png off disk just to confirm it is
1807
+ * already compatible would double the I/O of the common case for no benefit.
1808
+ */
1809
+ async function readImageSourceForConversion(payload) {
1810
+ if (Buffer.isBuffer(payload)) {
1811
+ const mimeType = detectMimeTypeFromBuffer(payload);
1812
+ return mimeType ? { buffer: payload, mimeType } : undefined;
1813
+ }
1814
+ if (typeof payload !== "string") {
1815
+ return undefined;
1816
+ }
1817
+ if (payload.startsWith("data:")) {
1818
+ const match = payload.match(/^data:([^;]+);base64,(.+)$/);
1819
+ return match
1820
+ ? { buffer: Buffer.from(match[2], "base64"), mimeType: match[1] }
1821
+ : undefined;
1822
+ }
1823
+ if (isInternetUrl(payload)) {
1824
+ return undefined;
1825
+ }
1826
+ const mimeType = getMimeTypeFromExtension(payload);
1827
+ if (!needsVisionTranscode(mimeType)) {
1828
+ return undefined;
1829
+ }
1830
+ try {
1831
+ // Preflight the size before reading. Conversion replaces the entry with a
1832
+ // data URI, which bypasses processImageToBase64's buffer-size guard — so
1833
+ // without this a large local HEIC/TIFF/BMP was read fully into memory and
1834
+ // then re-encoded, with no limit applied at either step.
1835
+ const { size } = await statAsync(payload);
1836
+ ImageProcessor.validateSize(size, `image at ${safeBasename(payload)}`);
1837
+ const buffer = await readFileAsync(payload);
1838
+ ImageProcessor.validateBufferSize(buffer, `image at ${safeBasename(payload)}`);
1839
+ return { buffer, mimeType };
1840
+ }
1841
+ catch (error) {
1842
+ // The path may be a signed URL or carry credentials in query params, so it
1843
+ // is redacted rather than interpolated verbatim (see logSanitize).
1844
+ logger.warn(`[messageBuilder] Could not read ${redactUrlForError(payload)} for image ` +
1845
+ `format conversion: ${error instanceof Error ? error.message : String(error)}`);
1846
+ return undefined;
1847
+ }
1848
+ }
1735
1849
  /**
1736
1850
  * Convert simple images format to Vercel AI SDK format with smart auto-detection
1737
1851
  * - URLs: Downloaded and converted to base64 for Vercel AI SDK compatibility
@@ -2027,6 +2141,105 @@ function getFileSource(file) {
2027
2141
  }
2028
2142
  return "buffer";
2029
2143
  }
2144
+ /**
2145
+ * Whether a file must be processed eagerly rather than lazily referenced.
2146
+ *
2147
+ * The lazy path registers a file and injects a short textual *preview* in place
2148
+ * of the file itself. Whether that is an acceptable trade depends entirely on
2149
+ * whether a description of the file can answer questions about it:
2150
+ *
2151
+ * pdf lazy -> preview carries the extracted text ✔ content survives
2152
+ * image lazy -> preview carries ~98 chars of prose ✘ the pixels are gone
2153
+ *
2154
+ * An image is the case where the bytes ARE the content: no prose summary
2155
+ * substitutes for them, so the model receives a description of a file instead
2156
+ * of the file. Measured end-to-end on `release`, asking "what number is
2157
+ * written in this image?":
2158
+ *
2159
+ * tiny.png 1.6 KB -> "7391" (under TINY_MAX, eager, correct)
2160
+ * big.png 11 KB -> NOTHING_RECEIVED (lazy, image never arrived)
2161
+ * big.jpg 19 KB -> NOTHING_RECEIVED
2162
+ *
2163
+ * 10 KB is far below any real photo, so this affected essentially every image
2164
+ * attached by path — an ordinary JPEG, not just unusual formats.
2165
+ *
2166
+ * Scoped to images deliberately, and audio deserves spelling out because the
2167
+ * reason is not the one it appears to be. An audio file's message contains only
2168
+ * a metadata block — duration, codec, sample rate — on BOTH paths; no audio
2169
+ * bytes are handed to the provider either way. Moving audio to the eager path
2170
+ * would therefore change nothing about what the model receives. That audio
2171
+ * content never reaches the model at all is a separate and pre-existing gap,
2172
+ * not something this threshold decision can repair, and it is easy to mistake
2173
+ * for working code because the metadata block answers exactly the questions
2174
+ * ("how long is it?", "what sample rate?") a test is most tempted to ask.
2175
+ * Video is left alone for the opposite reason: its frames are measurably
2176
+ * present in the message and a model reads them correctly.
2177
+ *
2178
+ * Note this costs no extra memory: `tryRegisterFileReference` already calls
2179
+ * `getFileBuffer()` and reads the whole file to register it. The lazy path was
2180
+ * never lazy about reading — only about processing — so the difference here is
2181
+ * simply whether the bytes survive.
2182
+ */
2183
+ function isEagerMultimodalFile(file) {
2184
+ if (typeof file === "string") {
2185
+ return isImageLikeType(inferFileTypeFromExtension(file));
2186
+ }
2187
+ if (Buffer.isBuffer(file)) {
2188
+ return isImageLikeType(inferFileTypeFromBuffer(file));
2189
+ }
2190
+ // A `FileWithMetadata` carries two independent declarations, and either one
2191
+ // alone is enough: the shape exists for Slack/Curator-style uploads that
2192
+ // arrive as bytes plus a mimetype, so its `filename` may be extensionless or
2193
+ // simply wrong. Reading them as a `??` chain meant the first *recognised*
2194
+ // name won outright — `upload.pdf` with `mimetype: "image/png"` classified as
2195
+ // a PDF and lost its pixels down the lazy path. Any declaration of an image
2196
+ // is therefore decisive.
2197
+ const declared = [
2198
+ inferFileTypeFromExtension(file.filename),
2199
+ inferFileTypeFromMimetype(file.mimetype),
2200
+ ];
2201
+ if (declared.some(isImageLikeType)) {
2202
+ return true;
2203
+ }
2204
+ // The buffer sniff is the last resort rather than a third vote, because it is
2205
+ // a substring scan: an HTML page with an inline `<svg>` icon in its head
2206
+ // would otherwise be pulled onto the eager path and sent in full, which is
2207
+ // the opposite of what the size tiers are for. It only speaks when nothing
2208
+ // else did.
2209
+ return declared.every((type) => type === undefined)
2210
+ ? isImageLikeType(inferFileTypeFromBuffer(file.buffer))
2211
+ : false;
2212
+ }
2213
+ /**
2214
+ * Whether a routing type should have its bytes preserved rather than previewed.
2215
+ *
2216
+ * "svg" is a separate routing type rather than a sub-case of "image" (it goes
2217
+ * to the sanitizer, not to a vision encoder), but it is still an image as far
2218
+ * as this decision is concerned: its markup IS its content, and previewing it
2219
+ * away leaves the model with nothing. Accepting both keeps this correct
2220
+ * whichever of the two type vocabularies the caller's map uses.
2221
+ */
2222
+ function isImageLikeType(type) {
2223
+ return type === "image" || type === "svg";
2224
+ }
2225
+ /**
2226
+ * Infer a routing type from a caller-declared mimetype.
2227
+ *
2228
+ * Only images matter here — this exists so the eager/lazy decision can read a
2229
+ * mimetype hint — and "application/octet-stream" is deliberately ignored,
2230
+ * because it is the opaque sentinel a caller sends when it knows nothing, not
2231
+ * a claim about content.
2232
+ */
2233
+ function inferFileTypeFromMimetype(mimetype) {
2234
+ if (!mimetype) {
2235
+ return undefined;
2236
+ }
2237
+ const normalized = mimetype.split(";")[0].trim().toLowerCase();
2238
+ if (normalized === "image/svg+xml") {
2239
+ return "svg";
2240
+ }
2241
+ return normalized.startsWith("image/") ? "image" : undefined;
2242
+ }
2030
2243
  /**
2031
2244
  * Try to register a file with the FileReferenceRegistry for lazy processing.
2032
2245
  * Returns true if registration succeeded, false if it failed (caller should