llm-switcher 1.6.3 → 1.6.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog — LLM Switcher
2
2
 
3
+ ## Release 1.6.5 — 2026-10-08
4
+
5
+ - **Automatic Tether Gate Disable for Claude Code (`CLAUDE_CODE_TETHER_LIVE=0`):** Automatically inject `CLAUDE_CODE_TETHER_LIVE=0` into `env-claude.sh` and `env-claude.cmd` whenever the Claude route is active, ensuring Claude Code runs in stateless mode and prevents `message-threads` validation failures on non-Anthropic upstreams. The shim cleans the variable on route deactivation, and the gateway safely rejects unexpected thread requests with `thread_unsupported_request` (HTTP 400).
6
+ - **Two-way Safe Tool Name Sanitization (64-char limit):** Automatically sanitize and shorten tool names exceeding 64 characters (e.g. MCP plugin tools such as `mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages`) into collision-free 64-character identifiers (`^[a-zA-Z0-9_-]{1,64}$`) with embedded SHA-256 hashes for OpenAI Chat and Vertex/Gemini upstreams. The gateway vocabulary bidirectional mapping seamlessly restores the original MCP tool name in responses to Claude Code without buffering delays.
7
+
8
+ ## Release 1.6.4 — 2026-10-08
9
+
10
+ - **Dynamic Model Provider Source for Idle Compaction:** Query live model listings directly from the selected profile's `baseURL` via `/models` instead of hardcoding provider URLs or static model lists.
11
+ - **Clean Model Combobox without Slot Aliases:** Eliminate hardcoded quick preset buttons, popular model arrays, and slot aliases from the summarization model combobox. Models are loaded dynamically from the upstream provider, sorted alphabetically with context limits, with support for instant search filtering and deterministic heuristic truncation (`""`).
12
+
3
13
  ## Release 1.6.3 — 2026-10-08
4
14
 
5
15
  - **Interactive Summarization Model Selection & Presets:** Replace raw text input for Idle Compaction's summarization model with a fully searchable combobox dropdown and quick 1-click preset chips. Users can pick directly from deterministic heuristic mode (`""`), slot aliases (`haiku`, `subagent`, `sonnet`, `main`), configured profile models (`zencore/*`, `codex/*`), and discovered catalog models, or type to filter in real-time. Selection automatically saves and updates active highlights across both light and dark themes.
package/formats.mjs CHANGED
@@ -24,6 +24,8 @@
24
24
  // }
25
25
  // ============================================================
26
26
 
27
+ import { sanitizeToolName } from './toolvocab.mjs';
28
+
27
29
  export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
28
30
 
29
31
  // ---------------- stop reasons ----------------
@@ -910,7 +912,7 @@ function repairToolCall(tc, vocab) {
910
912
  function toolNamesNotice(ir, systemText) {
911
913
  const names = (ir.tools || [])
912
914
  .filter(t => t && typeof t.name === 'string' && t.name && t.side !== 'provider')
913
- .map(t => t.name);
915
+ .map(t => sanitizeToolName(t.name));
914
916
  if (names.length < 2) return '';
915
917
  const missing = names.filter(n => !String(systemText || '').includes(n));
916
918
  if (!missing.length) return '';
@@ -1055,7 +1057,7 @@ function irToChatBody(ir, model, opts = {}) {
1055
1057
  if (m.content !== undefined) out.content = m.content;
1056
1058
  if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
1057
1059
  out.tool_calls = m.toolCalls.map(tc => {
1058
- const call = { id: tc.id, type: 'function', function: { name: tc.name, arguments: stringifyArgs(tc.args) } };
1060
+ const call = { id: tc.id, type: 'function', function: { name: sanitizeToolName(tc.name), arguments: stringifyArgs(tc.args) } };
1059
1061
  const sig = tc.sig || lookupToolSignature(tc.id);
1060
1062
  if (sig) call.extra_content = { google: { thought_signature: sig } };
1061
1063
  return call;
@@ -1072,14 +1074,14 @@ function irToChatBody(ir, model, opts = {}) {
1072
1074
  if (tools.length) {
1073
1075
  body.tools = tools.map(t => ({
1074
1076
  type: 'function',
1075
- function: { name: t.name, description: t.description || '', parameters: sanitizeJsonSchema(t.parameters || {}) }
1077
+ function: { name: sanitizeToolName(t.name), description: t.description || '', parameters: sanitizeJsonSchema(t.parameters || {}) }
1076
1078
  }));
1077
1079
  }
1078
1080
  if (ir.toolChoice) {
1079
1081
  if (typeof ir.toolChoice === 'string') {
1080
1082
  body.tool_choice = ir.toolChoice === 'required' ? 'required' : ir.toolChoice;
1081
1083
  } else if (ir.toolChoice.name) {
1082
- body.tool_choice = { type: 'function', function: { name: ir.toolChoice.name } };
1084
+ body.tool_choice = { type: 'function', function: { name: sanitizeToolName(ir.toolChoice.name) } };
1083
1085
  }
1084
1086
  }
1085
1087
  if (typeof ir.params.maxTokens === 'number') {
@@ -1176,7 +1178,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1176
1178
  if (m.role === 'assistant') {
1177
1179
  const blocks = contentToAnthropicBlocks(m.content).filter(b => b.type === 'text');
1178
1180
  for (const tc of (m.toolCalls || [])) {
1179
- blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: parseArgs(tc.args) });
1181
+ blocks.push({ type: 'tool_use', id: tc.id, name: sanitizeToolName(tc.name), input: parseArgs(tc.args) });
1180
1182
  }
1181
1183
  if (blocks.length) messages.push({ role: 'assistant', content: blocks });
1182
1184
  continue;
@@ -1220,7 +1222,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1220
1222
  const anthropicTools = clientTools(ir);
1221
1223
  if (anthropicTools.length) {
1222
1224
  body.tools = anthropicTools.map(t => ({
1223
- name: t.name, description: t.description || '',
1225
+ name: sanitizeToolName(t.name), description: t.description || '',
1224
1226
  input_schema: sanitizeJsonSchema(t.parameters || {})
1225
1227
  }));
1226
1228
  }
@@ -1228,7 +1230,7 @@ function irToAnthropicBody(ir, model, opts = {}) {
1228
1230
  if (typeof ir.toolChoice === 'string') {
1229
1231
  body.tool_choice = ir.toolChoice === 'required' ? { type: 'any' } : { type: ir.toolChoice };
1230
1232
  } else if (ir.toolChoice.name) {
1231
- body.tool_choice = { type: 'tool', name: ir.toolChoice.name };
1233
+ body.tool_choice = { type: 'tool', name: sanitizeToolName(ir.toolChoice.name) };
1232
1234
  }
1233
1235
  }
1234
1236
  if (ir.params.parallelToolCalls === false && body.tool_choice) body.tool_choice.disable_parallel_tool_use = true;
@@ -1327,13 +1329,13 @@ function irToVertexBody(ir, model, opts = {}) {
1327
1329
  resp = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : { result: parsed ?? '' };
1328
1330
  } catch { resp = { result: m.content ?? '' }; }
1329
1331
  // Gemini API & Vertex only accept roles 'user' | 'model' (functionResponse lives in the 'user' role).
1330
- push('user', [{ functionResponse: { name: m.name || 'tool', response: resp } }]);
1332
+ push('user', [{ functionResponse: { name: sanitizeToolName(m.name || 'tool', { allowHyphens: false }), response: resp } }]);
1331
1333
  continue;
1332
1334
  }
1333
1335
  if (m.role === 'assistant') {
1334
1336
  const parts = textPartsOf(m.content).filter(p => p.text);
1335
1337
  (m.toolCalls || []).forEach((tc, j) => {
1336
- const part = { functionCall: { name: tc.name, args: parseArgs(tc.args) } };
1338
+ const part = { functionCall: { name: sanitizeToolName(tc.name, { allowHyphens: false }), args: parseArgs(tc.args) } };
1337
1339
  const sig = tc.sig || lookupToolSignature(tc.id);
1338
1340
  if (sig) part.thoughtSignature = sig;
1339
1341
  // Only the first functionCall of each step in the current turn is checked.
@@ -1354,7 +1356,7 @@ function irToVertexBody(ir, model, opts = {}) {
1354
1356
  if (vertexTools.length) {
1355
1357
  body.tools = [{
1356
1358
  functionDeclarations: vertexTools.map(t => ({
1357
- name: t.name, description: t.description || '',
1359
+ name: sanitizeToolName(t.name, { allowHyphens: false }), description: t.description || '',
1358
1360
  parameters: toGeminiSchema(t.parameters || { type: 'object', properties: {} })
1359
1361
  }))
1360
1362
  }];
@@ -1362,7 +1364,7 @@ function irToVertexBody(ir, model, opts = {}) {
1362
1364
  if (ir.toolChoice) {
1363
1365
  const mode = ir.toolChoice === 'none' ? 'NONE' : ir.toolChoice === 'auto' ? 'AUTO' : 'ANY';
1364
1366
  const fcc = { mode };
1365
- if (typeof ir.toolChoice === 'object' && ir.toolChoice.name) fcc.allowedFunctionNames = [ir.toolChoice.name];
1367
+ if (typeof ir.toolChoice === 'object' && ir.toolChoice.name) fcc.allowedFunctionNames = [sanitizeToolName(ir.toolChoice.name, { allowHyphens: false })];
1366
1368
  if (body.tools) body.toolConfig = { functionCallingConfig: fcc };
1367
1369
  }
1368
1370
  const gc = {};
@@ -2071,10 +2073,11 @@ function createAnthropicStream(emit, model, opts = {}) {
2071
2073
  if (!st) {
2072
2074
  closeOpen();
2073
2075
  const fixed = vocab ? vocab.name(tc.name) : tc.name;
2076
+ const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
2074
2077
  st = {
2075
2078
  index: nextIndex++, id: tc.id || `toolu_${rand(24)}`, name: fixed || 'tool', closed: false,
2076
- // Only a renamed name needs the arguments held back.
2077
- buffered: '', held: Boolean(vocab && fixed && fixed !== tc.name)
2079
+ // Only a renamed name needs the arguments held back. Shortened names use caller's schema.
2080
+ buffered: '', held: Boolean(vocab && fixed && fixed !== tc.name && !isShort)
2078
2081
  };
2079
2082
  tools.set(key, st);
2080
2083
  emit('content_block_start', {
@@ -2186,7 +2189,8 @@ function createChatStream(emit, model, opts = {}) {
2186
2189
  const idx = tc.index ?? 0;
2187
2190
  if (!seenTools.has(idx)) {
2188
2191
  const name = vocab ? vocab.name(tc.name) : tc.name;
2189
- seenTools.set(idx, { idx, name, buffered: '', held: Boolean(vocab && name && name !== tc.name) });
2192
+ const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
2193
+ seenTools.set(idx, { idx, name, buffered: '', held: Boolean(vocab && name && name !== tc.name && !isShort) });
2190
2194
  chunk([{ index: 0, delta: { tool_calls: [{ index: idx, id: tc.id || `call_${rand(24)}`, type: 'function', function: { name: name || 'tool', arguments: '' } }] }, finish_reason: null }]);
2191
2195
  }
2192
2196
  const st = seenTools.get(idx);
@@ -2394,10 +2398,11 @@ function createResponsesStream(emit, model, opts = {}) {
2394
2398
  // Codex runs a tool on output_item.done, and the name is in that item. A renamed call
2395
2399
  // therefore cannot be streamed argument by argument: the keys are only known once the whole
2396
2400
  // object exists, so its arguments are held and go out with the item.
2401
+ const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
2397
2402
  st = {
2398
2403
  id: `${prefix}_${rand(24)}`, callId: tc.id || `call_${rand(24)}`, index: nextOutput++,
2399
2404
  name: name || '', args: '', done: false, kind, upstream: tc.name,
2400
- held: Boolean(vocab && name && name !== tc.name)
2405
+ held: Boolean(vocab && name && name !== tc.name && !isShort)
2401
2406
  };
2402
2407
  tools.set(key, st);
2403
2408
  if (kind !== 'local_shell') {
@@ -2598,5 +2603,5 @@ export {
2598
2603
  createResponsesStream, buildResponsesMessage,
2599
2604
  vertexFinish, createVertexStream, buildVertexMessage,
2600
2605
  createCodeAssistStream, buildCodeAssistMessage,
2601
- isAntigravityModel
2606
+ isAntigravityModel, sanitizeToolName
2602
2607
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
package/proxy.mjs CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  contractLabSettings, STATE_DIR, stopRecordedBlindfold
28
28
  } from './state.mjs';
29
29
  import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows } from './windows.mjs';
30
- import { createToolVocab } from './toolvocab.mjs';
30
+ import { createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
31
31
  import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
32
32
  import { checkForUpdate } from './version.mjs';
33
33
  import { applyUpdate } from './update.mjs';
@@ -498,14 +498,10 @@ function parsePayloadLine(line) {
498
498
  // training is written back in the caller's words instead of ending the turn with "No such tool
499
499
  // available". Built once per request from the tools the caller actually sent, so a request that
500
500
  // declared none pays nothing and a turn where the model agreed with the list changes nothing.
501
- let toolVocabCache = null;
502
- let toolVocabForIr = null;
503
501
  function toolVocabFor(ir) {
504
- if (toolVocabForIr !== ir) {
505
- toolVocabForIr = ir;
506
- toolVocabCache = createToolVocab(ir?.tools);
507
- }
508
- return toolVocabCache;
502
+ if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
503
+ if (!ir._vocab) ir._vocab = createToolVocab(ir.tools);
504
+ return ir._vocab;
509
505
  }
510
506
 
511
507
  function clientRenderer(clientFormat, res, model, opts = {}) {
@@ -918,6 +914,21 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
918
914
  return;
919
915
  }
920
916
 
917
+ // Handle Claude Code tether (message threads): reject with thread_unsupported_request
918
+ // so Claude Code immediately falls back to stateless mode and retries.
919
+ if (clientFormat === 'anthropic' && (payload?.thread || ((req.headers['anthropic-beta'] || '').includes('message-threads') && payload?.thread))) {
920
+ res.writeHead(400, { 'Content-Type': 'application/json' });
921
+ res.end(JSON.stringify({
922
+ type: 'error',
923
+ error: {
924
+ type: 'invalid_request_error',
925
+ message: 'Message threads (tether) are not supported by this upstream gateway.',
926
+ error_code: 'thread_unsupported_request'
927
+ }
928
+ }));
929
+ return;
930
+ }
931
+
921
932
  const wantIn = profile.inFormat || 'auto';
922
933
  if (wantIn !== 'auto' && wantIn !== clientFormat) {
923
934
  sendClientError(res, clientFormat, 400, `Profile "${profileKey}" expects "${wantIn}" input, got "${clientFormat}"`);
package/shim.mjs CHANGED
@@ -185,6 +185,7 @@ if tier_is_stale "$ANTHROPIC_DEFAULT_FABLE_MODEL" fable; then unset ANTHROPIC_DE
185
185
  [ "\${CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT:-}" = "1" ] && unset CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT
186
186
  [ "\${CLAUDE_CODE_AUTO_COMPACT_WINDOW:-}" = "900000" ] && unset CLAUDE_CODE_AUTO_COMPACT_WINDOW
187
187
  [ "\${OPENAI_MAX_CONTEXT_TOKENS:-}" = "1000000" ] && unset OPENAI_MAX_CONTEXT_TOKENS
188
+ [ "\$TOOL_ACTIVE" != "1" ] && [ "\${CLAUDE_CODE_TETHER_LIVE:-}" = "0" ] && unset CLAUDE_CODE_TETHER_LIVE
188
189
 
189
190
  # LLM_SWITCHER_CODEX_* was always a shim input, never the user's. Every OTHER LLM_SWITCHER_*
190
191
  # name is a user setting and is kept.
@@ -301,6 +302,7 @@ if defined ANTHROPIC_DEFAULT_FABLE_MODEL call :SCRUB_TIER ANTHROPIC_DEFAULT_FABL
301
302
  if "%CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT%"=="1" set "CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT="
302
303
  if "%CLAUDE_CODE_AUTO_COMPACT_WINDOW%"=="900000" set "CLAUDE_CODE_AUTO_COMPACT_WINDOW="
303
304
  if "%OPENAI_MAX_CONTEXT_TOKENS%"=="1000000" set "OPENAI_MAX_CONTEXT_TOKENS="
305
+ if not "%TOOL_ACTIVE%"=="1" if "%CLAUDE_CODE_TETHER_LIVE%"=="0" set "CLAUDE_CODE_TETHER_LIVE="
304
306
  for /f "tokens=1 delims==" %%V in ('set LLM_SWITCHER_CODEX_ 2^>nul ^| findstr /b /c:"LLM_SWITCHER_CODEX_"') do set "%%V="
305
307
  ${caBlock}
306
308
 
package/state.mjs CHANGED
@@ -1365,7 +1365,8 @@ export function computeLaunchState(cfg, port) {
1365
1365
 
1366
1366
  // NODE_EXTRA_CA_CERTS is deliberately absent from env-claude: the shim decides it at launch,
1367
1367
  // because only then does it know whether the user brought a CA of their own (R2).
1368
- if (claude) state.envClaude = proxyPairs();
1368
+ // When Claude route is ON, disable tether (message threads) so Claude Code operates stateless.
1369
+ if (claude) state.envClaude = [...proxyPairs(), ['CLAUDE_CODE_TETHER_LIVE', '0']];
1369
1370
  // Codex builds its TLS client from CODEX_CA_CERTIFICATE the moment it starts, and a path it cannot
1370
1371
  // read is fatal: it dies before its first request, and its long-lived daemon then fails its own
1371
1372
  // update with "Failed to read CA certificate file". So the CA is only offered when it is really
@@ -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,7 +2,7 @@
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 } from '../toolvocab.mjs';
6
6
  import {
7
7
  anthropicToIR, responsesToIR, irToChatBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
8
8
  createAnthropicStream, createChatStream, createResponsesStream
@@ -278,3 +278,112 @@ test('the notice and the think guide coexist', () => {
278
278
  assert.ok(sys.includes('<think> and </think>'), `the think guide is missing: ${sys}`);
279
279
  assert.match(sys, /use exactly these names/i);
280
280
  });
281
+
282
+ // Tool names longer than 64 characters (e.g. MCP plugin tools) must be sanitized for OpenAI/Gemini
283
+ // APIs (<= 64 chars, ^[a-zA-Z0-9_-]{1,64}$) and mapped back in responses.
284
+ test('sanitizeToolName: enforces 1-64 character length and valid identifier charset', () => {
285
+ assert.equal(sanitizeToolName('Bash'), 'Bash');
286
+ assert.equal(sanitizeToolName('Read'), 'Read');
287
+ assert.equal(sanitizeToolName('mcp__github__create_issue'), 'mcp__github__create_issue');
288
+
289
+ const name64 = 'a'.repeat(64);
290
+ assert.equal(sanitizeToolName(name64), name64);
291
+
292
+ // Exact problematic tool from chrome-devtools MCP (70 chars)
293
+ const chromeDevToolsTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
294
+ assert.equal(chromeDevToolsTool.length, 70);
295
+ const sanitized = sanitizeToolName(chromeDevToolsTool);
296
+ assert.ok(sanitized.length <= 64, `length must be <= 64, got ${sanitized.length}`);
297
+ assert.equal(sanitized.length, 64);
298
+ assert.match(sanitized, /^[a-zA-Z0-9_-]{1,64}$/);
299
+ assert.ok(sanitized.startsWith('mcp__plugin_chrome-devtools-mcp_'));
300
+ assert.ok(sanitized.endsWith('_list_console_messages'));
301
+
302
+ // Another long tool with different suffix gets a distinct hash
303
+ const networkTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_network_requests';
304
+ const sanitizedNetwork = sanitizeToolName(networkTool);
305
+ assert.equal(sanitizedNetwork.length, 64);
306
+ assert.notEqual(sanitized, sanitizedNetwork);
307
+
308
+ // allowHyphens: false for Vertex/Gemini
309
+ const vertexSanitized = sanitizeToolName(chromeDevToolsTool, { allowHyphens: false });
310
+ assert.equal(vertexSanitized.length, 64);
311
+ assert.match(vertexSanitized, /^[a-zA-Z_][a-zA-Z0-9_]*$/);
312
+ assert.ok(!vertexSanitized.includes('-'));
313
+ });
314
+
315
+ test('createToolVocab: round-trips >64 char tool names back to client declared name', () => {
316
+ const longName = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
317
+ const ir = anthropicToIR({
318
+ model: 'claude-opus-4-6', messages: [{ role: 'user', content: 'check console' }],
319
+ tools: [
320
+ { name: longName, input_schema: { properties: { count: { type: 'number' } } } }
321
+ ]
322
+ });
323
+
324
+ const vocab = createToolVocab(ir.tools);
325
+ const upstreamSafe = sanitizeToolName(longName);
326
+ assert.notEqual(upstreamSafe, longName);
327
+ assert.equal(upstreamSafe.length, 64);
328
+
329
+ // Model answers with the sanitized name -> vocab recovers original declared name
330
+ assert.equal(vocab.name(upstreamSafe), longName);
331
+ assert.ok(vocab.isShortened(upstreamSafe));
332
+
333
+ // Streaming does not hold arguments for shortened names (they keep caller's schema)
334
+ const deltas = [];
335
+ const renderer = createAnthropicStream((event, data) => deltas.push({ event, data }), 'claude-opus-4-6', { vocab });
336
+ renderer.start();
337
+ renderer.tool({ index: 0, id: 'call_1', name: upstreamSafe, args: '{"count": 10}' });
338
+ renderer.finish('stop');
339
+
340
+ const startBlock = deltas.find(d => d.event === 'content_block_start');
341
+ assert.equal(startBlock.data.content_block.name, longName);
342
+
343
+ const jsonDelta = deltas.find(d => d.event === 'content_block_delta' && d.data.delta.type === 'input_json_delta');
344
+ assert.ok(jsonDelta, 'input_json_delta should be emitted');
345
+ assert.equal(jsonDelta.data.delta.partial_json, '{"count": 10}');
346
+
347
+ // Non-streaming builder also restores the original name
348
+ const msg = buildAnthropicMessage({
349
+ model: 'claude-opus-4-6',
350
+ tools: [{ index: 0, id: 'call_1', name: upstreamSafe, args: '{"count": 10}' }],
351
+ finish: 'tool_calls',
352
+ vocab
353
+ });
354
+ const toolUse = msg.content.find(c => c.type === 'tool_use');
355
+ assert.equal(toolUse.name, longName);
356
+ });
357
+
358
+ test('irToChatBody: serializes long tool names, assistant history, and toolChoice within 64 chars', () => {
359
+ const longName = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
360
+ const ir = anthropicToIR({
361
+ model: 'gpt-4o',
362
+ messages: [
363
+ { role: 'user', content: 'first turn' },
364
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'call_1', name: longName, input: { count: 5 } }] },
365
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }
366
+ ],
367
+ tools: [
368
+ { name: longName, input_schema: { properties: { count: { type: 'number' } } } }
369
+ ]
370
+ });
371
+ ir.toolChoice = { name: longName };
372
+
373
+ const body = irToChatBody(ir, 'gpt-4o');
374
+
375
+ // Declared tools must have <=64 char function names matching regex
376
+ assert.ok(body.tools && body.tools.length === 1);
377
+ const toolFn = body.tools[0].function;
378
+ assert.ok(toolFn.name.length <= 64);
379
+ assert.match(toolFn.name, /^[a-zA-Z0-9_-]{1,64}$/);
380
+ assert.equal(toolFn.name, sanitizeToolName(longName));
381
+
382
+ // Assistant turn in history must have its tool call name shortened too
383
+ const asstMsg = body.messages.find(m => m.role === 'assistant');
384
+ assert.ok(asstMsg && asstMsg.tool_calls && asstMsg.tool_calls.length === 1);
385
+ assert.equal(asstMsg.tool_calls[0].function.name, sanitizeToolName(longName));
386
+
387
+ // toolChoice must be shortened
388
+ assert.equal(body.tool_choice.function.name, sanitizeToolName(longName));
389
+ });
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,6 +193,27 @@ 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.
@@ -205,8 +228,18 @@ export function createToolVocab(declared) {
205
228
  .filter(t => t && typeof t.name === 'string' && t.name && t.side !== 'provider');
206
229
  const byName = new Map();
207
230
  const candidates = [];
231
+ const shortenedToOriginal = new Map();
232
+
208
233
  for (const t of tools) {
209
234
  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
+ }
210
243
  const entry = { tool: t, name: t.name, namespace: namespaceOf(t.name), caps };
211
244
  // Keyed by the exact spelling: a client checks tool names exactly, so `read` is not the same
212
245
  // tool as `Read` to it, and that difference is the whole reason this layer exists.
@@ -217,11 +250,17 @@ export function createToolVocab(declared) {
217
250
  // there is no second answer to pick between, so the call stands and the client reports it.
218
251
  const useful = candidates.length >= 2 && candidates.some(c => c.caps.size > 0);
219
252
 
253
+ function isShortened(upstream) {
254
+ const raw = String(upstream || '');
255
+ return shortenedToOriginal.has(raw);
256
+ }
257
+
220
258
  function name(upstream) {
221
- if (!useful) return upstream;
222
259
  const raw = String(upstream || '');
223
260
  if (!raw || raw === 'tool') return upstream;
261
+ if (shortenedToOriginal.has(raw)) return shortenedToOriginal.get(raw);
224
262
  if (byName.has(raw)) return upstream; // already the client's own name
263
+ if (!useful) return upstream;
225
264
  const ns = namespaceOf(raw);
226
265
  if (ns) return upstream; // a server's tool never becomes a local one
227
266
  const want = capabilitiesOf(raw);
@@ -243,8 +282,9 @@ export function createToolVocab(declared) {
243
282
  }
244
283
 
245
284
  function args(upstream, argsJson) {
246
- if (!useful) return argsJson;
247
- const target = byName.get(name(upstream));
285
+ const fixedName = name(upstream);
286
+ if (!useful && !shortenedToOriginal.has(String(upstream || ''))) return argsJson;
287
+ const target = byName.get(fixedName);
248
288
  if (!target) return argsJson;
249
289
  let parsed;
250
290
  try {
@@ -260,13 +300,19 @@ export function createToolVocab(declared) {
260
300
  return {
261
301
  name,
262
302
  args,
303
+ isShortened,
263
304
  /** Both halves at once, for a call that is already whole. */
264
305
  repair(upstream, argsJson) {
265
306
  const fixedName = name(upstream);
266
- return { name: fixedName, args: args(fixedName, argsJson), renamed: fixedName !== upstream };
307
+ return { name: fixedName, args: args(upstream, argsJson), renamed: fixedName !== upstream };
267
308
  }
268
309
  };
269
310
  }
270
311
 
271
312
  /** 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 }) });
313
+ export const NULL_TOOL_VOCAB = Object.freeze({
314
+ name: n => n,
315
+ args: (n, a) => a,
316
+ isShortened: () => false,
317
+ repair: (n, a) => ({ name: n, args: a, renamed: false })
318
+ });
package/ui.html CHANGED
@@ -1105,12 +1105,23 @@
1105
1105
  </div>
1106
1106
  </div>
1107
1107
 
1108
+ <div class="row">
1109
+ <div class="who">
1110
+ <div class="l">Model Provider Source</div>
1111
+ <div class="meta" id="idle-compact-source-meta">Profile Base URL queried via /models to list upstream models</div>
1112
+ </div>
1113
+ <div style="display:flex; align-items:center; gap:0.5rem; margin-left:auto; min-width:18rem; max-width:26rem;">
1114
+ <select id="idle-compact-source-profile" onchange="onCompactSourceProfileChange()" style="flex:1; font-size:0.8rem;"></select>
1115
+ <button type="button" class="btn btn-outline btn-sm" id="btn-reload-compact-models" onclick="reloadCompactModels(true)" title="Fetch live models from provider Base URL">Reload</button>
1116
+ </div>
1117
+ </div>
1118
+
1108
1119
  <div class="row" style="align-items:flex-start;">
1109
1120
  <div class="who" style="padding-top:0.25rem;">
1110
1121
  <div class="l">Summarization Model</div>
1111
- <div class="meta">Select from discovered/profile models or enter custom ID (empty = deterministic truncation)</div>
1122
+ <div class="meta" id="idle-compact-models-status">Select model from provider URL or enter custom ID (empty = deterministic truncation)</div>
1112
1123
  </div>
1113
- <div style="min-width:18rem; flex:1; max-width:28rem; margin-left:auto; display:flex; flex-direction:column; gap:0.4rem;">
1124
+ <div style="min-width:18rem; flex:1; max-width:26rem; margin-left:auto;">
1114
1125
  <div class="combo" id="combo-compact-model">
1115
1126
  <input type="text" id="idle-compact-model" placeholder="empty = deterministic truncation" maxlength="512" onchange="saveIdleCompact()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="idle-compact-model-list" autocomplete="off" spellcheck="false" style="font-family:var(--font-mono); font-size:0.8rem;" title="Upstream model used to generate turn summary text">
1116
1127
  <button type="button" class="combo-toggle" tabindex="-1" aria-label="Show available summary models">
@@ -1118,9 +1129,6 @@
1118
1129
  </button>
1119
1130
  <ul class="combo-list" id="idle-compact-model-list" role="listbox" aria-label="Available summarization models" hidden></ul>
1120
1131
  </div>
1121
- <div id="compact-model-presets" style="display:flex; flex-wrap:wrap; align-items:center; gap:0.35rem;">
1122
- <!-- Quick Presets rendered dynamically -->
1123
- </div>
1124
1132
  </div>
1125
1133
  </div>
1126
1134
 
@@ -2072,10 +2080,7 @@
2072
2080
  const elKb = document.getElementById('idle-compact-kb');
2073
2081
  if (elKb) elKb.value = (c.minBytes ?? 65536) / 1024;
2074
2082
  const elModel = document.getElementById('idle-compact-model');
2075
- if (elModel) {
2076
- elModel.value = c.model || '';
2077
- updateCompactModelPresets(c.model || '');
2078
- }
2083
+ if (elModel) elModel.value = c.model || '';
2079
2084
  const elCodex = document.getElementById('idle-compact-codex');
2080
2085
  if (elCodex) elCodex.checked = !!c.codex;
2081
2086
  const elKeep = document.getElementById('idle-compact-keep');
@@ -2125,66 +2130,107 @@
2125
2130
  }
2126
2131
  }
2127
2132
 
2128
- async function ensureCatalogLoaded() {
2129
- if (cachedCatalog) return cachedCatalog;
2130
- try {
2131
- const res = await api('/api/catalog');
2132
- if (res.ok) cachedCatalog = await res.json();
2133
- } catch {}
2134
- return cachedCatalog;
2133
+ let compactLoadedModels = [];
2134
+ let compactLoadedLimits = {};
2135
+ let compactSourceProfileKey = '';
2136
+
2137
+ function populateCompactSourceProfiles() {
2138
+ const sel = document.getElementById('idle-compact-source-profile');
2139
+ if (!sel) return;
2140
+ const profiles = state.config?.profiles || {};
2141
+ const activeClaude = state.config?.activeProfiles?.claude;
2142
+ const activeCodex = state.config?.activeProfiles?.codex;
2143
+ const keys = Object.keys(profiles);
2144
+
2145
+ if (!compactSourceProfileKey || !profiles[compactSourceProfileKey]) {
2146
+ compactSourceProfileKey = activeClaude || activeCodex || keys[0] || '';
2147
+ }
2148
+
2149
+ if (!keys.length) {
2150
+ sel.innerHTML = '<option value="">(No profiles configured)</option>';
2151
+ return;
2152
+ }
2153
+
2154
+ sel.innerHTML = keys.map(k => {
2155
+ const p = profiles[k];
2156
+ const isActive = (k === activeClaude || k === activeCodex);
2157
+ const prefix = isActive ? '★ ' : '';
2158
+ const selected = (k === compactSourceProfileKey) ? 'selected' : '';
2159
+ return `<option value="${escapeHtml(k)}" ${selected}>${escapeHtml(prefix + (p.name || k))} (${escapeHtml(p.baseURL || 'no URL')})</option>`;
2160
+ }).join('');
2161
+
2162
+ updateCompactSourceMeta();
2135
2163
  }
2136
2164
 
2137
- function setCompactModel(model) {
2138
- const inp = document.getElementById('idle-compact-model');
2139
- if (inp) {
2140
- inp.value = model;
2141
- saveIdleCompact();
2142
- updateCompactModelPresets(model);
2165
+ function updateCompactSourceMeta() {
2166
+ const meta = document.getElementById('idle-compact-source-meta');
2167
+ const p = state.config?.profiles?.[compactSourceProfileKey];
2168
+ if (meta && p) {
2169
+ meta.textContent = `${p.baseURL || 'No Base URL configured'} · ${p.outFormat || p.mode || 'convert'}`;
2143
2170
  }
2144
2171
  }
2145
2172
 
2146
- function updateCompactModelPresets(currentModel) {
2147
- const container = document.getElementById('compact-model-presets');
2148
- if (!container) return;
2149
- if (currentModel === undefined) {
2150
- currentModel = document.getElementById('idle-compact-model')?.value.trim() || '';
2173
+ async function onCompactSourceProfileChange() {
2174
+ const sel = document.getElementById('idle-compact-source-profile');
2175
+ if (sel) {
2176
+ compactSourceProfileKey = sel.value;
2177
+ updateCompactSourceMeta();
2178
+ await reloadCompactModels(true);
2151
2179
  }
2180
+ }
2152
2181
 
2153
- const presets = [
2154
- { id: '', label: 'Deterministic (none)' },
2155
- { id: 'haiku', label: 'haiku' },
2156
- { id: 'subagent', label: 'subagent' }
2157
- ];
2182
+ async function reloadCompactModels(force = false) {
2183
+ const p = state.config?.profiles?.[compactSourceProfileKey];
2184
+ const statusEl = document.getElementById('idle-compact-models-status');
2185
+ const reloadBtn = document.getElementById('btn-reload-compact-models');
2186
+ if (!p || !p.baseURL) {
2187
+ compactLoadedModels = [];
2188
+ compactLoadedLimits = {};
2189
+ if (statusEl) statusEl.textContent = 'Configure a Base URL on the profile to load live models';
2190
+ return;
2191
+ }
2158
2192
 
2159
- const profiles = state.config?.profiles || {};
2160
- let added = 0;
2161
- for (const p of Object.values(profiles)) {
2162
- if (p.defaultModels) {
2163
- for (const [slot, m] of Object.entries(p.defaultModels)) {
2164
- if (m && added < 2 && !presets.some(x => x.id === m)) {
2165
- presets.push({ id: m, label: m.includes('/') ? m.split('/')[1] : m });
2166
- added++;
2167
- }
2193
+ if (statusEl) statusEl.textContent = `Loading models from ${p.baseURL}...`;
2194
+ if (reloadBtn) reloadBtn.disabled = true;
2195
+
2196
+ try {
2197
+ const res = await api('/api/fetch-models', {
2198
+ method: 'POST',
2199
+ headers: { 'Content-Type': 'application/json' },
2200
+ body: JSON.stringify({ baseURL: p.baseURL, apiKey: MASKED_KEY, key: compactSourceProfileKey })
2201
+ });
2202
+ const data = await res.json();
2203
+ if (data.ok && Array.isArray(data.models)) {
2204
+ compactLoadedModels = data.models;
2205
+ compactLoadedLimits = data.limits || {};
2206
+ if (statusEl) {
2207
+ statusEl.textContent = `${compactLoadedModels.length} models loaded from ${p.baseURL}. Select from list or enter model ID.`;
2208
+ }
2209
+ } else {
2210
+ compactLoadedModels = [];
2211
+ compactLoadedLimits = {};
2212
+ if (statusEl) {
2213
+ statusEl.textContent = `Models not loaded: ${modelsError(data.error)}`;
2168
2214
  }
2169
2215
  }
2216
+ } catch (err) {
2217
+ compactLoadedModels = [];
2218
+ compactLoadedLimits = {};
2219
+ if (statusEl) statusEl.textContent = `Failed to load models: ${err.message}`;
2220
+ } finally {
2221
+ if (reloadBtn) reloadBtn.disabled = false;
2222
+ const combo = document.getElementById('combo-compact-model');
2223
+ if (combo && combo.hasAttribute('data-open')) renderComboOptions(combo);
2170
2224
  }
2171
-
2172
- container.innerHTML = `<span class="hint" style="font-size:0.75rem; align-self:center; margin-right:0.2rem;">Quick Presets:</span>` +
2173
- presets.map(p => {
2174
- const active = (p.id === currentModel);
2175
- const style = active
2176
- ? 'padding:0.18rem 0.5rem; font-size:0.75rem; border-color:var(--accent); color:var(--accent); background:var(--accent-soft); font-weight:600;'
2177
- : 'padding:0.18rem 0.5rem; font-size:0.75rem;';
2178
- return `<button type="button" class="btn btn-outline btn-sm" style="${style}" onclick="setCompactModel('${escapeHtml(p.id)}')">${escapeHtml(p.label)}</button>`;
2179
- }).join('');
2180
2225
  }
2181
2226
 
2182
2227
  async function loadIdleCompact() {
2183
- ensureCatalogLoaded();
2228
+ populateCompactSourceProfiles();
2184
2229
  try {
2185
2230
  const r = await api('/api/idle-compact');
2186
2231
  if (r.ok) showIdleCompact((await r.json()).idleCompact);
2187
2232
  } catch { /* the card stays at its defaults; nothing here is required to route */ }
2233
+ await reloadCompactModels();
2188
2234
  }
2189
2235
 
2190
2236
  async function saveIdleCompact() {
@@ -2861,9 +2907,6 @@
2861
2907
  combo.dataset.filter = '1';
2862
2908
  combo.activeIndex = -1;
2863
2909
  openCombo(combo);
2864
- if (input.id === 'idle-compact-model') {
2865
- updateCompactModelPresets(input.value.trim());
2866
- }
2867
2910
  });
2868
2911
  input.addEventListener('keydown', onComboKeydown);
2869
2912
  input.addEventListener('blur', () => closeCombo(combo));
@@ -2971,54 +3014,16 @@
2971
3014
  // 1. Deterministic option (empty string)
2972
3015
  add('', 'Deterministic Truncation (no external call)', 'Pure heuristic context truncation · Zero latency & zero tokens', 'Deterministic');
2973
3016
 
2974
- // 2. Profile role/slot aliases (recommended for routing)
2975
- add('haiku', 'haiku (Claude Haiku Slot)', 'Auto-resolves to active profile Claude Haiku model', 'Slot Aliases');
2976
- add('subagent', 'subagent (Codex Subagent Slot)', 'Auto-resolves to active profile Codex Subagent model', 'Slot Aliases');
2977
- add('sonnet', 'sonnet (Claude Sonnet Slot)', 'Auto-resolves to active profile Claude Sonnet model', 'Slot Aliases');
2978
- add('main', 'main (Codex Main Slot)', 'Auto-resolves to active profile Codex Main model', 'Slot Aliases');
2979
-
2980
- // 3. Models configured in current profiles
2981
- const profiles = state.config?.profiles || {};
2982
- for (const [profKey, p] of Object.entries(profiles)) {
2983
- const profName = p.name || profKey;
2984
- if (p.defaultModels) {
2985
- for (const [slot, m] of Object.entries(p.defaultModels)) {
2986
- if (m) add(m, m, `${profName} · ${slot} slot`, 'Profile Models');
2987
- }
2988
- }
2989
- if (Array.isArray(p.publicModels)) {
2990
- for (const m of p.publicModels) {
2991
- if (m) add(m, m, `${profName}`, 'Profile Models');
2992
- }
2993
- }
2994
- }
2995
-
2996
- // 4. Discovered Catalog models
2997
- if (cachedCatalog) {
2998
- if (Array.isArray(cachedCatalog.claude?.models)) {
2999
- for (const m of cachedCatalog.claude.models) {
3000
- if (m.id) add(m.id, m.id, `Claude Catalog · ${m.tier || ''}`, 'Catalog');
3001
- }
3002
- }
3003
- if (Array.isArray(cachedCatalog.codex?.models)) {
3004
- for (const m of cachedCatalog.codex.models) {
3005
- if (m.id) add(m.id, m.id, `Codex Catalog · ${m.role || ''}`, 'Catalog');
3006
- }
3017
+ // 2. Models fetched live from the provider Base URL
3018
+ const prof = state.config?.profiles?.[compactSourceProfileKey];
3019
+ if (Array.isArray(compactLoadedModels) && compactLoadedModels.length) {
3020
+ for (const m of compactLoadedModels) {
3021
+ const l = compactLoadedLimits[m];
3022
+ const info = l?.context ? `${Math.round(l.context / 1000)}k context` : (prof?.name || 'Upstream');
3023
+ add(m, m, info, 'Upstream Model');
3007
3024
  }
3008
3025
  }
3009
3026
 
3010
- // 5. Popular lightweight models
3011
- const popular = [
3012
- { id: 'zencore/muse-spark-1.3-contributor-free', desc: 'ZenCore contributor fast model' },
3013
- { id: 'zencore/exo-free', desc: 'ZenCore free pool fast model' },
3014
- { id: 'claude-3-5-haiku-20241022', desc: 'Anthropic lightweight summarizer' },
3015
- { id: 'gpt-4o-mini', desc: 'OpenAI low-cost summarizer' },
3016
- { id: 'gemini-2.5-flash', desc: 'Google Gemini fast summarizer' }
3017
- ];
3018
- for (const p of popular) {
3019
- add(p.id, p.id, p.desc, 'Recommended');
3020
- }
3021
-
3022
3027
  return items;
3023
3028
  }
3024
3029
 
@@ -3030,13 +3035,13 @@
3030
3035
  const filtered = allItems.filter(item => {
3031
3036
  if (!q) return true;
3032
3037
  if (item.id === '' && ('deterministic'.includes(q) || 'none'.includes(q) || 'empty'.includes(q) || 'heuristic'.includes(q))) return true;
3033
- return item.id.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || item.desc.toLowerCase().includes(q);
3038
+ return item.id.toLowerCase().includes(q) || item.label.toLowerCase().includes(q);
3034
3039
  }).slice(0, 150);
3035
3040
 
3036
3041
  combo.matches = filtered.map(item => item.id);
3037
3042
 
3038
3043
  if (!filtered.length) {
3039
- list.innerHTML = '<li class="combo-empty">No matching model found. The typed model will be used directly.</li>';
3044
+ list.innerHTML = '<li class="combo-empty">No matching model found. The typed model ID will be used directly.</li>';
3040
3045
  return;
3041
3046
  }
3042
3047
 
@@ -3048,8 +3053,7 @@
3048
3053
  const isSelected = item.id === current;
3049
3054
  let badgeClass = 'badge outline';
3050
3055
  if (item.category === 'Deterministic') badgeClass = 'badge ok';
3051
- else if (item.category === 'Slot Aliases') badgeClass = 'badge accent';
3052
- else if (item.category === 'Profile Models') badgeClass = 'badge';
3056
+ else if (item.category === 'Upstream Model') badgeClass = 'badge';
3053
3057
 
3054
3058
  const cut = item.id.lastIndexOf('/') + 1;
3055
3059
  const prefix = cut ? `<span class="pfx">${markMatch(item.id.slice(0, cut), q)}</span>` : '';
@@ -3098,7 +3102,6 @@
3098
3102
  updateWindowNotes();
3099
3103
  if (input.id === 'idle-compact-model') {
3100
3104
  saveIdleCompact();
3101
- updateCompactModelPresets(model);
3102
3105
  }
3103
3106
  }
3104
3107