@juspay/neurolink 12.14.2 → 12.14.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.
@@ -38,10 +38,18 @@ export declare class ToolCache<T = unknown> extends EventEmitter {
38
38
  constructor(config: McpCacheConfig);
39
39
  /**
40
40
  * Get a value from the cache
41
+ *
42
+ * Returns an isolated copy of the stored value (see `cloneCachedValue`), so
43
+ * a caller mutating what it gets back cannot corrupt the entry for later
44
+ * hits or for other concurrent callers of the same key.
41
45
  */
42
46
  get(key: string): T | undefined;
43
47
  /**
44
48
  * Set a value in the cache
49
+ *
50
+ * Stores an isolated copy of `value` (see `cloneCachedValue`), so mutating
51
+ * the caller's original object after this call cannot reach into the
52
+ * cache entry.
45
53
  */
46
54
  set(key: string, value: T, ttl?: number): void;
47
55
  /**
@@ -89,6 +97,28 @@ export declare class ToolCache<T = unknown> extends EventEmitter {
89
97
  * Stop the auto-cleanup timer
90
98
  */
91
99
  destroy(): void;
100
+ /**
101
+ * Isolate a value crossing the cache boundary (on write into the entry,
102
+ * and on read back out of it) so no two callers — nor a caller and the
103
+ * stored entry itself — ever share object identity.
104
+ *
105
+ * Without this, `set()` stored the caller's object by reference and
106
+ * `get()` returned `entry.value` by the same reference on every hit: one
107
+ * caller mutating a result it got back (e.g. an in-place truncation or
108
+ * normalization pass) silently corrupted the entry for every later
109
+ * caller of the same key for the rest of the TTL.
110
+ *
111
+ * `structuredClone` is the primary path — it is a deep copy, has no
112
+ * caller-visible side effects, and (unlike a JSON round-trip) tolerates
113
+ * circular references, which a sufficiently deep or recursive tool
114
+ * result could contain. It throws on values it cannot clone (functions,
115
+ * some non-plain class instances); the JSON round-trip fallback covers
116
+ * that case for the plain-data shapes MCP tool results actually have
117
+ * (text/JSON content arrays), at the cost of silently dropping
118
+ * `undefined`, functions and symbol keys — acceptable for a cache that
119
+ * only ever holds serializable tool results.
120
+ */
121
+ private cloneCachedValue;
92
122
  private getFullKey;
93
123
  private isExpired;
94
124
  /**
@@ -60,6 +60,10 @@ export class ToolCache extends EventEmitter {
60
60
  }
61
61
  /**
62
62
  * Get a value from the cache
63
+ *
64
+ * Returns an isolated copy of the stored value (see `cloneCachedValue`), so
65
+ * a caller mutating what it gets back cannot corrupt the entry for later
66
+ * hits or for other concurrent callers of the same key.
63
67
  */
64
68
  get(key) {
65
69
  const fullKey = this.getFullKey(key);
@@ -83,11 +87,24 @@ export class ToolCache extends EventEmitter {
83
87
  entry.accessCount++;
84
88
  this.stats.hits++;
85
89
  this.updateHitRate();
86
- this.emit("hit", { key: fullKey, value: entry.value });
87
- return entry.value;
90
+ const returnedValue = this.cloneCachedValue(entry.value);
91
+ if (this.listenerCount("hit") > 0) {
92
+ // Listeners get their own copy. `emit` is synchronous, so a listener
93
+ // that mutates `event.value` would otherwise be mutating the very object
94
+ // the caller is about to receive.
95
+ this.emit("hit", {
96
+ key: fullKey,
97
+ value: this.cloneCachedValue(entry.value),
98
+ });
99
+ }
100
+ return returnedValue;
88
101
  }
89
102
  /**
90
103
  * Set a value in the cache
104
+ *
105
+ * Stores an isolated copy of `value` (see `cloneCachedValue`), so mutating
106
+ * the caller's original object after this call cannot reach into the
107
+ * cache entry.
91
108
  */
92
109
  set(key, value, ttl) {
93
110
  const fullKey = this.getFullKey(key);
@@ -98,7 +115,7 @@ export class ToolCache extends EventEmitter {
98
115
  this.evictOne();
99
116
  }
100
117
  const entry = {
101
- value,
118
+ value: this.cloneCachedValue(value),
102
119
  expires: now + effectiveTtl,
103
120
  createdAt: now,
104
121
  accessedAt: now,
@@ -250,6 +267,38 @@ export class ToolCache extends EventEmitter {
250
267
  this.clear();
251
268
  }
252
269
  // ==================== Private Methods ====================
270
+ /**
271
+ * Isolate a value crossing the cache boundary (on write into the entry,
272
+ * and on read back out of it) so no two callers — nor a caller and the
273
+ * stored entry itself — ever share object identity.
274
+ *
275
+ * Without this, `set()` stored the caller's object by reference and
276
+ * `get()` returned `entry.value` by the same reference on every hit: one
277
+ * caller mutating a result it got back (e.g. an in-place truncation or
278
+ * normalization pass) silently corrupted the entry for every later
279
+ * caller of the same key for the rest of the TTL.
280
+ *
281
+ * `structuredClone` is the primary path — it is a deep copy, has no
282
+ * caller-visible side effects, and (unlike a JSON round-trip) tolerates
283
+ * circular references, which a sufficiently deep or recursive tool
284
+ * result could contain. It throws on values it cannot clone (functions,
285
+ * some non-plain class instances); the JSON round-trip fallback covers
286
+ * that case for the plain-data shapes MCP tool results actually have
287
+ * (text/JSON content arrays), at the cost of silently dropping
288
+ * `undefined`, functions and symbol keys — acceptable for a cache that
289
+ * only ever holds serializable tool results.
290
+ */
291
+ cloneCachedValue(value) {
292
+ if (value === null || typeof value !== "object") {
293
+ return value;
294
+ }
295
+ try {
296
+ return structuredClone(value);
297
+ }
298
+ catch {
299
+ return JSON.parse(JSON.stringify(value));
300
+ }
301
+ }
253
302
  getFullKey(key) {
254
303
  return this.config.namespace ? `${this.config.namespace}:${key}` : key;
255
304
  }
@@ -387,6 +387,118 @@ export const v3ToolChoiceToOpenAI = (choice, toolNameToWire) => {
387
387
  };
388
388
  }
389
389
  };
390
+ /**
391
+ * OpenAI's strict structured-output mode rejects a schema unless every object
392
+ * node carries `additionalProperties: false` AND lists every one of its
393
+ * properties in `required` — recursively, including through array `items`.
394
+ * A plain JSON Schema satisfies neither, so sending one with `strict: true`
395
+ * fails the request outright rather than degrading.
396
+ *
397
+ * Adding `additionalProperties: false` is safe: it forbids keys the caller
398
+ * never asked for, which strict mode would forbid anyway. Filling in
399
+ * `required` is NOT safe — it would silently make the caller's optional
400
+ * fields mandatory. So when a schema still has optional properties after
401
+ * normalisation, the request drops to `strict: false`, which OpenAI accepts
402
+ * and which honours optionality. Callers whose schemas are already strict-
403
+ * compatible keep the stronger guarantee.
404
+ */
405
+ // `properties` and `$defs` are MAPS of schemas, not schemas — recursing into
406
+ // them as if they were nodes silently skips every child, which is exactly the
407
+ // bug that let a nested object through without `additionalProperties: false`.
408
+ const SCHEMA_MAPS = [
409
+ "properties",
410
+ "patternProperties",
411
+ "$defs",
412
+ "definitions",
413
+ ];
414
+ const SCHEMA_NODES = [
415
+ "items",
416
+ "prefixItems",
417
+ "anyOf",
418
+ "oneOf",
419
+ "allOf",
420
+ "not",
421
+ "then",
422
+ "else",
423
+ ];
424
+ // OpenAI's strict mode does not accept these composition keywords. A schema
425
+ // carrying one cannot be sent with `strict: true` at all, so it is not merely
426
+ // "not yet normalised" — it must drop to non-strict, where the schema is
427
+ // honoured as written.
428
+ const STRICT_UNSUPPORTED = ["allOf", "not", "if", "then", "else"];
429
+ const mapValues = (obj, fn) => Object.fromEntries(Object.entries((obj ?? {})).map(([k, v]) => [
430
+ k,
431
+ fn(v),
432
+ ]));
433
+ const withClosedObjects = (node) => {
434
+ if (Array.isArray(node)) {
435
+ return node.map(withClosedObjects);
436
+ }
437
+ if (!node || typeof node !== "object") {
438
+ return node;
439
+ }
440
+ const next = {
441
+ ...node,
442
+ };
443
+ for (const key of SCHEMA_MAPS) {
444
+ if (key in next) {
445
+ next[key] = mapValues(next[key], withClosedObjects);
446
+ }
447
+ }
448
+ for (const key of SCHEMA_NODES) {
449
+ if (key in next) {
450
+ next[key] = withClosedObjects(next[key]);
451
+ }
452
+ }
453
+ // A schema-valued `additionalProperties` is itself a schema (the index/value
454
+ // pattern) and its nested objects need closing too. A boolean one is a flag
455
+ // and must be left exactly as the caller wrote it.
456
+ if (next.additionalProperties &&
457
+ typeof next.additionalProperties === "object") {
458
+ next.additionalProperties = withClosedObjects(next.additionalProperties);
459
+ }
460
+ else if (next.type === "object" && !("additionalProperties" in next)) {
461
+ // Closed whether or not it declares `properties`: strict mode requires the
462
+ // key on EVERY object, including an empty one.
463
+ next.additionalProperties = false;
464
+ }
465
+ return next;
466
+ };
467
+ /**
468
+ * True when the schema can legally be sent with `strict: true`: every object
469
+ * node lists all of its properties as required, every object is closed, and
470
+ * no composition keyword OpenAI rejects appears anywhere.
471
+ *
472
+ * Deliberately conservative — a false negative costs only the stronger
473
+ * guarantee, while a false positive costs the whole request.
474
+ */
475
+ const satisfiesStrictRequired = (node) => {
476
+ if (Array.isArray(node)) {
477
+ return node.every(satisfiesStrictRequired);
478
+ }
479
+ if (!node || typeof node !== "object") {
480
+ return true;
481
+ }
482
+ const rec = node;
483
+ if (STRICT_UNSUPPORTED.some((k) => k in rec)) {
484
+ return false;
485
+ }
486
+ if (rec.type === "object") {
487
+ if (rec.additionalProperties !== false) {
488
+ return false;
489
+ }
490
+ const names = Object.keys((rec.properties ?? {}));
491
+ const required = Array.isArray(rec.required)
492
+ ? rec.required
493
+ : [];
494
+ if (names.some((n) => !required.includes(n))) {
495
+ return false;
496
+ }
497
+ }
498
+ const mapsOk = SCHEMA_MAPS.filter((k) => k in rec).every((k) => Object.values((rec[k] ?? {})).every(satisfiesStrictRequired));
499
+ const nodesOk = SCHEMA_NODES.filter((k) => k in rec).every((k) => satisfiesStrictRequired(rec[k]));
500
+ return mapsOk && nodesOk;
501
+ };
390
502
  export const v3ResponseFormatToOpenAI = (rf) => {
391
503
  if (rf.type === "text") {
392
504
  return { type: "text" };
@@ -394,13 +506,38 @@ export const v3ResponseFormatToOpenAI = (rf) => {
394
506
  if (!rf.schema) {
395
507
  return { type: "json_object" };
396
508
  }
509
+ // Mutate as little as possible, in this order:
510
+ //
511
+ // 1. already strict-legal -> send it UNTOUCHED with strict: true
512
+ // 2. legal once closed -> send the closed copy with strict: true
513
+ // 3. neither -> send it UNTOUCHED with strict: false
514
+ //
515
+ // Case 3 is why closure is not applied unconditionally. Non-strict mode
516
+ // honours the schema exactly as written, so injecting
517
+ // `additionalProperties: false` there would silently change the caller's
518
+ // contract — and for a composition it can make the schema unsatisfiable:
519
+ // closing two `allOf` members that declare different properties leaves no
520
+ // object able to satisfy both. Leaving case 3 untouched also means
521
+ // non-OpenAI endpoints in this family, which may not implement OpenAI's
522
+ // strict contract at all, see exactly the schema the caller wrote.
523
+ //
524
+ // Case 1 matters for parity: a Zod schema whose properties are all required
525
+ // already converts to a strict-legal shape, so it goes out byte-identical to
526
+ // what it did before this change.
527
+ const original = rf.schema;
528
+ const closed = withClosedObjects(original);
529
+ const schema = satisfiesStrictRequired(original)
530
+ ? original
531
+ : satisfiesStrictRequired(closed)
532
+ ? closed
533
+ : original;
397
534
  return {
398
535
  type: "json_schema",
399
536
  json_schema: {
400
537
  name: rf.name ?? "response",
401
- schema: rf.schema,
538
+ schema: schema,
402
539
  ...(rf.description ? { description: rf.description } : {}),
403
- strict: true,
540
+ strict: satisfiesStrictRequired(schema),
404
541
  },
405
542
  };
406
543
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.14.2",
3
+ "version": "12.14.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": {