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 +10 -0
- package/formats.mjs +21 -16
- package/package.json +1 -1
- package/proxy.mjs +19 -8
- package/shim.mjs +2 -0
- package/state.mjs +2 -1
- package/tests/state.test.mjs +6 -1
- package/tests/tool-repair.test.mjs +110 -1
- package/toolvocab.mjs +51 -5
- package/ui.html +107 -104
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
|
-
|
|
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
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 (
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
package/tests/state.test.mjs
CHANGED
|
@@ -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
|
-
|
|
247
|
-
|
|
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(
|
|
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({
|
|
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
|
|
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:
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
return
|
|
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
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
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
|
|
2147
|
-
const
|
|
2148
|
-
if (
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
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
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
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
|
-
|
|
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.
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
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)
|
|
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 === '
|
|
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
|
|