@juspay/neurolink 10.8.0 → 10.8.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.
@@ -10,6 +10,7 @@ function printAnalysis(report) {
10
10
  logger.always(chalk.bold.cyan("NeuroLink Proxy Analysis"));
11
11
  logger.always(chalk.gray("=".repeat(50)));
12
12
  logger.always(` Since: ${chalk.cyan(report.since)}`);
13
+ logger.always(` Until: ${chalk.cyan(report.until)}`);
13
14
  logger.always(` Logs: ${chalk.cyan(report.logsDir)}`);
14
15
  logger.always(` Files: ${report.files.requests} request, ${report.files.attempts} attempt, ${report.files.lifecycle} lifecycle, ${report.files.debug} debug`);
15
16
  logger.always("");
@@ -133,6 +134,10 @@ export const proxyAnalyzeCommand = {
133
134
  type: "string",
134
135
  default: "24h",
135
136
  description: "ISO timestamp or lookback such as 6h, 1d, or 1w",
137
+ })
138
+ .option("until", {
139
+ type: "string",
140
+ description: "ISO timestamp or lookback such as 6h, 1d, or 1w; defaults to analysis start time",
136
141
  })
137
142
  .option("format", {
138
143
  type: "string",
@@ -152,6 +157,7 @@ export const proxyAnalyzeCommand = {
152
157
  const report = await analyzeProxyLogs({
153
158
  logsDir: argv.logsDir,
154
159
  since: argv.since,
160
+ until: argv.until,
155
161
  });
156
162
  if (argv.format === "json") {
157
163
  logger.always(JSON.stringify(report, null, 2));
@@ -718,7 +718,7 @@ export declare class NeuroLink {
718
718
  * Curator P2-3: wraps a generate/stream call with the fallback
719
719
  * orchestration (`providerFallback` callback + `modelChain` walker).
720
720
  *
721
- * On a model-access-denied error from the inner call:
721
+ * On a qualifying error from the inner call:
722
722
  * 1. Resolve the effective callback (per-call > instance > synthesised
723
723
  * from modelChain) and the effective chain (per-call > instance).
724
724
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -726,6 +726,12 @@ export declare class NeuroLink {
726
726
  * model}.
727
727
  * 3. Stop on first success, on a callback returning null, or after
728
728
  * exhausting the chain (throw the most recent error).
729
+ *
730
+ * What qualifies depends on how fallback was configured: an EXPLICIT
731
+ * `providerFallback` callback is consulted for any error except client
732
+ * aborts (the callback owns the decision — it receives the error
733
+ * unmodified and can return null to bubble), while modelChain-only
734
+ * configs keep the narrow model-access-denied gate.
729
735
  */
730
736
  private runWithFallbackOrchestration;
731
737
  private attemptInner;
@@ -3261,7 +3261,7 @@ Current user's request: ${currentInput}`;
3261
3261
  * Curator P2-3: wraps a generate/stream call with the fallback
3262
3262
  * orchestration (`providerFallback` callback + `modelChain` walker).
3263
3263
  *
3264
- * On a model-access-denied error from the inner call:
3264
+ * On a qualifying error from the inner call:
3265
3265
  * 1. Resolve the effective callback (per-call > instance > synthesised
3266
3266
  * from modelChain) and the effective chain (per-call > instance).
3267
3267
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -3269,6 +3269,12 @@ Current user's request: ${currentInput}`;
3269
3269
  * model}.
3270
3270
  * 3. Stop on first success, on a callback returning null, or after
3271
3271
  * exhausting the chain (throw the most recent error).
3272
+ *
3273
+ * What qualifies depends on how fallback was configured: an EXPLICIT
3274
+ * `providerFallback` callback is consulted for any error except client
3275
+ * aborts (the callback owns the decision — it receives the error
3276
+ * unmodified and can return null to bubble), while modelChain-only
3277
+ * configs keep the narrow model-access-denied gate.
3272
3278
  */
3273
3279
  async runWithFallbackOrchestration(optionsOrPrompt, kind, inner) {
3274
3280
  const initialAttempt = await this.attemptInner(inner, optionsOrPrompt);
@@ -3276,10 +3282,8 @@ Current user's request: ${currentInput}`;
3276
3282
  return initialAttempt.ok;
3277
3283
  }
3278
3284
  let lastError = initialAttempt.error;
3279
- if (!looksLikeModelAccessDenied(lastError)) {
3280
- throw lastError;
3281
- }
3282
- // Build the chain orchestration.
3285
+ // Resolve the fallback configuration BEFORE gating so the gate can
3286
+ // distinguish an explicit callback from a modelChain-only setup.
3283
3287
  const requestedProvider = (typeof optionsOrPrompt === "object"
3284
3288
  ? optionsOrPrompt.provider
3285
3289
  : undefined);
@@ -3293,6 +3297,14 @@ Current user's request: ${currentInput}`;
3293
3297
  const perCallChain = callOpts.modelChain;
3294
3298
  const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
3295
3299
  const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
3300
+ // Explicit callback (per-call or instance providerFallback): the callback
3301
+ // owns the decision for any error except client aborts — it can return
3302
+ // null to bubble. modelChain-only keeps the narrow model-access-denied
3303
+ // gate so chain walkers don't retry errors the chain can't fix.
3304
+ const shouldOrchestrateFallback = (err) => effectiveCallback ? !isAbortError(err) : looksLikeModelAccessDenied(err);
3305
+ if (!shouldOrchestrateFallback(lastError)) {
3306
+ throw lastError;
3307
+ }
3296
3308
  if (!effectiveCallback && !effectiveChain) {
3297
3309
  throw lastError;
3298
3310
  }
@@ -3364,7 +3376,7 @@ Current user's request: ${currentInput}`;
3364
3376
  }
3365
3377
  lastError = retryAttempt.error;
3366
3378
  attemptedRequestedModel = next.model ?? attemptedRequestedModel;
3367
- if (!looksLikeModelAccessDenied(lastError)) {
3379
+ if (!shouldOrchestrateFallback(lastError)) {
3368
3380
  throw lastError;
3369
3381
  }
3370
3382
  }
@@ -724,6 +724,12 @@ export class AnthropicProvider extends BaseProvider {
724
724
  headers["anthropic-beta"] = ANTHROPIC_BETA_HEADERS["anthropic-beta"];
725
725
  }
726
726
  }
727
+ if (usingProxy) {
728
+ // WAFs in front of ANTHROPIC_BASE_URL proxies commonly block the bare
729
+ // SDK UA ("Anthropic/JS x.y.z"); send the claude-cli UA the OAuth path
730
+ // already uses. Direct-to-Anthropic traffic keeps the honest SDK UA.
731
+ headers["User-Agent"] = CLAUDE_CLI_USER_AGENT;
732
+ }
727
733
  // Add subscription-specific headers if applicable
728
734
  if (this.subscriptionTier !== "api") {
729
735
  headers["x-subscription-tier"] = this.subscriptionTier;
@@ -212,6 +212,19 @@ function parseSince(value, nowMs) {
212
212
  }
213
213
  return parsed;
214
214
  }
215
+ function parseUntil(value, nowMs) {
216
+ let parsed;
217
+ try {
218
+ parsed = parseSince(value, nowMs);
219
+ }
220
+ catch {
221
+ throw new Error(`Invalid --until value "${value}". Use an ISO timestamp or a duration such as 6h, 1d, or 1w.`);
222
+ }
223
+ if (parsed > nowMs) {
224
+ throw new Error(`Invalid --until value "${value}". It must not be later than the analysis start time.`);
225
+ }
226
+ return parsed;
227
+ }
215
228
  async function readJsonLines(filePath, onRecord, onMalformed) {
216
229
  let linesRead = 0;
217
230
  const lines = createInterface({
@@ -412,6 +425,10 @@ async function discoverLogFiles(logsDir) {
412
425
  export async function analyzeProxyLogs(options) {
413
426
  const nowMs = options?.nowMs ?? Date.now();
414
427
  const sinceMs = parseSince(options?.since ?? "24h", nowMs);
428
+ const untilMs = options?.until ? parseUntil(options.until, nowMs) : nowMs;
429
+ if (untilMs < sinceMs) {
430
+ throw new Error(`Invalid analysis window: --until must not be earlier than --since.`);
431
+ }
415
432
  const logsDir = resolve(options?.logsDir ?? join(homedir(), ".neurolink", "logs"));
416
433
  const { lifecycleFiles, requestFiles, attemptFiles, debugFiles } = await discoverLogFiles(logsDir);
417
434
  const observedRanges = {
@@ -448,7 +465,7 @@ export async function analyzeProxyLogs(options) {
448
465
  for (const filePath of lifecycleFiles) {
449
466
  linesRead += await readJsonLines(filePath, (record) => {
450
467
  const timestamp = observeTimestamp("lifecycle", record);
451
- if (timestamp === null || timestamp < sinceMs) {
468
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
452
469
  return;
453
470
  }
454
471
  const event = stringValue(record.event);
@@ -530,7 +547,7 @@ export async function analyzeProxyLogs(options) {
530
547
  for (const filePath of attemptFiles) {
531
548
  linesRead += await readJsonLines(filePath, (record) => {
532
549
  const timestamp = observeTimestamp("attempts", record);
533
- if (timestamp === null || timestamp < sinceMs) {
550
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
534
551
  return;
535
552
  }
536
553
  const requestId = stringValue(record.requestId);
@@ -595,7 +612,7 @@ export async function analyzeProxyLogs(options) {
595
612
  for (const filePath of requestFiles) {
596
613
  linesRead += await readJsonLines(filePath, (record) => {
597
614
  const timestamp = observeTimestamp("requests", record);
598
- if (timestamp === null || timestamp < sinceMs) {
615
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
599
616
  return;
600
617
  }
601
618
  const requestId = stringValue(record.requestId);
@@ -649,7 +666,7 @@ export async function analyzeProxyLogs(options) {
649
666
  for (const filePath of debugFiles) {
650
667
  linesRead += await readJsonLines(filePath, (record) => {
651
668
  const timestamp = observeTimestamp("debug", record);
652
- if (timestamp === null || timestamp < sinceMs) {
669
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
653
670
  return;
654
671
  }
655
672
  if (record.type !== "body_capture") {
@@ -709,6 +726,7 @@ export async function analyzeProxyLogs(options) {
709
726
  return {
710
727
  generatedAt: new Date(nowMs).toISOString(),
711
728
  since: new Date(sinceMs).toISOString(),
729
+ until: new Date(untilMs).toISOString(),
712
730
  logsDir,
713
731
  files: {
714
732
  lifecycle: lifecycleFiles.length,
@@ -795,6 +795,7 @@ export type ProxyStatusArgs = {
795
795
  export type ProxyAnalyzeArgs = {
796
796
  logsDir?: string;
797
797
  since?: string;
798
+ until?: string;
798
799
  format?: "text" | "json";
799
800
  quiet?: boolean;
800
801
  };
@@ -27,10 +27,14 @@ export type NeuroLinkConfig = {
27
27
  [key: string]: unknown;
28
28
  };
29
29
  /**
30
- * Curator P2-3: callback signature for centralized fallback policy. Invoked
31
- * when a generate/stream call fails with what looks like a model-access-denied
32
- * error. Return `{ provider, model }` (either / both optional) to drive a
33
- * retry; return `null` to bubble the original error untouched.
30
+ * Curator P2-3: callback signature for centralized fallback policy. When an
31
+ * explicit callback is configured (per-call or instance), it is invoked for
32
+ * ANY error thrown by a generate/stream call except client aborts network
33
+ * errors, 5xx, timeouts, auth failures included. The callback receives the
34
+ * error unmodified so hosts can classify it themselves (status codes,
35
+ * `isNonRetryableProviderError`, …). Return `{ provider, model }` (either /
36
+ * both optional) to drive a retry; return `null` to bubble the original
37
+ * error untouched.
34
38
  */
35
39
  export type ProviderFallbackCallback = (error: unknown) => Promise<{
36
40
  provider?: string;
@@ -65,16 +69,21 @@ export type NeurolinkConstructorConfig = {
65
69
  */
66
70
  credentials?: NeurolinkCredentials;
67
71
  /**
68
- * Curator P2-3: callback invoked on model-access-denied. Lets a host (e.g.
69
- * Curator) centrally drive fallback policy. The callback receives the
70
- * original error and returns the next `{ provider, model }` to try, or
71
- * `null` to bubble the error.
72
+ * Curator P2-3: callback invoked when a generate/stream call fails with
73
+ * any error except a client abort (network errors, 5xx, timeouts, auth
74
+ * failures, model-access-denied, …). Lets a host (e.g. Curator) centrally
75
+ * drive fallback policy "provider A primary, provider B on failure".
76
+ * The callback receives the original error unmodified and returns the
77
+ * next `{ provider, model }` to try, or `null` to bubble the error.
72
78
  */
73
79
  providerFallback?: ProviderFallbackCallback;
74
80
  /**
75
- * Curator P2-3: ordered list of model names to try in sequence on
76
- * model-access-denied. Sugar over `providerFallback`. The current
77
- * provider is preserved across the chain; only the model name changes.
81
+ * Curator P2-3: ordered list of model names to try in sequence. Sugar
82
+ * over `providerFallback`, but with a narrower trigger: without an
83
+ * explicit callback the chain only advances on model-access-denied
84
+ * errors — other failures (network, 5xx, timeouts) bubble immediately.
85
+ * The current provider is preserved across the chain; only the model
86
+ * name changes.
78
87
  */
79
88
  modelChain?: string[];
80
89
  /**
@@ -577,6 +577,9 @@ export type GenerateOptions = {
577
577
  /**
578
578
  * Curator P2-3: per-call fallback callback. Overrides any
579
579
  * instance-level `providerFallback` set on `new NeuroLink({...})`.
580
+ * Invoked for any error except client aborts (network errors, 5xx,
581
+ * timeouts, auth failures, model-access-denied, …); receives the error
582
+ * unmodified. Return `{ provider, model }` to retry, `null` to bubble.
580
583
  */
581
584
  providerFallback?: (error: unknown) => Promise<{
582
585
  provider?: string;
@@ -584,7 +587,9 @@ export type GenerateOptions = {
584
587
  } | null>;
585
588
  /**
586
589
  * Curator P2-3: per-call ordered model chain. Overrides any
587
- * instance-level `modelChain`. Tried in order on model-access-denied.
590
+ * instance-level `modelChain`. Without an explicit `providerFallback`
591
+ * callback the chain only advances on model-access-denied errors —
592
+ * other failures (network, 5xx, timeouts) bubble immediately.
588
593
  */
589
594
  modelChain?: string[];
590
595
  /**
@@ -1247,6 +1247,7 @@ export type ProxyAnalysisStreamName = "lifecycle" | "requests" | "attempts" | "d
1247
1247
  export type ProxyAnalysisReport = {
1248
1248
  generatedAt: string;
1249
1249
  since: string;
1250
+ until: string;
1250
1251
  logsDir: string;
1251
1252
  files: {
1252
1253
  lifecycle: number;
@@ -1352,6 +1353,7 @@ export type ProxyAnalysisReport = {
1352
1353
  export type ProxyAnalysisOptions = {
1353
1354
  logsDir?: string;
1354
1355
  since?: string;
1356
+ until?: string;
1355
1357
  nowMs?: number;
1356
1358
  };
1357
1359
  /** Attempt timing retained while joining offline proxy log records. */
@@ -487,6 +487,11 @@ export type StreamOptions = {
487
487
  /**
488
488
  * Curator P2-3: per-call fallback callback. Overrides any
489
489
  * instance-level `providerFallback` set on `new NeuroLink({...})`.
490
+ * Invoked for any error thrown while establishing the stream, except
491
+ * client aborts (network errors, 5xx, timeouts, auth failures,
492
+ * model-access-denied, …); receives the error unmodified. There is no
493
+ * mid-stream resume once chunks are flowing. Return `{ provider,
494
+ * model }` to retry, `null` to bubble.
490
495
  */
491
496
  providerFallback?: (error: unknown) => Promise<{
492
497
  provider?: string;
@@ -494,7 +499,9 @@ export type StreamOptions = {
494
499
  } | null>;
495
500
  /**
496
501
  * Curator P2-3: per-call ordered model chain. Overrides any
497
- * instance-level `modelChain`. Tried in order on model-access-denied.
502
+ * instance-level `modelChain`. Without an explicit `providerFallback`
503
+ * callback the chain only advances on model-access-denied errors —
504
+ * other failures (network, 5xx, timeouts) bubble immediately.
498
505
  */
499
506
  modelChain?: string[];
500
507
  /**
@@ -718,7 +718,7 @@ export declare class NeuroLink {
718
718
  * Curator P2-3: wraps a generate/stream call with the fallback
719
719
  * orchestration (`providerFallback` callback + `modelChain` walker).
720
720
  *
721
- * On a model-access-denied error from the inner call:
721
+ * On a qualifying error from the inner call:
722
722
  * 1. Resolve the effective callback (per-call > instance > synthesised
723
723
  * from modelChain) and the effective chain (per-call > instance).
724
724
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -726,6 +726,12 @@ export declare class NeuroLink {
726
726
  * model}.
727
727
  * 3. Stop on first success, on a callback returning null, or after
728
728
  * exhausting the chain (throw the most recent error).
729
+ *
730
+ * What qualifies depends on how fallback was configured: an EXPLICIT
731
+ * `providerFallback` callback is consulted for any error except client
732
+ * aborts (the callback owns the decision — it receives the error
733
+ * unmodified and can return null to bubble), while modelChain-only
734
+ * configs keep the narrow model-access-denied gate.
729
735
  */
730
736
  private runWithFallbackOrchestration;
731
737
  private attemptInner;
package/dist/neurolink.js CHANGED
@@ -3261,7 +3261,7 @@ Current user's request: ${currentInput}`;
3261
3261
  * Curator P2-3: wraps a generate/stream call with the fallback
3262
3262
  * orchestration (`providerFallback` callback + `modelChain` walker).
3263
3263
  *
3264
- * On a model-access-denied error from the inner call:
3264
+ * On a qualifying error from the inner call:
3265
3265
  * 1. Resolve the effective callback (per-call > instance > synthesised
3266
3266
  * from modelChain) and the effective chain (per-call > instance).
3267
3267
  * 2. Walk attempts: invoke callback (or pop next chain entry) → emit
@@ -3269,6 +3269,12 @@ Current user's request: ${currentInput}`;
3269
3269
  * model}.
3270
3270
  * 3. Stop on first success, on a callback returning null, or after
3271
3271
  * exhausting the chain (throw the most recent error).
3272
+ *
3273
+ * What qualifies depends on how fallback was configured: an EXPLICIT
3274
+ * `providerFallback` callback is consulted for any error except client
3275
+ * aborts (the callback owns the decision — it receives the error
3276
+ * unmodified and can return null to bubble), while modelChain-only
3277
+ * configs keep the narrow model-access-denied gate.
3272
3278
  */
3273
3279
  async runWithFallbackOrchestration(optionsOrPrompt, kind, inner) {
3274
3280
  const initialAttempt = await this.attemptInner(inner, optionsOrPrompt);
@@ -3276,10 +3282,8 @@ Current user's request: ${currentInput}`;
3276
3282
  return initialAttempt.ok;
3277
3283
  }
3278
3284
  let lastError = initialAttempt.error;
3279
- if (!looksLikeModelAccessDenied(lastError)) {
3280
- throw lastError;
3281
- }
3282
- // Build the chain orchestration.
3285
+ // Resolve the fallback configuration BEFORE gating so the gate can
3286
+ // distinguish an explicit callback from a modelChain-only setup.
3283
3287
  const requestedProvider = (typeof optionsOrPrompt === "object"
3284
3288
  ? optionsOrPrompt.provider
3285
3289
  : undefined);
@@ -3293,6 +3297,14 @@ Current user's request: ${currentInput}`;
3293
3297
  const perCallChain = callOpts.modelChain;
3294
3298
  const effectiveCallback = perCallCallback ?? this.fallbackConfig.providerFallback;
3295
3299
  const effectiveChain = perCallChain ?? this.fallbackConfig.modelChain;
3300
+ // Explicit callback (per-call or instance providerFallback): the callback
3301
+ // owns the decision for any error except client aborts — it can return
3302
+ // null to bubble. modelChain-only keeps the narrow model-access-denied
3303
+ // gate so chain walkers don't retry errors the chain can't fix.
3304
+ const shouldOrchestrateFallback = (err) => effectiveCallback ? !isAbortError(err) : looksLikeModelAccessDenied(err);
3305
+ if (!shouldOrchestrateFallback(lastError)) {
3306
+ throw lastError;
3307
+ }
3296
3308
  if (!effectiveCallback && !effectiveChain) {
3297
3309
  throw lastError;
3298
3310
  }
@@ -3364,7 +3376,7 @@ Current user's request: ${currentInput}`;
3364
3376
  }
3365
3377
  lastError = retryAttempt.error;
3366
3378
  attemptedRequestedModel = next.model ?? attemptedRequestedModel;
3367
- if (!looksLikeModelAccessDenied(lastError)) {
3379
+ if (!shouldOrchestrateFallback(lastError)) {
3368
3380
  throw lastError;
3369
3381
  }
3370
3382
  }
@@ -724,6 +724,12 @@ export class AnthropicProvider extends BaseProvider {
724
724
  headers["anthropic-beta"] = ANTHROPIC_BETA_HEADERS["anthropic-beta"];
725
725
  }
726
726
  }
727
+ if (usingProxy) {
728
+ // WAFs in front of ANTHROPIC_BASE_URL proxies commonly block the bare
729
+ // SDK UA ("Anthropic/JS x.y.z"); send the claude-cli UA the OAuth path
730
+ // already uses. Direct-to-Anthropic traffic keeps the honest SDK UA.
731
+ headers["User-Agent"] = CLAUDE_CLI_USER_AGENT;
732
+ }
727
733
  // Add subscription-specific headers if applicable
728
734
  if (this.subscriptionTier !== "api") {
729
735
  headers["x-subscription-tier"] = this.subscriptionTier;
@@ -212,6 +212,19 @@ function parseSince(value, nowMs) {
212
212
  }
213
213
  return parsed;
214
214
  }
215
+ function parseUntil(value, nowMs) {
216
+ let parsed;
217
+ try {
218
+ parsed = parseSince(value, nowMs);
219
+ }
220
+ catch {
221
+ throw new Error(`Invalid --until value "${value}". Use an ISO timestamp or a duration such as 6h, 1d, or 1w.`);
222
+ }
223
+ if (parsed > nowMs) {
224
+ throw new Error(`Invalid --until value "${value}". It must not be later than the analysis start time.`);
225
+ }
226
+ return parsed;
227
+ }
215
228
  async function readJsonLines(filePath, onRecord, onMalformed) {
216
229
  let linesRead = 0;
217
230
  const lines = createInterface({
@@ -412,6 +425,10 @@ async function discoverLogFiles(logsDir) {
412
425
  export async function analyzeProxyLogs(options) {
413
426
  const nowMs = options?.nowMs ?? Date.now();
414
427
  const sinceMs = parseSince(options?.since ?? "24h", nowMs);
428
+ const untilMs = options?.until ? parseUntil(options.until, nowMs) : nowMs;
429
+ if (untilMs < sinceMs) {
430
+ throw new Error(`Invalid analysis window: --until must not be earlier than --since.`);
431
+ }
415
432
  const logsDir = resolve(options?.logsDir ?? join(homedir(), ".neurolink", "logs"));
416
433
  const { lifecycleFiles, requestFiles, attemptFiles, debugFiles } = await discoverLogFiles(logsDir);
417
434
  const observedRanges = {
@@ -448,7 +465,7 @@ export async function analyzeProxyLogs(options) {
448
465
  for (const filePath of lifecycleFiles) {
449
466
  linesRead += await readJsonLines(filePath, (record) => {
450
467
  const timestamp = observeTimestamp("lifecycle", record);
451
- if (timestamp === null || timestamp < sinceMs) {
468
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
452
469
  return;
453
470
  }
454
471
  const event = stringValue(record.event);
@@ -530,7 +547,7 @@ export async function analyzeProxyLogs(options) {
530
547
  for (const filePath of attemptFiles) {
531
548
  linesRead += await readJsonLines(filePath, (record) => {
532
549
  const timestamp = observeTimestamp("attempts", record);
533
- if (timestamp === null || timestamp < sinceMs) {
550
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
534
551
  return;
535
552
  }
536
553
  const requestId = stringValue(record.requestId);
@@ -595,7 +612,7 @@ export async function analyzeProxyLogs(options) {
595
612
  for (const filePath of requestFiles) {
596
613
  linesRead += await readJsonLines(filePath, (record) => {
597
614
  const timestamp = observeTimestamp("requests", record);
598
- if (timestamp === null || timestamp < sinceMs) {
615
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
599
616
  return;
600
617
  }
601
618
  const requestId = stringValue(record.requestId);
@@ -649,7 +666,7 @@ export async function analyzeProxyLogs(options) {
649
666
  for (const filePath of debugFiles) {
650
667
  linesRead += await readJsonLines(filePath, (record) => {
651
668
  const timestamp = observeTimestamp("debug", record);
652
- if (timestamp === null || timestamp < sinceMs) {
669
+ if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
653
670
  return;
654
671
  }
655
672
  if (record.type !== "body_capture") {
@@ -709,6 +726,7 @@ export async function analyzeProxyLogs(options) {
709
726
  return {
710
727
  generatedAt: new Date(nowMs).toISOString(),
711
728
  since: new Date(sinceMs).toISOString(),
729
+ until: new Date(untilMs).toISOString(),
712
730
  logsDir,
713
731
  files: {
714
732
  lifecycle: lifecycleFiles.length,
@@ -795,6 +795,7 @@ export type ProxyStatusArgs = {
795
795
  export type ProxyAnalyzeArgs = {
796
796
  logsDir?: string;
797
797
  since?: string;
798
+ until?: string;
798
799
  format?: "text" | "json";
799
800
  quiet?: boolean;
800
801
  };
@@ -27,10 +27,14 @@ export type NeuroLinkConfig = {
27
27
  [key: string]: unknown;
28
28
  };
29
29
  /**
30
- * Curator P2-3: callback signature for centralized fallback policy. Invoked
31
- * when a generate/stream call fails with what looks like a model-access-denied
32
- * error. Return `{ provider, model }` (either / both optional) to drive a
33
- * retry; return `null` to bubble the original error untouched.
30
+ * Curator P2-3: callback signature for centralized fallback policy. When an
31
+ * explicit callback is configured (per-call or instance), it is invoked for
32
+ * ANY error thrown by a generate/stream call except client aborts network
33
+ * errors, 5xx, timeouts, auth failures included. The callback receives the
34
+ * error unmodified so hosts can classify it themselves (status codes,
35
+ * `isNonRetryableProviderError`, …). Return `{ provider, model }` (either /
36
+ * both optional) to drive a retry; return `null` to bubble the original
37
+ * error untouched.
34
38
  */
35
39
  export type ProviderFallbackCallback = (error: unknown) => Promise<{
36
40
  provider?: string;
@@ -65,16 +69,21 @@ export type NeurolinkConstructorConfig = {
65
69
  */
66
70
  credentials?: NeurolinkCredentials;
67
71
  /**
68
- * Curator P2-3: callback invoked on model-access-denied. Lets a host (e.g.
69
- * Curator) centrally drive fallback policy. The callback receives the
70
- * original error and returns the next `{ provider, model }` to try, or
71
- * `null` to bubble the error.
72
+ * Curator P2-3: callback invoked when a generate/stream call fails with
73
+ * any error except a client abort (network errors, 5xx, timeouts, auth
74
+ * failures, model-access-denied, …). Lets a host (e.g. Curator) centrally
75
+ * drive fallback policy "provider A primary, provider B on failure".
76
+ * The callback receives the original error unmodified and returns the
77
+ * next `{ provider, model }` to try, or `null` to bubble the error.
72
78
  */
73
79
  providerFallback?: ProviderFallbackCallback;
74
80
  /**
75
- * Curator P2-3: ordered list of model names to try in sequence on
76
- * model-access-denied. Sugar over `providerFallback`. The current
77
- * provider is preserved across the chain; only the model name changes.
81
+ * Curator P2-3: ordered list of model names to try in sequence. Sugar
82
+ * over `providerFallback`, but with a narrower trigger: without an
83
+ * explicit callback the chain only advances on model-access-denied
84
+ * errors — other failures (network, 5xx, timeouts) bubble immediately.
85
+ * The current provider is preserved across the chain; only the model
86
+ * name changes.
78
87
  */
79
88
  modelChain?: string[];
80
89
  /**
@@ -577,6 +577,9 @@ export type GenerateOptions = {
577
577
  /**
578
578
  * Curator P2-3: per-call fallback callback. Overrides any
579
579
  * instance-level `providerFallback` set on `new NeuroLink({...})`.
580
+ * Invoked for any error except client aborts (network errors, 5xx,
581
+ * timeouts, auth failures, model-access-denied, …); receives the error
582
+ * unmodified. Return `{ provider, model }` to retry, `null` to bubble.
580
583
  */
581
584
  providerFallback?: (error: unknown) => Promise<{
582
585
  provider?: string;
@@ -584,7 +587,9 @@ export type GenerateOptions = {
584
587
  } | null>;
585
588
  /**
586
589
  * Curator P2-3: per-call ordered model chain. Overrides any
587
- * instance-level `modelChain`. Tried in order on model-access-denied.
590
+ * instance-level `modelChain`. Without an explicit `providerFallback`
591
+ * callback the chain only advances on model-access-denied errors —
592
+ * other failures (network, 5xx, timeouts) bubble immediately.
588
593
  */
589
594
  modelChain?: string[];
590
595
  /**
@@ -1247,6 +1247,7 @@ export type ProxyAnalysisStreamName = "lifecycle" | "requests" | "attempts" | "d
1247
1247
  export type ProxyAnalysisReport = {
1248
1248
  generatedAt: string;
1249
1249
  since: string;
1250
+ until: string;
1250
1251
  logsDir: string;
1251
1252
  files: {
1252
1253
  lifecycle: number;
@@ -1352,6 +1353,7 @@ export type ProxyAnalysisReport = {
1352
1353
  export type ProxyAnalysisOptions = {
1353
1354
  logsDir?: string;
1354
1355
  since?: string;
1356
+ until?: string;
1355
1357
  nowMs?: number;
1356
1358
  };
1357
1359
  /** Attempt timing retained while joining offline proxy log records. */
@@ -487,6 +487,11 @@ export type StreamOptions = {
487
487
  /**
488
488
  * Curator P2-3: per-call fallback callback. Overrides any
489
489
  * instance-level `providerFallback` set on `new NeuroLink({...})`.
490
+ * Invoked for any error thrown while establishing the stream, except
491
+ * client aborts (network errors, 5xx, timeouts, auth failures,
492
+ * model-access-denied, …); receives the error unmodified. There is no
493
+ * mid-stream resume once chunks are flowing. Return `{ provider,
494
+ * model }` to retry, `null` to bubble.
490
495
  */
491
496
  providerFallback?: (error: unknown) => Promise<{
492
497
  provider?: string;
@@ -494,7 +499,9 @@ export type StreamOptions = {
494
499
  } | null>;
495
500
  /**
496
501
  * Curator P2-3: per-call ordered model chain. Overrides any
497
- * instance-level `modelChain`. Tried in order on model-access-denied.
502
+ * instance-level `modelChain`. Without an explicit `providerFallback`
503
+ * callback the chain only advances on model-access-denied errors —
504
+ * other failures (network, 5xx, timeouts) bubble immediately.
498
505
  */
499
506
  modelChain?: string[];
500
507
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "10.8.0",
3
+ "version": "10.8.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {
@@ -94,6 +94,7 @@
94
94
  "test:mcp:limits": "npx tsx test/continuous-test-suite-mcp-output-limits.ts",
95
95
  "test:mcp:infra": "npx tsx test/continuous-test-suite-mcp-infra.ts",
96
96
  "test:providers-mocked": "npx tsx test/continuous-test-suite-providers-mocked.ts",
97
+ "test:provider-fallback": "npx tsx test/continuous-test-suite-provider-fallback.ts",
97
98
  "test:rag": "npx tsx test/continuous-test-suite-rag.ts",
98
99
  "test:vector-pinecone": "npx tsx test/continuous-test-suite-vector-pinecone.ts",
99
100
  "test:vector-pgvector": "npx tsx test/continuous-test-suite-vector-pgvector.ts",