@juspay/neurolink 12.28.1 → 12.28.2

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.
@@ -32,6 +32,45 @@ export type VisionImageConversion = {
32
32
  /** True when the bytes were re-encoded; false when they were left alone. */
33
33
  readonly converted: boolean;
34
34
  };
35
+ /**
36
+ * Knobs for one vision-compatibility pass.
37
+ *
38
+ * `autoOrient` exists so a caller that also wants EXIF orientation applied
39
+ * gets it inside the same decode. Rotating first and transcoding second means
40
+ * two full decode/re-encode cycles, and for the lossy formats in both sets
41
+ * (TIFF, which sharp writes JPEG-compressed by default) the intermediate
42
+ * re-encode costs a generation of image quality that the model then reads.
43
+ *
44
+ * `outputFormat` picks the transcode target; see {@link VisionImageOutputFormat}
45
+ * for the default and why it stays PNG unless a caller opts into JPEG.
46
+ */
47
+ export type VisionTranscodeOptions = {
48
+ readonly autoOrient?: boolean;
49
+ readonly outputFormat?: VisionImageOutputFormat;
50
+ };
51
+ /**
52
+ * What a bounded header probe could establish about an image's EXIF
53
+ * orientation tag, without decoding the image or reading all of its bytes.
54
+ *
55
+ * `"inconclusive"` is not a failure — it is the honest answer whenever the
56
+ * prefix ran out before the parse reached a verdict, or the container is one
57
+ * this probe does not parse. Callers must treat it as "find out the expensive
58
+ * way", never as "no tag".
59
+ */
60
+ export type ExifOrientationProbe = "absent" | "present" | "inconclusive";
61
+ /**
62
+ * Outcome of an EXIF-orientation pass over one image.
63
+ *
64
+ * See `adapters/imageFormatSupport.ts` — `normalized` is false both when the
65
+ * image carried no orientation tag (or an already-upright `1`) and when the
66
+ * pass could not run (sharp unavailable, decode failure), so callers must not
67
+ * treat it as a success flag; either way `buffer` is safe to send as-is.
68
+ */
69
+ export type ImageOrientationNormalization = {
70
+ readonly buffer: Buffer;
71
+ /** True when the bytes were re-encoded to apply and strip EXIF orientation. */
72
+ readonly normalized: boolean;
73
+ };
35
74
  /**
36
75
  * Outcome of an audio-compatibility pass over one file.
37
76
  *
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "fs";
2
- import { readFile as readFileAsync, stat as statAsync } from "fs/promises";
2
+ import { open as openAsync, readFile as readFileAsync, stat as statAsync, } from "fs/promises";
3
3
  import pLimit from "p-limit";
4
4
  import { request } from "undici";
5
5
  import { redirectFollowingDispatcher } from "./redirectDispatcher.js";
@@ -10,7 +10,7 @@ import { PDF_LIMITS } from "../core/constants.js";
10
10
  import { enforceAggregateFileBudget, FILE_READ_BUDGET_PERCENT, } from "../context/fileTokenBudget.js";
11
11
  import { isCSVContent, isTextContent, SIZE_TIER_THRESHOLDS, } from "../types/index.js";
12
12
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
13
- import { needsVisionTranscode, toVisionCompatibleImage, } from "../adapters/imageFormatSupport.js";
13
+ import { EXIF_PROBE_PREFIX_BYTES, mayCarryExifOrientation, needsVisionTranscode, normalizeImageOrientation, probeExifOrientation, toVisionCompatibleImage, } from "../adapters/imageFormatSupport.js";
14
14
  import { FILE_TYPE_REGISTRY, lookupByExtension, } from "../processors/config/fileTypeRegistry.js";
15
15
  import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
16
16
  import { FileDetector } from "./fileDetector.js";
@@ -2007,12 +2007,17 @@ async function processImageToBase64(image, index, outputFormat) {
2007
2007
  ImageProcessor.validateBufferSize(image, `image input at index ${index}`);
2008
2008
  imageData = image.toString("base64");
2009
2009
  }
2010
- // Last line of defence for vision-format compatibility. `normalizeVisionImageFormats`
2011
- // handles `input.images` eagerly so the providers that read that array
2012
- // directly (Google AI Studio, Bedrock) see converted bytes, but images that
2013
- // arrive as URLs are downloaded further downstream and only become bytes
2014
- // here. A no-op for the universal formats, so the common path is unaffected.
2015
- if (needsVisionTranscode(mimeType)) {
2010
+ // Last line of defence for vision-format compatibility AND orientation.
2011
+ // `normalizeVisionImageFormats` handles `input.images` eagerly so the
2012
+ // providers that read that array directly (Google AI Studio, Bedrock) see
2013
+ // converted/oriented bytes, but images that arrive as URLs are downloaded
2014
+ // further downstream and only become bytes here. A no-op for PNG/GIF (which
2015
+ // cannot carry an orientation tag) already sent as-is, so the common
2016
+ // no-EXIF case is unaffected.
2017
+ const needsOrientationCheck = mayCarryExifOrientation(mimeType);
2018
+ const needsTranscode = needsVisionTranscode(mimeType);
2019
+ const context = `image input at index ${index}`;
2020
+ if (needsTranscode) {
2016
2021
  // Guard the decoded bytes, not just the Buffer input. The buffer branch
2017
2022
  // above is already checked, but a data: URI reaches here having only been
2018
2023
  // regex-matched — so an oversized one was handed straight to sharp/ffmpeg,
@@ -2020,15 +2025,41 @@ async function processImageToBase64(image, index, outputFormat) {
2020
2025
  //
2021
2026
  // Sized before the decode rather than after: checking the Buffer would mean
2022
2027
  // allocating the very thing the limit exists to refuse.
2023
- const context = `image input at index ${index}`;
2024
2028
  ImageProcessor.validateSize(base64DecodedByteLength(imageData), context);
2025
2029
  const rawImage = Buffer.from(imageData, "base64");
2026
- const compatible = await toVisionCompatibleImage(rawImage, mimeType, outputFormat);
2030
+ // Both passes need the same decode, so a format in both sets (HEIC,
2031
+ // HEIF, TIFF, AVIF) gets one pipeline rather than two — see
2032
+ // `toVisionCompatibleImage`'s `autoOrient`.
2033
+ const compatible = await toVisionCompatibleImage(rawImage, mimeType, {
2034
+ autoOrient: needsOrientationCheck,
2035
+ outputFormat,
2036
+ });
2027
2037
  if (compatible.converted) {
2028
2038
  imageData = compatible.buffer.toString("base64");
2029
2039
  mimeType = compatible.mimeType;
2030
2040
  }
2031
2041
  }
2042
+ else if (needsOrientationCheck) {
2043
+ // Ask a bounded header prefix whether this image needs touching at all
2044
+ // before paying for the size guard or a full decode. PNG/JPEG/WebP were
2045
+ // previously gated on `needsTranscode` alone — false for all three — so
2046
+ // this MIME type reached the provider with no local size check
2047
+ // whatsoever. A large image with no orientation tag is never going to be
2048
+ // modified by this pass, and must keep reaching the provider exactly as
2049
+ // it did before rather than being rejected locally for a limit that
2050
+ // exists to bound a decode nothing here needs.
2051
+ if (probeBase64ExifOrientation(imageData, mimeType) !== "absent") {
2052
+ ImageProcessor.validateSize(base64DecodedByteLength(imageData), context);
2053
+ const rawImage = Buffer.from(imageData, "base64");
2054
+ const oriented = await normalizeImageOrientation(rawImage, mimeType);
2055
+ // Re-encoding changes byte length. An oversized result falls back to
2056
+ // the already-validated original, matching normalizeVisionImageFormats.
2057
+ if (oriented.normalized &&
2058
+ withinConversionLimit(oriented.buffer, `oriented ${context}`)) {
2059
+ imageData = oriented.buffer.toString("base64");
2060
+ }
2061
+ }
2062
+ }
2032
2063
  return { imageData, mimeType };
2033
2064
  }
2034
2065
  /**
@@ -2058,14 +2089,46 @@ export async function normalizeVisionImageFormats(input, outputFormat) {
2058
2089
  ? entry.data
2059
2090
  : entry;
2060
2091
  const source = await readImageSourceForConversion(payload);
2061
- if (!source || !needsVisionTranscode(source.mimeType)) {
2092
+ if (!source ||
2093
+ (!needsVisionTranscode(source.mimeType) &&
2094
+ !mayCarryExifOrientation(source.mimeType))) {
2062
2095
  continue;
2063
2096
  }
2064
- const compatible = await toVisionCompatibleImage(source.buffer, source.mimeType, outputFormat);
2065
- if (!compatible.converted) {
2097
+ let buffer = source.buffer;
2098
+ let mimeType = source.mimeType;
2099
+ let changed = false;
2100
+ const orientable = mayCarryExifOrientation(mimeType);
2101
+ if (needsVisionTranscode(mimeType)) {
2102
+ // A format in both sets (HEIC, HEIF, TIFF, AVIF) is oriented inside the
2103
+ // transcode's own decode instead of ahead of it. Two sequential passes
2104
+ // cost two decodes and two encodes, and the intermediate re-encode is
2105
+ // lossy for some of these formats — so the model would read an image a
2106
+ // generation worse than the one it could have been sent.
2107
+ const compatible = await toVisionCompatibleImage(buffer, mimeType, {
2108
+ autoOrient: orientable,
2109
+ outputFormat,
2110
+ });
2111
+ if (compatible.converted) {
2112
+ buffer = compatible.buffer;
2113
+ mimeType = compatible.mimeType;
2114
+ changed = true;
2115
+ }
2116
+ }
2117
+ else if (orientable) {
2118
+ const oriented = await normalizeImageOrientation(buffer, mimeType);
2119
+ // Re-encoding changes byte length, so the size guard that gated the
2120
+ // read above must run again on the result — a re-encode that grew past
2121
+ // the limit must not silently replace an under-limit original.
2122
+ if (oriented.normalized &&
2123
+ withinConversionLimit(oriented.buffer, `oriented image at index ${index}`)) {
2124
+ buffer = oriented.buffer;
2125
+ changed = true;
2126
+ }
2127
+ }
2128
+ if (!changed) {
2066
2129
  continue;
2067
2130
  }
2068
- const dataUri = `data:${compatible.mimeType};base64,${compatible.buffer.toString("base64")}`;
2131
+ const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`;
2069
2132
  images[index] = isWrapped
2070
2133
  ? { ...entry, data: dataUri }
2071
2134
  : dataUri;
@@ -2112,6 +2175,34 @@ function base64DecodedByteLength(base64) {
2112
2175
  }
2113
2176
  return Math.max(0, Math.floor(significant / 4) * 3 - padding);
2114
2177
  }
2178
+ /**
2179
+ * Base64 characters sufficient to decode at least
2180
+ * {@link EXIF_PROBE_PREFIX_BYTES} bytes, rounded up to a 4-character group so
2181
+ * the slice below always starts at the beginning of the encoded stream.
2182
+ */
2183
+ const EXIF_PROBE_BASE64_CHARS = Math.ceil(EXIF_PROBE_PREFIX_BYTES / 3) * 4;
2184
+ /**
2185
+ * `probeExifOrientation`, but for a still-encoded data-URI payload: decodes
2186
+ * only its leading {@link EXIF_PROBE_BASE64_CHARS} characters rather than the
2187
+ * whole string, the same bounded-prefix trade `probeFileExifOrientation`
2188
+ * makes for a local file. This is what lets `processImageToBase64` ask "does
2189
+ * this image need touching at all?" before it pays for the size guard or a
2190
+ * full decode — see the call site for why that ordering is the point.
2191
+ *
2192
+ * Reports `"inconclusive"` on any decode failure rather than propagating it:
2193
+ * the caller's full-size path handles that verdict correctly already.
2194
+ */
2195
+ function probeBase64ExifOrientation(base64, mimeType) {
2196
+ try {
2197
+ const window = base64.length <= EXIF_PROBE_BASE64_CHARS
2198
+ ? base64
2199
+ : base64.slice(0, EXIF_PROBE_BASE64_CHARS);
2200
+ return probeExifOrientation(Buffer.from(window, "base64"), mimeType);
2201
+ }
2202
+ catch {
2203
+ return "inconclusive";
2204
+ }
2205
+ }
2115
2206
  /**
2116
2207
  * Whether a payload is small enough to hand to an image decoder, reported
2117
2208
  * rather than thrown. See {@link readImageSourceForConversion} for why this
@@ -2131,6 +2222,42 @@ function withinConversionByteLimit(bytes, context) {
2131
2222
  function withinConversionLimit(buffer, context) {
2132
2223
  return withinConversionByteLimit(buffer.length, context);
2133
2224
  }
2225
+ /**
2226
+ * Ask a file's own header bytes whether it carries an EXIF orientation,
2227
+ * reading at most {@link EXIF_PROBE_PREFIX_BYTES} to find out.
2228
+ *
2229
+ * This is what keeps the orientation pass off the JPEG hot path. Without it,
2230
+ * widening the conversion gate to cover orientation means every local `.jpg`
2231
+ * and `.webp` is stat'd, read into memory in full and handed to a decoder,
2232
+ * purely to discover that the overwhelming majority carry no tag — new I/O
2233
+ * on the single most common image input there is.
2234
+ *
2235
+ * Reports `"inconclusive"` on any read failure rather than propagating it:
2236
+ * the full read that follows will hit the same problem a few lines later,
2237
+ * where the existing handler already turns it into a warning and a skipped
2238
+ * conversion. Failing here instead would only duplicate that.
2239
+ */
2240
+ async function probeFileExifOrientation(path, mimeType, size) {
2241
+ // The caller has already stat'd, so a small file costs a small buffer
2242
+ // rather than the window's full width.
2243
+ const window = Math.min(Math.max(size, 0), EXIF_PROBE_PREFIX_BYTES);
2244
+ if (window === 0) {
2245
+ return "inconclusive";
2246
+ }
2247
+ let handle;
2248
+ try {
2249
+ handle = await openAsync(path, "r");
2250
+ const prefix = Buffer.alloc(window);
2251
+ const { bytesRead } = await handle.read(prefix, 0, prefix.length, 0);
2252
+ return probeExifOrientation(prefix.subarray(0, bytesRead), mimeType);
2253
+ }
2254
+ catch {
2255
+ return "inconclusive";
2256
+ }
2257
+ finally {
2258
+ await handle?.close().catch(() => undefined);
2259
+ }
2260
+ }
2134
2261
  async function readImageSourceForConversion(payload) {
2135
2262
  // Both in-memory shapes are size-checked before they can reach a decoder,
2136
2263
  // the same way the file-path branch below is. `withinConversionLimit`
@@ -2162,16 +2289,27 @@ async function readImageSourceForConversion(payload) {
2162
2289
  return undefined;
2163
2290
  }
2164
2291
  const mimeType = getMimeTypeFromExtension(payload);
2165
- if (!needsVisionTranscode(mimeType)) {
2292
+ const transcodable = needsVisionTranscode(mimeType);
2293
+ if (!transcodable && !mayCarryExifOrientation(mimeType)) {
2166
2294
  return undefined;
2167
2295
  }
2168
2296
  try {
2169
2297
  // Preflight the size before reading. Conversion replaces the entry with a
2170
2298
  // data URI, which bypasses processImageToBase64's buffer-size guard — so
2171
2299
  // without this a large local HEIC/TIFF/BMP was read fully into memory and
2172
- // then re-encoded, with no limit applied at either step.
2300
+ // then re-encoded, with no limit applied at either step. The same applies
2301
+ // to a local JPEG read only for its EXIF tag: still bounded before the read.
2173
2302
  const { size } = await statAsync(payload);
2174
2303
  ImageProcessor.validateSize(size, `image at ${safeBasename(payload)}`);
2304
+ // A file whose only reason to be here is the orientation question gets
2305
+ // that question answered from its header, and is not read at all when the
2306
+ // answer is no. A transcodable format is exempt: its bytes are needed
2307
+ // whatever the orientation turns out to be, so a probe would be pure
2308
+ // overhead.
2309
+ if (!transcodable &&
2310
+ (await probeFileExifOrientation(payload, mimeType, size)) === "absent") {
2311
+ return undefined;
2312
+ }
2175
2313
  const buffer = await readFileAsync(payload);
2176
2314
  ImageProcessor.validateBufferSize(buffer, `image at ${safeBasename(payload)}`);
2177
2315
  return { buffer, mimeType };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.28.1",
3
+ "version": "12.28.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -184,7 +184,7 @@
184
184
  "// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure + test:error-classifier-contract on every PR)": "",
185
185
  "test:tool-routing": "pnpm exec tsx test/continuous-test-suite-tool-routing.ts",
186
186
  "test:tool-routing-semantic": "pnpm exec tsx test/continuous-test-suite-tool-routing-semantic.ts",
187
- "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:mcp-breaker-resolved-errors && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
187
+ "test:unit": "pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:spans && pnpm run test:tool-routing && pnpm run test:tool-routing-cli && pnpm run test:tool-dedup && pnpm run test:model-pool && pnpm run test:classifier-router && pnpm run test:tool-routing-semantic && pnpm run test:mcp-result-cache && pnpm run test:mcp-direct-name-repair && pnpm run test:mcp-breaker-resolved-errors && pnpm run test:model-not-found-retryable && pnpm run test:archive:security && pnpm run test:office:security && pnpm run test:image-exif && pnpm run test:vector-chroma && pnpm run test:vector-pgvector && pnpm run test:vector-pinecone && pnpm run test:provider-wiring && pnpm run test:docs-mcp",
188
188
  "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:matrix, a different suite covering the full provider capability matrix, runs nightly via .github/workflows/live-matrix.yml — test:providers itself is still only wired into test:live, not any GitHub Actions workflow)": "",
189
189
  "test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
190
190
  "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "",
@@ -251,6 +251,7 @@
251
251
  "test:archive:security": "pnpm exec tsx test/continuous-test-suite-archive-security.ts",
252
252
  "test:office:security": "pnpm exec tsx test/continuous-test-suite-office-security.ts",
253
253
  "test:vision-image-format": "pnpm exec tsx test/continuous-test-suite-vision-image-format.ts",
254
+ "test:image-exif": "pnpm exec tsx test/continuous-test-suite-image-exif.ts",
254
255
  "test:model-pool": "pnpm exec tsx test/continuous-test-suite-model-pool.ts",
255
256
  "test:agents": "pnpm exec tsx test/continuous-test-suite-agents.ts",
256
257
  "test:classifier-router": "pnpm exec tsx test/continuous-test-suite-classifier-router.ts",