@juspay/neurolink 12.14.21 → 12.15.1

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 (57) hide show
  1. package/CHANGELOG.md +2 -6
  2. package/dist/browser/neurolink.min.js +397 -397
  3. package/dist/cli/commands/auth.js +6 -2
  4. package/dist/cli/commands/proxy.d.ts +4 -0
  5. package/dist/cli/commands/proxy.js +119 -39
  6. package/dist/cli/index.js +8 -18
  7. package/dist/cli/lifecycle.d.ts +8 -0
  8. package/dist/cli/lifecycle.js +90 -0
  9. package/dist/core/baseProvider.d.ts +6 -3
  10. package/dist/core/baseProvider.js +7 -3
  11. package/dist/providers/amazonBedrock/client.d.ts +7 -5
  12. package/dist/providers/amazonBedrock/client.js +125 -16
  13. package/dist/providers/cohere.d.ts +2 -2
  14. package/dist/providers/cohere.js +5 -1
  15. package/dist/providers/googleAiStudio/client.d.ts +3 -3
  16. package/dist/providers/googleAiStudio/client.js +6 -2
  17. package/dist/providers/googleVertex/client.d.ts +2 -2
  18. package/dist/providers/googleVertex/client.js +5 -1
  19. package/dist/providers/jina.d.ts +2 -2
  20. package/dist/providers/jina.js +5 -1
  21. package/dist/providers/litellm/client.d.ts +2 -2
  22. package/dist/providers/litellm/client.js +5 -1
  23. package/dist/providers/ollama/client.d.ts +2 -2
  24. package/dist/providers/ollama/client.js +5 -1
  25. package/dist/providers/openAI/client.d.ts +3 -3
  26. package/dist/providers/openAI/client.js +6 -2
  27. package/dist/providers/voyage.d.ts +2 -2
  28. package/dist/providers/voyage.js +5 -1
  29. package/dist/proxy/codexAccountUsage.js +18 -6
  30. package/dist/proxy/codexFallback.js +17 -10
  31. package/dist/proxy/codexUsage.js +3 -0
  32. package/dist/proxy/proxyAnalysis.js +14 -11
  33. package/dist/proxy/proxyTokenUsage.d.ts +7 -0
  34. package/dist/proxy/proxyTokenUsage.js +21 -0
  35. package/dist/proxy/proxyTracer.d.ts +2 -0
  36. package/dist/proxy/proxyTracer.js +21 -40
  37. package/dist/proxy/quotaHeaders.js +7 -3
  38. package/dist/rag/document/MDocument.d.ts +19 -1
  39. package/dist/rag/document/MDocument.js +30 -3
  40. package/dist/rag/document/imageLoader.d.ts +55 -0
  41. package/dist/rag/document/imageLoader.js +183 -0
  42. package/dist/rag/index.d.ts +1 -0
  43. package/dist/rag/index.js +2 -0
  44. package/dist/rag/pipeline/RAGPipeline.d.ts +54 -1
  45. package/dist/rag/pipeline/RAGPipeline.js +358 -1
  46. package/dist/rag/ragIntegration.js +146 -25
  47. package/dist/server/routes/claudeProxyRoutes.js +35 -1
  48. package/dist/server/routes/codexProxyRoutes.d.ts +5 -9
  49. package/dist/server/routes/codexProxyRoutes.js +106 -18
  50. package/dist/types/codex.d.ts +10 -0
  51. package/dist/types/providers.d.ts +21 -1
  52. package/dist/types/proxy.d.ts +24 -6
  53. package/dist/types/rag.d.ts +144 -0
  54. package/dist/types/rag.js +12 -1
  55. package/dist/utils/pricing.js +4 -2
  56. package/docs-site/static/search-index.json +1 -1
  57. package/package.json +2 -1
@@ -1,3 +1,4 @@
1
+ import { proxyTokenUsage } from "./proxyTokenUsage.js";
1
2
  import { createReadStream } from "node:fs";
2
3
  import { isDeepStrictEqual } from "node:util";
3
4
  import { isProxyAuxiliaryRequest } from "./proxyRequestKind.js";
@@ -411,8 +412,15 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
411
412
  request.outputTokens !== null ||
412
413
  request.cacheReadTokens !== null ||
413
414
  request.cacheCreationTokens !== null) {
415
+ const billingUsage = proxyTokenUsage({
416
+ inputIncludesCachedTokens: request.inputIncludesCachedTokens,
417
+ inputTokens: request.inputTokens ?? 0,
418
+ outputTokens: request.outputTokens ?? 0,
419
+ cacheCreationTokens: request.cacheCreationTokens ?? 0,
420
+ cacheReadTokens: request.cacheReadTokens ?? 0,
421
+ });
414
422
  requestsWithUsage += 1;
415
- inputTokens += request.inputTokens ?? 0;
423
+ inputTokens += billingUsage.input;
416
424
  outputTokens += request.outputTokens ?? 0;
417
425
  cacheReadTokens += request.cacheReadTokens ?? 0;
418
426
  cacheCreationTokens += request.cacheCreationTokens ?? 0;
@@ -422,16 +430,7 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
422
430
  // "openai-compatible" resolves to the cross-provider table search in
423
431
  // pricing.ts, which finds the model wherever it lives — a far better
424
432
  // guess than assuming Anthropic and pricing a GPT model at $0.
425
- const cost = calculateCost(request.provider ?? "openai-compatible", request.model, {
426
- input: request.inputTokens ?? 0,
427
- output: request.outputTokens ?? 0,
428
- total: (request.inputTokens ?? 0) +
429
- (request.outputTokens ?? 0) +
430
- (request.cacheCreationTokens ?? 0) +
431
- (request.cacheReadTokens ?? 0),
432
- cacheCreationTokens: request.cacheCreationTokens ?? 0,
433
- cacheReadTokens: request.cacheReadTokens ?? 0,
434
- });
433
+ const cost = calculateCost(request.provider ?? "openai-compatible", request.model, billingUsage);
435
434
  // Ask the table directly rather than inferring from cost > 0: a real
436
435
  // request with trivial usage can round to $0.000000 and is priced, not
437
436
  // unpriced.
@@ -982,6 +981,10 @@ export async function analyzeProxyLogs(options) {
982
981
  accountType: stringValue(record.accountType) ?? "unknown",
983
982
  model: stringValue(record.model),
984
983
  provider: stringValue(record.provider),
984
+ inputIncludesCachedTokens: typeof record.inputIncludesCachedTokens === "boolean"
985
+ ? record.inputIncludesCachedTokens
986
+ : typeof record.path === "string" &&
987
+ record.path.includes("/backend-api/codex/"),
985
988
  inputTokens: finiteNumber(record.inputTokens),
986
989
  outputTokens: finiteNumber(record.outputTokens),
987
990
  cacheReadTokens: finiteNumber(record.cacheReadTokens),
@@ -0,0 +1,7 @@
1
+ import type { TokenUsage, UsageContext } from "../types/index.js";
2
+ /** Convert proxy wire usage into disjoint billing buckets. Codex input already
3
+ * includes cache tokens; reasoning is included in output for both providers.
4
+ * OpenAI's documented formula subtracts both cached_tokens and cache_write_tokens:
5
+ * https://developers.openai.com/api/docs/guides/prompt-caching#monitor-cache-performance
6
+ */
7
+ export declare function proxyTokenUsage(usage: UsageContext): TokenUsage;
@@ -0,0 +1,21 @@
1
+ /** Convert proxy wire usage into disjoint billing buckets. Codex input already
2
+ * includes cache tokens; reasoning is included in output for both providers.
3
+ * OpenAI's documented formula subtracts both cached_tokens and cache_write_tokens:
4
+ * https://developers.openai.com/api/docs/guides/prompt-caching#monitor-cache-performance
5
+ */
6
+ export function proxyTokenUsage(usage) {
7
+ const cached = usage.cacheReadTokens + usage.cacheCreationTokens;
8
+ const input = usage.inputIncludesCachedTokens
9
+ ? Math.max(0, usage.inputTokens - cached)
10
+ : usage.inputTokens;
11
+ return {
12
+ input,
13
+ output: usage.outputTokens,
14
+ total: usage.inputIncludesCachedTokens
15
+ ? usage.inputTokens + usage.outputTokens
16
+ : input + cached + usage.outputTokens,
17
+ cacheReadTokens: usage.cacheReadTokens,
18
+ cacheCreationTokens: usage.cacheCreationTokens,
19
+ reasoning: usage.reasoningTokens,
20
+ };
21
+ }
@@ -57,6 +57,8 @@ declare class ProxyTracer {
57
57
  startStream(): Span;
58
58
  /** Record account selection outcome on the root span. */
59
59
  setAccountSelection(ctx: AccountSelectionContext): void;
60
+ /** Attribute fallback metrics to the account that actually served them. */
61
+ setServedAccount(account: string, accountType: string): void;
60
62
  /** Record token usage and cost on the root span. */
61
63
  setUsage(ctx: UsageContext): void;
62
64
  /**
@@ -16,6 +16,7 @@
16
16
  import { SpanStatusCode, SpanKind, ROOT_CONTEXT, isSpanContextValid, propagation, context, metrics, trace, } from "@opentelemetry/api";
17
17
  import { getTracer, setLangfuseContext, } from "../services/server/ai/observability/instrumentation.js";
18
18
  import { OtelBridge } from "../observability/otelBridge.js";
19
+ import { proxyTokenUsage } from "./proxyTokenUsage.js";
19
20
  import { calculateCost } from "../utils/pricing.js";
20
21
  import { TelemetryService } from "../telemetry/telemetryService.js";
21
22
  import { logger } from "../utils/logger.js";
@@ -360,17 +361,22 @@ class ProxyTracer {
360
361
  // Non-fatal
361
362
  });
362
363
  }
364
+ /** Attribute fallback metrics to the account that actually served them. */
365
+ setServedAccount(account, accountType) {
366
+ this.accountEmail = account;
367
+ this.rootSpan.setAttributes({
368
+ "proxy.account.served": account,
369
+ "proxy.account.served_type": accountType,
370
+ });
371
+ }
363
372
  /** Record token usage and cost on the root span. */
364
373
  setUsage(ctx) {
365
374
  this.usage = ctx;
366
- const totalTokens = ctx.inputTokens +
367
- ctx.outputTokens +
368
- ctx.cacheCreationTokens +
369
- ctx.cacheReadTokens +
370
- (ctx.reasoningTokens ?? 0);
371
- // NeuroLink-format token attributes (from SpanAttributes)
375
+ const billingUsage = proxyTokenUsage(ctx);
376
+ const totalTokens = billingUsage.total;
377
+ // Custom token buckets are disjoint; standard GenAI input includes cache.
372
378
  this.rootSpan.setAttributes({
373
- "ai.tokens.input": ctx.inputTokens,
379
+ "ai.tokens.input": billingUsage.input,
374
380
  "ai.tokens.output": ctx.outputTokens,
375
381
  "ai.tokens.total": totalTokens,
376
382
  "ai.tokens.cache_creation": ctx.cacheCreationTokens,
@@ -381,18 +387,12 @@ class ProxyTracer {
381
387
  }
382
388
  // GenAI semantic convention attributes (for Langfuse compatibility)
383
389
  this.rootSpan.setAttributes({
384
- "gen_ai.usage.input_tokens": ctx.inputTokens,
390
+ "gen_ai.usage.input_tokens": totalTokens - ctx.outputTokens,
385
391
  "gen_ai.usage.output_tokens": ctx.outputTokens,
386
392
  "gen_ai.usage.total_tokens": totalTokens,
387
393
  });
388
394
  // Cost calculation via pricing.ts
389
- const cost = calculateCost(this.billingProvider, this.model, {
390
- input: ctx.inputTokens,
391
- output: ctx.outputTokens,
392
- total: totalTokens,
393
- cacheCreationTokens: ctx.cacheCreationTokens,
394
- cacheReadTokens: ctx.cacheReadTokens,
395
- });
395
+ const cost = calculateCost(this.billingProvider, this.model, billingUsage);
396
396
  if (cost > 0) {
397
397
  this.rootSpan.setAttributes({
398
398
  "ai.cost.total": cost,
@@ -665,7 +665,7 @@ class ProxyTracer {
665
665
  model: this.model,
666
666
  account: this.accountEmail ?? "unknown",
667
667
  };
668
- m.tokensInput.add(this.usage.inputTokens, tokenLabels);
668
+ m.tokensInput.add(proxyTokenUsage(this.usage).input, tokenLabels);
669
669
  m.tokensOutput.add(this.usage.outputTokens, tokenLabels);
670
670
  m.tokensCacheRead.add(this.usage.cacheReadTokens, tokenLabels);
671
671
  m.tokensCacheCreation.add(this.usage.cacheCreationTokens, tokenLabels);
@@ -673,18 +673,8 @@ class ProxyTracer {
673
673
  m.tokensReasoning.add(this.usage.reasoningTokens, tokenLabels);
674
674
  }
675
675
  // Cost
676
- const totalTokens = this.usage.inputTokens +
677
- this.usage.outputTokens +
678
- this.usage.cacheCreationTokens +
679
- this.usage.cacheReadTokens +
680
- (this.usage.reasoningTokens ?? 0);
681
- const cost = calculateCost(this.billingProvider, this.model, {
682
- input: this.usage.inputTokens,
683
- output: this.usage.outputTokens,
684
- total: totalTokens,
685
- cacheCreationTokens: this.usage.cacheCreationTokens,
686
- cacheReadTokens: this.usage.cacheReadTokens,
687
- });
676
+ const billingUsage = proxyTokenUsage(this.usage);
677
+ const cost = calculateCost(this.billingProvider, this.model, billingUsage);
688
678
  if (cost > 0) {
689
679
  m.costTotal.add(cost, tokenLabels);
690
680
  }
@@ -711,19 +701,10 @@ class ProxyTracer {
711
701
  if (!this.usage) {
712
702
  return;
713
703
  }
714
- const totalTokens = this.usage.inputTokens +
715
- this.usage.outputTokens +
716
- this.usage.cacheCreationTokens +
717
- this.usage.cacheReadTokens +
718
- (this.usage.reasoningTokens ?? 0);
704
+ const billingUsage = proxyTokenUsage(this.usage);
705
+ const totalTokens = billingUsage.total;
719
706
  const durationMs = Date.now() - this.startTime;
720
- const cost = calculateCost(this.billingProvider, this.model, {
721
- input: this.usage.inputTokens,
722
- output: this.usage.outputTokens,
723
- total: totalTokens,
724
- cacheCreationTokens: this.usage.cacheCreationTokens,
725
- cacheReadTokens: this.usage.cacheReadTokens,
726
- });
707
+ const cost = calculateCost(this.billingProvider, this.model, billingUsage);
727
708
  TelemetryService.getInstance().recordAIRequest(this.billingProvider, this.model, totalTokens, durationMs, cost > 0 ? cost : undefined);
728
709
  }
729
710
  // -------------------------------------------------------------------------
@@ -133,10 +133,14 @@ export function buildQuotaResponseHeaders(context, now = Date.now()) {
133
133
  }
134
134
  const quota = context.quota;
135
135
  if (quota && context.source !== "none") {
136
- set("x-neurolink-quota-session-left-pct", utilizationToLeftPct(quota.sessionUsed));
136
+ set("x-neurolink-quota-session-left-pct", quota.sessionStatus === "unknown"
137
+ ? undefined
138
+ : utilizationToLeftPct(quota.sessionUsed));
137
139
  set("x-neurolink-quota-session-status", quota.sessionStatus);
138
140
  set("x-neurolink-quota-session-resets-in", secondsUntil(quota.sessionResetAt, now));
139
- set("x-neurolink-quota-weekly-left-pct", utilizationToLeftPct(quota.weeklyUsed));
141
+ set("x-neurolink-quota-weekly-left-pct", quota.weeklyStatus === "unknown"
142
+ ? undefined
143
+ : utilizationToLeftPct(quota.weeklyUsed));
140
144
  set("x-neurolink-quota-weekly-status", quota.weeklyStatus);
141
145
  set("x-neurolink-quota-weekly-resets-in", secondsUntil(quota.weeklyResetAt, now));
142
146
  set("x-neurolink-quota-unified-status", quota.unifiedStatus);
@@ -173,7 +177,7 @@ export function summarizePoolHeadroom(entries, now = Date.now()) {
173
177
  continue;
174
178
  }
175
179
  available += 1;
176
- if (entry.quota) {
180
+ if (entry.quota && entry.quota.sessionStatus !== "unknown") {
177
181
  const left = utilizationToLeftPct(entry.quota.sessionUsed);
178
182
  if (bestSessionLeftPct === undefined || left > bestSessionLeftPct) {
179
183
  bestSessionLeftPct = left;
@@ -99,9 +99,27 @@ export declare class MDocument {
99
99
  * Generate embeddings for all chunks
100
100
  * @param provider - Embedding provider name
101
101
  * @param modelName - Embedding model name
102
+ * @param imageData - Optional array of image data (Buffer or base64) aligned with chunks,
103
+ * enabling multi-modal embeddings for providers that support them
104
+ * @param mimeType - MIME type of the images (default: "image/png"). Pass an
105
+ * array to declare a per-image MIME type aligned with
106
+ * `imageData`; a plain string applies to every image.
102
107
  * @returns This MDocument instance (for chaining)
103
108
  */
104
- embed(provider?: string, modelName?: string): Promise<MDocument>;
109
+ embed(provider?: string, modelName?: string, imageData?: Array<Buffer | string>, mimeType?: string | string[]): Promise<MDocument>;
110
+ /**
111
+ * Generate multi-modal embeddings for all chunks using text + optional image data.
112
+ * Uses a provider that supports multi-modal embeddings (e.g. Bedrock Titan Image).
113
+ *
114
+ * @param provider - Embedding provider name
115
+ * @param modelName - Embedding model name
116
+ * @param imageData - Optional array of image data (Buffer or base64) aligned with chunks
117
+ * @param mimeType - MIME type of the images (default: "image/png"). Pass an
118
+ * array to declare a per-image MIME type aligned with
119
+ * `imageData`; a plain string applies to every image.
120
+ * @returns This MDocument instance (for chaining)
121
+ */
122
+ embedMultiModal(provider?: string, modelName?: string, imageData?: Array<Buffer | string>, mimeType?: string | string[]): Promise<MDocument>;
105
123
  /**
106
124
  * Get document ID
107
125
  */
@@ -194,9 +194,14 @@ export class MDocument {
194
194
  * Generate embeddings for all chunks
195
195
  * @param provider - Embedding provider name
196
196
  * @param modelName - Embedding model name
197
+ * @param imageData - Optional array of image data (Buffer or base64) aligned with chunks,
198
+ * enabling multi-modal embeddings for providers that support them
199
+ * @param mimeType - MIME type of the images (default: "image/png"). Pass an
200
+ * array to declare a per-image MIME type aligned with
201
+ * `imageData`; a plain string applies to every image.
197
202
  * @returns This MDocument instance (for chaining)
198
203
  */
199
- async embed(provider = "openai", modelName = "text-embedding-3-small") {
204
+ async embed(provider = "openai", modelName = "text-embedding-3-small", imageData, mimeType = "image/png") {
200
205
  if (this.state.chunks.length === 0) {
201
206
  logger.warn("[MDocument] No chunks to embed. Call chunk() first.");
202
207
  return this;
@@ -208,14 +213,21 @@ export class MDocument {
208
213
  chunkCount: this.state.chunks.length,
209
214
  provider,
210
215
  model: modelName,
216
+ hasImages: imageData !== undefined && imageData.length > 0,
211
217
  });
212
218
  const embeddingProvider = await ProviderFactory.createProvider(provider, modelName);
213
219
  if (typeof embeddingProvider.embed !== "function") {
214
220
  throw new Error(`Provider ${provider} does not support embeddings`);
215
221
  }
216
222
  this.state.embeddings = [];
217
- for (const chunk of this.state.chunks) {
218
- const embedding = await embeddingProvider.embed(chunk.text);
223
+ for (let i = 0; i < this.state.chunks.length; i++) {
224
+ const chunk = this.state.chunks[i];
225
+ const image = imageData?.[i];
226
+ const imageMimeType = typeof mimeType === "string" ? mimeType : (mimeType[i] ?? "image/png");
227
+ const input = image
228
+ ? { text: chunk.text, image, mimeType: imageMimeType }
229
+ : chunk.text;
230
+ const embedding = await embeddingProvider.embed(input);
219
231
  this.state.embeddings.push(embedding);
220
232
  chunk.embedding = embedding;
221
233
  }
@@ -227,6 +239,21 @@ export class MDocument {
227
239
  });
228
240
  return this;
229
241
  }
242
+ /**
243
+ * Generate multi-modal embeddings for all chunks using text + optional image data.
244
+ * Uses a provider that supports multi-modal embeddings (e.g. Bedrock Titan Image).
245
+ *
246
+ * @param provider - Embedding provider name
247
+ * @param modelName - Embedding model name
248
+ * @param imageData - Optional array of image data (Buffer or base64) aligned with chunks
249
+ * @param mimeType - MIME type of the images (default: "image/png"). Pass an
250
+ * array to declare a per-image MIME type aligned with
251
+ * `imageData`; a plain string applies to every image.
252
+ * @returns This MDocument instance (for chaining)
253
+ */
254
+ async embedMultiModal(provider = "bedrock", modelName = "amazon.titan-embed-image-v1", imageData, mimeType = "image/png") {
255
+ return this.embed(provider, modelName, imageData, mimeType);
256
+ }
230
257
  // ============================================================================
231
258
  // Accessor Methods
232
259
  // ============================================================================
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Image Loader for Multi-Modal RAG
3
+ *
4
+ * Loads image files (from disk or URLs) into ImageDocument objects
5
+ * that can be embedded and stored in a vector store alongside text chunks.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { ImageLoader } from './imageLoader.js';
10
+ *
11
+ * const loader = new ImageLoader();
12
+ *
13
+ * // Load from file path
14
+ * const doc = await loader.load('./photo.jpg');
15
+ *
16
+ * // Load from URL
17
+ * const doc2 = await loader.load('https://example.com/image.png');
18
+ *
19
+ * // Load from buffer
20
+ * const doc3 = loader.loadFromBuffer(imageBuffer, 'image/jpeg');
21
+ * ```
22
+ */
23
+ import type { ImageDocument, ImageLoaderOptions } from "../../types/index.js";
24
+ /**
25
+ * ImageLoader for multi-modal RAG.
26
+ * Loads images from file paths, URLs, or buffers into ImageDocument objects.
27
+ */
28
+ export declare class ImageLoader {
29
+ private options;
30
+ constructor(options?: ImageLoaderOptions);
31
+ /**
32
+ * Load an image from a file path or URL.
33
+ */
34
+ load(source: string, options?: Partial<ImageLoaderOptions>): Promise<ImageDocument>;
35
+ /**
36
+ * Load an image from a local file path.
37
+ */
38
+ private loadFromPath;
39
+ /**
40
+ * Load an image from a URL.
41
+ */
42
+ private loadFromURL;
43
+ /**
44
+ * Load an image directly from a Buffer.
45
+ */
46
+ loadFromBuffer(buffer: Buffer, mimeType: string, source?: string, options?: Partial<ImageLoaderOptions>): ImageDocument;
47
+ /**
48
+ * Check if a file path or extension is a supported image format.
49
+ */
50
+ static isImageFile(source: string): boolean;
51
+ /**
52
+ * Get the MIME type for a file extension.
53
+ */
54
+ static getMimeType(filePath: string): string | undefined;
55
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Image Loader for Multi-Modal RAG
3
+ *
4
+ * Loads image files (from disk or URLs) into ImageDocument objects
5
+ * that can be embedded and stored in a vector store alongside text chunks.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { ImageLoader } from './imageLoader.js';
10
+ *
11
+ * const loader = new ImageLoader();
12
+ *
13
+ * // Load from file path
14
+ * const doc = await loader.load('./photo.jpg');
15
+ *
16
+ * // Load from URL
17
+ * const doc2 = await loader.load('https://example.com/image.png');
18
+ *
19
+ * // Load from buffer
20
+ * const doc3 = loader.loadFromBuffer(imageBuffer, 'image/jpeg');
21
+ * ```
22
+ */
23
+ import { readFileSync, existsSync, statSync } from "fs";
24
+ import { extname } from "path";
25
+ import { logger } from "../../utils/logger.js";
26
+ import { safeDownload } from "../../utils/safeFetch.js";
27
+ import { redactUrlForError } from "../../utils/logSanitize.js";
28
+ import { ImageProcessor, VALID_IMAGE_EXTENSIONS, } from "../../utils/imageProcessor.js";
29
+ import { EXTENSION_MIME_MAP } from "../../processors/config/index.js";
30
+ /**
31
+ * Generate a text representation from the image source.
32
+ */
33
+ function generateTextDescription(source) {
34
+ // Drop any query string or fragment before deriving the name. On a presigned
35
+ // URL the signature lives in the query, and the query is part of the final
36
+ // slash-separated segment — so `.pop()` alone keeps it, and this caption is
37
+ // not a transient log line: it becomes embedded, BM25-indexed chunk text that
38
+ // persists in the vector store and is read back into model context.
39
+ const withoutQueryOrFragment = source.split(/[?#]/)[0];
40
+ const filename = withoutQueryOrFragment.split("/").pop() ?? source;
41
+ // Remove extension and clean up
42
+ const name = filename
43
+ .replace(/\.[^.]+$/, "")
44
+ .replace(/[-_]/g, " ")
45
+ .replace(/\s+/g, " ")
46
+ .trim();
47
+ return `Image: ${name}`;
48
+ }
49
+ /**
50
+ * ImageLoader for multi-modal RAG.
51
+ * Loads images from file paths, URLs, or buffers into ImageDocument objects.
52
+ */
53
+ export class ImageLoader {
54
+ options;
55
+ constructor(options) {
56
+ this.options = {
57
+ metadata: options?.metadata ?? {},
58
+ maxImageSize: options?.maxImageSize ?? 10 * 1024 * 1024, // 10MB
59
+ fetchTimeout: options?.fetchTimeout ?? 30_000,
60
+ headers: options?.headers ?? {},
61
+ };
62
+ }
63
+ /**
64
+ * Load an image from a file path or URL.
65
+ */
66
+ async load(source, options) {
67
+ const opts = { ...this.options, ...options };
68
+ if (source.startsWith("http://") || source.startsWith("https://")) {
69
+ return this.loadFromURL(source, opts);
70
+ }
71
+ return this.loadFromPath(source, opts);
72
+ }
73
+ /**
74
+ * Load an image from a local file path.
75
+ */
76
+ async loadFromPath(filePath, options) {
77
+ if (!existsSync(filePath)) {
78
+ throw new Error(`Image file not found: ${filePath}`);
79
+ }
80
+ const { size } = statSync(filePath);
81
+ if (size > options.maxImageSize) {
82
+ throw new Error(`Image size (${size} bytes) exceeds maximum (${options.maxImageSize} bytes)`);
83
+ }
84
+ const buffer = readFileSync(filePath);
85
+ const ext = extname(filePath).toLowerCase();
86
+ const mimeType = EXTENSION_MIME_MAP[ext] ?? ImageProcessor.detectImageType(buffer);
87
+ const dimensions = ImageProcessor.getImageDimensions(buffer);
88
+ logger.debug("[ImageLoader] Loaded image from path", {
89
+ path: filePath,
90
+ size: buffer.byteLength,
91
+ mimeType,
92
+ dimensions,
93
+ });
94
+ return {
95
+ id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
96
+ text: generateTextDescription(filePath),
97
+ image: buffer,
98
+ mimeType,
99
+ metadata: {
100
+ source: filePath,
101
+ width: dimensions?.width,
102
+ height: dimensions?.height,
103
+ format: ext.replace(".", ""),
104
+ size: buffer.byteLength,
105
+ ...options.metadata,
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * Load an image from a URL.
111
+ */
112
+ async loadFromURL(url, options) {
113
+ const buffer = await safeDownload(url, {
114
+ maxBytes: options.maxImageSize,
115
+ label: "RAG image",
116
+ timeoutMs: options.fetchTimeout,
117
+ });
118
+ const mimeType = ImageProcessor.detectImageType(buffer);
119
+ const dimensions = ImageProcessor.getImageDimensions(buffer);
120
+ logger.debug("[ImageLoader] Loaded image from URL", {
121
+ url: redactUrlForError(url),
122
+ size: buffer.byteLength,
123
+ mimeType,
124
+ dimensions,
125
+ });
126
+ return {
127
+ id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
128
+ text: generateTextDescription(url),
129
+ image: buffer,
130
+ mimeType,
131
+ metadata: {
132
+ // Redacted for the same reason as the caption above: `source` is
133
+ // persisted into the vector store and returned verbatim to the model
134
+ // as the RAG tool's citation, so a signed URL stored here outlives the
135
+ // request that fetched it.
136
+ source: redactUrlForError(url),
137
+ width: dimensions?.width,
138
+ height: dimensions?.height,
139
+ format: mimeType.split("/").at(1),
140
+ size: buffer.byteLength,
141
+ ...options.metadata,
142
+ },
143
+ };
144
+ }
145
+ /**
146
+ * Load an image directly from a Buffer.
147
+ */
148
+ loadFromBuffer(buffer, mimeType, source, options) {
149
+ const opts = { ...this.options, ...options };
150
+ if (buffer.byteLength > opts.maxImageSize) {
151
+ throw new Error(`Image size (${buffer.byteLength} bytes) exceeds maximum (${opts.maxImageSize} bytes)`);
152
+ }
153
+ const dimensions = ImageProcessor.getImageDimensions(buffer);
154
+ return {
155
+ id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
156
+ text: generateTextDescription(source ?? "inline-image"),
157
+ image: buffer,
158
+ mimeType,
159
+ metadata: {
160
+ source: source ?? "inline",
161
+ width: dimensions?.width,
162
+ height: dimensions?.height,
163
+ format: mimeType.split("/").at(1),
164
+ size: buffer.byteLength,
165
+ ...opts.metadata,
166
+ },
167
+ };
168
+ }
169
+ /**
170
+ * Check if a file path or extension is a supported image format.
171
+ */
172
+ static isImageFile(source) {
173
+ const ext = extname(source).toLowerCase().replace(/^\./, "");
174
+ return VALID_IMAGE_EXTENSIONS.includes(ext);
175
+ }
176
+ /**
177
+ * Get the MIME type for a file extension.
178
+ */
179
+ static getMimeType(filePath) {
180
+ const ext = extname(filePath).toLowerCase();
181
+ return EXTENSION_MIME_MAP[ext];
182
+ }
183
+ }
@@ -48,6 +48,7 @@ export { ChunkerRegistry as ChunkerRegistryV2, chunkerRegistry, getAvailableChun
48
48
  export * from "./chunkers/index.js";
49
49
  export { CharacterChunker, ChunkerRegistry, chunkText, HTMLChunker, JSONChunker, LaTeXChunker, MarkdownChunker, RecursiveChunker, SemanticChunker, SentenceChunker, TokenChunker, } from "./chunking/index.js";
50
50
  export { CSVLoader, HTMLLoader, JSONLoader, loadDocument, loadDocuments, MarkdownLoader, MDocument, PDFLoader, TextLoader, WebLoader, } from "./document/index.js";
51
+ export { ImageLoader } from "./document/imageLoader.js";
51
52
  export * from "./errors/index.js";
52
53
  export { GraphRAG } from "./graphRag/index.js";
53
54
  export { createMetadataExtractor, extractMetadata, getAvailableExtractors, getAvailableExtractorTypes, getExtractor, getExtractorDefaultConfig, getExtractorMetadata, getRegisteredExtractorMetadata, LLMMetadataExtractor, MetadataExtractorFactory, MetadataExtractorRegistry, metadataExtractorFactory, metadataExtractorRegistry, } from "./metadata/index.js";
package/dist/rag/index.js CHANGED
@@ -52,6 +52,8 @@ export * from "./chunkers/index.js";
52
52
  export { CharacterChunker, ChunkerRegistry, chunkText, HTMLChunker, JSONChunker, LaTeXChunker, MarkdownChunker, RecursiveChunker, SemanticChunker, SentenceChunker, TokenChunker, } from "./chunking/index.js";
53
53
  // Document Processing
54
54
  export { CSVLoader, HTMLLoader, JSONLoader, loadDocument, loadDocuments, MarkdownLoader, MDocument, PDFLoader, TextLoader, WebLoader, } from "./document/index.js";
55
+ // Image Loader for multi-modal RAG
56
+ export { ImageLoader } from "./document/imageLoader.js";
55
57
  // Error handling
56
58
  export * from "./errors/index.js";
57
59
  // Graph RAG
@@ -25,7 +25,7 @@
25
25
  * console.log(response.answer, response.sources);
26
26
  * ```
27
27
  */
28
- import type { RAGPipelineConfig, IngestOptions, QueryOptions, RAGResponse, PipelineStats } from "../../types/index.js";
28
+ import type { RAGPipelineConfig, IngestOptions, QueryOptions, RAGResponse, PipelineStats, MultiModalRAGConfig, MultiModalQuery, MultiModalSearchResult } from "../../types/index.js";
29
29
  import { MDocument } from "../document/MDocument.js";
30
30
  export declare class RAGPipeline {
31
31
  private id;
@@ -34,10 +34,21 @@ export declare class RAGPipeline {
34
34
  private bm25Index;
35
35
  private graphRAG;
36
36
  private embeddingProvider?;
37
+ private multiModalEmbeddingProvider?;
37
38
  private generationProvider?;
39
+ /**
40
+ * Vision provider for image captioning, when `multiModal.captionProvider` /
41
+ * `captionModel` name one. Undefined means captioning uses
42
+ * {@link generationProvider} — captioning has always run on that provider,
43
+ * and these two fields were declared but read nowhere, so a caller who set
44
+ * them got the pipeline's generation model with no indication otherwise.
45
+ */
46
+ private captionProvider?;
38
47
  private hybridSearch?;
39
48
  private documents;
40
49
  private allChunks;
50
+ private multiModalConfig?;
51
+ private imageLoader;
41
52
  constructor(config: RAGPipelineConfig);
42
53
  /**
43
54
  * Initialize the pipeline (lazy loading of providers)
@@ -73,6 +84,47 @@ export declare class RAGPipeline {
73
84
  * Clear all indexed data
74
85
  */
75
86
  clear(): Promise<void>;
87
+ /**
88
+ * Ingest images into the pipeline for multi-modal RAG.
89
+ * Loads images, generates embeddings via the configured multi-modal provider,
90
+ * and stores them alongside text chunks in the vector store.
91
+ *
92
+ * @param sources - Array of image file paths, URLs, or Buffer objects
93
+ * @param options - Ingestion options
94
+ */
95
+ ingestImages(sources: Array<string | Buffer>, options?: IngestOptions): Promise<{
96
+ imagesProcessed: number;
97
+ chunksCreated: number;
98
+ }>;
99
+ /**
100
+ * Query the pipeline with multi-modal input (text, image, or both).
101
+ *
102
+ * @param query - Text, image, or combined query
103
+ * @param options - Query options
104
+ * @returns Array of multi-modal search results
105
+ */
106
+ queryMultiModal(query: MultiModalQuery, options?: QueryOptions): Promise<MultiModalSearchResult[]>;
107
+ /**
108
+ * Get multi-modal pipeline statistics
109
+ */
110
+ getMultiModalStats(): {
111
+ totalImages: number;
112
+ totalTextChunks: number;
113
+ multiModalEnabled: boolean;
114
+ };
115
+ /**
116
+ * Generate a multi-modal embedding for text, image, or both.
117
+ * Uses the configured multi-modal embedding provider (e.g. Bedrock Titan Image).
118
+ */
119
+ private generateMultiModalEmbedding;
120
+ /**
121
+ * Generate a caption for an image using the generation provider's vision model.
122
+ */
123
+ private generateImageCaption;
124
+ /**
125
+ * Diversify multi-modal results to ensure a mix of text and image results.
126
+ */
127
+ private diversifyMultiModalResults;
76
128
  /**
77
129
  * Ensure pipeline is initialized
78
130
  */
@@ -102,4 +154,5 @@ export declare function createRAGPipeline(options: {
102
154
  generationModel?: string;
103
155
  enableHybrid?: boolean;
104
156
  enableGraph?: boolean;
157
+ multiModal?: MultiModalRAGConfig;
105
158
  }): RAGPipeline;