@juspay/neurolink 12.21.2 → 12.22.1

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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared string-distance helpers for "did you mean?" suggestions.
3
+ *
4
+ * Extracted from toolCallRepair.ts, which had the only implementation, so
5
+ * provider-name validation can reuse it rather than carry a second copy.
6
+ */
7
+ /**
8
+ * Longest string the distance primitive will compare.
9
+ *
10
+ * Exported so every call site can apply the same limit *before* calling
11
+ * `levenshtein` — skipping an over-limit operand entirely, rather than
12
+ * comparing a truncated prefix of it. Truncating silently merged any two
13
+ * strings that happened to share their first `MAX_COMPARABLE_LENGTH`
14
+ * characters into distance 0, which let a direct MCP tool-name lookup
15
+ * (`resolveToolName`) resolve to the wrong tool for a long, colliding name.
16
+ */
17
+ export const MAX_COMPARABLE_LENGTH = 128;
18
+ const OVER_LIMIT_MESSAGE = `levenshtein: input exceeds MAX_COMPARABLE_LENGTH (${MAX_COMPARABLE_LENGTH}); callers must skip over-limit operands rather than truncate them`;
19
+ /**
20
+ * Compute Levenshtein edit distance between two strings.
21
+ * Uses the iterative matrix approach — O(m*n) time, O(min(m,n)) space.
22
+ *
23
+ * Throws rather than truncating when either input exceeds
24
+ * `MAX_COMPARABLE_LENGTH`: silently comparing truncated prefixes produces a
25
+ * *wrong* distance (two distinct strings sharing a long prefix collapse to
26
+ * distance 0) instead of no distance, which is worse than refusing outright.
27
+ * Callers that want fuzzy matching over long input must apply the length
28
+ * check themselves and skip the comparison — see `suggestClosest` below and
29
+ * `resolveToolName`/`rankToolNameCandidates` in toolCallRepair.ts.
30
+ */
31
+ export function levenshtein(a, b) {
32
+ // Names reach this from request bodies and model output, where the static
33
+ // type is no runtime guarantee: a parsed-JSON object with a forged `length`
34
+ // must never size the matrix below. This `typeof` test is what CodeQL's
35
+ // js/loop-bound-injection recognises as ruling such an object out; a length
36
+ // comparison alone is not.
37
+ if (typeof a !== "string") {
38
+ throw new TypeError("levenshtein: operands must be strings");
39
+ }
40
+ if (typeof b !== "string") {
41
+ throw new TypeError("levenshtein: operands must be strings");
42
+ }
43
+ // Ahead of every return, so the limit holds for all inputs, including equal
44
+ // strings and an empty operand.
45
+ if (a.length > MAX_COMPARABLE_LENGTH) {
46
+ throw new RangeError(OVER_LIMIT_MESSAGE);
47
+ }
48
+ if (b.length > MAX_COMPARABLE_LENGTH) {
49
+ throw new RangeError(OVER_LIMIT_MESSAGE);
50
+ }
51
+ if (a === b) {
52
+ return 0;
53
+ }
54
+ if (a.length === 0) {
55
+ return b.length;
56
+ }
57
+ if (b.length === 0) {
58
+ return a.length;
59
+ }
60
+ // Use shorter string as column to minimize space
61
+ if (a.length > b.length) {
62
+ [a, b] = [b, a];
63
+ }
64
+ const aLen = a.length;
65
+ const bLen = b.length;
66
+ let prev = new Array(aLen + 1);
67
+ let curr = new Array(aLen + 1);
68
+ for (let i = 0; i <= aLen; i++) {
69
+ prev[i] = i;
70
+ }
71
+ for (let j = 1; j <= bLen; j++) {
72
+ curr[0] = j;
73
+ for (let i = 1; i <= aLen; i++) {
74
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
75
+ curr[i] = Math.min(prev[i] + 1, // deletion
76
+ curr[i - 1] + 1, // insertion
77
+ prev[i - 1] + cost);
78
+ }
79
+ [prev, curr] = [curr, prev];
80
+ }
81
+ return prev[aLen];
82
+ }
83
+ /**
84
+ * Candidates within `maxDistance` edits of `input`, closest first.
85
+ *
86
+ * Ties break on the candidate's own order in `candidates`, so a caller that
87
+ * passes canonical names before aliases gets the canonical one suggested.
88
+ * Comparison is case-insensitive; the returned strings keep their original
89
+ * casing so they can be pasted back verbatim.
90
+ *
91
+ * `input` longer than `MAX_COMPARABLE_LENGTH` is rejected outright (no
92
+ * suggestions), and any individual candidate over the same limit is skipped
93
+ * rather than compared — never truncated. Truncating either side risks
94
+ * exactly the false distance-0 match `levenshtein`'s guard rejects: two
95
+ * distinct strings sharing a long-enough prefix would otherwise look
96
+ * identical and "suggest" the wrong one.
97
+ *
98
+ * @param input - The string the user actually typed
99
+ * @param candidates - Valid values, most-preferred first
100
+ * @param options.maxDistance - Edit budget (default 3)
101
+ * @param options.limit - Most suggestions to return (default 3)
102
+ */
103
+ export function suggestClosest(input, candidates, options) {
104
+ const maxDistance = options?.maxDistance ?? 3;
105
+ const limit = options?.limit ?? 3;
106
+ // Limits apply to the lowercased strings, since those are what get compared
107
+ // and lowercasing can lengthen a string ("İ" becomes two code units).
108
+ const needle = input.toLowerCase();
109
+ if (needle.length > MAX_COMPARABLE_LENGTH) {
110
+ return [];
111
+ }
112
+ const scored = [];
113
+ candidates.forEach((candidate, order) => {
114
+ const lowered = candidate.toLowerCase();
115
+ // Two strings whose lengths differ by more than the edit budget cannot be
116
+ // within it, so this rules most candidates out without building a matrix
117
+ // for them.
118
+ if (Math.abs(lowered.length - needle.length) > maxDistance) {
119
+ return;
120
+ }
121
+ if (lowered.length > MAX_COMPARABLE_LENGTH) {
122
+ return;
123
+ }
124
+ const distance = levenshtein(needle, lowered);
125
+ if (distance <= maxDistance) {
126
+ scored.push({ candidate, order, distance });
127
+ }
128
+ });
129
+ return scored
130
+ .sort((a, b) => a.distance - b.distance || a.order - b.order)
131
+ .slice(0, limit)
132
+ .map((entry) => entry.candidate);
133
+ }
@@ -1,4 +1,5 @@
1
1
  import { logger } from "./logger.js";
2
+ import { levenshtein, MAX_COMPARABLE_LENGTH } from "./stringDistance.js";
2
3
  /**
3
4
  * Create an `experimental_repairToolCall` handler for streamText/generateText.
4
5
  * Fully dynamic — reads the tool schema at repair time, no configuration needed.
@@ -58,15 +59,27 @@ export function resolveToolName(calledName, availableTools) {
58
59
  }
59
60
  // 3. Levenshtein distance — accept if normalized distance < 0.3
60
61
  // Compare by normalized score (not raw edits) so length differences don't skew selection.
62
+ // `levenshtein` throws rather than truncates past MAX_COMPARABLE_LENGTH (see
63
+ // stringDistance.ts), so an over-limit name is skipped here instead of
64
+ // compared on a truncated prefix — a called name and a tool name that
65
+ // merely share a long-enough prefix must never tie at distance 0 and
66
+ // resolve to the wrong tool. The guard checks the lowercased operands, since
67
+ // those are what get compared and lowercasing can lengthen a string ("İ").
61
68
  let bestMatch = null;
62
69
  let bestNormalized = Infinity;
63
- for (const t of availableTools) {
64
- const dist = levenshtein(calledLower, t.toLowerCase());
65
- const maxLen = Math.max(calledName.length, t.length);
66
- const normalized = maxLen === 0 ? 0 : dist / maxLen;
67
- if (normalized < 0.3 && normalized < bestNormalized) {
68
- bestNormalized = normalized;
69
- bestMatch = t;
70
+ if (calledLower.length <= MAX_COMPARABLE_LENGTH) {
71
+ for (const t of availableTools) {
72
+ const tLower = t.toLowerCase();
73
+ if (tLower.length > MAX_COMPARABLE_LENGTH) {
74
+ continue;
75
+ }
76
+ const dist = levenshtein(calledLower, tLower);
77
+ const maxLen = Math.max(calledName.length, t.length);
78
+ const normalized = maxLen === 0 ? 0 : dist / maxLen;
79
+ if (normalized < 0.3 && normalized < bestNormalized) {
80
+ bestNormalized = normalized;
81
+ bestMatch = t;
82
+ }
70
83
  }
71
84
  }
72
85
  if (bestMatch) {
@@ -85,10 +98,27 @@ export function resolveToolName(calledName, availableTools) {
85
98
  */
86
99
  export function rankToolNameCandidates(calledName, availableTools, limit = 5) {
87
100
  const calledLower = calledName.toLowerCase();
88
- return [...availableTools]
89
- .sort((a, b) => levenshtein(calledLower, a.toLowerCase()) -
90
- levenshtein(calledLower, b.toLowerCase()))
91
- .slice(0, limit);
101
+ const calledWithinLimit = calledLower.length <= MAX_COMPARABLE_LENGTH;
102
+ // `levenshtein` throws past MAX_COMPARABLE_LENGTH instead of truncating, so
103
+ // an over-limit operand is scored as "furthest away" rather than compared
104
+ // on a truncated (and potentially falsely-tied) prefix. This still only
105
+ // orders the list — no candidate is dropped — matching this function's
106
+ // "no accept/reject judgment" contract.
107
+ const distanceTo = (name) => {
108
+ const nameLower = name.toLowerCase();
109
+ if (!calledWithinLimit || nameLower.length > MAX_COMPARABLE_LENGTH) {
110
+ return Number.POSITIVE_INFINITY;
111
+ }
112
+ return levenshtein(calledLower, nameLower);
113
+ };
114
+ // Precompute once per tool (not per comparison), and compare with an
115
+ // explicit equality check — two Infinity distances (both over-limit) would
116
+ // otherwise subtract to NaN, an invalid sort-comparator result.
117
+ return availableTools
118
+ .map((name) => ({ name, distance: distanceTo(name) }))
119
+ .sort((a, b) => (a.distance === b.distance ? 0 : a.distance - b.distance))
120
+ .slice(0, limit)
121
+ .map((entry) => entry.name);
92
122
  }
93
123
  /**
94
124
  * Thrown at a direct MCP execution boundary (`NeuroLink.executeExternalMCPTool`)
@@ -220,14 +250,21 @@ function findMatchingKey(inputKey, schemaKeys) {
220
250
  if (ciMatch) {
221
251
  return ciMatch;
222
252
  }
223
- // Levenshtein — threshold ≤2 edits
253
+ // Levenshtein — threshold ≤2 edits. Same over-limit guard as
254
+ // resolveToolName above: skip rather than let levenshtein() truncate.
224
255
  let best = null;
225
256
  let bestDist = Infinity;
226
- for (const k of schemaKeys) {
227
- const dist = levenshtein(inputLower, k.toLowerCase());
228
- if (dist <= 2 && dist < bestDist) {
229
- bestDist = dist;
230
- best = k;
257
+ if (inputLower.length <= MAX_COMPARABLE_LENGTH) {
258
+ for (const k of schemaKeys) {
259
+ const kLower = k.toLowerCase();
260
+ if (kLower.length > MAX_COMPARABLE_LENGTH) {
261
+ continue;
262
+ }
263
+ const dist = levenshtein(inputLower, kLower);
264
+ if (dist <= 2 && dist < bestDist) {
265
+ bestDist = dist;
266
+ best = k;
267
+ }
231
268
  }
232
269
  }
233
270
  return best;
@@ -308,41 +345,3 @@ export function coerceType(value, propSchema) {
308
345
  }
309
346
  return value;
310
347
  }
311
- // ─── Levenshtein Distance ──────────────────────────────────────────
312
- /**
313
- * Compute Levenshtein edit distance between two strings.
314
- * Uses the iterative matrix approach — O(m*n) time, O(min(m,n)) space.
315
- */
316
- function levenshtein(a, b) {
317
- if (a === b) {
318
- return 0;
319
- }
320
- if (a.length === 0) {
321
- return b.length;
322
- }
323
- if (b.length === 0) {
324
- return a.length;
325
- }
326
- // Use shorter string as column to minimize space
327
- if (a.length > b.length) {
328
- [a, b] = [b, a];
329
- }
330
- const aLen = a.length;
331
- const bLen = b.length;
332
- let prev = new Array(aLen + 1);
333
- let curr = new Array(aLen + 1);
334
- for (let i = 0; i <= aLen; i++) {
335
- prev[i] = i;
336
- }
337
- for (let j = 1; j <= bLen; j++) {
338
- curr[0] = j;
339
- for (let i = 1; i <= aLen; i++) {
340
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
341
- curr[i] = Math.min(prev[i] + 1, // deletion
342
- curr[i - 1] + 1, // insertion
343
- prev[i - 1] + cost);
344
- }
345
- [prev, curr] = [curr, prev];
346
- }
347
- return prev[aLen];
348
- }