llm-switcher 1.6.5 → 1.6.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.
package/toolvocab.mjs CHANGED
@@ -220,7 +220,55 @@ export function sanitizeToolName(name, { allowHyphens = true } = {}) {
220
220
  *
221
221
  * A list with no tools, or one where nothing can be confused, leaves every call untouched.
222
222
  */
223
- export function createToolVocab(declared) {
223
+ export function createToolNameMap(names, options = {}) {
224
+ const originals = [...new Set(names.filter(n => typeof n === 'string' && n))].sort();
225
+ const forward = new Map();
226
+ const reverse = new Map();
227
+ // Reserve unchanged names before allocating aliases, independent of declaration order.
228
+ for (const name of originals) {
229
+ if (sanitizeToolName(name, options) === name) { forward.set(name, name); reverse.set(name, name); }
230
+ }
231
+ for (const name of originals) {
232
+ if (forward.has(name)) continue;
233
+ const base = sanitizeToolName(name, options);
234
+ let alias = base;
235
+ for (let salt = 0; reverse.has(alias); salt++) {
236
+ const hash = crypto.createHash('sha256').update(`${name}\0${salt}`).digest('hex').slice(0, 12);
237
+ alias = `${base.slice(0, 51)}_${hash}`;
238
+ }
239
+ forward.set(name, alias); reverse.set(alias, name);
240
+ }
241
+ return { forward, reverse, name: n => forward.get(n) || sanitizeToolName(n, options) };
242
+ }
243
+
244
+ export function toolNameMapForIR(ir, options = {}) {
245
+ const names = [...(ir.tools || []).map(t => t.name),
246
+ ...(ir.messages || []).flatMap(m => [...(m.toolCalls || []).map(t => t.name), ...(m.name ? [m.name] : [])]),
247
+ ...(ir.toolChoice?.name ? [ir.toolChoice.name] : [])];
248
+ return createToolNameMap(names, options);
249
+ }
250
+
251
+ // Rewrite exact name mentions once, using the same collision-resolved map as declarations.
252
+ // A longer identifier containing a name is not a mention of that tool.
253
+ export function rewriteToolNameMentions(text, names) {
254
+ if (typeof text !== 'string') return text;
255
+ const changed = [...names].filter(([from, to]) => from && from !== to);
256
+ if (!changed.length) return text;
257
+ const lookup = new Map(changed);
258
+ const alternatives = changed.map(([from]) => from).sort((a, b) => b.length - a.length)
259
+ .map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
260
+ const pattern = new RegExp(`(?<![A-Za-z0-9_-])(?:${alternatives.join('|')})(?![A-Za-z0-9_-])`, 'g');
261
+ return text.replace(pattern, name => lookup.get(name));
262
+ }
263
+
264
+ // Only ToolSearch's query names other tools. Arbitrary tool arguments are caller data.
265
+ export function rewriteToolSearchArgs(name, args, names) {
266
+ if (name !== 'ToolSearch' || !args || typeof args !== 'object' || Array.isArray(args) || typeof args.query !== 'string') return args;
267
+ const query = rewriteToolNameMentions(args.query, names);
268
+ return query === args.query ? args : { ...args, query };
269
+ }
270
+
271
+ export function createToolVocab(declared, { allowHyphens = true, names = [] } = {}) {
224
272
  // Only a tool the caller executes can be a rename target. A tool the provider runs answers in
225
273
  // band and its result reaches the caller without the caller executing anything; handing such a
226
274
  // call back as a client tool moves the work, silently, to the machine the user is sitting at.
@@ -229,17 +277,12 @@ export function createToolVocab(declared) {
229
277
  const byName = new Map();
230
278
  const candidates = [];
231
279
  const shortenedToOriginal = new Map();
280
+ const aliases = createToolNameMap([...tools.map(t => t.name), ...names], { allowHyphens });
232
281
 
233
282
  for (const t of tools) {
234
283
  const caps = capabilitiesOf(t.name);
235
- const safeChat = sanitizeToolName(t.name, { allowHyphens: true });
236
- const safeVertex = sanitizeToolName(t.name, { allowHyphens: false });
237
- if (safeChat !== t.name) {
238
- shortenedToOriginal.set(safeChat, t.name);
239
- }
240
- if (safeVertex !== t.name) {
241
- shortenedToOriginal.set(safeVertex, t.name);
242
- }
284
+ const alias = aliases.forward.get(t.name);
285
+ if (alias !== t.name) shortenedToOriginal.set(alias, t.name);
243
286
  const entry = { tool: t, name: t.name, namespace: namespaceOf(t.name), caps };
244
287
  // Keyed by the exact spelling: a client checks tool names exactly, so `read` is not the same
245
288
  // tool as `Read` to it, and that difference is the whole reason this layer exists.
@@ -283,7 +326,7 @@ export function createToolVocab(declared) {
283
326
 
284
327
  function args(upstream, argsJson) {
285
328
  const fixedName = name(upstream);
286
- if (!useful && !shortenedToOriginal.has(String(upstream || ''))) return argsJson;
329
+ if (!useful && !shortenedToOriginal.has(String(upstream || '')) && !needsArgsRewrite(upstream)) return argsJson;
287
330
  const target = byName.get(fixedName);
288
331
  if (!target) return argsJson;
289
332
  let parsed;
@@ -292,15 +335,20 @@ export function createToolVocab(declared) {
292
335
  } catch {
293
336
  return argsJson; // a truncated payload is passed through: changing bytes helps nobody
294
337
  }
295
- const repaired = repairArgs(parsed, target.tool);
338
+ const repaired = rewriteToolSearchArgs(fixedName, repairArgs(parsed, target.tool), shortenedToOriginal);
296
339
  if (repaired === parsed) return argsJson;
297
340
  return JSON.stringify(repaired);
298
341
  }
299
342
 
343
+ function needsArgsRewrite(upstream) {
344
+ return name(upstream) === 'ToolSearch' && byName.has('ToolSearch') && shortenedToOriginal.size > 0;
345
+ }
346
+
300
347
  return {
301
348
  name,
302
349
  args,
303
350
  isShortened,
351
+ needsArgsRewrite,
304
352
  /** Both halves at once, for a call that is already whole. */
305
353
  repair(upstream, argsJson) {
306
354
  const fixedName = name(upstream);