@juspay/neurolink 11.25.2 → 11.25.4

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.
@@ -4,10 +4,6 @@ import type { NeuroLink } from "../neurolink.js";
4
4
  import type { UnknownRecord, MiddlewareFactoryOptions, StreamOptions, StreamResult, AIProvider, AnalyticsData, EnhancedGenerateResult, TextGenerationOptions, TextGenerationResult, ValidationSchema } from "../types/index.js";
5
5
  import { TelemetryHandler } from "./modules/TelemetryHandler.js";
6
6
  import type { LanguageModel, ModelMessage, Tool, ToolCallRepairFunction, ToolSet } from "../types/index.js";
7
- /**
8
- * Abstract base class for all AI providers
9
- * Tools are integrated as first-class citizens - always available by default
10
- */
11
7
  export declare abstract class BaseProvider implements AIProvider {
12
8
  protected modelName: string;
13
9
  protected readonly providerName: AIProviderName;
@@ -387,6 +383,56 @@ export declare abstract class BaseProvider implements AIProvider {
387
383
  * original identity so that isAbortError() can detect them in
388
384
  * retry/fallback loops (directProviderGeneration, performMCPGenerationRetries).
389
385
  */
386
+ /**
387
+ * Classify an error that escaped while the consumer was ITERATING a stream.
388
+ *
389
+ * `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider
390
+ * that discovers its failure lazily throws on first pull instead — so the raw upstream
391
+ * error reached the consumer with no provider tag and no classification, while the same
392
+ * failure through `generate()` was classified normally. Measured on OpenAI:
393
+ *
394
+ * streaming "You have no credits remaining."
395
+ * non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
396
+ *
397
+ * Three guards. Only the third is load-bearing against today's code; the first two are
398
+ * deliberate depth against a hazard that is real but not currently reachable. Measured,
399
+ * rather than assumed — see the note after the list.
400
+ *
401
+ * 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark,
402
+ * including the two formatters whose results escape the ProviderError hierarchy
403
+ * (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both
404
+ * extend Error directly). Without this they would be classified a second time and
405
+ * degraded.
406
+ * 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY,
407
+ * bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its
408
+ * own streaming catch (`anthropic/client.ts`), and its result is a ProviderError.
409
+ * 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider
410
+ * failure. `classifyProviderError` ends in an unconditional catch-all
411
+ * (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not
412
+ * gated on the error having come off the wire. Measured with the guard absent:
413
+ * TypeError "Cannot read properties of undefined (reading 'content')"
414
+ * became ProviderError "[openai] openai error: Cannot read properties of undefined..."
415
+ * which would hide a real defect behind a plausible provider message.
416
+ *
417
+ * WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is
418
+ * attached to a formatted error in exactly one place — `handleProviderError` below — and
419
+ * that same method applies the stamp. So a ProviderError carrying a status is always
420
+ * stamped (caught by guard 1's own condition), and a ProviderError produced by a direct
421
+ * `formatProviderError` call carries no status, so guard 3 already returns it untouched.
422
+ * Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked
423
+ * provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was
424
+ * byte-identical (`RateLimitError`, one `[anthropic]` prefix).
425
+ *
426
+ * They remain because the hazard they cover is measured and real: `handleProviderError`
427
+ * is NOT idempotent — it copies statusCode onto its own output, so a second pass
428
+ * re-matches the bare 429 rule and DEGRADES the classification:
429
+ * pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..."
430
+ * pass 2 RateLimitError "...rate limit exceeded..."
431
+ * The day any provider attaches a status to an error it formats itself, guard 3 stops
432
+ * covering that case and this degradation becomes live. Cheap insurance, not dead code —
433
+ * but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is.
434
+ */
435
+ protected classifyStreamError(error: unknown): Error;
390
436
  protected handleProviderError(error: unknown): Error;
391
437
  /**
392
438
  * Image generation method. Providers that support it should override this.
@@ -5,6 +5,7 @@ import { MiddlewareFactory } from "../middleware/factory.js";
5
5
  import { modelSupports } from "../models/modelRegistry.js";
6
6
  import { ATTR, tracers } from "../telemetry/index.js";
7
7
  import { ERROR_CODES, isAbortError, NeuroLinkError, } from "../utils/errorHandling.js";
8
+ import { ProviderError } from "../types/index.js";
8
9
  import { sanitizeErrorCause } from "../utils/logSanitize.js";
9
10
  import { createAnalytics as buildAnalytics } from "./analytics.js";
10
11
  import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
@@ -48,6 +49,47 @@ function getLifecycleMiddlewareConfig(options) {
48
49
  * Abstract base class for all AI providers
49
50
  * Tools are integrated as first-class citizens - always available by default
50
51
  */
52
+ /**
53
+ * Marks an error as already run through `formatProviderError`.
54
+ *
55
+ * `handleProviderError` is NOT idempotent — measured: feeding its own output back in
56
+ * degrades a specific classification into a generic one, because it copies `statusCode`
57
+ * onto the formatted error, so a second pass re-matches the bare 429 rule while the more
58
+ * specific rule (which keyed on the raw body) no longer can:
59
+ *
60
+ * pass 1 ProviderError "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
61
+ * pass 2 RateLimitError "[openai] OpenAI rate limit exceeded. Please try again later."
62
+ *
63
+ * Since the stream path can now reach a classifier that other paths already reached, that
64
+ * second pass became possible and had to be made impossible.
65
+ *
66
+ * Same `Symbol.for` stamping technique as `utils/lifecycleCallbacks.ts` and for the same
67
+ * reason: it survives across module copies where a closed-over WeakSet would not, and a
68
+ * frozen error degrades to one extra classification rather than a throw.
69
+ */
70
+ const PROVIDER_ERROR_CLASSIFIED = Symbol.for("neurolink.providerErrorClassified");
71
+ function markProviderErrorClassified(error) {
72
+ if (error === null || typeof error !== "object") {
73
+ return;
74
+ }
75
+ try {
76
+ Object.defineProperty(error, PROVIDER_ERROR_CLASSIFIED, {
77
+ value: true,
78
+ enumerable: false,
79
+ writable: false,
80
+ configurable: false,
81
+ });
82
+ }
83
+ catch {
84
+ // Non-extensible error — worst case is one redundant classification.
85
+ }
86
+ }
87
+ function isProviderErrorClassified(error) {
88
+ if (error === null || typeof error !== "object") {
89
+ return false;
90
+ }
91
+ return error[PROVIDER_ERROR_CLASSIFIED] === true;
92
+ }
51
93
  export class BaseProvider {
52
94
  // Not `readonly` because providers that auto-discover the model from a
53
95
  // /v1/models endpoint (lm-studio, llamacpp) need to update modelName after
@@ -307,10 +349,14 @@ export class BaseProvider {
307
349
  */
308
350
  wrapStreamWithLifecycleCallbacks(result, options) {
309
351
  const lifecycle = getLifecycleMiddlewareConfig(options);
310
- if (!lifecycle?.onChunk && !lifecycle?.onFinish && !lifecycle?.onError) {
311
- return result;
312
- }
313
- const { onChunk, onFinish, onError } = lifecycle;
352
+ // No early return when there are no callbacks. This wrapper is the only point
353
+ // every provider's stream passes through unconditionally (the real-streaming path
354
+ // plus all three fake-streaming paths), and returning `result` untouched here is
355
+ // exactly why a provider error that surfaces during ITERATION reached the consumer
356
+ // unclassified: the object handed back was the provider's own generator, by
357
+ // reference, and every layer below is a proven passthrough. The callbacks below are
358
+ // each individually guarded, so with none registered this only adds the catch.
359
+ const { onChunk, onFinish, onError } = lifecycle ?? {};
314
360
  const startTime = Date.now();
315
361
  const originalStream = result.stream;
316
362
  // Lifecycle callbacks are awaited with a bounded deadline so callers
@@ -334,6 +380,9 @@ export class BaseProvider {
334
380
  logger.warn(`[lifecycle] ${label} callback error:`, e);
335
381
  }
336
382
  };
383
+ // Arrow, like `safeFire` above: the generator is a plain function expression, so
384
+ // `this` is not bound inside it.
385
+ const classifyStreamError = (e) => this.classifyStreamError(e);
337
386
  const wrappedStream = (async function* () {
338
387
  let accumulated = "";
339
388
  let seq = 0;
@@ -382,7 +431,7 @@ export class BaseProvider {
382
431
  recoverable: false,
383
432
  }), "onError");
384
433
  }
385
- throw err;
434
+ throw classifyStreamError(err);
386
435
  }
387
436
  })();
388
437
  return { ...result, stream: wrappedStream };
@@ -1573,6 +1622,65 @@ export class BaseProvider {
1573
1622
  * original identity so that isAbortError() can detect them in
1574
1623
  * retry/fallback loops (directProviderGeneration, performMCPGenerationRetries).
1575
1624
  */
1625
+ /**
1626
+ * Classify an error that escaped while the consumer was ITERATING a stream.
1627
+ *
1628
+ * `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider
1629
+ * that discovers its failure lazily throws on first pull instead — so the raw upstream
1630
+ * error reached the consumer with no provider tag and no classification, while the same
1631
+ * failure through `generate()` was classified normally. Measured on OpenAI:
1632
+ *
1633
+ * streaming "You have no credits remaining."
1634
+ * non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
1635
+ *
1636
+ * Three guards. Only the third is load-bearing against today's code; the first two are
1637
+ * deliberate depth against a hazard that is real but not currently reachable. Measured,
1638
+ * rather than assumed — see the note after the list.
1639
+ *
1640
+ * 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark,
1641
+ * including the two formatters whose results escape the ProviderError hierarchy
1642
+ * (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both
1643
+ * extend Error directly). Without this they would be classified a second time and
1644
+ * degraded.
1645
+ * 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY,
1646
+ * bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its
1647
+ * own streaming catch (`anthropic/client.ts`), and its result is a ProviderError.
1648
+ * 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider
1649
+ * failure. `classifyProviderError` ends in an unconditional catch-all
1650
+ * (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not
1651
+ * gated on the error having come off the wire. Measured with the guard absent:
1652
+ * TypeError "Cannot read properties of undefined (reading 'content')"
1653
+ * became ProviderError "[openai] openai error: Cannot read properties of undefined..."
1654
+ * which would hide a real defect behind a plausible provider message.
1655
+ *
1656
+ * WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is
1657
+ * attached to a formatted error in exactly one place — `handleProviderError` below — and
1658
+ * that same method applies the stamp. So a ProviderError carrying a status is always
1659
+ * stamped (caught by guard 1's own condition), and a ProviderError produced by a direct
1660
+ * `formatProviderError` call carries no status, so guard 3 already returns it untouched.
1661
+ * Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked
1662
+ * provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was
1663
+ * byte-identical (`RateLimitError`, one `[anthropic]` prefix).
1664
+ *
1665
+ * They remain because the hazard they cover is measured and real: `handleProviderError`
1666
+ * is NOT idempotent — it copies statusCode onto its own output, so a second pass
1667
+ * re-matches the bare 429 rule and DEGRADES the classification:
1668
+ * pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..."
1669
+ * pass 2 RateLimitError "...rate limit exceeded..."
1670
+ * The day any provider attaches a status to an error it formats itself, guard 3 stops
1671
+ * covering that case and this degradation becomes live. Cheap insurance, not dead code —
1672
+ * but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is.
1673
+ */
1674
+ classifyStreamError(error) {
1675
+ const err = error instanceof Error ? error : new Error(String(error));
1676
+ if (isProviderErrorClassified(err) || err instanceof ProviderError) {
1677
+ return err;
1678
+ }
1679
+ if (duckTypedStatusCode(err) === undefined) {
1680
+ return err;
1681
+ }
1682
+ return this.handleProviderError(err);
1683
+ }
1576
1684
  handleProviderError(error) {
1577
1685
  if (isAbortError(error)) {
1578
1686
  // Preserve AbortError identity — never wrap in provider-specific formatting
@@ -1644,6 +1752,10 @@ export class BaseProvider {
1644
1752
  catch {
1645
1753
  // Non-blocking — telemetry failures shouldn't mask the original error
1646
1754
  }
1755
+ // Stamp AFTER formatting so any later catch site can tell this error has
1756
+ // already been classified. Deliberately not applied to the AbortError
1757
+ // passthrough above — that returns the original error untouched.
1758
+ markProviderErrorClassified(formatted);
1647
1759
  return formatted;
1648
1760
  }
1649
1761
  /**
@@ -27,8 +27,8 @@ import { readdir, stat } from "fs/promises";
27
27
  import { homedir } from "os";
28
28
  import { join } from "path";
29
29
  import { calculateCost, hasPricing } from "../utils/pricing.js";
30
+ import { resolveScanCutoffMs } from "./scanWindow.js";
30
31
  const CLI_ID = "claude-code";
31
- const DEFAULT_SINCE_DAYS = 30;
32
32
  const PROVIDER = "anthropic";
33
33
  function projectsRoot() {
34
34
  return join(homedir(), ".claude", "projects");
@@ -190,11 +190,7 @@ export async function createClaudeCodeReader() {
190
190
  // 32.8s — the same unbounded sweep this guard exists to prevent, reached
191
191
  // by a different door. The old guard caught it with Number.isFinite and
192
192
  // the replacement dropped that check.
193
- const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
194
- const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
195
- const cutoff = sinceDays === Infinity
196
- ? undefined
197
- : Date.now() - Math.max(0, sinceDays) * 86_400_000;
193
+ const cutoff = resolveScanCutoffMs(options?.sinceDays);
198
194
  let filesScanned = 0;
199
195
  for (const file of files) {
200
196
  try {
@@ -31,8 +31,8 @@ import { createInterface } from "readline";
31
31
  import { readdir, stat } from "fs/promises";
32
32
  import { homedir } from "os";
33
33
  import { join } from "path";
34
+ import { resolveScanCutoffMs } from "./scanWindow.js";
34
35
  const CLI_ID = "codex";
35
- const DEFAULT_SINCE_DAYS = 30;
36
36
  function sessionsRoot() {
37
37
  return join(homedir(), ".codex", "sessions");
38
38
  }
@@ -178,11 +178,7 @@ export async function createCodexReader() {
178
178
  // 32.8s — the same unbounded sweep this guard exists to prevent, reached
179
179
  // by a different door. The old guard caught it with Number.isFinite and
180
180
  // the replacement dropped that check.
181
- const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
182
- const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
183
- const cutoff = sinceDays === Infinity
184
- ? undefined
185
- : Date.now() - Math.max(0, sinceDays) * 86_400_000;
181
+ const cutoff = resolveScanCutoffMs(options?.sinceDays);
186
182
  let filesScanned = 0;
187
183
  for (const file of files) {
188
184
  try {
@@ -35,8 +35,8 @@
35
35
  import { stat } from "fs/promises";
36
36
  import { homedir } from "os";
37
37
  import { join } from "path";
38
+ import { resolveScanCutoffMs } from "./scanWindow.js";
38
39
  const CLI_ID = "opencode";
39
- const DEFAULT_SINCE_DAYS = 30;
40
40
  function databasePath() {
41
41
  return join(homedir(), ".local", "share", "opencode", "opencode.db");
42
42
  }
@@ -131,11 +131,9 @@ export async function createOpenCodeReader() {
131
131
  // "no such column: NaN" — so the whole scan failed rather than
132
132
  // over-reading. The cutoff is a bound parameter now, so a value can
133
133
  // never be SQL syntax whatever it is.
134
- const requestedDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
135
- const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
136
- const cutoffMs = sinceDays === Infinity
137
- ? 0
138
- : Date.now() - Math.max(0, sinceDays) * 86_400_000;
134
+ // `?? 0` because this value becomes a SQL parameter: an unbounded scan
135
+ // is expressed as "since the epoch", never as a non-finite number.
136
+ const cutoffMs = resolveScanCutoffMs(options?.sinceDays) ?? 0;
139
137
  let db;
140
138
  try {
141
139
  // Read-only: this is the user's live store and OpenCode may be running.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * One definition of "how far back does a local-usage scan reach".
3
+ *
4
+ * This calculation lived in three copies — claudeCodeReader, codexReader and
5
+ * openCodeReader — and has been wrong three separate times, each a different
6
+ * door into the same failure: an unbounded sweep of every transcript on the
7
+ * machine in answer to the narrowest possible request.
8
+ *
9
+ * sinceDays: 0 left the cutoff undefined 17,534 files, 35.9s
10
+ * sinceDays: NaN Math.max(0, NaN) is NaN, and every comparison
11
+ * against NaN is false, so the filter passed
12
+ * everything 17,537 files, 32.8s
13
+ * sinceDays: MAX_VALUE MAX_VALUE * 86_400_000 overflows to Infinity, so the
14
+ * cutoff became -Infinity and every file passed
15
+ *
16
+ * Three fixes in three files each closed one door and left the others open.
17
+ * The logic lives here once now, so the next door closes everywhere at once.
18
+ *
19
+ * Contract: `Infinity` is the ONLY value meaning all-history. Anything that is
20
+ * not a usable finite window — NaN, negative, or so large the arithmetic stops
21
+ * being finite — collapses to a zero-length window, which is what a
22
+ * nonsensical request should read as.
23
+ */
24
+ export declare const DEFAULT_SINCE_DAYS = 30;
25
+ /**
26
+ * @returns the epoch-ms cutoff a scan must not read past, or `undefined` for an
27
+ * unbounded (all-history) scan — which only `Infinity` produces.
28
+ */
29
+ export declare function resolveScanCutoffMs(sinceDays: number | undefined, defaultDays?: number): number | undefined;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * One definition of "how far back does a local-usage scan reach".
3
+ *
4
+ * This calculation lived in three copies — claudeCodeReader, codexReader and
5
+ * openCodeReader — and has been wrong three separate times, each a different
6
+ * door into the same failure: an unbounded sweep of every transcript on the
7
+ * machine in answer to the narrowest possible request.
8
+ *
9
+ * sinceDays: 0 left the cutoff undefined 17,534 files, 35.9s
10
+ * sinceDays: NaN Math.max(0, NaN) is NaN, and every comparison
11
+ * against NaN is false, so the filter passed
12
+ * everything 17,537 files, 32.8s
13
+ * sinceDays: MAX_VALUE MAX_VALUE * 86_400_000 overflows to Infinity, so the
14
+ * cutoff became -Infinity and every file passed
15
+ *
16
+ * Three fixes in three files each closed one door and left the others open.
17
+ * The logic lives here once now, so the next door closes everywhere at once.
18
+ *
19
+ * Contract: `Infinity` is the ONLY value meaning all-history. Anything that is
20
+ * not a usable finite window — NaN, negative, or so large the arithmetic stops
21
+ * being finite — collapses to a zero-length window, which is what a
22
+ * nonsensical request should read as.
23
+ */
24
+ export const DEFAULT_SINCE_DAYS = 30;
25
+ const MS_PER_DAY = 86_400_000;
26
+ /**
27
+ * @returns the epoch-ms cutoff a scan must not read past, or `undefined` for an
28
+ * unbounded (all-history) scan — which only `Infinity` produces.
29
+ */
30
+ export function resolveScanCutoffMs(sinceDays, defaultDays = DEFAULT_SINCE_DAYS) {
31
+ const requested = sinceDays ?? defaultDays;
32
+ const days = Number.isNaN(requested) ? 0 : requested;
33
+ if (days === Infinity) {
34
+ return undefined;
35
+ }
36
+ const span = Math.max(0, days) * MS_PER_DAY;
37
+ // A finite `days` can still produce a non-finite span: Number.MAX_VALUE and
38
+ // anything near it overflow on the multiply. Treat that as no window rather
39
+ // than letting `Date.now() - Infinity` become -Infinity, which every
40
+ // timestamp compares as newer than.
41
+ return Date.now() - (Number.isFinite(span) ? span : 0);
42
+ }
@@ -1738,7 +1738,28 @@ export class AnthropicProvider extends BaseProvider {
1738
1738
  }
1739
1739
  }
1740
1740
  })();
1741
- const result = await resultPromise;
1741
+ // `pump` is detached: it starts draining the engine's channel the moment
1742
+ // it is created, and `await pump` below is the only thing that adopts its
1743
+ // rejection. When resultPromise rejects, that line is never reached, so
1744
+ // pump's rejection stays unhandled — and an unhandled rejection
1745
+ // TERMINATES the consumer's process. Measured: a caller that correctly
1746
+ // try/catches a streaming error still died with ERR_UNHANDLED_REJECTION,
1747
+ // exit code 1, with no way to defend against it from outside this
1748
+ // library. The rejection carried the raw SDK error, distinct from the
1749
+ // formatted one the caller received, which is why the existing
1750
+ // `loopPromise.catch` guard below does not cover it.
1751
+ //
1752
+ // Same shape googleAiStudio/client.ts and googleVertex/client.ts already
1753
+ // use at every one of their pump sites; Anthropic was the only provider
1754
+ // missing it.
1755
+ let result;
1756
+ try {
1757
+ result = await resultPromise;
1758
+ }
1759
+ catch (error) {
1760
+ await pump.catch(() => { });
1761
+ throw error;
1762
+ }
1742
1763
  await pump;
1743
1764
  totalInput += result.usage.inputTokens;
1744
1765
  totalOutput += result.usage.outputTokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.25.2",
3
+ "version": "11.25.4",
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": {