@juspay/neurolink 9.94.5 → 9.94.6
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/browser/neurolink.min.js +398 -398
- package/dist/core/modules/MessageBuilder.js +32 -3
- package/dist/lib/core/modules/MessageBuilder.js +32 -3
- package/dist/lib/types/generate.d.ts +1 -0
- package/dist/lib/types/multimodal.d.ts +34 -3
- package/dist/lib/types/stream.d.ts +1 -0
- package/dist/lib/utils/errorHandling.d.ts +22 -0
- package/dist/lib/utils/errorHandling.js +55 -0
- package/dist/lib/utils/fileDetector.js +97 -7
- package/dist/lib/utils/imageProcessor.d.ts +27 -0
- package/dist/lib/utils/imageProcessor.js +87 -6
- package/dist/lib/utils/messageBuilder.js +80 -9
- package/dist/types/generate.d.ts +1 -0
- package/dist/types/multimodal.d.ts +34 -3
- package/dist/types/stream.d.ts +1 -0
- package/dist/utils/errorHandling.d.ts +22 -0
- package/dist/utils/errorHandling.js +55 -0
- package/dist/utils/fileDetector.js +97 -7
- package/dist/utils/imageProcessor.d.ts +27 -0
- package/dist/utils/imageProcessor.js +87 -6
- package/dist/utils/messageBuilder.js +80 -9
- package/package.json +1 -1
|
@@ -5,12 +5,12 @@ import { MultimodalLogger, ProviderImageAdapter, } from "../adapters/providerIma
|
|
|
5
5
|
import { CONVERSATION_INSTRUCTIONS, STRUCTURED_OUTPUT_INSTRUCTIONS, } from "../config/conversationMemory.js";
|
|
6
6
|
import { getAvailableInputTokens } from "../constants/contextWindows.js";
|
|
7
7
|
import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
|
|
8
|
-
import { SIZE_TIER_THRESHOLDS } from "../types/index.js";
|
|
8
|
+
import { isCSVContent, SIZE_TIER_THRESHOLDS } from "../types/index.js";
|
|
9
9
|
import { tracers, ATTR, withSpan } from "../telemetry/index.js";
|
|
10
|
-
import { NeuroLinkError } from "./errorHandling.js";
|
|
10
|
+
import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
|
|
11
11
|
import { FileDetector } from "./fileDetector.js";
|
|
12
12
|
import { getImageCache } from "./imageCache.js";
|
|
13
|
-
import { ImageProcessor } from "./imageProcessor.js";
|
|
13
|
+
import { ImageProcessor, imageUtils } from "./imageProcessor.js";
|
|
14
14
|
import { logger } from "./logger.js";
|
|
15
15
|
import { PDFImageConverter, PDFProcessor } from "./pdfProcessor.js";
|
|
16
16
|
import { urlDownloadRateLimiter } from "./rateLimiter.js";
|
|
@@ -860,7 +860,11 @@ export async function processUnifiedFilesArray(options, maxSize, provider) {
|
|
|
860
860
|
}
|
|
861
861
|
catch (error) {
|
|
862
862
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
863
|
-
|
|
863
|
+
// #273: don't silently drop a failed file — log, then throw so the
|
|
864
|
+
// caller learns the file couldn't be processed (matches the explicit
|
|
865
|
+
// pdf/csv paths' fail-loud behavior).
|
|
866
|
+
logger.error(`[NEUROLINK] File processing failed: ${filename} — reason: ${errMsg}`);
|
|
867
|
+
throw ErrorFactory.fileProcessingFailed(filename, error instanceof Error ? error : new Error(errMsg));
|
|
864
868
|
}
|
|
865
869
|
}
|
|
866
870
|
span.setAttribute(ATTR.FILE_INCLUDED_COUNT, includedCount);
|
|
@@ -935,10 +939,12 @@ async function processExplicitCsvFiles(options) {
|
|
|
935
939
|
logger.info(`[CSV] ✅ Processed: ${filename}`);
|
|
936
940
|
}
|
|
937
941
|
catch (error) {
|
|
938
|
-
logger.error(`[CSV] ❌ Failed:`, error);
|
|
939
942
|
const filename = extractFilename(csvFile, i);
|
|
940
|
-
|
|
941
|
-
|
|
943
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
944
|
+
// #273: fail loud instead of embedding the error into the prompt text
|
|
945
|
+
// (which the model would then "analyze"). Log, then throw.
|
|
946
|
+
logger.error(`[CSV] ❌ Failed to process ${filename}: ${errMsg}`);
|
|
947
|
+
throw ErrorFactory.csvProcessingFailed(filename, error instanceof Error ? error : new Error(errMsg));
|
|
942
948
|
}
|
|
943
949
|
}
|
|
944
950
|
}
|
|
@@ -1067,6 +1073,18 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
|
|
|
1067
1073
|
// local const so TypeScript sees the definite (non-optional) type in the
|
|
1068
1074
|
// rest of this function, avoiding 60+ "possibly undefined" errors.
|
|
1069
1075
|
const inp = options.input;
|
|
1076
|
+
// #284: audioFiles/videoFiles have no dedicated processor — route them
|
|
1077
|
+
// through the same auto-detecting `files` pipeline that already
|
|
1078
|
+
// understands "audio"/"video" FileDetector results (see
|
|
1079
|
+
// appendDetectedFileResult), instead of silently dropping them once
|
|
1080
|
+
// detectMultimodal() routes an audio/video-only request here.
|
|
1081
|
+
if (inp.audioFiles?.length || inp.videoFiles?.length) {
|
|
1082
|
+
inp.files = [
|
|
1083
|
+
...(inp.files || []),
|
|
1084
|
+
...(inp.audioFiles || []),
|
|
1085
|
+
...(inp.videoFiles || []),
|
|
1086
|
+
];
|
|
1087
|
+
}
|
|
1070
1088
|
// Compute provider-specific max PDF size once for consistent validation
|
|
1071
1089
|
const pdfConfig = PDFProcessor.getProviderConfig(provider);
|
|
1072
1090
|
const maxSize = pdfConfig
|
|
@@ -1088,6 +1106,13 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
|
|
|
1088
1106
|
const hasPDFs = pdfFiles.length > 0;
|
|
1089
1107
|
// If no images or PDFs, use standard message building and convert to MultimodalChatMessage[]
|
|
1090
1108
|
if (!hasImages && !hasPDFs) {
|
|
1109
|
+
// #289: CSV content[] items don't need vision, so they never reach the
|
|
1110
|
+
// multimodal converter below — process them into the prompt text here
|
|
1111
|
+
// (otherwise a `content: [{type:"csv"}]`-only request silently drops it).
|
|
1112
|
+
const csvContentItems = inp.content?.filter(isCSVContent) ?? [];
|
|
1113
|
+
if (csvContentItems.length > 0) {
|
|
1114
|
+
inp.text = await appendCsvContentToText(csvContentItems, inp.text ?? "");
|
|
1115
|
+
}
|
|
1091
1116
|
if (inp.csvFiles) {
|
|
1092
1117
|
inp.csvFiles = [];
|
|
1093
1118
|
}
|
|
@@ -1216,6 +1241,47 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
|
|
|
1216
1241
|
throw error;
|
|
1217
1242
|
}
|
|
1218
1243
|
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Timeout for detecting/parsing an in-memory CSV `content[]` buffer (#325
|
|
1246
|
+
* review, round 2). Bounds a stalled detector rather than blocking the
|
|
1247
|
+
* request indefinitely.
|
|
1248
|
+
*/
|
|
1249
|
+
const CSV_CONTENT_DETECTION_TIMEOUT_MS = 30_000;
|
|
1250
|
+
/**
|
|
1251
|
+
* #289: process CSV `content[]` items into appended prompt text. CSV is
|
|
1252
|
+
* delivered to the model as text (like the explicit `csvFiles` path), so this
|
|
1253
|
+
* is shared by both the no-vision gate and the multimodal converter.
|
|
1254
|
+
*/
|
|
1255
|
+
async function appendCsvContentToText(csvItems, baseText) {
|
|
1256
|
+
let text = baseText;
|
|
1257
|
+
for (const csv of csvItems) {
|
|
1258
|
+
const raw = csv.data;
|
|
1259
|
+
if (raw === undefined) {
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
// #325: raw CSV text (e.g. "a,b\n1,2") is not base64 — decoding it as
|
|
1263
|
+
// base64 silently corrupts the content. Only treat the string as base64
|
|
1264
|
+
// when it actually validates as such; otherwise treat it as literal
|
|
1265
|
+
// UTF-8 CSV text, matching how Buffer inputs are already handled as-is.
|
|
1266
|
+
const buffer = typeof raw === "string"
|
|
1267
|
+
? imageUtils.isValidBase64(raw)
|
|
1268
|
+
? Buffer.from(raw, "base64")
|
|
1269
|
+
: Buffer.from(raw, "utf-8")
|
|
1270
|
+
: raw;
|
|
1271
|
+
const name = csv.metadata?.filename || "data.csv";
|
|
1272
|
+
// #325 review (round 2): a stalled/hung detector must not block the
|
|
1273
|
+
// request indefinitely — wrap with the project's standard withTimeout.
|
|
1274
|
+
const result = await withTimeout(FileDetector.detectAndProcess(buffer, {
|
|
1275
|
+
allowedTypes: ["csv"],
|
|
1276
|
+
csvOptions: {
|
|
1277
|
+
maxRows: csv.metadata?.maxRows,
|
|
1278
|
+
formatStyle: csv.metadata?.formatStyle,
|
|
1279
|
+
},
|
|
1280
|
+
}), CSV_CONTENT_DETECTION_TIMEOUT_MS, new Error(`Timed out processing CSV content "${name}" after ${CSV_CONTENT_DETECTION_TIMEOUT_MS}ms`));
|
|
1281
|
+
text += `${text ? "\n\n" : ""}## CSV Data from ${name}\n${result.content}`;
|
|
1282
|
+
}
|
|
1283
|
+
return text;
|
|
1284
|
+
}
|
|
1219
1285
|
/**
|
|
1220
1286
|
* Convert advanced content format to provider-specific format
|
|
1221
1287
|
*/
|
|
@@ -1223,14 +1289,19 @@ async function convertContentToProviderFormat(content, provider, _model) {
|
|
|
1223
1289
|
const textContent = content.find((c) => c.type === "text");
|
|
1224
1290
|
const imageContent = content.filter((c) => c.type === "image");
|
|
1225
1291
|
const pdfContent = content.filter((c) => c.type === "pdf");
|
|
1292
|
+
const csvContent = content.filter(isCSVContent);
|
|
1226
1293
|
// Allow empty text when multimodal content is present (enables image-only or PDF-only queries)
|
|
1227
|
-
|
|
1294
|
+
let text = textContent?.text || "";
|
|
1295
|
+
// #289: CSV content[] items were silently dropped — fold each into the text.
|
|
1296
|
+
if (csvContent.length > 0) {
|
|
1297
|
+
text = await appendCsvContentToText(csvContent, text);
|
|
1298
|
+
}
|
|
1228
1299
|
const hasMultimodal = imageContent.length > 0 || pdfContent.length > 0;
|
|
1229
1300
|
// Validate that we have at least some content
|
|
1230
1301
|
if (!hasMultimodal && !text) {
|
|
1231
1302
|
throw new Error("Content must include either text or multimodal content");
|
|
1232
1303
|
}
|
|
1233
|
-
// Text-only case
|
|
1304
|
+
// Text-only case (CSV has already been folded into `text`)
|
|
1234
1305
|
if (imageContent.length === 0 && pdfContent.length === 0) {
|
|
1235
1306
|
return text;
|
|
1236
1307
|
}
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type GenerateOptions = {
|
|
|
53
53
|
images?: Array<Buffer | string | ImageWithAltText>;
|
|
54
54
|
csvFiles?: Array<Buffer | string>;
|
|
55
55
|
pdfFiles?: Array<Buffer | string>;
|
|
56
|
+
audioFiles?: Array<Buffer | string>;
|
|
56
57
|
videoFiles?: Array<Buffer | string>;
|
|
57
58
|
files?: Array<Buffer | string | FileWithMetadata>;
|
|
58
59
|
content?: Content[];
|
|
@@ -419,15 +419,46 @@ export type MultimodalInput = {
|
|
|
419
419
|
segments?: DirectorSegment[];
|
|
420
420
|
};
|
|
421
421
|
/**
|
|
422
|
-
* Content format for multimodal messages (used internally)
|
|
423
|
-
*
|
|
422
|
+
* Content format for multimodal messages (used internally).
|
|
423
|
+
*
|
|
424
|
+
* #325: the loose `[key: string]: unknown` index signature has been replaced
|
|
425
|
+
* with the concrete fields the codebase actually reads/writes across the
|
|
426
|
+
* text / image / file / tool-call / tool-result shapes. This keeps the broad
|
|
427
|
+
* structural compatibility the internal pipeline relies on (a single object
|
|
428
|
+
* type, not a strict discriminated union that would force narrowing at every
|
|
429
|
+
* consumer) while removing the "any key is allowed" hole that let typos and
|
|
430
|
+
* unrelated keys through unchecked.
|
|
424
431
|
*/
|
|
425
432
|
export type MessageContent = {
|
|
426
433
|
type: string;
|
|
434
|
+
/** Text content (`type: "text"`). */
|
|
427
435
|
text?: string;
|
|
436
|
+
/** Base64 / data-URI image (`type: "image"`). */
|
|
428
437
|
image?: string;
|
|
438
|
+
/** MIME type for image/file parts. */
|
|
429
439
|
mimeType?: string;
|
|
430
|
-
|
|
440
|
+
/** Raw file bytes or base64 (`type: "file"`/document parts). */
|
|
441
|
+
data?: string | Buffer;
|
|
442
|
+
/** File name for document/file parts. */
|
|
443
|
+
name?: string;
|
|
444
|
+
/** File name (alias used by some file parts). */
|
|
445
|
+
filename?: string;
|
|
446
|
+
/** Tool-call identifier (`type: "tool-call"`/`"tool-result"`). */
|
|
447
|
+
toolCallId?: string;
|
|
448
|
+
/** Tool name (`type: "tool-call"`/`"tool-result"`). */
|
|
449
|
+
toolName?: string;
|
|
450
|
+
/** Tool-call arguments (`type: "tool-call"`). */
|
|
451
|
+
args?: Record<string, unknown>;
|
|
452
|
+
/** Tool-result payload (`type: "tool-result"`). */
|
|
453
|
+
result?: unknown;
|
|
454
|
+
/** Whether a tool-result represents an error (`type: "tool-result"`). */
|
|
455
|
+
isError?: boolean;
|
|
456
|
+
/**
|
|
457
|
+
* Provider-specific per-block options (e.g. Anthropic cache_control).
|
|
458
|
+
* Read as `item.providerOptions` when converting `MessageContent[]` to
|
|
459
|
+
* `ModelMessage[]` in `MessageBuilder.ts`.
|
|
460
|
+
*/
|
|
461
|
+
providerOptions?: Record<string, unknown>;
|
|
431
462
|
};
|
|
432
463
|
/**
|
|
433
464
|
* Extended chat message for multimodal support (internal use)
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -193,6 +193,7 @@ export type StreamOptions = {
|
|
|
193
193
|
images?: Array<Buffer | string | ImageWithAltText>;
|
|
194
194
|
csvFiles?: Array<Buffer | string>;
|
|
195
195
|
pdfFiles?: Array<Buffer | string>;
|
|
196
|
+
audioFiles?: Array<Buffer | string>;
|
|
196
197
|
videoFiles?: Array<Buffer | string>;
|
|
197
198
|
files?: Array<Buffer | string | FileWithMetadata>;
|
|
198
199
|
content?: Content[];
|
|
@@ -35,6 +35,9 @@ export declare const ERROR_CODES: {
|
|
|
35
35
|
readonly IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL";
|
|
36
36
|
readonly INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT";
|
|
37
37
|
readonly INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE";
|
|
38
|
+
readonly IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID";
|
|
39
|
+
readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
|
|
40
|
+
readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
|
|
38
41
|
readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
|
|
39
42
|
readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
|
|
40
43
|
readonly RATE_LIMITER_QUEUE_TIMEOUT: "RATE_LIMITER_QUEUE_TIMEOUT";
|
|
@@ -216,6 +219,13 @@ export declare class ErrorFactory {
|
|
|
216
219
|
* Create an invalid image format error
|
|
217
220
|
*/
|
|
218
221
|
static invalidImageFormat(): NeuroLinkError;
|
|
222
|
+
/**
|
|
223
|
+
* Create an image buffer validation error: empty, undersized, or a
|
|
224
|
+
* truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
|
|
225
|
+
* Takes the fully-formed message so call sites keep their specific
|
|
226
|
+
* byte-count detail instead of a fixed generic message.
|
|
227
|
+
*/
|
|
228
|
+
static imageBufferInvalid(message: string): NeuroLinkError;
|
|
219
229
|
/**
|
|
220
230
|
* Create a rate limiter queue full error
|
|
221
231
|
*/
|
|
@@ -287,6 +297,18 @@ export declare class ErrorFactory {
|
|
|
287
297
|
* generic `Error` instances.
|
|
288
298
|
*/
|
|
289
299
|
static proxyWorkerLifecycle(message: string, context?: Record<string, unknown>): NeuroLinkError;
|
|
300
|
+
/**
|
|
301
|
+
* Create a generic file-processing-failed error (e.g. an unrecognized or
|
|
302
|
+
* corrupt file in `processUnifiedFilesArray`). Preserves the original error
|
|
303
|
+
* as `originalError` (stack + message copied onto the new error's context).
|
|
304
|
+
*/
|
|
305
|
+
static fileProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
|
|
306
|
+
/**
|
|
307
|
+
* Create a generic CSV-processing-failed error (explicit `csvFiles` path,
|
|
308
|
+
* distinct from `fileProcessingFailed` so callers can classify CSV-specific
|
|
309
|
+
* failures separately). Preserves the original error as `originalError`.
|
|
310
|
+
*/
|
|
311
|
+
static csvProcessingFailed(filename: string, originalError: Error): NeuroLinkError;
|
|
290
312
|
}
|
|
291
313
|
/**
|
|
292
314
|
* Timeout wrapper for async operations
|
|
@@ -46,6 +46,10 @@ export const ERROR_CODES = {
|
|
|
46
46
|
IMAGE_TOO_SMALL: "IMAGE_TOO_SMALL",
|
|
47
47
|
INVALID_IMAGE_FORMAT: "INVALID_IMAGE_FORMAT",
|
|
48
48
|
INVALID_IMAGE_SIZE: "INVALID_IMAGE_SIZE",
|
|
49
|
+
IMAGE_BUFFER_INVALID: "IMAGE_BUFFER_INVALID",
|
|
50
|
+
// Generic file/CSV processing errors
|
|
51
|
+
FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED",
|
|
52
|
+
CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
|
|
49
53
|
// PDF validation errors
|
|
50
54
|
PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
|
|
51
55
|
// Rate limiter errors
|
|
@@ -627,6 +631,22 @@ export class ErrorFactory {
|
|
|
627
631
|
},
|
|
628
632
|
});
|
|
629
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Create an image buffer validation error: empty, undersized, or a
|
|
636
|
+
* truncated buffer detected by `ImageProcessor.validateBufferNotEmpty()`.
|
|
637
|
+
* Takes the fully-formed message so call sites keep their specific
|
|
638
|
+
* byte-count detail instead of a fixed generic message.
|
|
639
|
+
*/
|
|
640
|
+
static imageBufferInvalid(message) {
|
|
641
|
+
return new NeuroLinkError({
|
|
642
|
+
code: ERROR_CODES.IMAGE_BUFFER_INVALID,
|
|
643
|
+
message,
|
|
644
|
+
category: ErrorCategory.VALIDATION,
|
|
645
|
+
severity: ErrorSeverity.MEDIUM,
|
|
646
|
+
retriable: false,
|
|
647
|
+
context: { field: "input.images" },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
630
650
|
// ============================================================================
|
|
631
651
|
// RATE LIMITER ERRORS
|
|
632
652
|
// ============================================================================
|
|
@@ -924,6 +944,41 @@ export class ErrorFactory {
|
|
|
924
944
|
context: context || {},
|
|
925
945
|
});
|
|
926
946
|
}
|
|
947
|
+
// ============================================================================
|
|
948
|
+
// GENERIC FILE / CSV PROCESSING ERRORS
|
|
949
|
+
// ============================================================================
|
|
950
|
+
/**
|
|
951
|
+
* Create a generic file-processing-failed error (e.g. an unrecognized or
|
|
952
|
+
* corrupt file in `processUnifiedFilesArray`). Preserves the original error
|
|
953
|
+
* as `originalError` (stack + message copied onto the new error's context).
|
|
954
|
+
*/
|
|
955
|
+
static fileProcessingFailed(filename, originalError) {
|
|
956
|
+
return new NeuroLinkError({
|
|
957
|
+
code: ERROR_CODES.FILE_PROCESSING_FAILED,
|
|
958
|
+
message: `Failed to process file "${filename}": ${originalError.message}`,
|
|
959
|
+
category: ErrorCategory.EXECUTION,
|
|
960
|
+
severity: ErrorSeverity.HIGH,
|
|
961
|
+
retriable: false,
|
|
962
|
+
context: { filename },
|
|
963
|
+
originalError,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Create a generic CSV-processing-failed error (explicit `csvFiles` path,
|
|
968
|
+
* distinct from `fileProcessingFailed` so callers can classify CSV-specific
|
|
969
|
+
* failures separately). Preserves the original error as `originalError`.
|
|
970
|
+
*/
|
|
971
|
+
static csvProcessingFailed(filename, originalError) {
|
|
972
|
+
return new NeuroLinkError({
|
|
973
|
+
code: ERROR_CODES.CSV_PROCESSING_FAILED,
|
|
974
|
+
message: `Failed to process CSV file "${filename}": ${originalError.message}`,
|
|
975
|
+
category: ErrorCategory.EXECUTION,
|
|
976
|
+
severity: ErrorSeverity.HIGH,
|
|
977
|
+
retriable: false,
|
|
978
|
+
context: { filename },
|
|
979
|
+
originalError,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
927
982
|
}
|
|
928
983
|
/**
|
|
929
984
|
* Timeout wrapper for async operations
|
|
@@ -24,6 +24,7 @@ import { tracers, ATTR, withSpan } from "../telemetry/index.js";
|
|
|
24
24
|
import { CSVProcessor } from "./csvProcessor.js";
|
|
25
25
|
import { ImageProcessor } from "./imageProcessor.js";
|
|
26
26
|
import { logger } from "./logger.js";
|
|
27
|
+
import { withTimeout } from "./errorHandling.js";
|
|
27
28
|
import { redactUrlForError, sanitizeErrorCause } from "./logSanitize.js";
|
|
28
29
|
import { mimeHintToExtension, mimeHintToFileType, normalizeMimeHint, } from "./mimeTypeHints.js";
|
|
29
30
|
import { PDFProcessor } from "./pdfProcessor.js";
|
|
@@ -32,6 +33,80 @@ import { PDFProcessor } from "./pdfProcessor.js";
|
|
|
32
33
|
*/
|
|
33
34
|
const DEFAULT_MAX_RETRIES = 3;
|
|
34
35
|
const DEFAULT_RETRY_DELAY = 1000; // milliseconds
|
|
36
|
+
/**
|
|
37
|
+
* Short-TTL cache of URL → Content-Type (#323). A URL is commonly detected more
|
|
38
|
+
* than once (repeated multimodal prompts reuse the same asset URL); caching the
|
|
39
|
+
* HEAD's content-type avoids re-issuing the HEAD each time. `loadFromURL` also
|
|
40
|
+
* populates it from its GET response, so once a URL's body has been fetched a
|
|
41
|
+
* subsequent detection needs no network round-trip at all.
|
|
42
|
+
*
|
|
43
|
+
* Trade-off (round-2 review): this is a module-level cache shared by every
|
|
44
|
+
* request in the process, and correctness relies solely on the 60s TTL — a
|
|
45
|
+
* signed URL whose response changes at the same path within that window would
|
|
46
|
+
* read stale. That is an intentional, bounded trade-off (60s of possible
|
|
47
|
+
* staleness for far fewer HEAD round-trips), not a freshness guarantee.
|
|
48
|
+
* Expired entries are removed lazily on their next `get()` (see
|
|
49
|
+
* `getCachedUrlContentType`); `setCachedUrlContentType` additionally sweeps
|
|
50
|
+
* expired entries opportunistically once the cache hits its size cap, so a
|
|
51
|
+
* URL that is cached once and never looked up again doesn't linger until the
|
|
52
|
+
* FIFO eviction below forces it out.
|
|
53
|
+
*/
|
|
54
|
+
const URL_CONTENT_TYPE_TTL_MS = 60_000;
|
|
55
|
+
const URL_CONTENT_TYPE_CACHE_MAX_SIZE = 512;
|
|
56
|
+
const urlContentTypeCache = new Map();
|
|
57
|
+
function getCachedUrlContentType(url, now) {
|
|
58
|
+
const hit = urlContentTypeCache.get(url);
|
|
59
|
+
if (hit && hit.expiresAt > now) {
|
|
60
|
+
// Bump recency: Map iteration order follows insertion order, and the
|
|
61
|
+
// eviction below deletes the *first* key, so a plain `get` on a hot
|
|
62
|
+
// entry would leave it first in line for eviction despite being the
|
|
63
|
+
// most recently used. Re-inserting turns the size-bounded FIFO below
|
|
64
|
+
// into an actual LRU.
|
|
65
|
+
urlContentTypeCache.delete(url);
|
|
66
|
+
urlContentTypeCache.set(url, hit);
|
|
67
|
+
return hit.contentType;
|
|
68
|
+
}
|
|
69
|
+
if (hit) {
|
|
70
|
+
// Entry exists but its TTL has passed — treat as a miss and evict it
|
|
71
|
+
// immediately rather than serving (or retaining) stale data.
|
|
72
|
+
urlContentTypeCache.delete(url);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Opportunistically remove already-expired entries. Only called once the
|
|
78
|
+
* cache is at its size cap (see `setCachedUrlContentType`) so it doesn't add
|
|
79
|
+
* an O(n) scan to the common-case hot path.
|
|
80
|
+
*/
|
|
81
|
+
function pruneExpiredUrlContentTypeEntries(now) {
|
|
82
|
+
for (const [key, entry] of urlContentTypeCache) {
|
|
83
|
+
if (entry.expiresAt <= now) {
|
|
84
|
+
urlContentTypeCache.delete(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function setCachedUrlContentType(url, contentType, now) {
|
|
89
|
+
if (!contentType) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
urlContentTypeCache.set(url, {
|
|
93
|
+
contentType,
|
|
94
|
+
expiresAt: now + URL_CONTENT_TYPE_TTL_MS,
|
|
95
|
+
});
|
|
96
|
+
// Bound the cache so a long-lived process can't grow it unbounded. Prefer
|
|
97
|
+
// reclaiming already-expired entries first; only fall back to evicting the
|
|
98
|
+
// oldest still-live entry (FIFO/LRU-ish, see getCachedUrlContentType) if
|
|
99
|
+
// the cache is still over the cap after pruning.
|
|
100
|
+
if (urlContentTypeCache.size > URL_CONTENT_TYPE_CACHE_MAX_SIZE) {
|
|
101
|
+
pruneExpiredUrlContentTypeEntries(now);
|
|
102
|
+
}
|
|
103
|
+
if (urlContentTypeCache.size > URL_CONTENT_TYPE_CACHE_MAX_SIZE) {
|
|
104
|
+
const oldest = urlContentTypeCache.keys().next().value;
|
|
105
|
+
if (oldest !== undefined) {
|
|
106
|
+
urlContentTypeCache.delete(oldest);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
35
110
|
/**
|
|
36
111
|
* Retryable network error codes (Node.js/undici network errors)
|
|
37
112
|
*/
|
|
@@ -1351,6 +1426,9 @@ export class FileDetector {
|
|
|
1351
1426
|
// not be echoed into a thrown error.
|
|
1352
1427
|
throw new Error(`HTTP ${response.statusCode} fetching ${redactUrlForError(url)}`);
|
|
1353
1428
|
}
|
|
1429
|
+
// #323: cache the Content-Type from this GET so a subsequent detection
|
|
1430
|
+
// of the same URL needs no HEAD.
|
|
1431
|
+
setCachedUrlContentType(url, response.headers["content-type"] || "", Date.now());
|
|
1354
1432
|
const chunks = [];
|
|
1355
1433
|
let totalSize = 0;
|
|
1356
1434
|
for await (const chunk of response.body) {
|
|
@@ -1698,13 +1776,25 @@ class MimeTypeStrategy {
|
|
|
1698
1776
|
return this.unknown();
|
|
1699
1777
|
}
|
|
1700
1778
|
try {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1779
|
+
// #323: reuse a recently-seen Content-Type for this URL instead of
|
|
1780
|
+
// re-issuing a HEAD (populated here and by loadFromURL's GET).
|
|
1781
|
+
const now = Date.now();
|
|
1782
|
+
let contentType = getCachedUrlContentType(input, now);
|
|
1783
|
+
if (contentType === undefined) {
|
|
1784
|
+
// Wrap the whole HEAD (request + body drain) in withTimeout so a stalled
|
|
1785
|
+
// dump() can't hang detection, per the project's async-timeout guideline.
|
|
1786
|
+
contentType = await withTimeout((async () => {
|
|
1787
|
+
const response = await request(input, {
|
|
1788
|
+
dispatcher: getGlobalDispatcher().compose(interceptors.redirect({ maxRedirections: 5 })),
|
|
1789
|
+
method: "HEAD",
|
|
1790
|
+
headersTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
|
|
1791
|
+
bodyTimeout: FileDetector.DEFAULT_HEAD_TIMEOUT,
|
|
1792
|
+
});
|
|
1793
|
+
await response.body.dump();
|
|
1794
|
+
return response.headers["content-type"] || "";
|
|
1795
|
+
})(), FileDetector.DEFAULT_HEAD_TIMEOUT);
|
|
1796
|
+
setCachedUrlContentType(input, contentType, now);
|
|
1797
|
+
}
|
|
1708
1798
|
const type = this.mimeToFileType(contentType);
|
|
1709
1799
|
return {
|
|
1710
1800
|
type,
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { redactPathFromMessage } from "./logSanitize.js";
|
|
6
6
|
import type { ProcessedImage, FileProcessingResult } from "../types/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* Minimum bytes required just to detect an image format from magic bytes (#293).
|
|
9
|
+
*/
|
|
10
|
+
export declare const MIN_IMAGE_SIZE = 12;
|
|
11
|
+
/**
|
|
12
|
+
* Minimum plausible byte size for a non-empty image of each format (#293). A
|
|
13
|
+
* buffer that carries a format's magic bytes but is smaller than this is
|
|
14
|
+
* truncated/corrupt and would fail (or render blank) downstream — reject early
|
|
15
|
+
* with a clear message instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MIN_VALID_IMAGE_SIZE: Record<string, number>;
|
|
7
18
|
/**
|
|
8
19
|
* Image processor class for handling provider-specific image formatting
|
|
9
20
|
*/
|
|
@@ -17,6 +28,22 @@ export declare class ImageProcessor {
|
|
|
17
28
|
* @returns Processed image as data URI
|
|
18
29
|
*/
|
|
19
30
|
static process(content: Buffer, _options?: unknown): Promise<FileProcessingResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Strict magic-byte match: does `content` actually begin with `mediaType`'s
|
|
33
|
+
* signature? `detectImageType` returns `image/jpeg` as a *fallback* for
|
|
34
|
+
* unrecognized data, so a per-format minimum must only be enforced when the
|
|
35
|
+
* bytes genuinely match — otherwise valid BMP/TIFF/unknown buffers would be
|
|
36
|
+
* wrongly rejected as "truncated jpeg" (#293 review).
|
|
37
|
+
*/
|
|
38
|
+
private static hasStrictImageMagic;
|
|
39
|
+
/**
|
|
40
|
+
* Validate that a buffer is a plausibly-complete image (#293): non-empty and,
|
|
41
|
+
* for a strictly-identified raster format, at least the minimum plausible byte
|
|
42
|
+
* size. Returns the detected media type.
|
|
43
|
+
*
|
|
44
|
+
* @throws Error when the buffer is empty or a recognized format is truncated.
|
|
45
|
+
*/
|
|
46
|
+
private static validateBufferNotEmpty;
|
|
20
47
|
/**
|
|
21
48
|
* Validate processed output meets required format
|
|
22
49
|
* Checks:
|
|
@@ -11,6 +11,23 @@ import { SYSTEM_LIMITS } from "../core/constants.js";
|
|
|
11
11
|
import { SIZE_LIMITS_BYTES } from "../processors/config/sizeLimits.js";
|
|
12
12
|
import { getImageCache } from "./imageCache.js";
|
|
13
13
|
import { ErrorFactory, NeuroLinkError } from "./errorHandling.js";
|
|
14
|
+
/**
|
|
15
|
+
* Minimum bytes required just to detect an image format from magic bytes (#293).
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_IMAGE_SIZE = 12;
|
|
18
|
+
/**
|
|
19
|
+
* Minimum plausible byte size for a non-empty image of each format (#293). A
|
|
20
|
+
* buffer that carries a format's magic bytes but is smaller than this is
|
|
21
|
+
* truncated/corrupt and would fail (or render blank) downstream — reject early
|
|
22
|
+
* with a clear message instead.
|
|
23
|
+
*/
|
|
24
|
+
export const MIN_VALID_IMAGE_SIZE = {
|
|
25
|
+
"image/png": 67,
|
|
26
|
+
"image/jpeg": 125,
|
|
27
|
+
"image/gif": 43,
|
|
28
|
+
"image/webp": 20,
|
|
29
|
+
"image/avif": 100,
|
|
30
|
+
};
|
|
14
31
|
/**
|
|
15
32
|
* Network error codes that should trigger a retry
|
|
16
33
|
*/
|
|
@@ -92,12 +109,9 @@ export class ImageProcessor {
|
|
|
92
109
|
* @returns Processed image as data URI
|
|
93
110
|
*/
|
|
94
111
|
static async process(content, _options) {
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new Error("Invalid image processing: buffer is empty");
|
|
99
|
-
}
|
|
100
|
-
const mediaType = this.detectImageType(content);
|
|
112
|
+
// #293: reject empty AND small/truncated buffers (not just 0 bytes) before
|
|
113
|
+
// processing; returns the detected media type.
|
|
114
|
+
const mediaType = this.validateBufferNotEmpty(content);
|
|
101
115
|
// #261/#286 follow-up: fail loud instead of packaging the octet-stream
|
|
102
116
|
// sentinel as a "valid" image (see assertKnownImageType() above).
|
|
103
117
|
assertKnownImageType(mediaType);
|
|
@@ -115,6 +129,73 @@ export class ImageProcessor {
|
|
|
115
129
|
},
|
|
116
130
|
};
|
|
117
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Strict magic-byte match: does `content` actually begin with `mediaType`'s
|
|
134
|
+
* signature? `detectImageType` returns `image/jpeg` as a *fallback* for
|
|
135
|
+
* unrecognized data, so a per-format minimum must only be enforced when the
|
|
136
|
+
* bytes genuinely match — otherwise valid BMP/TIFF/unknown buffers would be
|
|
137
|
+
* wrongly rejected as "truncated jpeg" (#293 review).
|
|
138
|
+
*/
|
|
139
|
+
static hasStrictImageMagic(content, mediaType) {
|
|
140
|
+
const b = content;
|
|
141
|
+
switch (mediaType) {
|
|
142
|
+
case "image/png":
|
|
143
|
+
return (b.length >= 4 &&
|
|
144
|
+
b[0] === 0x89 &&
|
|
145
|
+
b[1] === 0x50 &&
|
|
146
|
+
b[2] === 0x4e &&
|
|
147
|
+
b[3] === 0x47);
|
|
148
|
+
case "image/jpeg":
|
|
149
|
+
return b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
|
|
150
|
+
case "image/gif":
|
|
151
|
+
return b.length >= 3 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46;
|
|
152
|
+
case "image/webp":
|
|
153
|
+
return (b.length >= 12 &&
|
|
154
|
+
b.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
155
|
+
b.subarray(8, 12).toString("latin1") === "WEBP");
|
|
156
|
+
case "image/avif":
|
|
157
|
+
return (b.length >= 12 &&
|
|
158
|
+
b.subarray(4, 8).toString("latin1") === "ftyp" &&
|
|
159
|
+
b.subarray(8, 12).toString("latin1").startsWith("avif"));
|
|
160
|
+
default:
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Validate that a buffer is a plausibly-complete image (#293): non-empty and,
|
|
166
|
+
* for a strictly-identified raster format, at least the minimum plausible byte
|
|
167
|
+
* size. Returns the detected media type.
|
|
168
|
+
*
|
|
169
|
+
* @throws Error when the buffer is empty or a recognized format is truncated.
|
|
170
|
+
*/
|
|
171
|
+
static validateBufferNotEmpty(content) {
|
|
172
|
+
if (content.length === 0) {
|
|
173
|
+
logger.error("Empty buffer provided");
|
|
174
|
+
throw ErrorFactory.imageBufferInvalid("Invalid image processing: buffer is empty");
|
|
175
|
+
}
|
|
176
|
+
const mediaType = this.detectImageType(content);
|
|
177
|
+
// SVG is text and can be legitimately tiny (e.g. `<svg/>`), so the raster
|
|
178
|
+
// size floors don't apply to it (#293 review).
|
|
179
|
+
if (mediaType === "image/svg+xml") {
|
|
180
|
+
return mediaType;
|
|
181
|
+
}
|
|
182
|
+
// A buffer too small to carry any raster header is not a usable image.
|
|
183
|
+
if (content.length < MIN_IMAGE_SIZE) {
|
|
184
|
+
throw ErrorFactory.imageBufferInvalid(`Invalid image processing: buffer too small (${content.length} bytes) — ` +
|
|
185
|
+
`a minimum of ${MIN_IMAGE_SIZE} bytes is required for a valid image.`);
|
|
186
|
+
}
|
|
187
|
+
// Only enforce a per-format minimum when the format was strictly identified
|
|
188
|
+
// from magic bytes (never from detectImageType's jpeg fallback), so valid
|
|
189
|
+
// BMP/TIFF/unknown buffers are not mis-rejected as "truncated jpeg".
|
|
190
|
+
const minForFormat = MIN_VALID_IMAGE_SIZE[mediaType];
|
|
191
|
+
if (minForFormat !== undefined &&
|
|
192
|
+
content.length < minForFormat &&
|
|
193
|
+
this.hasStrictImageMagic(content, mediaType)) {
|
|
194
|
+
throw ErrorFactory.imageBufferInvalid(`Invalid image processing: ${mediaType} buffer is truncated ` +
|
|
195
|
+
`(${content.length} bytes, minimum ${minForFormat} for a valid ${mediaType}).`);
|
|
196
|
+
}
|
|
197
|
+
return mediaType;
|
|
198
|
+
}
|
|
118
199
|
/**
|
|
119
200
|
* Validate processed output meets required format
|
|
120
201
|
* Checks:
|