@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/adapters/audioFormatSupport.d.ts +53 -0
  3. package/dist/adapters/audioFormatSupport.js +200 -0
  4. package/dist/browser/neurolink.min.js +398 -397
  5. package/dist/lib/adapters/audioFormatSupport.d.ts +53 -0
  6. package/dist/lib/adapters/audioFormatSupport.js +201 -0
  7. package/dist/lib/processors/archive/ArchiveProcessor.d.ts +37 -0
  8. package/dist/lib/processors/archive/ArchiveProcessor.js +347 -32
  9. package/dist/lib/providers/googleAiStudio/client.d.ts +17 -0
  10. package/dist/lib/providers/googleAiStudio/client.js +45 -19
  11. package/dist/lib/providers/googleNativeGemini3/utils.d.ts +22 -1
  12. package/dist/lib/providers/googleNativeGemini3/utils.js +54 -0
  13. package/dist/lib/providers/googleVertex/client.js +3 -0
  14. package/dist/lib/types/file.d.ts +41 -0
  15. package/dist/lib/types/generate.d.ts +12 -1
  16. package/dist/lib/types/processor.d.ts +20 -1
  17. package/dist/lib/types/providers.d.ts +7 -0
  18. package/dist/lib/utils/fileDetector.d.ts +27 -0
  19. package/dist/lib/utils/fileDetector.js +130 -7
  20. package/dist/lib/utils/imageProcessor.js +31 -0
  21. package/dist/lib/utils/messageBuilder.d.ts +0 -9
  22. package/dist/lib/utils/messageBuilder.js +380 -56
  23. package/dist/processors/archive/ArchiveProcessor.d.ts +37 -0
  24. package/dist/processors/archive/ArchiveProcessor.js +347 -32
  25. package/dist/providers/googleAiStudio/client.d.ts +17 -0
  26. package/dist/providers/googleAiStudio/client.js +45 -19
  27. package/dist/providers/googleNativeGemini3/utils.d.ts +22 -1
  28. package/dist/providers/googleNativeGemini3/utils.js +54 -0
  29. package/dist/providers/googleVertex/client.js +3 -0
  30. package/dist/types/file.d.ts +41 -0
  31. package/dist/types/generate.d.ts +12 -1
  32. package/dist/types/processor.d.ts +20 -1
  33. package/dist/types/providers.d.ts +7 -0
  34. package/dist/utils/fileDetector.d.ts +27 -0
  35. package/dist/utils/fileDetector.js +130 -7
  36. package/dist/utils/imageProcessor.js +31 -0
  37. package/dist/utils/messageBuilder.d.ts +0 -9
  38. package/dist/utils/messageBuilder.js +380 -56
  39. 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
- const totalFiles = options.input.files.length;
812
- const files = options.input.files;
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
- // If no images or PDFs, use standard message building and convert to MultimodalChatMessage[]
1227
- if (!hasImages && !hasPDFs) {
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
- userContent = await convertContentToProviderFormat(inp.content, provider, model, options.pdfOptions);
1332
- }
1333
- else if ((inp.images && inp.images.length > 0) || pdfFiles.length > 0) {
1334
- userContent = await convertMultimodalToProviderFormat(inp.text ?? "", inp.images || [], pdfFiles, provider, model);
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
- const hasMultimodal = imageContent.length > 0 || pdfContent.length > 0;
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 (imageContent.length === 0 && pdfContent.length === 0) {
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 "....jP ".
1633
- if (buffer.length >= 8 &&
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
- const compatible = await toVisionCompatibleImage(Buffer.from(imageData, "base64"), mimeType);
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
- return mimeType ? { buffer: payload, mimeType } : undefined;
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
- return match
1820
- ? { buffer: Buffer.from(match[2], "base64"), mimeType: match[1] }
1821
- : undefined;
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
- * 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 blockduration, 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.
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 exclusionpreview 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 isImageLikeType(inferFileTypeFromExtension(file));
2471
+ return isEagerType(inferFileTypeFromExtension(file));
2186
2472
  }
2187
2473
  if (Buffer.isBuffer(file)) {
2188
- return isImageLikeType(inferFileTypeFromBuffer(file));
2474
+ return isEagerType(inferFileTypeFromBuffer(file));
2189
2475
  }
2190
- // A `FileWithMetadata` carries two independent declarations, and either one
2191
- // alone is enough: the shape exists for Slack/Curator-style uploads that
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 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.
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(isImageLikeType)) {
2487
+ if (declared.some(isEagerType)) {
2202
2488
  return true;
2203
2489
  }
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.
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
- ? isImageLikeType(inferFileTypeFromBuffer(file.buffer))
2496
+ ? isEagerType(inferFileTypeFromBuffer(file.buffer))
2211
2497
  : false;
2212
2498
  }
2213
2499
  /**
2214
- * Whether a routing type should have its bytes preserved rather than previewed.
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 isImageLikeType(type) {
2223
- return type === "image" || type === "svg";
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
- if (normalized === "image/svg+xml") {
2239
- return "svg";
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
- return normalized.startsWith("image/") ? "image" : undefined;
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.