@juspay/neurolink 10.11.2 → 10.11.3
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 +6 -0
- package/dist/adapters/audioFormatSupport.d.ts +53 -0
- package/dist/adapters/audioFormatSupport.js +200 -0
- package/dist/browser/neurolink.min.js +398 -397
- package/dist/lib/adapters/audioFormatSupport.d.ts +53 -0
- package/dist/lib/adapters/audioFormatSupport.js +201 -0
- package/dist/lib/processors/archive/ArchiveProcessor.d.ts +37 -0
- package/dist/lib/processors/archive/ArchiveProcessor.js +347 -32
- package/dist/lib/providers/googleAiStudio/client.d.ts +17 -0
- package/dist/lib/providers/googleAiStudio/client.js +45 -19
- package/dist/lib/providers/googleNativeGemini3/utils.d.ts +22 -1
- package/dist/lib/providers/googleNativeGemini3/utils.js +54 -0
- package/dist/lib/providers/googleVertex/client.js +3 -0
- package/dist/lib/types/file.d.ts +41 -0
- package/dist/lib/types/generate.d.ts +12 -1
- package/dist/lib/types/processor.d.ts +20 -1
- package/dist/lib/types/providers.d.ts +7 -0
- package/dist/lib/utils/fileDetector.d.ts +27 -0
- package/dist/lib/utils/fileDetector.js +130 -7
- package/dist/lib/utils/imageProcessor.js +31 -0
- package/dist/lib/utils/messageBuilder.d.ts +0 -9
- package/dist/lib/utils/messageBuilder.js +380 -56
- package/dist/processors/archive/ArchiveProcessor.d.ts +37 -0
- package/dist/processors/archive/ArchiveProcessor.js +347 -32
- package/dist/providers/googleAiStudio/client.d.ts +17 -0
- package/dist/providers/googleAiStudio/client.js +45 -19
- package/dist/providers/googleNativeGemini3/utils.d.ts +22 -1
- package/dist/providers/googleNativeGemini3/utils.js +54 -0
- package/dist/providers/googleVertex/client.js +3 -0
- package/dist/types/file.d.ts +41 -0
- package/dist/types/generate.d.ts +12 -1
- package/dist/types/processor.d.ts +20 -1
- package/dist/types/providers.d.ts +7 -0
- package/dist/utils/fileDetector.d.ts +27 -0
- package/dist/utils/fileDetector.js +130 -7
- package/dist/utils/imageProcessor.js +31 -0
- package/dist/utils/messageBuilder.d.ts +0 -9
- package/dist/utils/messageBuilder.js +380 -56
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ import { FILE_TYPE_REGISTRY, lookupByExtension, } from "../processors/config/fil
|
|
|
13
13
|
import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
|
|
14
14
|
import { FileDetector } from "./fileDetector.js";
|
|
15
15
|
import { detectIsoBmffImageMimeType } from "./isoBmff.js";
|
|
16
|
+
import { needsAudioTranscode, supportsNativeAudio, toProviderCompatibleAudio, } from "../adapters/audioFormatSupport.js";
|
|
16
17
|
import { getImageCache } from "./imageCache.js";
|
|
17
18
|
import { ImageProcessor, imageUtils } from "./imageProcessor.js";
|
|
18
19
|
import { logger } from "./logger.js";
|
|
@@ -37,6 +38,11 @@ import { estimateTokens } from "./tokenEstimation.js";
|
|
|
37
38
|
* exactly the large media files whose estimate matters most.
|
|
38
39
|
*/
|
|
39
40
|
const EXTENSION_TYPE_MAP = Object.fromEntries(FILE_TYPE_REGISTRY.flatMap((entry) => entry.extensions.map((ext) => [ext.slice(1), entry.fileType])));
|
|
41
|
+
/**
|
|
42
|
+
* MIME type → routing type, derived from the same registry as
|
|
43
|
+
* {@link EXTENSION_TYPE_MAP} so the two can never disagree about a format.
|
|
44
|
+
*/
|
|
45
|
+
const MIMETYPE_TYPE_MAP = Object.fromEntries(FILE_TYPE_REGISTRY.flatMap((entry) => entry.mimeTypes.map((mime) => [mime.toLowerCase(), entry.fileType])));
|
|
40
46
|
/**
|
|
41
47
|
* Infer file type from extension in a file path or URL.
|
|
42
48
|
* Returns undefined if no extension or unrecognized.
|
|
@@ -625,11 +631,85 @@ function enforceFileBudget(options, provider, model) {
|
|
|
625
631
|
logger.warn(`[FileDetector] Aggregate file budget enforcement: excluded ${budgetResult.excluded.length} file(s)`);
|
|
626
632
|
}
|
|
627
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Per input, the file entries already folded into text and media.
|
|
636
|
+
*
|
|
637
|
+
* A WeakMap so a long-lived process cannot accumulate references to request
|
|
638
|
+
* payloads: the record vanishes with the input object it is keyed on.
|
|
639
|
+
*/
|
|
640
|
+
const PREPROCESSED_FILES = new WeakMap();
|
|
641
|
+
/**
|
|
642
|
+
* Ceiling on reading one already-detected local file back off disk.
|
|
643
|
+
*
|
|
644
|
+
* Sized for the 100 MB this path admits from cold storage, not for the warm
|
|
645
|
+
* page cache the read usually hits — detection has just read the same bytes.
|
|
646
|
+
*/
|
|
647
|
+
const FILE_READ_TIMEOUT_MS = 30_000;
|
|
648
|
+
/**
|
|
649
|
+
* Read a file input's bytes, or null when they cannot be had.
|
|
650
|
+
*
|
|
651
|
+
* Asynchronous because this path admits files up to 100 MB: a synchronous read
|
|
652
|
+
* of one blocks the event loop for every other in-flight request on the
|
|
653
|
+
* process, which for a server handling concurrent generations is not a
|
|
654
|
+
* micro-optimisation to trade away.
|
|
655
|
+
*
|
|
656
|
+
* A URL or data URI yields null rather than a fetch: those arrive already
|
|
657
|
+
* materialised by the time detection runs, and re-fetching a remote URL here
|
|
658
|
+
* would issue a second network request behind the caller's back.
|
|
659
|
+
*/
|
|
660
|
+
async function readFileInputBytes(file) {
|
|
661
|
+
try {
|
|
662
|
+
if (isFileWithMetadata(file)) {
|
|
663
|
+
return file.buffer;
|
|
664
|
+
}
|
|
665
|
+
if (Buffer.isBuffer(file)) {
|
|
666
|
+
return file;
|
|
667
|
+
}
|
|
668
|
+
if (typeof file === "string") {
|
|
669
|
+
const { readFile, stat } = await import("node:fs/promises");
|
|
670
|
+
// Two different hangs live here, and they need different guards.
|
|
671
|
+
//
|
|
672
|
+
// A FIFO or device node blocks inside the read syscall, where neither a
|
|
673
|
+
// timeout nor an abort can reach it — measured: with a signal attached,
|
|
674
|
+
// a blocked FIFO read stays pending through both open and mid-read. The
|
|
675
|
+
// timeout would return while that read sat there forever. So refuse
|
|
676
|
+
// anything that is not a regular file up front; that is the only thing
|
|
677
|
+
// that actually prevents this case.
|
|
678
|
+
//
|
|
679
|
+
// A regular file on a slow or hung mount does return control between
|
|
680
|
+
// chunks, so there the signal works (measured: rejects with AbortError
|
|
681
|
+
// in flight) — and it matters, because racing the promise alone leaves
|
|
682
|
+
// the read filling a buffer nobody will collect. Aborting in `finally`
|
|
683
|
+
// covers both exits; after a resolved read it is a no-op.
|
|
684
|
+
//
|
|
685
|
+
// stat-then-read is a TOCTOU window, but a narrow one, and it is strictly
|
|
686
|
+
// better than the unbounded read it replaces.
|
|
687
|
+
const stats = await withTimeout(stat(file), FILE_READ_TIMEOUT_MS);
|
|
688
|
+
if (!stats.isFile()) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const controller = new AbortController();
|
|
692
|
+
try {
|
|
693
|
+
return await withTimeout(readFile(file, { signal: controller.signal }), FILE_READ_TIMEOUT_MS);
|
|
694
|
+
}
|
|
695
|
+
finally {
|
|
696
|
+
controller.abort();
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// An unreadable file is not an error here: the metadata summary was
|
|
702
|
+
// already appended, so the caller degrades to previous behaviour. This
|
|
703
|
+
// covers the missing-file case that an `existsSync` pre-check used to
|
|
704
|
+
// (without the TOCTOU gap between check and read) and the timeout above.
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
628
708
|
/**
|
|
629
709
|
* Append a detected file result to options.input based on its type.
|
|
630
710
|
* Handles CSV, SVG, image, PDF, video, audio, archive, xlsx, docx, pptx, text, and unknown types.
|
|
631
711
|
*/
|
|
632
|
-
function appendDetectedFileResult(result, file, options) {
|
|
712
|
+
async function appendDetectedFileResult(result, file, options) {
|
|
633
713
|
options.input ??= {};
|
|
634
714
|
const filename = extractFilename(file);
|
|
635
715
|
if (result.type === "csv") {
|
|
@@ -682,6 +762,16 @@ function appendDetectedFileResult(result, file, options) {
|
|
|
682
762
|
if (result.content) {
|
|
683
763
|
options.input.text += `\n\n## Audio File: "${filename}"\n${result.content}\n`;
|
|
684
764
|
}
|
|
765
|
+
// Carry the bytes forward as well as the summary. Whether they are used is
|
|
766
|
+
// decided later, per provider: one that can listen receives the audio, one
|
|
767
|
+
// that cannot still gets the summary above and is no worse off than before.
|
|
768
|
+
const audioBytes = await readFileInputBytes(file);
|
|
769
|
+
if (audioBytes) {
|
|
770
|
+
options.input.nativeAudioFiles = [
|
|
771
|
+
...(options.input.nativeAudioFiles || []),
|
|
772
|
+
{ buffer: audioBytes, filename, mimeType: result.mimeType },
|
|
773
|
+
];
|
|
774
|
+
}
|
|
685
775
|
if (result.images && result.images.length > 0) {
|
|
686
776
|
options.input.images = [
|
|
687
777
|
...(options.input.images || []),
|
|
@@ -803,13 +893,52 @@ function warnIfVideoTranscriptionRequested(videoOptions) {
|
|
|
803
893
|
* `input.files` — without this, mimetype-hint and text-file inputs
|
|
804
894
|
* would silently never reach the model on those paths.
|
|
805
895
|
*/
|
|
896
|
+
/**
|
|
897
|
+
* Record that one file entry has been folded into an input.
|
|
898
|
+
*
|
|
899
|
+
* Marked per entry as each completes, not per run: the loop throws on the
|
|
900
|
+
* first file it cannot process (#273, fail loud), and the SDK's own retry path
|
|
901
|
+
* re-invokes this function with the same input. Marking the whole run on entry
|
|
902
|
+
* would make that retry a silent no-op — permanently skipping the failed file
|
|
903
|
+
* and shipping a half-populated request with nothing surfaced. Marking the
|
|
904
|
+
* whole run on exit would instead re-process the files that had already
|
|
905
|
+
* succeeded, duplicating them. Per entry is the only version that is right in
|
|
906
|
+
* both directions.
|
|
907
|
+
*/
|
|
908
|
+
function markFileProcessed(input, entry) {
|
|
909
|
+
const processed = PREPROCESSED_FILES.get(input) ?? new Set();
|
|
910
|
+
processed.add(entry);
|
|
911
|
+
PREPROCESSED_FILES.set(input, processed);
|
|
912
|
+
}
|
|
806
913
|
export async function processUnifiedFilesArray(options, maxSize, provider) {
|
|
807
914
|
options.input ??= {};
|
|
808
915
|
if (!options.input.files || options.input.files.length === 0) {
|
|
809
916
|
return;
|
|
810
917
|
}
|
|
811
|
-
|
|
812
|
-
|
|
918
|
+
// Every result this function produces is *appended* — the summary onto
|
|
919
|
+
// `text`, the bytes onto `nativeAudioFiles`/`images`/`pdfFiles` — and
|
|
920
|
+
// `files` is only ever read, never consumed. Running it twice over the same
|
|
921
|
+
// entry therefore doubles the injected text and attaches the same recording
|
|
922
|
+
// twice, which a provider sees as two distinct files.
|
|
923
|
+
//
|
|
924
|
+
// That is reachable: providers whose native paths preprocess in both
|
|
925
|
+
// `generate()` and `executeStream()` share one `options.input` reference
|
|
926
|
+
// with `BaseProvider`'s real-stream → fake-stream fallback, so a retried
|
|
927
|
+
// stream runs this a second time over the same object.
|
|
928
|
+
//
|
|
929
|
+
// Tracked per *entry* rather than per input, because a caller that appends a
|
|
930
|
+
// file to an input it has already used must still get the new one processed
|
|
931
|
+
// — treating the whole input as done would silently drop it. Entries are
|
|
932
|
+
// compared by identity (or by value for a path string), which is what a
|
|
933
|
+
// repeat of the same attachment actually looks like.
|
|
934
|
+
const alreadyProcessed = PREPROCESSED_FILES.get(options.input) ?? new Set();
|
|
935
|
+
const pending = options.input.files.filter((entry) => !alreadyProcessed.has(entry));
|
|
936
|
+
if (pending.length === 0) {
|
|
937
|
+
logger.debug("[NEUROLINK] Every attached file has already been processed for this input — skipping to avoid duplicate attachments");
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const totalFiles = pending.length;
|
|
941
|
+
const files = pending;
|
|
813
942
|
warnIfVideoTranscriptionRequested(options.videoOptions);
|
|
814
943
|
return withSpan({
|
|
815
944
|
name: "neurolink.file.process_all",
|
|
@@ -841,6 +970,7 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
|
|
|
841
970
|
if (registered) {
|
|
842
971
|
logger.info(`[NEUROLINK] File lazily registered: ${filename} (${fileSize} bytes) — deferred processing`);
|
|
843
972
|
includedCount++;
|
|
973
|
+
markFileProcessed(inp2, file);
|
|
844
974
|
continue;
|
|
845
975
|
}
|
|
846
976
|
}
|
|
@@ -853,6 +983,16 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
|
|
|
853
983
|
const fileMimetypeHint = isFileWithMetadata(file)
|
|
854
984
|
? file.mimetype
|
|
855
985
|
: undefined;
|
|
986
|
+
// The name has to travel the same way, and for the same reason: the
|
|
987
|
+
// line above unwraps the object to its buffer, so by the time
|
|
988
|
+
// detection resolves an extension there is no name left to read one
|
|
989
|
+
// from. Without this a `.tar` supplied as bytes-plus-name is
|
|
990
|
+
// unidentifiable — its "ustar" marker sits at byte 257, not at
|
|
991
|
+
// offset 0 — and reports "Could not extract content" for an archive
|
|
992
|
+
// that extracts perfectly when handed its filename.
|
|
993
|
+
const fileFilenameHint = isFileWithMetadata(file)
|
|
994
|
+
? file.filename
|
|
995
|
+
: undefined;
|
|
856
996
|
const result = await FileDetector.detectAndProcess(rawFileInput, {
|
|
857
997
|
maxSize: genericFileMaxSize,
|
|
858
998
|
allowedTypes: [
|
|
@@ -881,12 +1021,14 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
|
|
|
881
1021
|
: undefined,
|
|
882
1022
|
provider: provider,
|
|
883
1023
|
mimetypeHint: fileMimetypeHint,
|
|
1024
|
+
filenameHint: fileFilenameHint,
|
|
884
1025
|
});
|
|
885
|
-
appendDetectedFileResult(result, file, options);
|
|
1026
|
+
await appendDetectedFileResult(result, file, options);
|
|
886
1027
|
includedCount++;
|
|
887
1028
|
// Log what content type was added to the message
|
|
888
1029
|
const contentType = result.type === "image" ? "image" : "text";
|
|
889
1030
|
logger.info(`[NEUROLINK] File added to message: ${filename} as ${contentType} (type: ${result.type})`);
|
|
1031
|
+
markFileProcessed(inp2, file);
|
|
890
1032
|
}
|
|
891
1033
|
catch (error) {
|
|
892
1034
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
@@ -1223,8 +1365,14 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
|
|
|
1223
1365
|
// convertContentToProviderFormat at all.
|
|
1224
1366
|
const hasPDFs = pdfFiles.length > 0 ||
|
|
1225
1367
|
!!(inp.content && inp.content.some((c) => c.type === "pdf"));
|
|
1226
|
-
//
|
|
1227
|
-
|
|
1368
|
+
// Audio that is to be delivered natively is multimodal for the same reason a
|
|
1369
|
+
// PDF is: it becomes a non-text part. Without this an audio-only turn — the
|
|
1370
|
+
// ordinary "transcribe this recording" request — took the text-only branch
|
|
1371
|
+
// below, where the collected bytes have nowhere to go and only the metadata
|
|
1372
|
+
// summary survives.
|
|
1373
|
+
const hasNativeAudio = (inp.nativeAudioFiles?.length ?? 0) > 0 && supportsNativeAudio(provider);
|
|
1374
|
+
// If no images, PDFs or audio, use standard message building and convert to MultimodalChatMessage[]
|
|
1375
|
+
if (!hasImages && !hasPDFs && !hasNativeAudio) {
|
|
1228
1376
|
// #289: CSV content[] items don't need vision, so they never reach the
|
|
1229
1377
|
// multimodal converter below — process them into the prompt text here
|
|
1230
1378
|
// (otherwise a `content: [{type:"csv"}]`-only request silently drops it).
|
|
@@ -1328,10 +1476,22 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
|
|
|
1328
1476
|
try {
|
|
1329
1477
|
let userContent;
|
|
1330
1478
|
if (inp.content && inp.content.length > 0) {
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1479
|
+
// Audio detected from `input.files` has to reach this branch too. A
|
|
1480
|
+
// caller that supplies structured `content` AND attaches an audio file
|
|
1481
|
+
// is not asking for the audio to be discarded — but this branch bypasses
|
|
1482
|
+
// the multimodal converter below, so the bytes were dropped and only the
|
|
1483
|
+
// metadata summary folded into `text` survived. Exactly the failure this
|
|
1484
|
+
// change exists to remove, reintroduced through the other door.
|
|
1485
|
+
userContent = await convertContentToProviderFormat(inp.content, provider, model, options.pdfOptions, inp.nativeAudioFiles ?? []);
|
|
1486
|
+
}
|
|
1487
|
+
else if ((inp.images && inp.images.length > 0) ||
|
|
1488
|
+
pdfFiles.length > 0 ||
|
|
1489
|
+
// Audio alone must still take the multimodal path. Without this clause an
|
|
1490
|
+
// audio-only turn fell through to the plain-text branch below, so the
|
|
1491
|
+
// recording was dropped and only its metadata summary — already folded
|
|
1492
|
+
// into `text` — ever reached the model.
|
|
1493
|
+
(inp.nativeAudioFiles?.length ?? 0) > 0) {
|
|
1494
|
+
userContent = await convertMultimodalToProviderFormat(inp.text ?? "", inp.images || [], pdfFiles, provider, model, inp.nativeAudioFiles ?? []);
|
|
1335
1495
|
}
|
|
1336
1496
|
else {
|
|
1337
1497
|
userContent = inp.text;
|
|
@@ -1407,7 +1567,7 @@ async function appendCsvContentToText(csvItems, baseText) {
|
|
|
1407
1567
|
/**
|
|
1408
1568
|
* Convert advanced content format to provider-specific format
|
|
1409
1569
|
*/
|
|
1410
|
-
async function convertContentToProviderFormat(content, provider, _model, pdfOptions) {
|
|
1570
|
+
async function convertContentToProviderFormat(content, provider, _model, pdfOptions, audioFiles = []) {
|
|
1411
1571
|
const textContent = content.find((c) => c.type === "text");
|
|
1412
1572
|
const imageContent = content.filter((c) => c.type === "image");
|
|
1413
1573
|
const pdfContent = content.filter((c) => c.type === "pdf");
|
|
@@ -1418,13 +1578,25 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
|
|
|
1418
1578
|
if (csvContent.length > 0) {
|
|
1419
1579
|
text = await appendCsvContentToText(csvContent, text);
|
|
1420
1580
|
}
|
|
1421
|
-
|
|
1581
|
+
// Audio the provider can actually read counts as multimodal content, on both
|
|
1582
|
+
// of the checks below. Computed before the validation rather than after it:
|
|
1583
|
+
// a request whose structured `content` carries no text but does carry an
|
|
1584
|
+
// attached recording is a complete request, and leaving audio out of
|
|
1585
|
+
// `hasMultimodal` rejected it as empty before delivery could be considered.
|
|
1586
|
+
//
|
|
1587
|
+
// The same flag then keeps it off the text-only early return, which would
|
|
1588
|
+
// otherwise hand back a plain string and lose the bytes — the exact drop this
|
|
1589
|
+
// change exists to stop, reached through a different branch. Gated on the
|
|
1590
|
+
// provider accepting audio so one that cannot keeps the cheaper plain-text
|
|
1591
|
+
// shape rather than an array carrying a part it will ignore.
|
|
1592
|
+
const deliversAudio = audioFiles.length > 0 && supportsNativeAudio(provider);
|
|
1593
|
+
const hasMultimodal = imageContent.length > 0 || pdfContent.length > 0 || deliversAudio;
|
|
1422
1594
|
// Validate that we have at least some content
|
|
1423
1595
|
if (!hasMultimodal && !text) {
|
|
1424
1596
|
throw new Error("Content must include either text or multimodal content");
|
|
1425
1597
|
}
|
|
1426
|
-
// Text-only case (CSV has already been folded into `text`)
|
|
1427
|
-
if (
|
|
1598
|
+
// Text-only case (CSV has already been folded into `text`).
|
|
1599
|
+
if (!hasMultimodal) {
|
|
1428
1600
|
return text;
|
|
1429
1601
|
}
|
|
1430
1602
|
// Extract images as Buffer | string array
|
|
@@ -1448,7 +1620,7 @@ async function convertContentToProviderFormat(content, provider, _model, pdfOpti
|
|
|
1448
1620
|
await enforceAggregatePdfLimits(pdfFiles, provider, {
|
|
1449
1621
|
trustSuppliedPageCounts: false,
|
|
1450
1622
|
});
|
|
1451
|
-
return await convertMultimodalToProviderFormat(text, images, pdfFiles, provider, _model);
|
|
1623
|
+
return await convertMultimodalToProviderFormat(text, images, pdfFiles, provider, _model, audioFiles);
|
|
1452
1624
|
}
|
|
1453
1625
|
/**
|
|
1454
1626
|
* Check if a string is an internet URL
|
|
@@ -1629,13 +1801,20 @@ function detectMimeTypeFromBuffer(buffer) {
|
|
|
1629
1801
|
if (looksLikeSvgMarkup(buffer)) {
|
|
1630
1802
|
return "image/svg+xml";
|
|
1631
1803
|
}
|
|
1632
|
-
// JPEG 2000: 12-byte signature box
|
|
1633
|
-
|
|
1804
|
+
// JPEG 2000: the full 12-byte signature box, trailing 0D 0A 87 0A included.
|
|
1805
|
+
// Those four bytes are a line-ending probe that a transfer mangling newlines
|
|
1806
|
+
// or stripping the eighth bit corrupts, so checking only length and brand
|
|
1807
|
+
// accepts exactly the damaged files the signature exists to reject.
|
|
1808
|
+
if (buffer.length >= 12 &&
|
|
1634
1809
|
buffer[0] === 0x00 &&
|
|
1635
1810
|
buffer[1] === 0x00 &&
|
|
1636
1811
|
buffer[2] === 0x00 &&
|
|
1637
1812
|
buffer[3] === 0x0c &&
|
|
1638
|
-
buffer.toString("latin1", 4, 8) === "jP "
|
|
1813
|
+
buffer.toString("latin1", 4, 8) === "jP " &&
|
|
1814
|
+
buffer[8] === 0x0d &&
|
|
1815
|
+
buffer[9] === 0x0a &&
|
|
1816
|
+
buffer[10] === 0x87 &&
|
|
1817
|
+
buffer[11] === 0x0a) {
|
|
1639
1818
|
return "image/jp2";
|
|
1640
1819
|
}
|
|
1641
1820
|
return undefined;
|
|
@@ -1750,7 +1929,17 @@ async function processImageToBase64(image, index) {
|
|
|
1750
1929
|
// arrive as URLs are downloaded further downstream and only become bytes
|
|
1751
1930
|
// here. A no-op for the universal formats, so the common path is unaffected.
|
|
1752
1931
|
if (needsVisionTranscode(mimeType)) {
|
|
1753
|
-
|
|
1932
|
+
// Guard the decoded bytes, not just the Buffer input. The buffer branch
|
|
1933
|
+
// above is already checked, but a data: URI reaches here having only been
|
|
1934
|
+
// regex-matched — so an oversized one was handed straight to sharp/ffmpeg,
|
|
1935
|
+
// which decode it in full.
|
|
1936
|
+
//
|
|
1937
|
+
// Sized before the decode rather than after: checking the Buffer would mean
|
|
1938
|
+
// allocating the very thing the limit exists to refuse.
|
|
1939
|
+
const context = `image input at index ${index}`;
|
|
1940
|
+
ImageProcessor.validateSize(base64DecodedByteLength(imageData), context);
|
|
1941
|
+
const rawImage = Buffer.from(imageData, "base64");
|
|
1942
|
+
const compatible = await toVisionCompatibleImage(rawImage, mimeType);
|
|
1754
1943
|
if (compatible.converted) {
|
|
1755
1944
|
imageData = compatible.buffer.toString("base64");
|
|
1756
1945
|
mimeType = compatible.mimeType;
|
|
@@ -1806,19 +1995,84 @@ export async function normalizeVisionImageFormats(input) {
|
|
|
1806
1995
|
* conversion. Reading every attached .png off disk just to confirm it is
|
|
1807
1996
|
* already compatible would double the I/O of the common case for no benefit.
|
|
1808
1997
|
*/
|
|
1998
|
+
/**
|
|
1999
|
+
* Byte length `Buffer.from(b64, "base64")` will allocate, computed without
|
|
2000
|
+
* allocating it.
|
|
2001
|
+
*
|
|
2002
|
+
* Base64 encodes 3 bytes per 4 characters, so the encoded length settles the
|
|
2003
|
+
* decoded length up front. The point is ordering: a size guard applied to the
|
|
2004
|
+
* decoded Buffer has already paid for the allocation it exists to prevent, so
|
|
2005
|
+
* every base64 site here checks this first and decodes second — the same shape
|
|
2006
|
+
* as the file-path branch, which stats before it reads.
|
|
2007
|
+
*
|
|
2008
|
+
* This bounds the decode, not the whole request: the encoded string is already
|
|
2009
|
+
* resident by the time we see it, so an oversized payload still costs its own
|
|
2010
|
+
* length in memory. What it removes is the second, larger allocation on top.
|
|
2011
|
+
*
|
|
2012
|
+
* Whitespace is skipped because the decoder ignores it, which keeps the count
|
|
2013
|
+
* exact rather than a conservative over-estimate that would reject legitimate
|
|
2014
|
+
* payloads sitting just under the limit.
|
|
2015
|
+
*/
|
|
2016
|
+
function base64DecodedByteLength(base64) {
|
|
2017
|
+
let significant = 0;
|
|
2018
|
+
let padding = 0;
|
|
2019
|
+
for (let i = 0; i < base64.length; i++) {
|
|
2020
|
+
const code = base64.charCodeAt(i);
|
|
2021
|
+
if (code === 32 || code === 9 || code === 10 || code === 13) {
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
significant++;
|
|
2025
|
+
if (code === 61) {
|
|
2026
|
+
padding++;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return Math.max(0, Math.floor(significant / 4) * 3 - padding);
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Whether a payload is small enough to hand to an image decoder, reported
|
|
2033
|
+
* rather than thrown. See {@link readImageSourceForConversion} for why this
|
|
2034
|
+
* pass degrades instead of failing.
|
|
2035
|
+
*/
|
|
2036
|
+
function withinConversionByteLimit(bytes, context) {
|
|
2037
|
+
try {
|
|
2038
|
+
ImageProcessor.validateSize(bytes, context);
|
|
2039
|
+
return true;
|
|
2040
|
+
}
|
|
2041
|
+
catch (error) {
|
|
2042
|
+
logger.warn(`[messageBuilder] Skipping vision-format conversion for ${context}: ` +
|
|
2043
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
2044
|
+
return false;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
function withinConversionLimit(buffer, context) {
|
|
2048
|
+
return withinConversionByteLimit(buffer.length, context);
|
|
2049
|
+
}
|
|
1809
2050
|
async function readImageSourceForConversion(payload) {
|
|
2051
|
+
// Both in-memory shapes are size-checked before they can reach a decoder,
|
|
2052
|
+
// the same way the file-path branch below is. `withinConversionLimit`
|
|
2053
|
+
// reports rather than throws, because this whole pass is best-effort: an
|
|
2054
|
+
// image too large to convert is left in its original format for the
|
|
2055
|
+
// downstream guard to reject, which is what an unconvertible image already
|
|
2056
|
+
// does. Throwing here would turn a normalisation step into a hard failure.
|
|
1810
2057
|
if (Buffer.isBuffer(payload)) {
|
|
1811
2058
|
const mimeType = detectMimeTypeFromBuffer(payload);
|
|
1812
|
-
|
|
2059
|
+
if (!mimeType || !withinConversionLimit(payload, "image buffer")) {
|
|
2060
|
+
return undefined;
|
|
2061
|
+
}
|
|
2062
|
+
return { buffer: payload, mimeType };
|
|
1813
2063
|
}
|
|
1814
2064
|
if (typeof payload !== "string") {
|
|
1815
2065
|
return undefined;
|
|
1816
2066
|
}
|
|
1817
2067
|
if (payload.startsWith("data:")) {
|
|
1818
2068
|
const match = payload.match(/^data:([^;]+);base64,(.+)$/);
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
2069
|
+
if (!match) {
|
|
2070
|
+
return undefined;
|
|
2071
|
+
}
|
|
2072
|
+
if (!withinConversionByteLimit(base64DecodedByteLength(match[2]), "image data URI")) {
|
|
2073
|
+
return undefined;
|
|
2074
|
+
}
|
|
2075
|
+
return { buffer: Buffer.from(match[2], "base64"), mimeType: match[1] };
|
|
1822
2076
|
}
|
|
1823
2077
|
if (isInternetUrl(payload)) {
|
|
1824
2078
|
return undefined;
|
|
@@ -1933,7 +2187,7 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
|
|
|
1933
2187
|
async function convertMultimodalToProviderFormat(text, images,
|
|
1934
2188
|
// The canonical entry shape (#309) rather than a fourth copy of it inline —
|
|
1935
2189
|
// which is what let the render knobs stop short of this function.
|
|
1936
|
-
pdfFiles, provider, model) {
|
|
2190
|
+
pdfFiles, provider, model, audioFiles = []) {
|
|
1937
2191
|
const content = [
|
|
1938
2192
|
{ type: "text", text },
|
|
1939
2193
|
];
|
|
@@ -1948,6 +2202,37 @@ pdfFiles, provider, model) {
|
|
|
1948
2202
|
});
|
|
1949
2203
|
}
|
|
1950
2204
|
}
|
|
2205
|
+
// Attach audio to providers that can listen. The metadata summary was
|
|
2206
|
+
// already appended to `text` during detection, so this adds the recording
|
|
2207
|
+
// itself rather than replacing the description — a model asked "how long is
|
|
2208
|
+
// this?" keeps the exact answer, and one asked "what is said?" can now
|
|
2209
|
+
// answer at all.
|
|
2210
|
+
if (audioFiles.length > 0 && supportsNativeAudio(provider)) {
|
|
2211
|
+
for (const audio of audioFiles) {
|
|
2212
|
+
// Derived from the trimmed basename so a directory containing a dot
|
|
2213
|
+
// (`/srv/v1.2/recording`) cannot be mistaken for the file's extension.
|
|
2214
|
+
const base = safeBasename(audio.filename);
|
|
2215
|
+
const dot = base.lastIndexOf(".");
|
|
2216
|
+
const extension = dot > 0 ? base.slice(dot) : ".bin";
|
|
2217
|
+
const compatible = await toProviderCompatibleAudio(audio.buffer, audio.mimeType, extension);
|
|
2218
|
+
// A conversion that could not run leaves a container the provider does
|
|
2219
|
+
// not accept. Sending it anyway turns a metadata answer into an opaque
|
|
2220
|
+
// HTTP 400, so it is skipped and the summary stands.
|
|
2221
|
+
if (needsAudioTranscode(compatible.mimeType)) {
|
|
2222
|
+
logger.warn(`[Audio] Skipping native delivery of ${base}: ` +
|
|
2223
|
+
`${compatible.mimeType} is not accepted by ${provider} and could ` +
|
|
2224
|
+
`not be converted. The metadata summary was still included.`);
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
content.push({
|
|
2228
|
+
type: "file",
|
|
2229
|
+
data: compatible.buffer,
|
|
2230
|
+
mediaType: compatible.mimeType,
|
|
2231
|
+
});
|
|
2232
|
+
logger.info(`[Audio] ✅ Added to content (native audio): ${base}` +
|
|
2233
|
+
`${compatible.converted ? ` (converted to ${compatible.mimeType})` : ""}`);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
1951
2236
|
// Check if provider supports native PDF processing
|
|
1952
2237
|
const supportsNativePDF = PDFProcessor.supportsNativePDF(provider);
|
|
1953
2238
|
if (supportsNativePDF) {
|
|
@@ -2163,17 +2448,18 @@ function getFileSource(file) {
|
|
|
2163
2448
|
* 10 KB is far below any real photo, so this affected essentially every image
|
|
2164
2449
|
* attached by path — an ordinary JPEG, not just unusual formats.
|
|
2165
2450
|
*
|
|
2166
|
-
*
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
* ("how long is it?", "what sample
|
|
2175
|
-
*
|
|
2176
|
-
*
|
|
2451
|
+
* Images were only the most visible case. The same reasoning generalises to
|
|
2452
|
+
* every type whose content is not text, which is why the rule below is stated
|
|
2453
|
+
* as an exclusion — preview lazily what a text slice can faithfully represent,
|
|
2454
|
+
* process everything else — rather than as a list of media types. See
|
|
2455
|
+
* {@link isEagerType} for what that costs and buys per modality.
|
|
2456
|
+
*
|
|
2457
|
+
* Audio deserves a note because it is the case most likely to look fine while
|
|
2458
|
+
* being broken: its message carries a metadata block — duration, codec, sample
|
|
2459
|
+
* rate — that answers exactly the questions ("how long is it?", "what sample
|
|
2460
|
+
* rate?") a test is most tempted to ask, and answers them correctly with no
|
|
2461
|
+
* audio whatsoever attached. A test asking those questions passes against a
|
|
2462
|
+
* model that received nothing.
|
|
2177
2463
|
*
|
|
2178
2464
|
* Note this costs no extra memory: `tryRegisterFileReference` already calls
|
|
2179
2465
|
* `getFileBuffer()` and reads the whole file to register it. The lazy path was
|
|
@@ -2182,45 +2468,69 @@ function getFileSource(file) {
|
|
|
2182
2468
|
*/
|
|
2183
2469
|
function isEagerMultimodalFile(file) {
|
|
2184
2470
|
if (typeof file === "string") {
|
|
2185
|
-
return
|
|
2471
|
+
return isEagerType(inferFileTypeFromExtension(file));
|
|
2186
2472
|
}
|
|
2187
2473
|
if (Buffer.isBuffer(file)) {
|
|
2188
|
-
return
|
|
2474
|
+
return isEagerType(inferFileTypeFromBuffer(file));
|
|
2189
2475
|
}
|
|
2190
|
-
// A `FileWithMetadata` carries two independent declarations, and either
|
|
2191
|
-
//
|
|
2476
|
+
// A `FileWithMetadata` carries two independent declarations, and either is
|
|
2477
|
+
// enough on its own: the shape exists for Slack/Curator-style uploads that
|
|
2192
2478
|
// arrive as bytes plus a mimetype, so its `filename` may be extensionless or
|
|
2193
|
-
// simply wrong. Reading them as a `??` chain
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
// is
|
|
2479
|
+
// simply wrong. Reading them as a `??` chain let the first *recognised* name
|
|
2480
|
+
// win outright — `recording.txt` with `mimetype: "audio/mpeg"` classified as
|
|
2481
|
+
// text, stayed lazy, and never reached the native-audio path this change
|
|
2482
|
+
// exists to feed. A file is kept lazy only when nothing about it disagrees.
|
|
2197
2483
|
const declared = [
|
|
2198
2484
|
inferFileTypeFromExtension(file.filename),
|
|
2199
2485
|
inferFileTypeFromMimetype(file.mimetype),
|
|
2200
2486
|
];
|
|
2201
|
-
if (declared.some(
|
|
2487
|
+
if (declared.some(isEagerType)) {
|
|
2202
2488
|
return true;
|
|
2203
2489
|
}
|
|
2204
|
-
// The buffer sniff is
|
|
2205
|
-
// a substring scan: an HTML page with an inline `<svg>` icon in its
|
|
2206
|
-
// would otherwise be pulled onto the eager path and sent in full, which
|
|
2207
|
-
// the opposite of what the size tiers are for. It only
|
|
2208
|
-
//
|
|
2490
|
+
// The buffer sniff is a last resort rather than a third vote, because it is
|
|
2491
|
+
// partly a substring scan: an HTML page with an inline `<svg>` icon in its
|
|
2492
|
+
// head would otherwise be pulled onto the eager path and sent in full, which
|
|
2493
|
+
// is the opposite of what the size tiers are for. It speaks only when neither
|
|
2494
|
+
// declaration did.
|
|
2209
2495
|
return declared.every((type) => type === undefined)
|
|
2210
|
-
?
|
|
2496
|
+
? isEagerType(inferFileTypeFromBuffer(file.buffer))
|
|
2211
2497
|
: false;
|
|
2212
2498
|
}
|
|
2213
2499
|
/**
|
|
2214
|
-
* Whether a routing type
|
|
2500
|
+
* Whether a routing type must be processed rather than previewed.
|
|
2215
2501
|
*
|
|
2216
2502
|
* "svg" is a separate routing type rather than a sub-case of "image" (it goes
|
|
2217
2503
|
* to the sanitizer, not to a vision encoder), but it is still an image as far
|
|
2218
2504
|
* as this decision is concerned: its markup IS its content, and previewing it
|
|
2219
2505
|
* away leaves the model with nothing. Accepting both keeps this correct
|
|
2220
2506
|
* whichever of the two type vocabularies the caller's map uses.
|
|
2507
|
+
*
|
|
2508
|
+
* Audio and video join them, for one shared reason: the lazy path never runs
|
|
2509
|
+
* the detection branch that produces their model-visible content — the decoded
|
|
2510
|
+
* audio buffer, the extracted video keyframes — so a lazily registered file is
|
|
2511
|
+
* summarised away no matter what the dispatch side is willing to send.
|
|
2512
|
+
*
|
|
2513
|
+
* Video is the clearest demonstration that this is a delivery problem and not a
|
|
2514
|
+
* format one. Keyframe extraction is identical across containers (three frames,
|
|
2515
|
+
* ~38 KB each, for every one of mp4/wmv/flv/mpg/m2ts), and a frame pulled from
|
|
2516
|
+
* any of them shows the test token perfectly legibly. Yet only mp4 answered
|
|
2517
|
+
* correctly, because Gemini accepts an mp4 natively and never needed the
|
|
2518
|
+
* frames; every container it cannot decode returned NOTHING_RECEIVED, since the
|
|
2519
|
+
* frames that would have carried the answer were never extracted. Making video
|
|
2520
|
+
* eager fixed all four at once.
|
|
2521
|
+
*
|
|
2522
|
+
* Documents and archives are here for the same reason once removed. The lazy
|
|
2523
|
+
* preview is a truncated slice of the raw bytes, so it is only ever faithful
|
|
2524
|
+
* for a file that IS text. A .rtf sliced raw is RTF control words, and a
|
|
2525
|
+
* .bz2/.xz/.zst is compressed bytes — the model was told "binary file of
|
|
2526
|
+
* unknown type" about files whose processors extract them cleanly.
|
|
2527
|
+
*
|
|
2528
|
+
* Which leaves plain text and CSV on the lazy path, and they are exactly the
|
|
2529
|
+
* cases it was built for: a truncated sample of a large CSV is a faithful
|
|
2530
|
+
* sample, and the file tools can read the rest on demand.
|
|
2221
2531
|
*/
|
|
2222
|
-
function
|
|
2223
|
-
return type
|
|
2532
|
+
function isEagerType(type) {
|
|
2533
|
+
return type !== undefined && type !== "text" && type !== "csv";
|
|
2224
2534
|
}
|
|
2225
2535
|
/**
|
|
2226
2536
|
* Infer a routing type from a caller-declared mimetype.
|
|
@@ -2235,10 +2545,24 @@ function inferFileTypeFromMimetype(mimetype) {
|
|
|
2235
2545
|
return undefined;
|
|
2236
2546
|
}
|
|
2237
2547
|
const normalized = mimetype.split(";")[0].trim().toLowerCase();
|
|
2238
|
-
|
|
2239
|
-
|
|
2548
|
+
// "application/octet-stream" is deliberately absent from the registry side of
|
|
2549
|
+
// this lookup: it is the opaque sentinel a caller sends when it knows
|
|
2550
|
+
// nothing, not a claim about content, and treating it as one would let a
|
|
2551
|
+
// shrugging uploader force a routing decision.
|
|
2552
|
+
if (normalized === "application/octet-stream") {
|
|
2553
|
+
return undefined;
|
|
2240
2554
|
}
|
|
2241
|
-
|
|
2555
|
+
const exact = MIMETYPE_TYPE_MAP[normalized];
|
|
2556
|
+
if (exact) {
|
|
2557
|
+
return exact;
|
|
2558
|
+
}
|
|
2559
|
+
// A family fallback for types the registry does not enumerate — `audio/webm`,
|
|
2560
|
+
// `image/x-something`. The leading segment is enough to decide eager vs lazy
|
|
2561
|
+
// even when the exact codec is unknown to us.
|
|
2562
|
+
const family = normalized.split("/")[0];
|
|
2563
|
+
return family === "image" || family === "audio" || family === "video"
|
|
2564
|
+
? family
|
|
2565
|
+
: undefined;
|
|
2242
2566
|
}
|
|
2243
2567
|
/**
|
|
2244
2568
|
* Try to register a file with the FileReferenceRegistry for lazy processing.
|