llm-switcher 1.6.4 → 1.6.6

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.
@@ -95,6 +95,19 @@ function startUpstream() {
95
95
  return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
96
96
  }
97
97
  if (req.url.startsWith('/chat/v1/chat/completions')) {
98
+ if (lastUser.includes('TOOL_ALIAS_REPLAY')) {
99
+ const reference = json.messages.find(m => m.role === 'tool');
100
+ const name = JSON.parse(reference.content).tool_name;
101
+ if (!json.tools.some(t => t.function.name === name)) {
102
+ return sse(res, [{ error: { message: `upstream called undeclared tool '${name}'`, type: 'server_error' } }]);
103
+ }
104
+ return sse(res, [
105
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'console1', type: 'function', function: { name, arguments: '{"pageSize":0}' } }] } }] },
106
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 1, id: 'search2', type: 'function', function: { name: 'ToolSearch', arguments: '{"query":"select:' } }] } }] },
107
+ { choices: [{ index: 0, delta: { tool_calls: [{ index: 1, function: { arguments: `${name}","max_results":5}` } }] } }] },
108
+ { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 10, completion_tokens: 15 } }
109
+ ], { raw: ['data: [DONE]\n\n'] });
110
+ }
98
111
  if (lastUser.includes('RATE_LIMIT')) {
99
112
  res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
100
113
  return res.end(JSON.stringify({ error: { message: 'slow down' } }));
@@ -650,6 +663,40 @@ test('Bifrost stays off for a model that is not a Claude Code account', async ()
650
663
  assert.equal(received.at(-1).url, '/intact/v1/chat/completions');
651
664
  });
652
665
 
666
+ test('ToolSearch replay calls declared upstream aliases and restores original tool names and queries to Claude', async () => {
667
+ const name = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
668
+ const res = await post('/v1/messages', {
669
+ model: 'claude-opus-4-6', max_tokens: 256, stream: true, system: `Use ${name} to inspect errors.`,
670
+ tools: [
671
+ { name: 'ToolSearch', input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'integer' } } } },
672
+ { name, input_schema: { type: 'object', properties: { pageSize: { type: 'integer' } } } }
673
+ ],
674
+ messages: [
675
+ { role: 'user', content: 'Inspect errors.' },
676
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'search1', name: 'ToolSearch', input: { query: `select:${name}` } }] },
677
+ { role: 'user', content: [
678
+ { type: 'tool_result', tool_use_id: 'search1', content: [{ type: 'tool_reference', tool_name: name }] },
679
+ { type: 'text', text: 'TOOL_ALIAS_REPLAY' }
680
+ ] }
681
+ ]
682
+ });
683
+ assert.equal(res.status, 200);
684
+ const events = parseSSE(await res.text());
685
+ assertValidAnthropicEvents(events);
686
+ assert.ok(events.some(e => e.event === 'message_stop'));
687
+ assert.ok(!events.some(e => e.event === 'error'));
688
+ const starts = events.filter(e => e.data?.content_block?.type === 'tool_use');
689
+ assert.deepEqual(starts.map(e => e.data.content_block.name), [name, 'ToolSearch']);
690
+ const args = start => JSON.parse(events.filter(e => e.data?.index === start.data.index && e.data?.delta?.type === 'input_json_delta').map(e => e.data.delta.partial_json).join(''));
691
+ assert.deepEqual(args(starts[0]), { pageSize: 0 });
692
+ assert.deepEqual(args(starts[1]), { query: `select:${name}`, max_results: 5 });
693
+ const up = received.at(-1).body;
694
+ const alias = up.tools.find(t => t.function.name !== 'ToolSearch').function.name;
695
+ assert.equal(alias.length, 64);
696
+ assert.ok(!JSON.stringify(up.messages).includes(name));
697
+ assert.equal(JSON.parse(up.messages.find(m => m.tool_calls).tool_calls[0].function.arguments).query, `select:${alias}`);
698
+ });
699
+
653
700
  test('Bifrost stays off for a client that is not Claude Code', async () => {
654
701
  await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
655
702
  { 'x-llm-profile': 'intactcc', 'user-agent': 'some-sdk/1.0' });
@@ -139,7 +139,7 @@ test('computeLaunchState derives launch state per target profile, not from one g
139
139
  const names = (pairs) => pairs.map(([k]) => k).sort();
140
140
  // R2: exactly these names, each tool in its own file. Nothing here names an endpoint, a
141
141
  // model or a context window, so no tool can be reconfigured and none can capture the other.
142
- assert.deepEqual(names(st.envClaude), ['HTTPS_PROXY', 'NO_PROXY', 'https_proxy', 'no_proxy']);
142
+ assert.deepEqual(names(st.envClaude), ['CLAUDE_CODE_TETHER_LIVE', 'HTTPS_PROXY', 'NO_PROXY', 'https_proxy', 'no_proxy']);
143
143
  assert.deepEqual(names(st.envCodex),
144
144
  ['CODEX_CA_CERTIFICATE', 'HTTPS_PROXY', 'NO_PROXY', 'https_proxy', 'no_proxy']);
145
145
  // The proxy is the interceptor, not the gateway itself (R3).
@@ -261,6 +261,10 @@ test('every active tool is routed through the interceptor with only the variable
261
261
  for (const [label, pairs] of [['claude', st.envClaude], ['codex', st.envCodex]]) {
262
262
  for (const [k, v] of pairs) {
263
263
  assert.ok(!/_URL$|BASE_URL/.test(k), `${label}: ${k} would print an override banner`);
264
+ if (k === 'CLAUDE_CODE_TETHER_LIVE') {
265
+ assert.equal(v, '0', `${label}: tether must be disabled`);
266
+ continue;
267
+ }
264
268
  assert.ok(!/MODEL|CONTEXT|WINDOW|CLAUDE_CODE_|OPENAI_/.test(k), `${label}: ${k} names a model`);
265
269
  assert.ok(typeof v === 'string' && v.length > 0, `${label}: ${k} must have a value`);
266
270
  }
@@ -382,6 +386,7 @@ test('the launch state writes no 1M tag or model variable, whatever model1M says
382
386
  const st = computeLaunchState(cfg, 4000);
383
387
  assert.equal(st.active, true, 'the profile is active whatever it marks 1M');
384
388
  for (const [k] of [...st.envClaude, ...st.envCodex]) {
389
+ if (k === 'CLAUDE_CODE_TETHER_LIVE') continue;
385
390
  assert.ok(!/ANTHROPIC|CLAUDE_CODE|OPENAI_|MODEL|1M|CONTEXT|WINDOW/.test(k),
386
391
  `${k} must not be written for ${JSON.stringify(model1M)}`);
387
392
  }
@@ -2,9 +2,9 @@
2
2
  // declared. Sources for every name here are collected in docs/tool-vocabulary.md.
3
3
  import test from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
- import { createToolVocab, NULL_TOOL_VOCAB, tokenize } from '../toolvocab.mjs';
5
+ import { createToolVocab, NULL_TOOL_VOCAB, tokenize, sanitizeToolName, toolNameMapForIR } from '../toolvocab.mjs';
6
6
  import {
7
- anthropicToIR, responsesToIR, irToChatBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
7
+ anthropicToIR, responsesToIR, irToChatBody, irToAnthropicBody, irToVertexBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
8
8
  createAnthropicStream, createChatStream, createResponsesStream
9
9
  } from '../formats.mjs';
10
10
 
@@ -17,6 +17,123 @@ const claudeTools = () => anthropicToIR({
17
17
  ]
18
18
  });
19
19
 
20
+ const consoleTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
21
+ const networkTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_network_requests';
22
+
23
+ function toolSearchHistory(extraTools = []) {
24
+ return anthropicToIR({
25
+ model: 'claude-opus-5-5', system: `Inspect errors with ${consoleTool} and ${networkTool}.`,
26
+ messages: [
27
+ { role: 'user', content: 'Inspect console errors.' },
28
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'search1', name: 'ToolSearch', input: { query: `select:${consoleTool},${networkTool}`, max_results: 5 } }] },
29
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'search1', content: [
30
+ { type: 'tool_reference', tool_name: consoleTool }, { type: 'tool_reference', tool_name: networkTool }
31
+ ] }] }
32
+ ],
33
+ tools: [
34
+ { name: 'ToolSearch', description: `Find tools such as ${consoleTool}.`, input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'number' } } } },
35
+ ...[consoleTool, networkTool, ...extraTools].map(name => ({ name, input_schema: { type: 'object', properties: { pageSize: { type: 'integer' } } } }))
36
+ ]
37
+ });
38
+ }
39
+
40
+ test('ToolSearch references, query history and instructions agree with declared aliases across upstream formats', () => {
41
+ const ir = toolSearchHistory();
42
+ const original = JSON.stringify(ir);
43
+ for (const [emit, model, opts] of [
44
+ [irToChatBody, 'zencore/space-bunny-free', {}],
45
+ [irToChatBody, 'zencore/space-bunny-free', { thinkingMode: 'off' }],
46
+ [irToChatBody, 'zencore/space-bunny-free', { thinkingMode: 'native' }],
47
+ [irToAnthropicBody, 'claude-opus-5-5', {}],
48
+ [irToVertexBody, 'gemini-3.8-flash', {}]
49
+ ]) {
50
+ const body = emit(ir, model, opts);
51
+ const serialized = JSON.stringify(body);
52
+ const names = toolNameMapForIR(ir, { allowHyphens: emit !== irToVertexBody });
53
+ for (const name of [consoleTool, networkTool]) {
54
+ assert.ok(serialized.includes(names.name(name)), 'model sees the declared alias');
55
+ assert.ok(!serialized.includes(name), 'original no longer competes with the alias');
56
+ }
57
+ assert.equal(JSON.stringify(ir), original, 'conversion never mutates caller history');
58
+ }
59
+ });
60
+
61
+ test('ToolSearch instructions use collision-resolved aliases instead of the reserved original name', () => {
62
+ const reserved = sanitizeToolName(consoleTool);
63
+ const ir = toolSearchHistory([reserved]);
64
+ const names = toolNameMapForIR(ir);
65
+ assert.notEqual(names.name(consoleTool), reserved);
66
+ const body = irToChatBody(ir, 'zencore/space-bunny-free');
67
+ assert.equal(body.tools.find(t => t.function.name === reserved).function.name, reserved);
68
+ const references = body.messages.find(m => m.role === 'tool').content.split('\n').map(JSON.parse);
69
+ assert.equal(references[0].tool_name, names.name(consoleTool));
70
+ assert.ok(body.messages[0].content.includes(names.name(consoleTool)));
71
+ const notice = irToChatBody({ ...ir, system: '' }, 'x').messages[0].content;
72
+ assert.ok(notice.includes(names.name(consoleTool)), 'notice uses the collision-resolved declaration too');
73
+ const vocab = createToolVocab(ir.tools);
74
+ const response = buildAnthropicMessage({ model: 'x', tools: [{ name: 'ToolSearch', args: JSON.stringify({ query: `select:${names.name(consoleTool)},${reserved}` }) }], vocab });
75
+ assert.equal(response.content[0].input.query, `select:${consoleTool},${reserved}`);
76
+ });
77
+
78
+ test('only structured references and ToolSearch queries change; caller data, schemas and unknown tools survive', () => {
79
+ const ir = toolSearchHistory();
80
+ const unknown = 'mcp__not_declared__inspect';
81
+ ir.messages[0].content = `User data mentions ${consoleTool}.`;
82
+ ir.messages[1].toolCalls.push({ id: 'data1', name: consoleTool, args: { pageSize: 0, query: consoleTool } });
83
+ ir.messages.push({ role: 'tool', toolCallId: 'data1', content: `Log contains ${consoleTool}.` });
84
+ ir.messages[2].toolResultParts.push({ type: 'tool_reference', tool_name: unknown });
85
+ ir.tools[1].parameters.properties.query = { type: 'string', const: consoleTool };
86
+ const body = irToChatBody(ir, 'x');
87
+ assert.equal(body.messages.find(m => m.role === 'user').content, ir.messages[0].content);
88
+ assert.equal(body.messages.find(m => m.tool_call_id === 'data1').content, `Log contains ${consoleTool}.`);
89
+ const calls = body.messages.find(m => m.tool_calls).tool_calls;
90
+ assert.deepEqual(JSON.parse(calls[1].function.arguments), { pageSize: 0, query: consoleTool });
91
+ assert.equal(JSON.parse(calls[0].function.arguments).max_results, 5);
92
+ assert.ok(body.messages.find(m => m.tool_call_id === 'search1').content.includes(unknown));
93
+ assert.equal(body.tools[1].function.parameters.properties.query.const, consoleTool);
94
+ const vocab = createToolVocab(ir.tools);
95
+ assert.equal(vocab.name(unknown), unknown);
96
+ const malformed = '{"query":"select:';
97
+ assert.equal(vocab.args('ToolSearch', malformed), malformed);
98
+ });
99
+
100
+ test('fragmented streamed ToolSearch queries restore original names before the client executes them', () => {
101
+ const ir = toolSearchHistory();
102
+ const vocab = createToolVocab(ir.tools);
103
+ const args = JSON.stringify({ query: `select:${sanitizeToolName(consoleTool)},${sanitizeToolName(networkTool)}`, max_results: 5 });
104
+ for (const create of [createAnthropicStream, createChatStream, createResponsesStream]) {
105
+ const events = [];
106
+ const renderer = create((event, data) => events.push({ event, data }), 'x', { vocab });
107
+ renderer.start();
108
+ renderer.tool({ index: 0, id: 'search2', name: 'ToolSearch', args: '' });
109
+ for (let i = 0; i < args.length; i += 7) renderer.tool({ index: 0, args: args.slice(i, i + 7) });
110
+ renderer.finish('tool_calls', { hasTools: true });
111
+ let restored;
112
+ if (create === createAnthropicStream) restored = events.filter(e => e.data?.delta?.type === 'input_json_delta').map(e => e.data.delta.partial_json).join('');
113
+ else if (create === createChatStream) restored = events.flatMap(e => e.data?.choices?.[0]?.delta?.tool_calls || []).map(c => c.function?.arguments || '').join('');
114
+ else restored = events.find(e => e.event === 'response.output_item.done').data.item.arguments;
115
+ assert.deepEqual(JSON.parse(restored), { query: `select:${consoleTool},${networkTool}`, max_results: 5 });
116
+ }
117
+ const argsUnrelated = JSON.stringify({ query: sanitizeToolName(consoleTool) });
118
+ assert.equal(vocab.args(consoleTool, argsUnrelated), argsUnrelated, 'other tools keep their arguments');
119
+ });
120
+
121
+ test('repairing one parallel Chat ToolSearch call never flushes another unfinished query', () => {
122
+ const vocab = createToolVocab(toolSearchHistory().tools);
123
+ const events = [];
124
+ const renderer = createChatStream((event, data) => events.push(data), 'x', { vocab });
125
+ renderer.start();
126
+ renderer.tool({ index: 0, name: 'ToolSearch', args: '{"query":"select:' });
127
+ renderer.tool({ index: 1, name: 'ToolSearch', args: JSON.stringify({ query: `select:${sanitizeToolName(networkTool)}` }) });
128
+ renderer.tool({ index: 0, args: `${sanitizeToolName(consoleTool)}"}` });
129
+ renderer.finish('tool_calls');
130
+ for (const [index, original] of [[0, consoleTool], [1, networkTool]]) {
131
+ const args = events.flatMap(e => e.choices?.[0]?.delta?.tool_calls || [])
132
+ .filter(c => c.index === index).map(c => c.function?.arguments || '').join('');
133
+ assert.equal(JSON.parse(args).query, `select:${original}`);
134
+ }
135
+ });
136
+
20
137
  // A model that answers `read` when the caller declared `Read` ends the turn with
21
138
  // "No such tool available: read". The name comes back in the caller's words.
22
139
  test('a name the model reached for comes back in the caller own words', () => {
@@ -278,3 +395,112 @@ test('the notice and the think guide coexist', () => {
278
395
  assert.ok(sys.includes('<think> and </think>'), `the think guide is missing: ${sys}`);
279
396
  assert.match(sys, /use exactly these names/i);
280
397
  });
398
+
399
+ // Tool names longer than 64 characters (e.g. MCP plugin tools) must be sanitized for OpenAI/Gemini
400
+ // APIs (<= 64 chars, ^[a-zA-Z0-9_-]{1,64}$) and mapped back in responses.
401
+ test('sanitizeToolName: enforces 1-64 character length and valid identifier charset', () => {
402
+ assert.equal(sanitizeToolName('Bash'), 'Bash');
403
+ assert.equal(sanitizeToolName('Read'), 'Read');
404
+ assert.equal(sanitizeToolName('mcp__github__create_issue'), 'mcp__github__create_issue');
405
+
406
+ const name64 = 'a'.repeat(64);
407
+ assert.equal(sanitizeToolName(name64), name64);
408
+
409
+ // Exact problematic tool from chrome-devtools MCP (70 chars)
410
+ const chromeDevToolsTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
411
+ assert.equal(chromeDevToolsTool.length, 70);
412
+ const sanitized = sanitizeToolName(chromeDevToolsTool);
413
+ assert.ok(sanitized.length <= 64, `length must be <= 64, got ${sanitized.length}`);
414
+ assert.equal(sanitized.length, 64);
415
+ assert.match(sanitized, /^[a-zA-Z0-9_-]{1,64}$/);
416
+ assert.ok(sanitized.startsWith('mcp__plugin_chrome-devtools-mcp_'));
417
+ assert.ok(sanitized.endsWith('_list_console_messages'));
418
+
419
+ // Another long tool with different suffix gets a distinct hash
420
+ const networkTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_network_requests';
421
+ const sanitizedNetwork = sanitizeToolName(networkTool);
422
+ assert.equal(sanitizedNetwork.length, 64);
423
+ assert.notEqual(sanitized, sanitizedNetwork);
424
+
425
+ // allowHyphens: false for Vertex/Gemini
426
+ const vertexSanitized = sanitizeToolName(chromeDevToolsTool, { allowHyphens: false });
427
+ assert.equal(vertexSanitized.length, 64);
428
+ assert.match(vertexSanitized, /^[a-zA-Z_][a-zA-Z0-9_]*$/);
429
+ assert.ok(!vertexSanitized.includes('-'));
430
+ });
431
+
432
+ test('createToolVocab: round-trips >64 char tool names back to client declared name', () => {
433
+ const longName = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
434
+ const ir = anthropicToIR({
435
+ model: 'claude-opus-4-6', messages: [{ role: 'user', content: 'check console' }],
436
+ tools: [
437
+ { name: longName, input_schema: { properties: { count: { type: 'number' } } } }
438
+ ]
439
+ });
440
+
441
+ const vocab = createToolVocab(ir.tools);
442
+ const upstreamSafe = sanitizeToolName(longName);
443
+ assert.notEqual(upstreamSafe, longName);
444
+ assert.equal(upstreamSafe.length, 64);
445
+
446
+ // Model answers with the sanitized name -> vocab recovers original declared name
447
+ assert.equal(vocab.name(upstreamSafe), longName);
448
+ assert.ok(vocab.isShortened(upstreamSafe));
449
+
450
+ // Streaming does not hold arguments for shortened names (they keep caller's schema)
451
+ const deltas = [];
452
+ const renderer = createAnthropicStream((event, data) => deltas.push({ event, data }), 'claude-opus-4-6', { vocab });
453
+ renderer.start();
454
+ renderer.tool({ index: 0, id: 'call_1', name: upstreamSafe, args: '{"count": 10}' });
455
+ renderer.finish('stop');
456
+
457
+ const startBlock = deltas.find(d => d.event === 'content_block_start');
458
+ assert.equal(startBlock.data.content_block.name, longName);
459
+
460
+ const jsonDelta = deltas.find(d => d.event === 'content_block_delta' && d.data.delta.type === 'input_json_delta');
461
+ assert.ok(jsonDelta, 'input_json_delta should be emitted');
462
+ assert.equal(jsonDelta.data.delta.partial_json, '{"count": 10}');
463
+
464
+ // Non-streaming builder also restores the original name
465
+ const msg = buildAnthropicMessage({
466
+ model: 'claude-opus-4-6',
467
+ tools: [{ index: 0, id: 'call_1', name: upstreamSafe, args: '{"count": 10}' }],
468
+ finish: 'tool_calls',
469
+ vocab
470
+ });
471
+ const toolUse = msg.content.find(c => c.type === 'tool_use');
472
+ assert.equal(toolUse.name, longName);
473
+ });
474
+
475
+ test('irToChatBody: serializes long tool names, assistant history, and toolChoice within 64 chars', () => {
476
+ const longName = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
477
+ const ir = anthropicToIR({
478
+ model: 'gpt-4o',
479
+ messages: [
480
+ { role: 'user', content: 'first turn' },
481
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'call_1', name: longName, input: { count: 5 } }] },
482
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }
483
+ ],
484
+ tools: [
485
+ { name: longName, input_schema: { properties: { count: { type: 'number' } } } }
486
+ ]
487
+ });
488
+ ir.toolChoice = { name: longName };
489
+
490
+ const body = irToChatBody(ir, 'gpt-4o');
491
+
492
+ // Declared tools must have <=64 char function names matching regex
493
+ assert.ok(body.tools && body.tools.length === 1);
494
+ const toolFn = body.tools[0].function;
495
+ assert.ok(toolFn.name.length <= 64);
496
+ assert.match(toolFn.name, /^[a-zA-Z0-9_-]{1,64}$/);
497
+ assert.equal(toolFn.name, sanitizeToolName(longName));
498
+
499
+ // Assistant turn in history must have its tool call name shortened too
500
+ const asstMsg = body.messages.find(m => m.role === 'assistant');
501
+ assert.ok(asstMsg && asstMsg.tool_calls && asstMsg.tool_calls.length === 1);
502
+ assert.equal(asstMsg.tool_calls[0].function.name, sanitizeToolName(longName));
503
+
504
+ // toolChoice must be shortened
505
+ assert.equal(body.tool_choice.function.name, sanitizeToolName(longName));
506
+ });
@@ -184,7 +184,7 @@ test('a release on npm is installed before the checkout is asked at all', async
184
184
  const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
185
185
 
186
186
  const order = calls.map(c => c[0]);
187
- assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
187
+ assert.deepEqual(order.slice(0, 2), [process.platform === 'win32' ? 'cmd.exe' : 'npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
188
188
  assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
189
189
  // npm put the release in the global prefix, which is not this directory, so the checkout is what
190
190
  // moved -- and that is the version this gateway runs, which is the only reason the pull happened.
@@ -226,7 +226,7 @@ test('an npm install that fails does not stop a checkout from updating', async (
226
226
  const logs = [];
227
227
  // npm refuses the version a fixture registry names, the way a real registry would.
228
228
  const run = async (cmd, args, opts) => {
229
- if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
229
+ if (cmd === 'npm' || cmd === 'cmd.exe') throw new Error('npm install failed: notarget No matching version found');
230
230
  return recording([])(cmd, args, opts);
231
231
  };
232
232
  const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
@@ -0,0 +1,30 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readUpstreamPayloads } from '../upstream-stream.mjs';
4
+
5
+ async function collect(text, widths = [1, 3, 7]) {
6
+ const bytes = Buffer.from(text); let offset = 0, i = 0;
7
+ const body = new ReadableStream({ pull(controller) {
8
+ if (offset === bytes.length) return controller.close();
9
+ const end = Math.min(bytes.length, offset + widths[i++ % widths.length]);
10
+ controller.enqueue(bytes.subarray(offset, end)); offset = end;
11
+ } });
12
+ const values = [];
13
+ for await (const value of readUpstreamPayloads({ body })) values.push(value);
14
+ return values;
15
+ }
16
+ test('multiline SSE joins data fields across CRLF, byte chunks and UTF-8', async () => {
17
+ const text = ': ping\r\nevent: message\r\nid: 1\r\nretry: 1000\r\ndata: {"choices":\r\ndata: [{"delta":{"content":"giữ ngữ cảnh"}}]}\r\n\r\ndata: [DONE]\r\n\r\n';
18
+ assert.deepEqual(await collect(text), [{ choices: [{ delta: { content: 'giữ ngữ cảnh' } }] }]);
19
+ });
20
+ test('Vertex JSON lines and final SSE event without delimiter both survive', async () => {
21
+ assert.deepEqual(await collect('{"one":1}\n{"two":2}'), [{ one: 1 }, { two: 2 }]);
22
+ assert.deepEqual(await collect('data: {"one":1}'), [{ one: 1 }]);
23
+ });
24
+ test('malformed JSON is surfaced and stopping consumption cancels the upstream', async () => {
25
+ await assert.rejects(collect('data: {"partial":\n\n'), SyntaxError);
26
+ let canceled = false;
27
+ const body = new ReadableStream({ start(c) { c.enqueue(Buffer.from('data: {"one":1}\n\n')); }, cancel() { canceled = true; } });
28
+ for await (const item of readUpstreamPayloads({ body })) { assert.equal(item.one, 1); break; }
29
+ assert.equal(canceled, true);
30
+ });
package/toolvocab.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import crypto from 'node:crypto';
2
+
1
3
  // Writing a tool call back in the vocabulary the client declared.
2
4
  //
3
5
  // A tool name is not an identifier an API checks. The client sends its tools, the model answers
@@ -191,13 +193,82 @@ function repairArgs(args, tool) {
191
193
  return out;
192
194
  }
193
195
 
196
+ /**
197
+ * Sanitize a tool name to conform to upstream API length and character constraints.
198
+ * OpenAI Chat: 1-64 letters, digits, underscores or hyphens (^[a-zA-Z0-9_-]{1,64}$).
199
+ * Vertex AI / Gemini: 1-64 alphanumeric or underscore (^[a-zA-Z_][a-zA-Z0-9_]*$).
200
+ * If length > 64, preserves a 32-char head, 22-char tail, and embeds an 8-char sha256 hash.
201
+ */
202
+ export function sanitizeToolName(name, { allowHyphens = true } = {}) {
203
+ if (typeof name !== 'string' || !name) return 'tool';
204
+ const pattern = allowHyphens ? /[^a-zA-Z0-9_-]/g : /[^a-zA-Z0-9_]/g;
205
+ let cleaned = name.replace(pattern, '_');
206
+ if (!allowHyphens && /^[0-9]/.test(cleaned)) {
207
+ cleaned = `_${cleaned}`;
208
+ }
209
+ if (cleaned.length <= 64 && cleaned === name) return name;
210
+ if (cleaned.length <= 64) return cleaned;
211
+ const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 8);
212
+ const head = cleaned.slice(0, 32);
213
+ const tail = cleaned.slice(-22);
214
+ return `${head}_${hash}_${tail}`;
215
+ }
216
+
194
217
  /**
195
218
  * The vocabulary one request declared. `declared` is the client's own tool list, as `ir.tools` holds
196
219
  * it: `{ name, parameters }`, the same names and schemas the client will check the call against.
197
220
  *
198
221
  * A list with no tools, or one where nothing can be confused, leaves every call untouched.
199
222
  */
200
- 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 = [] } = {}) {
201
272
  // Only a tool the caller executes can be a rename target. A tool the provider runs answers in
202
273
  // band and its result reaches the caller without the caller executing anything; handing such a
203
274
  // call back as a client tool moves the work, silently, to the machine the user is sitting at.
@@ -205,8 +276,13 @@ export function createToolVocab(declared) {
205
276
  .filter(t => t && typeof t.name === 'string' && t.name && t.side !== 'provider');
206
277
  const byName = new Map();
207
278
  const candidates = [];
279
+ const shortenedToOriginal = new Map();
280
+ const aliases = createToolNameMap([...tools.map(t => t.name), ...names], { allowHyphens });
281
+
208
282
  for (const t of tools) {
209
283
  const caps = capabilitiesOf(t.name);
284
+ const alias = aliases.forward.get(t.name);
285
+ if (alias !== t.name) shortenedToOriginal.set(alias, t.name);
210
286
  const entry = { tool: t, name: t.name, namespace: namespaceOf(t.name), caps };
211
287
  // Keyed by the exact spelling: a client checks tool names exactly, so `read` is not the same
212
288
  // tool as `Read` to it, and that difference is the whole reason this layer exists.
@@ -217,11 +293,17 @@ export function createToolVocab(declared) {
217
293
  // there is no second answer to pick between, so the call stands and the client reports it.
218
294
  const useful = candidates.length >= 2 && candidates.some(c => c.caps.size > 0);
219
295
 
296
+ function isShortened(upstream) {
297
+ const raw = String(upstream || '');
298
+ return shortenedToOriginal.has(raw);
299
+ }
300
+
220
301
  function name(upstream) {
221
- if (!useful) return upstream;
222
302
  const raw = String(upstream || '');
223
303
  if (!raw || raw === 'tool') return upstream;
304
+ if (shortenedToOriginal.has(raw)) return shortenedToOriginal.get(raw);
224
305
  if (byName.has(raw)) return upstream; // already the client's own name
306
+ if (!useful) return upstream;
225
307
  const ns = namespaceOf(raw);
226
308
  if (ns) return upstream; // a server's tool never becomes a local one
227
309
  const want = capabilitiesOf(raw);
@@ -243,8 +325,9 @@ export function createToolVocab(declared) {
243
325
  }
244
326
 
245
327
  function args(upstream, argsJson) {
246
- if (!useful) return argsJson;
247
- const target = byName.get(name(upstream));
328
+ const fixedName = name(upstream);
329
+ if (!useful && !shortenedToOriginal.has(String(upstream || '')) && !needsArgsRewrite(upstream)) return argsJson;
330
+ const target = byName.get(fixedName);
248
331
  if (!target) return argsJson;
249
332
  let parsed;
250
333
  try {
@@ -252,21 +335,32 @@ export function createToolVocab(declared) {
252
335
  } catch {
253
336
  return argsJson; // a truncated payload is passed through: changing bytes helps nobody
254
337
  }
255
- const repaired = repairArgs(parsed, target.tool);
338
+ const repaired = rewriteToolSearchArgs(fixedName, repairArgs(parsed, target.tool), shortenedToOriginal);
256
339
  if (repaired === parsed) return argsJson;
257
340
  return JSON.stringify(repaired);
258
341
  }
259
342
 
343
+ function needsArgsRewrite(upstream) {
344
+ return name(upstream) === 'ToolSearch' && byName.has('ToolSearch') && shortenedToOriginal.size > 0;
345
+ }
346
+
260
347
  return {
261
348
  name,
262
349
  args,
350
+ isShortened,
351
+ needsArgsRewrite,
263
352
  /** Both halves at once, for a call that is already whole. */
264
353
  repair(upstream, argsJson) {
265
354
  const fixedName = name(upstream);
266
- return { name: fixedName, args: args(fixedName, argsJson), renamed: fixedName !== upstream };
355
+ return { name: fixedName, args: args(upstream, argsJson), renamed: fixedName !== upstream };
267
356
  }
268
357
  };
269
358
  }
270
359
 
271
360
  /** A vocabulary that never rewrites anything, for a request that declared no tools. */
272
- export const NULL_TOOL_VOCAB = Object.freeze({ name: n => n, args: (n, a) => a, repair: (n, a) => ({ name: n, args: a, renamed: false }) });
361
+ export const NULL_TOOL_VOCAB = Object.freeze({
362
+ name: n => n,
363
+ args: (n, a) => a,
364
+ isShortened: () => false,
365
+ repair: (n, a) => ({ name: n, args: a, renamed: false })
366
+ });
@@ -0,0 +1,37 @@
1
+ // SSE dispatches one event at a blank line; data fields are joined with newlines.
2
+ // Vertex also sends bare JSON lines. Keep both framings without parsing data fields separately.
3
+ export async function* readUpstreamPayloads(upstreamRes) {
4
+ const reader = upstreamRes.body.getReader();
5
+ const decoder = new TextDecoder();
6
+ let buffer = '', data = [], finished = false;
7
+ const parse = value => {
8
+ const text = value.trim();
9
+ if (!text || text === '[DONE]') return null;
10
+ return JSON.parse(text);
11
+ };
12
+ function line(value) {
13
+ if (value.endsWith('\r')) value = value.slice(0, -1);
14
+ if (!value) { const payload = data.join('\n'); data = []; return parse(payload); }
15
+ if (value.startsWith('data:')) { data.push(value.slice(5).replace(/^ /, '')); return null; }
16
+ if (value.startsWith('{') && !data.length) return parse(value);
17
+ return null;
18
+ }
19
+ try {
20
+ while (true) {
21
+ const chunk = await reader.read();
22
+ if (chunk.done) { finished = true; buffer += decoder.decode(); break; }
23
+ buffer += decoder.decode(chunk.value, { stream: true });
24
+ let newline;
25
+ while ((newline = buffer.indexOf('\n')) !== -1) {
26
+ const payload = line(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1);
27
+ if (payload) yield payload;
28
+ }
29
+ }
30
+ if (buffer) { const payload = line(buffer); if (payload) yield payload; }
31
+ // Tolerate an upstream's missing final blank line; completion is validated by the caller.
32
+ const tail = parse(data.join('\n')); if (tail) yield tail;
33
+ } finally {
34
+ if (!finished) await reader.cancel().catch(() => {});
35
+ reader.releaseLock();
36
+ }
37
+ }