@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.
|
|
87
|
-
|
|
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:
|
|
538
|
+
schema: schema,
|
|
402
539
|
...(rf.description ? { description: rf.description } : {}),
|
|
403
|
-
strict:
|
|
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.
|
|
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": {
|