@juspay/neurolink 9.50.2 → 9.51.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.
@@ -26,7 +26,14 @@ const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/obse
26
26
  // =============================================================================
27
27
  // STATE MANAGEMENT
28
28
  // =============================================================================
29
- const proxyStateManager = new StateFileManager("proxy-state.json");
29
+ let proxyStateManager = new StateFileManager("proxy-state.json");
30
+ /**
31
+ * Reinitialise the state manager with a custom base directory.
32
+ * Called when --dev redirects writable paths to .neurolink-dev/.
33
+ */
34
+ function setProxyStateDir(baseDir) {
35
+ proxyStateManager = new StateFileManager("proxy-state.json", baseDir);
36
+ }
30
37
  function saveProxyState(state) {
31
38
  proxyStateManager.save(state);
32
39
  }
@@ -333,12 +340,12 @@ async function loadProxyStartEnv(argv, spinner) {
333
340
  process.exit(1);
334
341
  }
335
342
  }
336
- async function createProxyNeurolinkRuntime() {
343
+ async function createProxyNeurolinkRuntime(logsDir) {
337
344
  process.env.NEUROLINK_SKIP_MCP = "true";
338
345
  const { NeuroLink } = await import("../../lib/neurolink.js");
339
346
  const neurolink = new NeuroLink();
340
347
  const { initRequestLogger, cleanupLogs } = await import("../../lib/proxy/requestLogger.js");
341
- initRequestLogger(true);
348
+ initRequestLogger(true, logsDir);
342
349
  cleanupLogs(7, 500);
343
350
  return { neurolink, cleanupLogs };
344
351
  }
@@ -701,7 +708,7 @@ function registerProxyShutdownHandlers(params) {
701
708
  catch {
702
709
  // non-fatal — proxy shutdown must not block on OTel
703
710
  }
704
- if (signal === "SIGINT") {
711
+ if (signal === "SIGINT" && !params.isDev) {
705
712
  try {
706
713
  const shutdownHost = params.host === "0.0.0.0" ? "localhost" : params.host;
707
714
  await clearClaudeProxySettings(`http://${shutdownHost}:${params.port}`);
@@ -733,7 +740,11 @@ async function startProxyRuntime(params) {
733
740
  port: params.port,
734
741
  hostname: params.host,
735
742
  });
736
- const guardPid = spawnFailOpenGuard(params.host, params.port, process.pid);
743
+ // Skip the fail-open guard in dev mode — it monitors the proxy and clears
744
+ // global Claude settings on exit, which is exactly what we want to avoid.
745
+ const guardPid = params.argv.dev
746
+ ? undefined
747
+ : spawnFailOpenGuard(params.host, params.port, process.pid);
737
748
  const readinessHost = params.host === "0.0.0.0" ? "127.0.0.1" : params.host;
738
749
  await waitForProxyReadiness({
739
750
  host: readinessHost,
@@ -767,10 +778,16 @@ async function startProxyRuntime(params) {
767
778
  if (params.spinner) {
768
779
  params.spinner.succeed(chalk.green("Claude proxy started successfully"));
769
780
  }
781
+ const isDev = params.argv.dev ?? false;
770
782
  const normalizedHost = params.host === "0.0.0.0" ? "localhost" : params.host;
771
783
  const url = `http://${normalizedHost}:${params.port}`;
772
784
  printProxyBanner(url, params.strategy);
773
- logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(params.passthrough ? "passthrough" : "full")}`);
785
+ if (isDev) {
786
+ logger.always(` ${chalk.bold("Mode:")} ${chalk.magenta("dev (isolated — state in .neurolink-dev/)")}`);
787
+ }
788
+ else {
789
+ logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(params.passthrough ? "passthrough" : "full")}`);
790
+ }
774
791
  if (params.passthrough) {
775
792
  logger.always(chalk.yellow(" ! Passthrough mode forwards client auth directly to Anthropic"));
776
793
  logger.always(chalk.dim(" Stored proxy OAuth/API credentials are ignored; clients need their own valid Anthropic auth."));
@@ -778,29 +795,52 @@ async function startProxyRuntime(params) {
778
795
  if (params.loadedEnvFile) {
779
796
  logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(params.loadedEnvFile)}`);
780
797
  }
781
- try {
782
- await setClaudeProxySettings(url);
783
- logger.always(chalk.green(" ✓ Auto-configured Claude Code settings"));
784
- logger.always(chalk.dim(" Restart Claude Code to connect through proxy"));
798
+ if (!isDev) {
799
+ try {
800
+ await setClaudeProxySettings(url);
801
+ logger.always(chalk.green(" Auto-configured Claude Code settings"));
802
+ logger.always(chalk.dim(" Restart Claude Code to connect through proxy"));
803
+ }
804
+ catch (error) {
805
+ logger.debug("[proxy] Failed to auto-configure Claude Code: " +
806
+ (error instanceof Error ? error.message : String(error)));
807
+ }
785
808
  }
786
- catch (error) {
787
- logger.debug("[proxy] Failed to auto-configure Claude Code: " +
788
- (error instanceof Error ? error.message : String(error)));
809
+ else {
810
+ logger.always(chalk.dim(" Dev mode: skipping client auto-configuration"));
789
811
  }
790
812
  const maintenance = startProxyBackgroundMaintenance(params.cleanupLogs);
791
813
  registerProxyShutdownHandlers({
792
814
  server,
793
815
  host: params.host,
794
816
  port: params.port,
817
+ isDev,
795
818
  ...maintenance,
796
819
  });
797
820
  }
798
821
  async function startProxyCommandHandler(argv) {
799
822
  const spinner = argv.quiet ? null : ora("Starting Claude proxy...").start();
823
+ const isDev = argv.dev ?? false;
800
824
  try {
801
- await ensureProxyStartAllowed(spinner);
825
+ // In dev mode: redirect writable state to .neurolink-dev/ and skip singleton check
826
+ let devPaths;
827
+ if (isDev) {
828
+ const { resolveProxyPaths } = await import("../../lib/proxy/proxyPaths.js");
829
+ devPaths = resolveProxyPaths(true);
830
+ setProxyStateDir(devPaths.stateDir);
831
+ const { initAccountQuota } = await import("../../lib/proxy/accountQuota.js");
832
+ initAccountQuota(devPaths.quotaFile);
833
+ // Ensure the dev state directory exists
834
+ const { mkdirSync, existsSync } = await import("fs");
835
+ if (!existsSync(devPaths.stateDir)) {
836
+ mkdirSync(devPaths.stateDir, { recursive: true, mode: 0o700 });
837
+ }
838
+ }
839
+ if (!isDev) {
840
+ await ensureProxyStartAllowed(spinner);
841
+ }
802
842
  const loadedEnvFile = await loadProxyStartEnv(argv, spinner);
803
- const { neurolink, cleanupLogs } = await createProxyNeurolinkRuntime();
843
+ const { neurolink, cleanupLogs } = await createProxyNeurolinkRuntime(devPaths?.logsDir);
804
844
  const { proxyConfig, strategy, modelRouter, passthrough } = await loadProxyStartConfiguration(argv, spinner);
805
845
  if (spinner) {
806
846
  spinner.text = "Configuring server...";
@@ -904,6 +944,11 @@ export const proxyStartCommand = {
904
944
  type: "boolean",
905
945
  default: false,
906
946
  description: "Run in transparent passthrough mode (no retry, no rotation, no polyfill)",
947
+ })
948
+ .option("dev", {
949
+ type: "boolean",
950
+ default: false,
951
+ description: "Run in isolated dev mode — state files scoped to .neurolink-dev/ in cwd, no client auto-configuration, no singleton check",
907
952
  })
908
953
  .example("neurolink proxy start", "Start proxy on default port 55669 with fill-first strategy")
909
954
  .example("neurolink proxy start -p 8080 -s fill-first", "Start proxy on port 8080 with fill-first")
@@ -44,8 +44,9 @@ export declare class StateFileManager<T> {
44
44
  /**
45
45
  * Create a new state file manager
46
46
  * @param filename - Name of the state file (e.g., "serve-state.json")
47
+ * @param baseDir - Optional base directory (defaults to ~/.neurolink)
47
48
  */
48
- constructor(filename: string);
49
+ constructor(filename: string, baseDir?: string);
49
50
  /**
50
51
  * Get the full path to the state file
51
52
  */
@@ -92,9 +92,10 @@ export class StateFileManager {
92
92
  /**
93
93
  * Create a new state file manager
94
94
  * @param filename - Name of the state file (e.g., "serve-state.json")
95
+ * @param baseDir - Optional base directory (defaults to ~/.neurolink)
95
96
  */
96
- constructor(filename) {
97
- this.filePath = path.join(getNeuroLinkDir(), filename);
97
+ constructor(filename, baseDir) {
98
+ this.filePath = path.join(baseDir ?? getNeuroLinkDir(), filename);
98
99
  }
99
100
  /**
100
101
  * Get the full path to the state file
@@ -107,7 +108,10 @@ export class StateFileManager {
107
108
  * @param state - State object to save
108
109
  */
109
110
  save(state) {
110
- ensureStateDir();
111
+ const dir = path.dirname(this.filePath);
112
+ if (!fs.existsSync(dir)) {
113
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
114
+ }
111
115
  fs.writeFileSync(this.filePath, JSON.stringify(state, null, 2));
112
116
  }
113
117
  /**
@@ -206,7 +206,11 @@ export class Utilities {
206
206
  if (result && typeof result === "object" && "success" in result) {
207
207
  const mcpResult = result;
208
208
  if (mcpResult.success) {
209
- return mcpResult.data;
209
+ // If `data` field exists, return it (standard MCP format).
210
+ // Otherwise fall back to the full result object so the LLM
211
+ // receives the actual payload instead of `undefined`, which
212
+ // would cause it to re-call the tool in a loop.
213
+ return mcpResult.data !== undefined ? mcpResult.data : result;
210
214
  }
211
215
  else {
212
216
  // Instead of throwing, return a structured error result
@@ -206,7 +206,11 @@ export class Utilities {
206
206
  if (result && typeof result === "object" && "success" in result) {
207
207
  const mcpResult = result;
208
208
  if (mcpResult.success) {
209
- return mcpResult.data;
209
+ // If `data` field exists, return it (standard MCP format).
210
+ // Otherwise fall back to the full result object so the LLM
211
+ // receives the actual payload instead of `undefined`, which
212
+ // would cause it to re-call the tool in a loop.
213
+ return mcpResult.data !== undefined ? mcpResult.data : result;
210
214
  }
211
215
  else {
212
216
  // Instead of throwing, return a structured error result
@@ -16,6 +16,12 @@ import type { AccountQuota } from "../types/index.js";
16
16
  * Pure computation — no I/O, no blocking.
17
17
  */
18
18
  export declare function parseQuotaHeaders(headers: Headers | Record<string, string>): AccountQuota | null;
19
+ /**
20
+ * Initialise the quota module with a custom file path.
21
+ * When set, all reads/writes go to this path instead of the default
22
+ * ~/.neurolink/account-quotas.json. Call before the first load/save.
23
+ */
24
+ export declare function initAccountQuota(quotaFilePath: string): void;
19
25
  /**
20
26
  * Load all persisted account quotas.
21
27
  * First call reads from disk; subsequent calls return the in-memory cache.
@@ -9,7 +9,7 @@
9
9
  * updates an in-memory cache and debounces disk writes so the request/response
10
10
  * path is never blocked by file I/O.
11
11
  */
12
- import { join } from "path";
12
+ import { dirname, join } from "path";
13
13
  import { homedir } from "os";
14
14
  import { promises as fs } from "fs";
15
15
  // ---------------------------------------------------------------------------
@@ -73,11 +73,32 @@ let memoryCache = {};
73
73
  let cacheLoaded = false;
74
74
  let dirty = false;
75
75
  let flushTimer = null;
76
+ /** Custom quota file path set via initAccountQuota(). */
77
+ let customQuotaFilePath = null;
78
+ /**
79
+ * Initialise the quota module with a custom file path.
80
+ * When set, all reads/writes go to this path instead of the default
81
+ * ~/.neurolink/account-quotas.json. Call before the first load/save.
82
+ */
83
+ export function initAccountQuota(quotaFilePath) {
84
+ customQuotaFilePath = quotaFilePath;
85
+ // Cancel any pending flush from a previous configuration so it does not
86
+ // write stale data to the new path.
87
+ if (flushTimer) {
88
+ clearTimeout(flushTimer);
89
+ flushTimer = null;
90
+ }
91
+ // Reset cache so the new path is picked up on next load
92
+ memoryCache = {};
93
+ cacheLoaded = false;
94
+ dirty = false;
95
+ }
76
96
  function getQuotaFilePath() {
77
- return join(homedir(), ".neurolink", QUOTA_FILE);
97
+ return customQuotaFilePath ?? join(homedir(), ".neurolink", QUOTA_FILE);
78
98
  }
79
99
  async function ensureDir() {
80
- const dir = join(homedir(), ".neurolink");
100
+ const filePath = getQuotaFilePath();
101
+ const dir = dirname(filePath);
81
102
  await fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => {
82
103
  // Non-fatal: directory may already exist
83
104
  });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Proxy file path resolver.
3
+ *
4
+ * In normal mode, all paths resolve under ~/.neurolink/.
5
+ * In dev mode (--dev), writable paths resolve under <cwd>/.neurolink-dev/
6
+ * so a local dev proxy never touches the global proxy's state.
7
+ *
8
+ * Read-only paths (like .env) always point to the global location
9
+ * since credentials must be shared.
10
+ *
11
+ * NOTE: Claude Code header snapshots (~/.neurolink/header-snapshots/) are
12
+ * not redirected in dev mode. They are only written when a real Claude Code
13
+ * client connects, which typically does not happen during dev testing.
14
+ */
15
+ export type ProxyPaths = {
16
+ /** Base directory for proxy state files */
17
+ stateDir: string;
18
+ /** logs/ — request/response logs */
19
+ logsDir: string;
20
+ /** account-quotas.json — per-account rate limit state */
21
+ quotaFile: string;
22
+ /** Whether this is a dev-mode isolated instance */
23
+ isDev: boolean;
24
+ };
25
+ export declare function resolveProxyPaths(dev: boolean): ProxyPaths;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Proxy file path resolver.
3
+ *
4
+ * In normal mode, all paths resolve under ~/.neurolink/.
5
+ * In dev mode (--dev), writable paths resolve under <cwd>/.neurolink-dev/
6
+ * so a local dev proxy never touches the global proxy's state.
7
+ *
8
+ * Read-only paths (like .env) always point to the global location
9
+ * since credentials must be shared.
10
+ *
11
+ * NOTE: Claude Code header snapshots (~/.neurolink/header-snapshots/) are
12
+ * not redirected in dev mode. They are only written when a real Claude Code
13
+ * client connects, which typically does not happen during dev testing.
14
+ */
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ export function resolveProxyPaths(dev) {
18
+ if (dev) {
19
+ const base = join(process.cwd(), ".neurolink-dev");
20
+ return {
21
+ stateDir: base,
22
+ logsDir: join(base, "logs"),
23
+ quotaFile: join(base, "account-quotas.json"),
24
+ isDev: true,
25
+ };
26
+ }
27
+ const base = join(homedir(), ".neurolink");
28
+ return {
29
+ stateDir: base,
30
+ logsDir: join(base, "logs"),
31
+ quotaFile: join(base, "account-quotas.json"),
32
+ isDev: false,
33
+ };
34
+ }
35
+ //# sourceMappingURL=proxyPaths.js.map
@@ -6,7 +6,7 @@
6
6
  * Useful for debugging and auditing proxy traffic.
7
7
  */
8
8
  import type { RequestAttemptLogEntry, RequestLogEntry } from "../types/index.js";
9
- export declare function initRequestLogger(enabled?: boolean): void;
9
+ export declare function initRequestLogger(enabled?: boolean, customLogsDir?: string): void;
10
10
  export declare function logRequest(entry: RequestLogEntry): Promise<void>;
11
11
  /**
12
12
  * Log an upstream attempt separately from the final request outcome.
@@ -44,13 +44,13 @@ const SENSITIVE_HEADER_NAMES = new Set([
44
44
  const SENSITIVE_HEADER_PATTERN = /token|secret|key|password|credential/i;
45
45
  /** JSON keys whose values should be redacted in request/response bodies. */
46
46
  const SENSITIVE_BODY_KEYS = /("(?:password|access_token|refresh_token|api_key|apiKey|secret|authorization|token|credential|x-api-key)"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
47
- export function initRequestLogger(enabled = true) {
47
+ export function initRequestLogger(enabled = true, customLogsDir) {
48
48
  logEnabled = enabled;
49
49
  if (!enabled) {
50
50
  return;
51
51
  }
52
52
  try {
53
- logDir = join(homedir(), ".neurolink", "logs");
53
+ logDir = customLogsDir ?? join(homedir(), ".neurolink", "logs");
54
54
  if (!existsSync(logDir)) {
55
55
  mkdirSync(logDir, { recursive: true, mode: 0o700 });
56
56
  }
@@ -765,6 +765,7 @@ export type ProxyStartArgs = {
765
765
  config?: string;
766
766
  envFile?: string;
767
767
  passthrough?: boolean;
768
+ dev?: boolean;
768
769
  };
769
770
  /** Arguments accepted by `neurolink proxy status` */
770
771
  export type ProxyStatusArgs = {
@@ -385,7 +385,7 @@ export type AgenticLoopReportType = "META" | "GOOGLEADS" | "GOOGLEGA4" | "OTHER"
385
385
  /**
386
386
  * Status of an agentic loop report
387
387
  */
388
- export type AgenticLoopReportStatus = "INPROGRESS" | "COMPLETED";
388
+ export type AgenticLoopReportStatus = "INPROGRESS" | "COMPLETED" | "CANCELLED" | "FAILED";
389
389
  /**
390
390
  * Metadata for an individual agentic loop report
391
391
  * A conversation session can have multiple reports tracked via this type
@@ -495,6 +495,8 @@ export type ConversationSummary = ConversationBase & {
495
495
  export type RedisStorageConfig = {
496
496
  /** Redis connection URL (e.g., 'rediss://host:6379' for TLS) */
497
497
  url?: string;
498
+ /** Redis username for ACL authentication (optional) */
499
+ username?: string;
498
500
  /** Redis host (default: 'localhost') */
499
501
  host?: string;
500
502
  /** Redis port (default: 6379) */
@@ -1209,6 +1209,155 @@ async function downloadImageFromUrl(url) {
1209
1209
  throw new Error(`Failed to download image from ${url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1210
1210
  }
1211
1211
  }
1212
+ /**
1213
+ * Get MIME type from file extension
1214
+ */
1215
+ function getMimeTypeFromExtension(filePath) {
1216
+ const ext = filePath.toLowerCase().split(".").pop();
1217
+ switch (ext) {
1218
+ case "png":
1219
+ return "image/png";
1220
+ case "gif":
1221
+ return "image/gif";
1222
+ case "webp":
1223
+ return "image/webp";
1224
+ case "bmp":
1225
+ return "image/bmp";
1226
+ case "tiff":
1227
+ case "tif":
1228
+ return "image/tiff";
1229
+ default:
1230
+ return "image/jpeg";
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Detect MIME type from buffer magic bytes
1235
+ * Returns undefined if format cannot be detected
1236
+ */
1237
+ function detectMimeTypeFromBuffer(buffer) {
1238
+ // JPEG: FF D8 FF
1239
+ if (buffer.length >= 3 &&
1240
+ buffer[0] === 0xff &&
1241
+ buffer[1] === 0xd8 &&
1242
+ buffer[2] === 0xff) {
1243
+ return "image/jpeg";
1244
+ }
1245
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
1246
+ if (buffer.length >= 8 &&
1247
+ buffer[0] === 0x89 &&
1248
+ buffer[1] === 0x50 &&
1249
+ buffer[2] === 0x4e &&
1250
+ buffer[3] === 0x47 &&
1251
+ buffer[4] === 0x0d &&
1252
+ buffer[5] === 0x0a &&
1253
+ buffer[6] === 0x1a &&
1254
+ buffer[7] === 0x0a) {
1255
+ return "image/png";
1256
+ }
1257
+ // GIF: 47 49 46 38 (37|39) 61
1258
+ if (buffer.length >= 6 &&
1259
+ buffer[0] === 0x47 &&
1260
+ buffer[1] === 0x49 &&
1261
+ buffer[2] === 0x46 &&
1262
+ buffer[3] === 0x38 &&
1263
+ (buffer[4] === 0x37 || buffer[4] === 0x39) &&
1264
+ buffer[5] === 0x61) {
1265
+ return "image/gif";
1266
+ }
1267
+ // WebP: 52 49 46 46 ?? ?? ?? ?? 57 45 42 50
1268
+ if (buffer.length >= 12 &&
1269
+ buffer[0] === 0x52 &&
1270
+ buffer[1] === 0x49 &&
1271
+ buffer[2] === 0x46 &&
1272
+ buffer[3] === 0x46 &&
1273
+ buffer[8] === 0x57 &&
1274
+ buffer[9] === 0x45 &&
1275
+ buffer[10] === 0x42 &&
1276
+ buffer[11] === 0x50) {
1277
+ return "image/webp";
1278
+ }
1279
+ // BMP: 42 4D
1280
+ if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) {
1281
+ return "image/bmp";
1282
+ }
1283
+ // TIFF: (49 49 2A 00) or (4D 4D 00 2A)
1284
+ if (buffer.length >= 4 &&
1285
+ ((buffer[0] === 0x49 &&
1286
+ buffer[1] === 0x49 &&
1287
+ buffer[2] === 0x2a &&
1288
+ buffer[3] === 0x00) ||
1289
+ (buffer[0] === 0x4d &&
1290
+ buffer[1] === 0x4d &&
1291
+ buffer[2] === 0x00 &&
1292
+ buffer[3] === 0x2a))) {
1293
+ return "image/tiff";
1294
+ }
1295
+ return undefined;
1296
+ }
1297
+ /**
1298
+ * Convert file path to raw base64 string.
1299
+ * Returns raw base64 (not a data: URI) to avoid SSRF validation in AI SDK v6.
1300
+ */
1301
+ function convertFilePathToBase64(filePath) {
1302
+ if (!existsSync(filePath)) {
1303
+ throw new Error(`Image file not found: ${filePath}`);
1304
+ }
1305
+ const buffer = readFileSync(filePath);
1306
+ return buffer.toString("base64");
1307
+ }
1308
+ /**
1309
+ * Process a single image input and convert to raw base64 format.
1310
+ * IMPORTANT: Returns raw base64 (not a data: URI) to avoid SSRF validation
1311
+ * in Vercel AI SDK v6. The SDK calls `new URL(image)` on string values;
1312
+ * a data: URI is a valid URL, causing the SDK to "download" it and hit
1313
+ * validateDownloadUrl which throws "URL scheme must be http or https, got data:".
1314
+ * Passing raw base64 avoids this because `new URL(base64string)` throws and
1315
+ * the SDK treats the string as inline base64 data instead.
1316
+ */
1317
+ function processImageToBase64(image, index) {
1318
+ let imageData;
1319
+ let mimeType = "image/jpeg"; // Default mime type
1320
+ if (typeof image === "string") {
1321
+ if (image.startsWith("data:")) {
1322
+ // Data URI (including downloaded URLs) - extract mime type and raw base64
1323
+ const match = image.match(/^data:([^;]+);base64,(.+)$/);
1324
+ if (match) {
1325
+ mimeType = match[1];
1326
+ imageData = match[2]; // Raw base64 only — NOT the full data: URI
1327
+ }
1328
+ else {
1329
+ imageData = image;
1330
+ }
1331
+ }
1332
+ else if (isInternetUrl(image)) {
1333
+ // This should not happen as URLs are processed separately
1334
+ throw new Error(`Unprocessed URL found in actualImages: ${image}`);
1335
+ }
1336
+ else {
1337
+ // File path string - convert to raw base64
1338
+ try {
1339
+ imageData = convertFilePathToBase64(image);
1340
+ mimeType = getMimeTypeFromExtension(image);
1341
+ }
1342
+ catch (error) {
1343
+ MultimodalLogger.logError("FILE_PATH_CONVERSION", error, {
1344
+ index,
1345
+ filePath: image,
1346
+ });
1347
+ throw new Error(`Failed to convert file path to base64: ${image}. ${error}`, { cause: error });
1348
+ }
1349
+ }
1350
+ }
1351
+ else {
1352
+ // Buffer - convert to raw base64 with proper MIME type detection
1353
+ const detectedMimeType = detectMimeTypeFromBuffer(image);
1354
+ if (detectedMimeType) {
1355
+ mimeType = detectedMimeType;
1356
+ }
1357
+ imageData = image.toString("base64");
1358
+ }
1359
+ return { imageData, mimeType };
1360
+ }
1212
1361
  /**
1213
1362
  * Convert simple images format to Vercel AI SDK format with smart auto-detection
1214
1363
  * - URLs: Downloaded and converted to base64 for Vercel AI SDK compatibility
@@ -1270,80 +1419,8 @@ async function convertSimpleImagesToProviderFormat(text, images, provider, _mode
1270
1419
  // Process all images (including downloaded URLs) for Vercel AI SDK
1271
1420
  actualImages.forEach(({ data: image }, index) => {
1272
1421
  try {
1273
- // Vercel AI SDK v6 expects { type: 'image', image: Buffer | string, mimeType?: string }
1274
- // IMPORTANT: The `image` field must be raw base64 or a Buffer — NOT a data: URI string.
1275
- // The AI SDK v6's download pipeline calls `new URL(image)` on string values. A data: URI
1276
- // is a valid URL, so the SDK tries to "download" it, which hits SSRF validation
1277
- // (validateDownloadUrl) and throws "URL scheme must be http or https, got data:".
1278
- // Passing raw base64 avoids this because `new URL(base64string)` throws and the SDK
1279
- // treats the string as inline base64 data instead.
1280
- let imageData;
1281
- let mimeType = "image/jpeg"; // Default mime type
1282
- if (typeof image === "string") {
1283
- if (image.startsWith("data:")) {
1284
- // Data URI (including downloaded URLs) - extract mime type and raw base64
1285
- const match = image.match(/^data:([^;]+);base64,(.+)$/);
1286
- if (match) {
1287
- mimeType = match[1];
1288
- imageData = match[2]; // Raw base64 only — NOT the full data: URI
1289
- }
1290
- else {
1291
- imageData = image;
1292
- }
1293
- }
1294
- else if (isInternetUrl(image)) {
1295
- // This should not happen as URLs are processed separately above
1296
- // But handle it gracefully just in case
1297
- throw new Error(`Unprocessed URL found in actualImages: ${image}`);
1298
- }
1299
- else {
1300
- // File path string - convert to base64
1301
- try {
1302
- if (existsSync(image)) {
1303
- const buffer = readFileSync(image);
1304
- const base64 = buffer.toString("base64");
1305
- // Detect mime type from file extension
1306
- const ext = image.toLowerCase().split(".").pop();
1307
- switch (ext) {
1308
- case "png":
1309
- mimeType = "image/png";
1310
- break;
1311
- case "gif":
1312
- mimeType = "image/gif";
1313
- break;
1314
- case "webp":
1315
- mimeType = "image/webp";
1316
- break;
1317
- case "bmp":
1318
- mimeType = "image/bmp";
1319
- break;
1320
- case "tiff":
1321
- case "tif":
1322
- mimeType = "image/tiff";
1323
- break;
1324
- default:
1325
- mimeType = "image/jpeg";
1326
- break;
1327
- }
1328
- imageData = base64; // Raw base64 only
1329
- }
1330
- else {
1331
- throw new Error(`Image file not found: ${image}`);
1332
- }
1333
- }
1334
- catch (error) {
1335
- MultimodalLogger.logError("FILE_PATH_CONVERSION", error, {
1336
- index,
1337
- filePath: image,
1338
- });
1339
- throw new Error(`Failed to convert file path to base64: ${image}. ${error}`, { cause: error });
1340
- }
1341
- }
1342
- }
1343
- else {
1344
- // Buffer - convert to raw base64
1345
- imageData = image.toString("base64");
1346
- }
1422
+ // Use helper function to process image and reduce nesting depth
1423
+ const { imageData, mimeType } = processImageToBase64(image, index);
1347
1424
  content.push({
1348
1425
  type: "image",
1349
1426
  image: imageData,
@@ -353,6 +353,7 @@ export function getNormalizedConfig(config) {
353
353
  const defaultUserSessionsPrefix = keyPrefix.replace(/conversation:?$/, "user:sessions:");
354
354
  let host = config.host || "localhost";
355
355
  let port = config.port || 6379;
356
+ let username = config.username || "";
356
357
  let password = config.password || "";
357
358
  let db = config.db || 0;
358
359
  let url = config.url;
@@ -361,13 +362,14 @@ export function getNormalizedConfig(config) {
361
362
  const parsedUrl = new URL(url);
362
363
  host = parsedUrl.hostname;
363
364
  port = parsedUrl.port ? parseInt(parsedUrl.port) : 6379;
365
+ username = parsedUrl.username || username;
364
366
  password = parsedUrl.password || password;
365
367
  db = parsedUrl.pathname
366
368
  ? parseInt(parsedUrl.pathname.replace("/", "")) || 0
367
369
  : 0;
368
370
  }
369
371
  catch (e) {
370
- const sanitizedUrl = url.replace(/:\/\/[^:]+:[^@]+@/, "://[redacted]@");
372
+ const sanitizedUrl = url.replace(/:\/\/[^@]+@/, "://[redacted]@");
371
373
  logger.warn("[redisUtils] Failed to parse Redis URL, falling back to component-based connection", {
372
374
  url: sanitizedUrl,
373
375
  error: e instanceof Error ? e.message : String(e),
@@ -380,6 +382,7 @@ export function getNormalizedConfig(config) {
380
382
  host,
381
383
  port,
382
384
  password,
385
+ username,
383
386
  db,
384
387
  keyPrefix,
385
388
  userSessionsKeyPrefix: config.userSessionsKeyPrefix || defaultUserSessionsPrefix,