@juspay/neurolink 12.12.16 → 12.14.0

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.
@@ -978,3 +978,104 @@ export function createValidationSummary(result) {
978
978
  export function hasOnlyWarnings(result) {
979
979
  return result.errors.length === 0 && result.warnings.length > 0;
980
980
  }
981
+ // ============================================================================
982
+ // EXECUTION CONTROL
983
+ // ============================================================================
984
+ /**
985
+ * Default bound on a `beforeStep` callback (ms).
986
+ *
987
+ * A boundary callback sits between two model calls, in the one place no other
988
+ * timer in the turn is watching: the request deadline has been disposed and
989
+ * the next one is not armed yet. An unbounded callback therefore stalls the
990
+ * turn silently and indefinitely.
991
+ */
992
+ export const DEFAULT_BEFORE_STEP_TIMEOUT_MS = 30_000;
993
+ function isFinitePositive(value) {
994
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
995
+ }
996
+ /**
997
+ * Validate an opt-in `executionControl` object and its provider support.
998
+ *
999
+ * Rejecting rather than ignoring is the whole point. A caller that asked for
1000
+ * no lifetime ceiling and got one anyway does not find out at call time — it
1001
+ * finds out much later, when a long turn dies at a limit its owner believed it
1002
+ * had removed, reported as an ordinary cancel. So an unsupported provider is an
1003
+ * error here, and so is every shape that could be read two ways.
1004
+ *
1005
+ * Throws ValidationError; returns void when the control is absent or valid.
1006
+ *
1007
+ * @param request the sibling options on the SAME call that this control has to
1008
+ * be read together with. `turnTimeoutMs`, because a combination in which one
1009
+ * of the two ceilings would be silently discarded is rejected instead; and
1010
+ * `toolTimeoutMs`, because removing the turn's ceiling makes the per-tool
1011
+ * deadline the last thing watching a tool that never returns.
1012
+ */
1013
+ export function validateExecutionControl(control, providerName, supported, request = {}) {
1014
+ const { turnTimeoutMs, toolTimeoutMs } = request;
1015
+ if (control === undefined || control === null) {
1016
+ return;
1017
+ }
1018
+ if (!isNonNullObject(control)) {
1019
+ throw new ValidationError("executionControl must be an object", "executionControl", "INVALID_TYPE");
1020
+ }
1021
+ if (!supported) {
1022
+ throw new ValidationError(`executionControl is not supported by provider "${providerName}" — it is implemented only on the native Anthropic stream path. Remove it, or route this turn to anthropic.`, "executionControl", "UNSUPPORTED_PROVIDER", [
1023
+ "Use provider: 'anthropic' for turns that need executionControl",
1024
+ "Use turnTimeoutMs / maxSteps for other providers",
1025
+ ]);
1026
+ }
1027
+ const opts = control;
1028
+ if (!isFinitePositive(opts.requestTimeoutMs)) {
1029
+ throw new ValidationError(`executionControl.requestTimeoutMs is required and must be a finite positive number of milliseconds (received ${String(opts.requestTimeoutMs)}). It is the deadline that bounds a stalled upstream even when the turn has no lifetime ceiling, so there is no valid turn without it.`, "executionControl.requestTimeoutMs", "INVALID_VALUE");
1030
+ }
1031
+ // `null` is a value here, not an omission: it is the only way to say "no
1032
+ // lifetime timer at all", which no number can express — a very large one is
1033
+ // still a ceiling that fires mid-turn.
1034
+ if (opts.lifetimeTimeoutMs !== undefined &&
1035
+ opts.lifetimeTimeoutMs !== null &&
1036
+ !isFinitePositive(opts.lifetimeTimeoutMs)) {
1037
+ throw new ValidationError(`executionControl.lifetimeTimeoutMs must be null (no lifetime ceiling), a finite positive number of milliseconds, or absent (inherit the legacy timeout handling). Received ${String(opts.lifetimeTimeoutMs)}.`, "executionControl.lifetimeTimeoutMs", "INVALID_VALUE");
1038
+ }
1039
+ // Two whole-turn ceilings, one turn. An explicit `lifetimeTimeoutMs` — a
1040
+ // number or `null` — takes over the turn's lifetime timer completely, so a
1041
+ // `turnTimeoutMs` supplied alongside it was read by nothing at all. Dropping
1042
+ // it silently is the same defect this contract exists to remove, one layer
1043
+ // up: the caller's stated ceiling is discarded and it finds out when the
1044
+ // turn ends somewhere it did not expect.
1045
+ //
1046
+ // Scoped deliberately to the case where the drop happens. `lifetimeTimeoutMs`
1047
+ // ABSENT means "no opinion about the turn's lifetime", and that documented
1048
+ // case inherits `turnTimeoutMs` and honours it — there is nothing to reject.
1049
+ if (opts.lifetimeTimeoutMs !== undefined &&
1050
+ typeof turnTimeoutMs === "number" &&
1051
+ Number.isFinite(turnTimeoutMs) &&
1052
+ turnTimeoutMs > 0) {
1053
+ throw new ValidationError(`turnTimeoutMs (${turnTimeoutMs}) cannot be combined with executionControl.lifetimeTimeoutMs (${String(opts.lifetimeTimeoutMs)}): both set the turn's wall-clock ceiling, and executionControl wins, so the turnTimeoutMs would be ignored. Set exactly one of them.`, "executionControl.lifetimeTimeoutMs", "INVALID_VALUE", [
1054
+ "Drop turnTimeoutMs and express the ceiling as executionControl.lifetimeTimeoutMs",
1055
+ "Or drop executionControl.lifetimeTimeoutMs to inherit turnTimeoutMs unchanged",
1056
+ ]);
1057
+ }
1058
+ // No ceiling, and no floor either. `lifetimeTimeoutMs: null` deliberately
1059
+ // arms no turn-level timer, which leaves the per-tool deadline as the only
1060
+ // thing that will ever end a tool that neither returns nor honours its
1061
+ // signal — the step cap does not advance while a tool is in flight, and the
1062
+ // request deadline was disposed when the step settled. `toolTimeoutMs: null`
1063
+ // removes that too, and the turn then has nothing watching it anywhere.
1064
+ //
1065
+ // Refused rather than resolved, because there is no safe way to pick which
1066
+ // of the two the caller meant to keep, and picking one silently is how a
1067
+ // turn ends up bounded by a limit its owner did not choose.
1068
+ if (opts.lifetimeTimeoutMs === null && toolTimeoutMs === null) {
1069
+ throw new ValidationError("executionControl.lifetimeTimeoutMs: null removes the turn's wall-clock ceiling, which leaves toolTimeoutMs as the only bound on a tool that never returns — and toolTimeoutMs: null removes that one too, so the turn would have no bound anywhere. Keep one of them.", "executionControl.lifetimeTimeoutMs", "INVALID_VALUE", [
1070
+ "Give toolTimeoutMs a finite bound, or omit it to take the 300000ms default",
1071
+ "Or give executionControl.lifetimeTimeoutMs a finite ceiling instead of null",
1072
+ ]);
1073
+ }
1074
+ if (opts.beforeStep !== undefined && typeof opts.beforeStep !== "function") {
1075
+ throw new ValidationError("executionControl.beforeStep must be a function", "executionControl.beforeStep", "INVALID_TYPE");
1076
+ }
1077
+ if (opts.beforeStepTimeoutMs !== undefined &&
1078
+ !isFinitePositive(opts.beforeStepTimeoutMs)) {
1079
+ throw new ValidationError(`executionControl.beforeStepTimeoutMs must be a finite positive number of milliseconds when supplied. Received ${String(opts.beforeStepTimeoutMs)}.`, "executionControl.beforeStepTimeoutMs", "INVALID_VALUE");
1080
+ }
1081
+ }
@@ -259,12 +259,35 @@ export async function withTimeout(promise, timeout, provider, operation) {
259
259
  if (!timeoutMs) {
260
260
  return promise;
261
261
  }
262
+ // The handle is captured, unref'd and cleared — the same shape
263
+ // `createTimeoutPromise` above already uses. `Promise.race` settles on the
264
+ // first outcome but cancels nothing, so an uncaptured timer stayed pending
265
+ // for its full duration after the wrapped promise had already resolved: one
266
+ // live timer per call, each holding the event loop open until it fired. The
267
+ // `finally` clears it the moment the race is decided, which is what makes
268
+ // this safe to wrap around something invoked once per tool call.
269
+ let timeoutHandle;
262
270
  const timeoutPromise = new Promise((_, reject) => {
263
- setTimeout(() => {
271
+ const timer = setTimeout(() => {
264
272
  reject(new TimeoutError(`${provider} ${operation} operation timed out after ${timeoutMs}ms`, timeoutMs, provider, operation));
265
273
  }, timeoutMs);
274
+ timeoutHandle = timer;
275
+ // Unref the timer so it doesn't keep the process alive (Node.js only)
276
+ if (typeof timer === "object" &&
277
+ timer &&
278
+ "unref" in timer &&
279
+ typeof timer.unref === "function") {
280
+ timer.unref();
281
+ }
266
282
  });
267
- return Promise.race([promise, timeoutPromise]);
283
+ try {
284
+ return await Promise.race([promise, timeoutPromise]);
285
+ }
286
+ finally {
287
+ if (timeoutHandle !== undefined) {
288
+ clearTimeout(timeoutHandle);
289
+ }
290
+ }
268
291
  }
269
292
  /**
270
293
  * Wrap a streaming async generator with timeout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.12.16",
3
+ "version": "12.14.0",
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": {
@@ -236,6 +236,7 @@
236
236
  "test:bedrock-loop-characterization": "tsx test/continuous-test-suite-bedrock-loop-characterization.ts",
237
237
  "test:sagemaker-streaming": "tsx test/continuous-test-suite-sagemaker-streaming.ts",
238
238
  "test:anthropic-loop-characterization": "tsx test/continuous-test-suite-anthropic-loop-characterization.ts",
239
+ "test:anthropic-execution-control": "tsx test/continuous-test-suite-anthropic-execution-control.ts",
239
240
  "test:aistudio-loop-characterization": "tsx test/continuous-test-suite-aistudio-loop-characterization.ts",
240
241
  "test:docs-mcp": "pnpm exec tsx test/continuous-test-suite-docs-mcp.ts",
241
242
  "test:vertex-claude-characterization": "tsx test/continuous-test-suite-vertex-claude-characterization.ts"