@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.
- package/CHANGELOG.md +12 -0
- package/dist/adapters/imageFormatSupport.d.ts +75 -0
- package/dist/adapters/imageFormatSupport.js +283 -0
- package/dist/adapters/video/ffmpegAdapter.d.ts +6 -0
- package/dist/adapters/video/ffmpegAdapter.js +1 -1
- package/dist/browser/neurolink.min.js +399 -398
- package/dist/lib/adapters/imageFormatSupport.d.ts +75 -0
- package/dist/lib/adapters/imageFormatSupport.js +284 -0
- package/dist/lib/adapters/video/ffmpegAdapter.d.ts +6 -0
- package/dist/lib/adapters/video/ffmpegAdapter.js +1 -1
- package/dist/lib/processors/config/fileExtensions.d.ts +32 -15
- package/dist/lib/processors/config/fileExtensions.js +27 -66
- package/dist/lib/processors/config/fileTypeRegistry.d.ts +106 -0
- package/dist/lib/processors/config/fileTypeRegistry.js +702 -0
- package/dist/lib/processors/config/index.d.ts +2 -1
- package/dist/lib/processors/config/index.js +5 -1
- package/dist/lib/processors/config/mimeConstants.d.ts +22 -7
- package/dist/lib/processors/config/mimeConstants.js +45 -66
- package/dist/lib/processors/media/AudioProcessor.js +16 -38
- package/dist/lib/processors/media/VideoProcessor.js +11 -32
- package/dist/lib/providers/googleAiStudio/client.js +12 -1
- package/dist/lib/providers/googleVertex/client.js +123 -66
- package/dist/lib/types/file.d.ts +41 -0
- package/dist/lib/utils/fileDetector.js +367 -251
- package/dist/lib/utils/imageProcessor.js +14 -17
- package/dist/lib/utils/markupSniff.d.ts +37 -0
- package/dist/lib/utils/markupSniff.js +125 -0
- package/dist/lib/utils/messageBuilder.d.ts +14 -0
- package/dist/lib/utils/messageBuilder.js +287 -74
- package/dist/processors/config/fileExtensions.d.ts +32 -15
- package/dist/processors/config/fileExtensions.js +27 -66
- package/dist/processors/config/fileTypeRegistry.d.ts +106 -0
- package/dist/processors/config/fileTypeRegistry.js +701 -0
- package/dist/processors/config/index.d.ts +2 -1
- package/dist/processors/config/index.js +5 -1
- package/dist/processors/config/mimeConstants.d.ts +22 -7
- package/dist/processors/config/mimeConstants.js +45 -66
- package/dist/processors/media/AudioProcessor.js +16 -38
- package/dist/processors/media/VideoProcessor.js +11 -32
- package/dist/providers/googleAiStudio/client.js +12 -1
- package/dist/providers/googleVertex/client.js +123 -66
- package/dist/types/file.d.ts +41 -0
- package/dist/utils/fileDetector.js +367 -251
- package/dist/utils/imageProcessor.js +14 -17
- package/dist/utils/markupSniff.d.ts +37 -0
- package/dist/utils/markupSniff.js +124 -0
- package/dist/utils/messageBuilder.d.ts +14 -0
- package/dist/utils/messageBuilder.js +287 -74
- 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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 &&
|
|
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
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|