@juspay/neurolink 12.7.6 → 12.7.7

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.
@@ -1260,7 +1260,18 @@ export class BaseProvider {
1260
1260
  // never enforced on this path, and enforcing them now would break
1261
1261
  // long-running generations that have always been allowed.
1262
1262
  const descriptorGenerateMs = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts?.generateMs;
1263
- const effectiveTimeout = options.timeout ?? Math.max(descriptorGenerateMs ?? 0, 180_000);
1263
+ // An explicit, valid turnTimeoutMs is the caller's whole-turn contract
1264
+ // and owns this hard abort; `timeout` then keeps its per-model-call
1265
+ // meaning (it reaches the model layer via providerOptions.neurolink).
1266
+ // Before this, `timeout` alone bounded the ENTIRE multi-step loop, so a
1267
+ // caller asking for a 40-minute turn of 5-minute calls was killed at 5
1268
+ // minutes flat — mid-loop, dressed as "Request was aborted.".
1269
+ const hasValidTurnTimeout = typeof options.turnTimeoutMs === "number" &&
1270
+ Number.isFinite(options.turnTimeoutMs) &&
1271
+ options.turnTimeoutMs > 0;
1272
+ const effectiveTimeout = hasValidTurnTimeout
1273
+ ? options.turnTimeoutMs
1274
+ : (options.timeout ?? Math.max(descriptorGenerateMs ?? 0, 180_000));
1264
1275
  const timeoutController = createTimeoutController(effectiveTimeout, this.providerName, "generate");
1265
1276
  const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
1266
1277
  const composedOptions = composedSignal
@@ -1270,6 +1281,24 @@ export class BaseProvider {
1270
1281
  try {
1271
1282
  generateResult = await this.executeGeneration(model, messages, tools, composedOptions);
1272
1283
  }
1284
+ catch (error) {
1285
+ // When OUR timer fired, provider SDKs typically normalize the abort
1286
+ // into their own generic cancel shape (e.g. Anthropic's
1287
+ // APIUserAbortError, "Request was aborted.") and discard the signal's
1288
+ // reason. The TimeoutError on the signal is the honest identity —
1289
+ // rethrow it so logs and abort classification see a timeout, not a
1290
+ // caller cancel. A genuine caller abort (their signal fired) keeps its
1291
+ // original shape even if our timer also expired in the race window.
1292
+ const reason = timeoutController?.controller.signal.aborted
1293
+ ? timeoutController.controller.signal.reason
1294
+ : undefined;
1295
+ if (reason instanceof TimeoutError &&
1296
+ isAbortError(error) &&
1297
+ options.abortSignal?.aborted !== true) {
1298
+ throw reason;
1299
+ }
1300
+ throw error;
1301
+ }
1273
1302
  finally {
1274
1303
  timeoutController?.cleanup();
1275
1304
  }
@@ -26,7 +26,7 @@ import { stringifyAnthropicToolOutput } from "./toolOutput.js";
26
26
  import { createAnthropicLoopAdapter } from "./loopAdapter.js";
27
27
  import { runAgenticLoop } from "../../core/loopEngine.js";
28
28
  import { createAnthropicConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
29
- import { composeAbortSignals, createTimeoutController, mergeAbortSignals, } from "../../utils/timeout.js";
29
+ import { composeAbortSignals, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../../utils/timeout.js";
30
30
  import { resolveToolChoice } from "../../utils/toolChoice.js";
31
31
  import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js";
32
32
  import { NoOutputGeneratedError } from "../../utils/generationErrors.js";
@@ -1126,28 +1126,47 @@ export class AnthropicProvider extends BaseProvider {
1126
1126
  ...(toolChoice ? { tool_choice: toolChoice } : {}),
1127
1127
  ...(thinking ? { thinking } : {}),
1128
1128
  };
1129
- // The 60s anthropic generate default was tuned for the old ~4096
1130
- // max_tokens. Now that the default ceiling is the model's real max,
1131
- // a large structured response needs more wall-clock to be produced —
1132
- // otherwise the inner controller aborts mid-generation (the AI-SDK
1133
- // doGenerate layer doesn't see the caller's `timeout`). Raise the
1134
- // floor to 5 min when a large output budget is in play — but only
1135
- // when the caller did NOT set an explicit timeout: an explicit value
1136
- // is a contract and must never be silently extended. The abort
1137
- // signal stays the real bound.
1138
- const callerTimeout = options
1139
- .timeout;
1140
- const callerSpecifiedTimeout = callerTimeout !== undefined && callerTimeout !== null;
1141
- const generateTimeoutMs = params.max_tokens > 8192 && !callerSpecifiedTimeout
1142
- ? Math.max(getTimeoutForOptions(options), 300_000)
1143
- : getTimeoutForOptions(options);
1129
+ // The caller's resolved `timeout` reaches this layer only through
1130
+ // providerOptions.neurolink.timeoutMs (AI-SDK call options carry no
1131
+ // `timeout`; the old `options.timeout` read here never fired on V3).
1132
+ // An explicit value is a per-call contract: never floored, never
1133
+ // extended. Without one, the 60s anthropic default was tuned for the
1134
+ // old ~4096 max_tokens now that the default ceiling is the model's
1135
+ // real max, raise the floor to 5 min when a large output budget is
1136
+ // in play. The abort signal stays the real bound.
1137
+ const neurolinkNs = options.providerOptions?.neurolink;
1138
+ const forwardedTimeoutMs = typeof neurolinkNs?.timeoutMs === "number" &&
1139
+ Number.isFinite(neurolinkNs.timeoutMs) &&
1140
+ neurolinkNs.timeoutMs > 0
1141
+ ? neurolinkNs.timeoutMs
1142
+ : undefined;
1143
+ const generateTimeoutMs = forwardedTimeoutMs !== undefined
1144
+ ? forwardedTimeoutMs
1145
+ : params.max_tokens > 8192
1146
+ ? Math.max(getTimeoutForOptions(options), 300_000)
1147
+ : getTimeoutForOptions(options);
1144
1148
  const timeoutController = createTimeoutController(generateTimeoutMs, providerName, "generate");
1149
+ const requestSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
1145
1150
  let response;
1146
1151
  try {
1147
1152
  response = await client.messages.create(params, {
1148
- signal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
1153
+ signal: requestSignal,
1149
1154
  });
1150
1155
  }
1156
+ catch (error) {
1157
+ // The Anthropic SDK collapses ANY fired signal into its generic
1158
+ // APIUserAbortError ("Request was aborted."), discarding the
1159
+ // signal's reason. When the abort came from one of NeuroLink's own
1160
+ // timers (this per-call timer, or the turn-level one upstream),
1161
+ // the TimeoutError reason is the honest identity — surface it.
1162
+ const reason = requestSignal?.aborted
1163
+ ? requestSignal.reason
1164
+ : undefined;
1165
+ if (reason instanceof TimeoutError) {
1166
+ throw reason;
1167
+ }
1168
+ throw error;
1169
+ }
1151
1170
  finally {
1152
1171
  timeoutController?.cleanup();
1153
1172
  }
@@ -60,6 +60,13 @@ export type ConversationMemoryConfig = {
60
60
  summarizationProvider?: string;
61
61
  /** Model to use for summarization */
62
62
  summarizationModel?: string;
63
+ /**
64
+ * Wall-clock cap for one summarization generate call, in milliseconds
65
+ * (default: 60000). A summary that overruns is dropped, not fatal — the
66
+ * turn continues without it — so size this for the slowest summary a real
67
+ * conversation produces rather than losing compaction summaries silently.
68
+ */
69
+ summarizationTimeoutMs?: number;
63
70
  /** Memory SDK config (condensed key-value memory per user). Set enabled: true to activate. */
64
71
  memory?: HippocampusMemory;
65
72
  /** Redis configuration (optional) - overrides environment variables */
@@ -348,7 +348,14 @@ export type GenerateOptions = {
348
348
  * multi-step tool loop (Vertex Gemini / Vertex Claude), this bounds EACH
349
349
  * model call in the loop, not the whole turn — a tool-heavy turn may run
350
350
  * far longer than this value in total. Size it for the slowest single
351
- * step (default 300s), and use `abortSignal` for a total-turn deadline.
351
+ * step (default 300s), and use `turnTimeoutMs` (or `abortSignal`) for a
352
+ * total-turn deadline.
353
+ *
354
+ * On the AI-SDK loop path (direct Anthropic, litellm, OpenAI-compatible)
355
+ * the same split holds only when `turnTimeoutMs` is ALSO set: then this
356
+ * value bounds each model call and `turnTimeoutMs` bounds the turn. With
357
+ * `turnTimeoutMs` unset, this value bounds the WHOLE turn there (the
358
+ * pre-existing defensive behavior, kept for backward compatibility).
352
359
  *
353
360
  * When set explicitly, a step timeout is surfaced immediately instead of
354
361
  * burning internal retries/fallbacks that would re-run the same
@@ -363,9 +370,11 @@ export type GenerateOptions = {
363
370
  * imposes no product policy).
364
371
  *
365
372
  * Enforced by the native Vertex loops (Gemini + Claude) AND the AI-SDK
366
- * loop path (litellm and other OpenAI-compatible providers). On the AI-SDK
367
- * path, an explicit `timeout` also engages the same wrap-up when
368
- * `turnTimeoutMs` is unset. Once the wrap-up window begins (see
373
+ * loop path (direct Anthropic, litellm and other OpenAI-compatible
374
+ * providers). On the AI-SDK path this value also owns the whole-turn hard
375
+ * abort: when set, `timeout` keeps its per-model-call meaning instead of
376
+ * bounding the entire loop. An explicit `timeout` also engages the same
377
+ * wrap-up when `turnTimeoutMs` is unset. Once the wrap-up window begins (see
369
378
  * `wrapupTimeLeadMs`), the loop forcibly sets `toolChoice: "none"` for the
370
379
  * remaining steps — overriding any caller-supplied `toolChoice` or
371
380
  * `prepareStep` tool selection — and appends an honest time message that a
@@ -593,7 +593,15 @@ export function getEffectiveTokenThreshold(provider, model, envOverride, session
593
593
  export async function generateSummary(messages, config, logPrefix = "[ConversationMemory]", previousSummary, requestId) {
594
594
  const summarizationPrompt = createSummarizationPrompt(messages, previousSummary);
595
595
  const SUMMARIZER_INIT_TIMEOUT = 15_000;
596
- const SUMMARIZER_GENERATE_TIMEOUT = 60_000;
596
+ // Config-driven: a compaction summary of a large conversation routinely
597
+ // needs more than the old hard-coded 60s, and each overrun silently loses
598
+ // one summary (non-fatal — the turn continues) with no knob to raise it.
599
+ const configuredTimeoutMs = config.summarizationTimeoutMs;
600
+ const SUMMARIZER_GENERATE_TIMEOUT = typeof configuredTimeoutMs === "number" &&
601
+ Number.isFinite(configuredTimeoutMs) &&
602
+ configuredTimeoutMs > 0
603
+ ? configuredTimeoutMs
604
+ : 60_000;
597
605
  try {
598
606
  if (!cachedSummarizer) {
599
607
  cachedSummarizer = await withTimeout((async () => {
@@ -1189,7 +1189,11 @@ export function isAbortError(error) {
1189
1189
  if (error instanceof Error &&
1190
1190
  (error.message?.includes("This operation was aborted") ||
1191
1191
  error.message?.includes("The operation was aborted") ||
1192
- error.message?.includes("The user aborted a request"))) {
1192
+ error.message?.includes("The user aborted a request") ||
1193
+ // Anthropic SDK's APIUserAbortError — name is plain "Error", so only
1194
+ // the message identifies it. Missing this classified real aborts as
1195
+ // provider failures (ERROR log + fallback consulted on a user cancel).
1196
+ error.message?.includes("Request was aborted"))) {
1193
1197
  return true;
1194
1198
  }
1195
1199
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.7.6",
3
+ "version": "12.7.7",
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": {