llm-switcher 1.3.8 → 1.4.0
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 +19 -0
- package/README.md +25 -17
- package/README.vi.md +24 -13
- package/config.example.json +0 -11
- package/docs/TOKEN-OPTIMIZER-INTEROP.md +110 -110
- package/docs/response-matrix.json +1130 -1130
- package/docs/tool-vocabulary.md +236 -0
- package/formats.mjs +277 -55
- package/package.json +1 -1
- package/proxy.mjs +55 -19
- package/skills/llm-switcher/SKILL.md +93 -93
- package/state.mjs +23 -91
- package/switch +0 -0
- package/switch.cmd +2 -2
- package/switch.mjs +2 -2
- package/tests/dashboard.e2e.test.mjs +10 -13
- package/tests/formats.test.mjs +98 -1
- package/tests/gateway.e2e.test.mjs +23 -9
- package/tests/helpers.mjs +24 -24
- package/tests/live-optimizer-interop.mjs +205 -205
- package/tests/state.test.mjs +6 -7
- package/tests/tool-repair.test.mjs +280 -0
- package/tests/ui.test.mjs +22 -15
- package/tests/windows.test.mjs +124 -0
- package/toolvocab.mjs +272 -0
- package/ui.html +32 -76
- package/windows.mjs +141 -0
package/toolvocab.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Writing a tool call back in the vocabulary the client declared.
|
|
2
|
+
//
|
|
3
|
+
// A tool name is not an identifier an API checks. The client sends its tools, the model answers
|
|
4
|
+
// with a name, and the client runs the call or refuses it ("No such tool available: read"). The
|
|
5
|
+
// gateway converts protocols; it cannot convert the model's preference. What it can do is notice
|
|
6
|
+
// that the model answered in a different vocabulary and write the call back in the client's.
|
|
7
|
+
//
|
|
8
|
+
// Two rules hold no matter what the name looks like:
|
|
9
|
+
//
|
|
10
|
+
// 1. Only a tool the client declared is a candidate. A client that never declared `Bash` cannot
|
|
11
|
+
// run a `shell` call, so rewriting it to some other name changes nothing and hides the error.
|
|
12
|
+
// 2. Nothing is guessed while two declared tools are both plausible. Claude Code on macOS has no
|
|
13
|
+
// `Glob` and no `Grep`; a session with both `Bash` and `PowerShell` has two shell tools. When
|
|
14
|
+
// the choice is not clear the name stands, and the client reports the mismatch itself, which is
|
|
15
|
+
// the honest path: a wrong call runs, a refused one does not.
|
|
16
|
+
//
|
|
17
|
+
// The capability names below are the ones each coding tool documents, with their sources collected in
|
|
18
|
+
// docs/tool-vocabulary.md. Capabilities that are not interchangeable stay apart: `apply_patch` speaks
|
|
19
|
+
// a diff, Claude Code's `Edit` speaks old_string/new_string, so a patch is never rewritten into an
|
|
20
|
+
// Edit. The same holds for `Monitor`, which watches a command rather than running one.
|
|
21
|
+
//
|
|
22
|
+
// Edit and patch are the one pair here that is a decision rather than an oversight, so it is recorded
|
|
23
|
+
// in both places. Renaming `Edit` to `apply_patch` would translate a payload, not a name: a usable
|
|
24
|
+
// `old_string` needs the file, and a gateway does not read one. The result would be a call that looks
|
|
25
|
+
// right and fails, which is worse than a name the model got wrong. docs/tool-vocabulary.md has the
|
|
26
|
+
// three ways to want this instead, and the first of them needs no translation at all: pass the
|
|
27
|
+
// provider's own edit tool through — OpenAI's server-defined `apply_patch`, Anthropic's `text_editor`,
|
|
28
|
+
// Gemini's `replace`.
|
|
29
|
+
|
|
30
|
+
const CAP_SHELL = 'shell';
|
|
31
|
+
const CAP_READ = 'read';
|
|
32
|
+
const CAP_WRITE = 'write';
|
|
33
|
+
const CAP_EDIT = 'edit';
|
|
34
|
+
const CAP_PATCH = 'patch';
|
|
35
|
+
const CAP_SEARCH = 'search';
|
|
36
|
+
const CAP_GLOB = 'glob';
|
|
37
|
+
const CAP_LIST = 'list';
|
|
38
|
+
const CAP_PLAN = 'plan';
|
|
39
|
+
const CAP_WEB = 'web';
|
|
40
|
+
const CAP_WEBSEARCH = 'websearch';
|
|
41
|
+
const CAP_MONITOR = 'monitor';
|
|
42
|
+
const CAP_LSP = 'lsp';
|
|
43
|
+
const CAP_SKILL = 'skill';
|
|
44
|
+
const CAP_AGENT = 'agent';
|
|
45
|
+
const CAP_ASK = 'ask';
|
|
46
|
+
|
|
47
|
+
// The name each tool documents, as it stands. Case is irrelevant to a name a model reaches for, and
|
|
48
|
+
// MCP servers are case-sensitive, so this is only ever read on the model's side of the boundary.
|
|
49
|
+
const NAME_CAPABILITY = new Map([
|
|
50
|
+
// Claude Code — code.claude.com/docs/en/tools.md
|
|
51
|
+
['bash', CAP_SHELL], ['powershell', CAP_SHELL], ['monitor', CAP_MONITOR],
|
|
52
|
+
['read', CAP_READ], ['write', CAP_WRITE], ['edit', CAP_EDIT], ['multiedit', CAP_EDIT],
|
|
53
|
+
['notebookedit', CAP_EDIT], ['notebookread', CAP_READ],
|
|
54
|
+
['grep', CAP_SEARCH], ['glob', CAP_GLOB],
|
|
55
|
+
['todowrite', CAP_PLAN], ['taskcreate', CAP_PLAN], ['taskget', CAP_PLAN], ['taskupdate', CAP_PLAN],
|
|
56
|
+
['tasklist', CAP_LIST], ['taskstop', CAP_PLAN], ['taskoutput', CAP_READ],
|
|
57
|
+
['webfetch', CAP_WEB], ['websearch', CAP_WEBSEARCH],
|
|
58
|
+
['lsp', CAP_LSP], ['skill', CAP_SKILL], ['agent', CAP_AGENT], ['askuserquestion', CAP_ASK],
|
|
59
|
+
['toolsearch', CAP_LIST], ['listmcpresourcestool', CAP_LIST], ['readmcpresourcetool', CAP_READ],
|
|
60
|
+
|
|
61
|
+
// Codex CLI — developers.openai.com codex prompting guide, openai/codex codex-rs
|
|
62
|
+
['shell', CAP_SHELL], ['cmd', CAP_SHELL], ['run_terminal_cmd', CAP_SHELL], ['runterminalcmd', CAP_SHELL],
|
|
63
|
+
['apply_patch', CAP_PATCH], ['update_plan', CAP_PLAN], ['todo_write', CAP_PLAN],
|
|
64
|
+
['read_file', CAP_READ], ['list_dir', CAP_LIST], ['glob_file_search', CAP_GLOB], ['rg', CAP_SEARCH],
|
|
65
|
+
|
|
66
|
+
// OpenCode — opencode.ai/docs/tools/
|
|
67
|
+
['edit', CAP_EDIT], ['write', CAP_WRITE], ['read', CAP_READ], ['grep', CAP_SEARCH],
|
|
68
|
+
['glob', CAP_GLOB], ['todowrite', CAP_PLAN], ['webfetch', CAP_WEB], ['websearch', CAP_WEBSEARCH],
|
|
69
|
+
['lsp', CAP_LSP], ['skill', CAP_SKILL], ['question', CAP_ASK],
|
|
70
|
+
|
|
71
|
+
// Other agents the gateway meets — bytedance/trae-agent, QwenLM/qwen-code
|
|
72
|
+
['run_shell_command', CAP_SHELL], ['str_replace_editor', CAP_EDIT], ['str_replace_based_edit_tool', CAP_EDIT],
|
|
73
|
+
['list_directory', CAP_LIST], ['read_file', CAP_READ], ['write_file', CAP_WRITE], ['file_search', CAP_GLOB],
|
|
74
|
+
['web_fetch', CAP_WEB]
|
|
75
|
+
].map(([name, cap]) => [name.toLowerCase(), cap]));
|
|
76
|
+
|
|
77
|
+
// Tokens, for a name no tool documents. Only a token one vocabulary uses for one thing is here, so a
|
|
78
|
+
// name built from these words reads as a single capability rather than as a guess. A name with no
|
|
79
|
+
// capability token is not repaired at all.
|
|
80
|
+
const TOKEN_CAPABILITY = new Map([
|
|
81
|
+
['shell', CAP_SHELL], ['bash', CAP_SHELL], ['sh', CAP_SHELL], ['zsh', CAP_SHELL], ['cmd', CAP_SHELL],
|
|
82
|
+
['command', CAP_SHELL], ['commands', CAP_SHELL], ['terminal', CAP_SHELL], ['console', CAP_SHELL],
|
|
83
|
+
['exec', CAP_SHELL], ['execute', CAP_SHELL], ['run', CAP_SHELL], ['spawn', CAP_SHELL], ['process', CAP_SHELL],
|
|
84
|
+
['monitor', CAP_MONITOR],
|
|
85
|
+
['read', CAP_READ], ['cat', CAP_READ], ['view', CAP_READ], ['open', CAP_READ],
|
|
86
|
+
['write', CAP_WRITE], ['create', CAP_WRITE], ['dump', CAP_WRITE], ['save', CAP_WRITE],
|
|
87
|
+
['edit', CAP_EDIT], ['replace', CAP_EDIT], ['modify', CAP_EDIT], ['multiedit', CAP_EDIT],
|
|
88
|
+
['strreplace', CAP_EDIT], ['streditor', CAP_EDIT], ['stredit', CAP_EDIT],
|
|
89
|
+
['patch', CAP_PATCH], ['applypatch', CAP_PATCH],
|
|
90
|
+
['grep', CAP_SEARCH], ['rg', CAP_SEARCH], ['ripgrep', CAP_SEARCH], ['ugrep', CAP_SEARCH],
|
|
91
|
+
['grepcontent', CAP_SEARCH],
|
|
92
|
+
['glob', CAP_GLOB], ['filesearch', CAP_GLOB], ['globfiles', CAP_GLOB],
|
|
93
|
+
['list', CAP_LIST], ['ls', CAP_LIST], ['dir', CAP_LIST], ['directory', CAP_LIST],
|
|
94
|
+
['plan', CAP_PLAN], ['todo', CAP_PLAN], ['todos', CAP_PLAN], ['todowrite', CAP_PLAN], ['updateplan', CAP_PLAN],
|
|
95
|
+
['fetch', CAP_WEB], ['webfetch', CAP_WEB], ['url', CAP_WEB], ['http', CAP_WEB], ['download', CAP_WEB],
|
|
96
|
+
['websearch', CAP_WEBSEARCH],
|
|
97
|
+
['lsp', CAP_LSP], ['skill', CAP_SKILL],
|
|
98
|
+
['agent', CAP_AGENT], ['subagent', CAP_AGENT],
|
|
99
|
+
['question', CAP_ASK], ['ask', CAP_ASK]
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// `TodoWrite` is one word to the model that made it; `str_replace_editor` is three.
|
|
103
|
+
export function tokenize(name) {
|
|
104
|
+
return String(name || '')
|
|
105
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
106
|
+
.split(/[^A-Za-z0-9]+/)
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// `mcp__github__create_issue` belongs to the github server. A namespace is kept as a whole and
|
|
111
|
+
// joined by single underscores so a multi-word server name still compares.
|
|
112
|
+
function namespaceOf(name) {
|
|
113
|
+
const m = /^([a-z0-9]+(?:__[a-z0-9]+)+)__([A-Za-z0-9_]+)$/.exec(String(name || ''));
|
|
114
|
+
return m ? m[1].split('__').join('_').toLowerCase() : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What the name is for: the documented one when a tool documents this exact name, else the set of
|
|
118
|
+
// capabilities its tokens carry. Empty means the name says nothing and nothing is rewritten.
|
|
119
|
+
function capabilitiesOf(name) {
|
|
120
|
+
const exact = NAME_CAPABILITY.get(String(name || '').toLowerCase());
|
|
121
|
+
if (exact) return new Set([exact]);
|
|
122
|
+
const set = new Set();
|
|
123
|
+
for (const token of tokenize(name)) {
|
|
124
|
+
const cap = TOKEN_CAPABILITY.get(token.toLowerCase());
|
|
125
|
+
if (cap) set.add(cap);
|
|
126
|
+
}
|
|
127
|
+
return set;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A key is the same key however it is spelled: file_path, filePath and file-path are one name.
|
|
131
|
+
function normalizeKey(key) {
|
|
132
|
+
return String(key || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function schemaKeysOf(tool) {
|
|
136
|
+
const props = tool?.parameters?.properties;
|
|
137
|
+
if (!props || typeof props !== 'object' || Array.isArray(props)) return [];
|
|
138
|
+
return Object.keys(props);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Which schema key an incoming key is another way of writing. Two ways, strongest first:
|
|
142
|
+
//
|
|
143
|
+
// 1. the same letters: filePath, file-path and file_path are one key.
|
|
144
|
+
// 2. one name inside the other: path and file_path, dir and dir_path. Only for a string value, so a
|
|
145
|
+
// number or a flag is never moved into a longer name it happens to sit inside.
|
|
146
|
+
//
|
|
147
|
+
// One candidate or nothing. Two schema keys that both fit is a guess, and a wrong key is an argument
|
|
148
|
+
// the caller will refuse in a way that names the wrong thing.
|
|
149
|
+
function schemaMatch(key, value, keys) {
|
|
150
|
+
const shape = normalizeKey(key);
|
|
151
|
+
if (!shape) return null;
|
|
152
|
+
const exact = keys.filter(k => normalizeKey(k) === shape);
|
|
153
|
+
if (exact.length === 1) return exact[0];
|
|
154
|
+
if (exact.length > 1) return null;
|
|
155
|
+
if (typeof value !== 'string') return null;
|
|
156
|
+
const loose = keys.filter(k => {
|
|
157
|
+
const other = normalizeKey(k);
|
|
158
|
+
return other !== shape && (other.includes(shape) || shape.includes(other));
|
|
159
|
+
});
|
|
160
|
+
return loose.length === 1 ? loose[0] : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The arguments as the client's schema names them.
|
|
165
|
+
*
|
|
166
|
+
* A rename happens only when the schema names exactly one key this one is another way of writing, and
|
|
167
|
+
* that key is not already used. A key the schema does not name is left alone: the client may still
|
|
168
|
+
* read it, and a dropped key is a lost instruction. A falsy value is a value.
|
|
169
|
+
*/
|
|
170
|
+
function repairArgs(args, tool) {
|
|
171
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) return args;
|
|
172
|
+
const keys = schemaKeysOf(tool);
|
|
173
|
+
if (!keys.length) return args;
|
|
174
|
+
const entries = Object.entries(args);
|
|
175
|
+
const renames = new Map();
|
|
176
|
+
for (const [key, value] of entries) {
|
|
177
|
+
if (keys.includes(key)) continue; // the schema already has this one
|
|
178
|
+
const target = schemaMatch(key, value, keys);
|
|
179
|
+
if (!target || Object.hasOwn(args, target)) continue; // no single fit, or the client has both
|
|
180
|
+
renames.set(key, target);
|
|
181
|
+
}
|
|
182
|
+
if (!renames.size) return args;
|
|
183
|
+
// Rebuilt in the original order, with each renamed key standing where the old one stood: an
|
|
184
|
+
// argument list is read by people in diffs, and a reordered one hides what changed.
|
|
185
|
+
const out = {};
|
|
186
|
+
for (const [key, value] of entries) {
|
|
187
|
+
const target = renames.get(key);
|
|
188
|
+
if (target) out[target] = value;
|
|
189
|
+
else out[key] = value;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The vocabulary one request declared. `declared` is the client's own tool list, as `ir.tools` holds
|
|
196
|
+
* it: `{ name, parameters }`, the same names and schemas the client will check the call against.
|
|
197
|
+
*
|
|
198
|
+
* A list with no tools, or one where nothing can be confused, leaves every call untouched.
|
|
199
|
+
*/
|
|
200
|
+
export function createToolVocab(declared) {
|
|
201
|
+
// Only a tool the caller executes can be a rename target. A tool the provider runs answers in
|
|
202
|
+
// band and its result reaches the caller without the caller executing anything; handing such a
|
|
203
|
+
// call back as a client tool moves the work, silently, to the machine the user is sitting at.
|
|
204
|
+
const tools = (Array.isArray(declared) ? declared : [])
|
|
205
|
+
.filter(t => t && typeof t.name === 'string' && t.name && t.side !== 'provider');
|
|
206
|
+
const byName = new Map();
|
|
207
|
+
const candidates = [];
|
|
208
|
+
for (const t of tools) {
|
|
209
|
+
const caps = capabilitiesOf(t.name);
|
|
210
|
+
const entry = { tool: t, name: t.name, namespace: namespaceOf(t.name), caps };
|
|
211
|
+
// Keyed by the exact spelling: a client checks tool names exactly, so `read` is not the same
|
|
212
|
+
// tool as `Read` to it, and that difference is the whole reason this layer exists.
|
|
213
|
+
byName.set(t.name, entry);
|
|
214
|
+
candidates.push(entry);
|
|
215
|
+
}
|
|
216
|
+
// Nothing is gained when the client declared one tool and the model answered with another name:
|
|
217
|
+
// there is no second answer to pick between, so the call stands and the client reports it.
|
|
218
|
+
const useful = candidates.length >= 2 && candidates.some(c => c.caps.size > 0);
|
|
219
|
+
|
|
220
|
+
function name(upstream) {
|
|
221
|
+
if (!useful) return upstream;
|
|
222
|
+
const raw = String(upstream || '');
|
|
223
|
+
if (!raw || raw === 'tool') return upstream;
|
|
224
|
+
if (byName.has(raw)) return upstream; // already the client's own name
|
|
225
|
+
const ns = namespaceOf(raw);
|
|
226
|
+
if (ns) return upstream; // a server's tool never becomes a local one
|
|
227
|
+
const want = capabilitiesOf(raw);
|
|
228
|
+
if (!want.size) return upstream;
|
|
229
|
+
let winner = null;
|
|
230
|
+
let ties = 0;
|
|
231
|
+
for (const c of candidates) {
|
|
232
|
+
if (c.namespace) continue; // and the other way round
|
|
233
|
+
let covers = true;
|
|
234
|
+
for (const cap of want) {
|
|
235
|
+
if (!c.caps.has(cap)) { covers = false; break; }
|
|
236
|
+
}
|
|
237
|
+
if (!covers) continue;
|
|
238
|
+
ties++;
|
|
239
|
+
winner = c;
|
|
240
|
+
}
|
|
241
|
+
// One tool covers everything the name asks for, and only one does.
|
|
242
|
+
return ties === 1 ? winner.name : upstream;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function args(upstream, argsJson) {
|
|
246
|
+
if (!useful) return argsJson;
|
|
247
|
+
const target = byName.get(name(upstream));
|
|
248
|
+
if (!target) return argsJson;
|
|
249
|
+
let parsed;
|
|
250
|
+
try {
|
|
251
|
+
parsed = JSON.parse(argsJson || '{}');
|
|
252
|
+
} catch {
|
|
253
|
+
return argsJson; // a truncated payload is passed through: changing bytes helps nobody
|
|
254
|
+
}
|
|
255
|
+
const repaired = repairArgs(parsed, target.tool);
|
|
256
|
+
if (repaired === parsed) return argsJson;
|
|
257
|
+
return JSON.stringify(repaired);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
name,
|
|
262
|
+
args,
|
|
263
|
+
/** Both halves at once, for a call that is already whole. */
|
|
264
|
+
repair(upstream, argsJson) {
|
|
265
|
+
const fixedName = name(upstream);
|
|
266
|
+
return { name: fixedName, args: args(fixedName, argsJson), renamed: fixedName !== upstream };
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 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 }) });
|
package/ui.html
CHANGED
|
@@ -432,7 +432,6 @@
|
|
|
432
432
|
}
|
|
433
433
|
.chip .lbl { grid-column: 1 / -1; font-size: 0.7rem; overflow-wrap: anywhere; }
|
|
434
434
|
.chip .val { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
435
|
-
.chip .b1m { font-weight: 600; font-size: 0.7rem; }
|
|
436
435
|
|
|
437
436
|
.copyline {
|
|
438
437
|
display: flex;
|
|
@@ -552,11 +551,8 @@
|
|
|
552
551
|
|
|
553
552
|
/* Model slot combobox: one listbox per slot, provider prefix dimmed, match in the accent. */
|
|
554
553
|
.combo { position: relative; }
|
|
555
|
-
.slot-
|
|
556
|
-
|
|
557
|
-
.slot-1m-note:empty { margin-top: -0.25rem; }
|
|
558
|
-
.slot-1m-note.warn { color: var(--warn); }
|
|
559
|
-
.slot-1m-note.err { color: var(--err); }
|
|
554
|
+
.slot-note { font-size: 0.72rem; color: var(--faint); } /* Kept in the page when empty: a live region added later is often not read. */
|
|
555
|
+
.slot-note:empty { margin-top: -0.25rem; }
|
|
560
556
|
.combo > input { padding-right: 2.25rem; font-family: var(--font-mono); font-size: 0.8rem; }
|
|
561
557
|
.combo-toggle {
|
|
562
558
|
position: absolute; right: 4px; top: 4px; width: 28px; height: 28px;
|
|
@@ -1212,12 +1208,8 @@
|
|
|
1212
1208
|
</div>
|
|
1213
1209
|
</div>
|
|
1214
1210
|
|
|
1215
|
-
<!-- TAB 3: Models
|
|
1211
|
+
<!-- TAB 3: Models -->
|
|
1216
1212
|
<div id="tab-models" class="tab-content" style="display:none; gap:0.9rem; flex-direction:column;">
|
|
1217
|
-
<div class="badge warn" id="warn-1m-callout" style="display:none; padding:0.5rem 0.75rem; border-radius:8px;">
|
|
1218
|
-
<span id="warn-1m-icon">⚠️</span> <span id="warn-1m-text">1M context flag is enabled</span>
|
|
1219
|
-
</div>
|
|
1220
|
-
|
|
1221
1213
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">
|
|
1222
1214
|
<h3 id="model-slot-heading" style="font-size:0.92rem; font-weight:600;">Model Slots Mapping</h3>
|
|
1223
1215
|
<div id="models-status" class="models-status" aria-live="polite"></div>
|
|
@@ -1280,7 +1272,7 @@
|
|
|
1280
1272
|
let formLoadedKey = null;
|
|
1281
1273
|
let formDirty = false;
|
|
1282
1274
|
let loadedModels = [];
|
|
1283
|
-
let loadedLimits = {}; // model id -> { context, input, output }, as the gateway lists them
|
|
1275
|
+
let loadedLimits = {}; // model id -> { context, compact, input, output }, as the gateway lists them
|
|
1284
1276
|
let currentProfileBuffer = null;
|
|
1285
1277
|
let renderedSlotFormat = 'auto';
|
|
1286
1278
|
let logRefreshTimer = null;
|
|
@@ -1613,7 +1605,6 @@
|
|
|
1613
1605
|
agy: {
|
|
1614
1606
|
title: 'Antigravity CLI Agent Model (Code Assist)',
|
|
1615
1607
|
// agy sizes its own window; * stands for the model agy picked (antigravity/* keeps it on intact).
|
|
1616
|
-
no1m: true,
|
|
1617
1608
|
slots: [
|
|
1618
1609
|
{ id: 'main', name: 'Agent Model (* = the model agy picked)', placeholder: 'antigravity/* or claude/claude-opus-5' }
|
|
1619
1610
|
]
|
|
@@ -1632,11 +1623,6 @@
|
|
|
1632
1623
|
return defaultModels[slotId] || '';
|
|
1633
1624
|
}
|
|
1634
1625
|
|
|
1635
|
-
function flagForSlot(model1M, slotId) {
|
|
1636
|
-
if (!model1M) return false;
|
|
1637
|
-
return Boolean(model1M[slotId]);
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
1626
|
function renderModelSlots(slotFmt, p) {
|
|
1641
1627
|
const container = document.getElementById('slot-fields');
|
|
1642
1628
|
const heading = document.getElementById('model-slot-heading');
|
|
@@ -1649,31 +1635,24 @@
|
|
|
1649
1635
|
|
|
1650
1636
|
for (const s of cfg.slots) {
|
|
1651
1637
|
const val = currentProfileBuffer ? valueForSlot(currentProfileBuffer.defaultModels, s.id) : valueForSlot(p?.defaultModels, s.id);
|
|
1652
|
-
const checked = currentProfileBuffer ? flagForSlot(currentProfileBuffer.model1M, s.id) : flagForSlot(p?.model1M, s.id);
|
|
1653
1638
|
|
|
1654
1639
|
const slotRow = document.createElement('div');
|
|
1655
1640
|
slotRow.style.cssText = 'display:flex; flex-direction:column; gap:0.25rem;';
|
|
1656
1641
|
slotRow.innerHTML = `
|
|
1657
|
-
<
|
|
1658
|
-
<label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
|
|
1659
|
-
${cfg.no1m ? '' : `<label style="display:flex; align-items:center; gap:0.35rem; font-size:0.75rem; color:var(--accent); cursor:pointer;">
|
|
1660
|
-
<input type="checkbox" id="p-${s.id}-1m" aria-label="1M context for ${escapeHtml(s.name)}" aria-describedby="p-${s.id}-1m-note" style="width:13px; height:13px; min-height:0;" onchange="update1mWarnings()" ${checked ? 'checked' : ''}>
|
|
1661
|
-
1M context
|
|
1662
|
-
</label>`}
|
|
1663
|
-
</div>
|
|
1642
|
+
<label for="p-${s.id}" style="font-size:0.82rem; font-weight:500;">${escapeHtml(s.name)}</label>
|
|
1664
1643
|
<div class="combo">
|
|
1665
|
-
<input type="text" id="p-${s.id}" oninput="
|
|
1644
|
+
<input type="text" id="p-${s.id}" oninput="updateWindowNotes()" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="p-${s.id}-list" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(s.placeholder)}" value="${escapeHtml(val)}">
|
|
1666
1645
|
<button type="button" class="combo-toggle" tabindex="-1" aria-label="Show models for ${escapeHtml(s.name)}">
|
|
1667
1646
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg>
|
|
1668
1647
|
</button>
|
|
1669
1648
|
<ul class="combo-list" id="p-${s.id}-list" role="listbox" aria-label="${escapeHtml(s.name)} models" hidden></ul>
|
|
1670
1649
|
</div>
|
|
1671
|
-
<div class="slot-
|
|
1650
|
+
<div class="slot-note" id="p-${s.id}-note" role="status" aria-live="polite"></div>
|
|
1672
1651
|
`;
|
|
1673
1652
|
container.appendChild(slotRow);
|
|
1674
1653
|
wireCombo(slotRow.querySelector('.combo'));
|
|
1675
1654
|
}
|
|
1676
|
-
|
|
1655
|
+
updateWindowNotes();
|
|
1677
1656
|
}
|
|
1678
1657
|
|
|
1679
1658
|
function onToolChange() {
|
|
@@ -1685,31 +1664,20 @@
|
|
|
1685
1664
|
const cfg = getActiveSlotConfig(renderedSlotFormat);
|
|
1686
1665
|
if (!currentProfileBuffer) currentProfileBuffer = {};
|
|
1687
1666
|
const defaultModels = { ...(currentProfileBuffer.defaultModels || {}) };
|
|
1688
|
-
const model1M = { ...(currentProfileBuffer.model1M || {}) };
|
|
1689
1667
|
for (const s of cfg.slots) {
|
|
1690
1668
|
const inp = document.getElementById('p-' + s.id);
|
|
1691
|
-
const chk = document.getElementById('p-' + s.id + '-1m');
|
|
1692
1669
|
if (inp) {
|
|
1693
1670
|
const val = inp.value.trim();
|
|
1694
1671
|
if (val) defaultModels[s.id] = val;
|
|
1695
1672
|
else delete defaultModels[s.id];
|
|
1696
1673
|
}
|
|
1697
|
-
if (chk) {
|
|
1698
|
-
if (chk.checked) model1M[s.id] = true;
|
|
1699
|
-
else delete model1M[s.id];
|
|
1700
|
-
}
|
|
1701
1674
|
}
|
|
1702
1675
|
currentProfileBuffer.defaultModels = defaultModels;
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1676
|
+
// An old config can still carry the retired 1M flags. They are dropped on the next save
|
|
1677
|
+
// instead of being written back: the window is the model's own now.
|
|
1678
|
+
delete currentProfileBuffer.model1M;
|
|
1679
|
+
return { defaultModels };
|
|
1706
1680
|
|
|
1707
|
-
// A window known to be under 1M locks the box; an unknown one only warns, since many
|
|
1708
|
-
// gateways list no limits at all.
|
|
1709
|
-
function contextVerdict(l) {
|
|
1710
|
-
const window = (l && (l.context || l.input)) || 0;
|
|
1711
|
-
if (!window) return { kind: 'unknown', window: 0 };
|
|
1712
|
-
return { kind: window >= 1000000 ? 'ok' : 'small', window };
|
|
1713
1681
|
}
|
|
1714
1682
|
|
|
1715
1683
|
// 128000 reads as 128K and 262144 as 256K: a round decimal count keeps base 1000.
|
|
@@ -1718,32 +1686,24 @@
|
|
|
1718
1686
|
return n >= b * b ? +(n / b / b).toFixed(1) + 'M' : n >= b ? Math.round(n / b) + 'K' : String(n);
|
|
1719
1687
|
}
|
|
1720
1688
|
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1689
|
+
// The window each listed model has, as the gateway reports it: the whole one and the smaller
|
|
1690
|
+
// one to compress at. Shown, never a setting: the tool sizes the session from it.
|
|
1691
|
+
function windowLine(model) {
|
|
1692
|
+
const l = loadedLimits[model];
|
|
1693
|
+
if (!l || !(l.context || l.compact)) return '';
|
|
1694
|
+
const parts = [];
|
|
1695
|
+
if (l.context) parts.push(fmtTokens(l.context) + ' context');
|
|
1696
|
+
if (l.compact && l.compact < (l.context || 0)) parts.push('compacts at ' + fmtTokens(l.compact));
|
|
1697
|
+
return parts.join(', ');
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function updateWindowNotes() {
|
|
1701
|
+
for (const s of getActiveSlotConfig(renderedSlotFormat).slots) {
|
|
1702
|
+
const note = document.getElementById('p-' + s.id + '-note');
|
|
1703
|
+
if (!note) continue;
|
|
1727
1704
|
const model = document.getElementById('p-' + s.id)?.value.trim() || '';
|
|
1728
|
-
|
|
1729
|
-
const v = contextVerdict(model ? loadedLimits[model] : null);
|
|
1730
|
-
const locked = v.kind === 'small';
|
|
1731
|
-
if (locked) chk.checked = false;
|
|
1732
|
-
chk.disabled = locked;
|
|
1733
|
-
chk.closest('label').style.opacity = locked ? '0.5' : '';
|
|
1734
|
-
chk.closest('label').style.cursor = locked ? 'not-allowed' : 'pointer';
|
|
1735
|
-
if (note) {
|
|
1736
|
-
let text = '', cls = '';
|
|
1737
|
-
if (locked) { text = `Context ${fmtTokens(v.window)} is under 1M, so 1M context is off for this model.`; cls = 'err'; }
|
|
1738
|
-
else if (v.kind === 'ok') text = `Context ${fmtTokens(v.window)}.`;
|
|
1739
|
-
else if (chk.checked && model) { text = 'The gateway does not list this model\'s context. Make sure it takes 1M tokens.'; cls = 'warn'; }
|
|
1740
|
-
note.textContent = text;
|
|
1741
|
-
note.className = 'slot-1m-note' + (cls ? ' ' + cls : '');
|
|
1742
|
-
}
|
|
1743
|
-
if (chk.checked) any1m = true;
|
|
1705
|
+
note.textContent = model ? windowLine(model) : '';
|
|
1744
1706
|
}
|
|
1745
|
-
const warnCallout = document.getElementById('warn-1m-callout');
|
|
1746
|
-
if (warnCallout) warnCallout.style.display = any1m ? 'inline-flex' : 'none';
|
|
1747
1707
|
}
|
|
1748
1708
|
|
|
1749
1709
|
// Prefetch Counts for Nav Badges
|
|
@@ -1888,8 +1848,7 @@
|
|
|
1888
1848
|
const modelChips = pCfg.slots.map(s => {
|
|
1889
1849
|
const val = valueForSlot(p.defaultModels, s.id);
|
|
1890
1850
|
if (!val) return '';
|
|
1891
|
-
|
|
1892
|
-
return '<div class="chip"><span class="faint lbl">' + escapeHtml(s.name) + '</span><span class="val">' + escapeHtml(val) + '</span>' + (has1m ? '<b class="b1m">1M</b>' : '') + '</div>';
|
|
1851
|
+
return '<div class="chip"><span class="faint lbl">' + escapeHtml(s.name) + '</span><span class="val">' + escapeHtml(val) + '</span></div>';
|
|
1893
1852
|
}).filter(Boolean).join('');
|
|
1894
1853
|
|
|
1895
1854
|
card.innerHTML = `
|
|
@@ -2018,8 +1977,7 @@
|
|
|
2018
1977
|
outFormat: 'openai-chat',
|
|
2019
1978
|
mode: 'convert',
|
|
2020
1979
|
thinkingMode: 'auto',
|
|
2021
|
-
defaultModels: {}
|
|
2022
|
-
model1M: {}
|
|
1980
|
+
defaultModels: {}
|
|
2023
1981
|
};
|
|
2024
1982
|
|
|
2025
1983
|
document.getElementById('form-title').textContent = 'Add New Provider Profile';
|
|
@@ -2163,8 +2121,6 @@
|
|
|
2163
2121
|
};
|
|
2164
2122
|
// A save with no edit must leave the profile as it was: write a default only where the profile
|
|
2165
2123
|
// already had the key.
|
|
2166
|
-
const model1M = { ...(currentProfileBuffer?.model1M || {}) };
|
|
2167
|
-
if (Object.keys(model1M).length || existing.model1M) profilePayload.model1M = model1M;
|
|
2168
2124
|
const thinkingMode = document.getElementById('p-thinking-mode').value;
|
|
2169
2125
|
if (thinkingMode !== 'auto' || existing.thinkingMode) profilePayload.thinkingMode = thinkingMode;
|
|
2170
2126
|
|
|
@@ -2369,7 +2325,7 @@
|
|
|
2369
2325
|
setModelsStatus('error', 'Models not loaded: ' + err.message, 'Retry');
|
|
2370
2326
|
}
|
|
2371
2327
|
document.querySelectorAll('.combo[data-open]').forEach(renderComboOptions);
|
|
2372
|
-
|
|
2328
|
+
updateWindowNotes();
|
|
2373
2329
|
}
|
|
2374
2330
|
|
|
2375
2331
|
// Slot combobox (ARIA combobox with a listbox). Typing filters; the arrow opens the full list.
|
|
@@ -2487,7 +2443,7 @@
|
|
|
2487
2443
|
combo.querySelector('input').value = model;
|
|
2488
2444
|
formDirty = true;
|
|
2489
2445
|
closeCombo(combo);
|
|
2490
|
-
|
|
2446
|
+
updateWindowNotes();
|
|
2491
2447
|
}
|
|
2492
2448
|
|
|
2493
2449
|
function onComboKeydown(e) {
|
package/windows.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The real window of every model a profile serves.
|
|
2
|
+
//
|
|
3
|
+
// A coding tool sizes a session, and its auto-compact point, from the window its own catalog
|
|
4
|
+
// gives it. So the gateway reports what the model really has instead of a number someone picked
|
|
5
|
+
// in a checkbox. Two numbers come out of a provider's list:
|
|
6
|
+
//
|
|
7
|
+
// context the whole window the model accepts
|
|
8
|
+
// compact the window to compress at, which a Codex model publishes as `context_window`
|
|
9
|
+
// next to `max_context_window` (272K against 872K)
|
|
10
|
+
//
|
|
11
|
+
// A model whose list gives only one window has no compact window, and a provider that lists no
|
|
12
|
+
// limits at all leaves both unknown: the caller then falls back to its own template rather than
|
|
13
|
+
// inventing a number.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
|
|
18
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
19
|
+
const DEFAULT_TTL_MS = ONE_HOUR_MS;
|
|
20
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
21
|
+
|
|
22
|
+
const n = v => (Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
|
|
23
|
+
|
|
24
|
+
// One entry of a provider's list -> the two windows, or null when it names none.
|
|
25
|
+
export function parseModelWindows(m) {
|
|
26
|
+
if (!m || typeof m !== 'object') return null;
|
|
27
|
+
const top = m.top_provider && typeof m.top_provider === 'object' ? m.top_provider : {};
|
|
28
|
+
const context = n(m.max_context_window) || n(m.context_length) || n(top.context_length) || n(m.context_window);
|
|
29
|
+
// intact serves the threshold under its own name; a Codex-shaped list carries it as the
|
|
30
|
+
// smaller of the two windows it publishes.
|
|
31
|
+
const compact = n(m.compact_window) || n(m.compaction_threshold)
|
|
32
|
+
|| (context && n(m.context_window) && n(m.context_window) < context ? n(m.context_window) : 0);
|
|
33
|
+
if (!context && !compact) return null;
|
|
34
|
+
return { context, compact };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The list as it names the models, so a caller can look one up by the id a profile maps.
|
|
38
|
+
export function parseModelList(data) {
|
|
39
|
+
const entries = Array.isArray(data?.data) ? data.data
|
|
40
|
+
: Array.isArray(data) ? data
|
|
41
|
+
: Array.isArray(data?.models) ? data.models
|
|
42
|
+
: [];
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const m of entries) {
|
|
45
|
+
const id = String(typeof m === 'string' ? m : (m?.id || m?.name || '')).replace(/^models\//, '');
|
|
46
|
+
if (!id) continue;
|
|
47
|
+
const w = parseModelWindows(m);
|
|
48
|
+
if (w) out[id] = w;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reads a provider's model list for its windows. Returns {} on any failure: a profile whose
|
|
55
|
+
* upstream cannot be reached keeps the last answer, never a wrong window.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchProfileWindows(baseURL, apiKey, { timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
58
|
+
const base = String(baseURL || '').replace(/\/+$/, '');
|
|
59
|
+
if (!base) return {};
|
|
60
|
+
try {
|
|
61
|
+
const headers = { accept: 'application/json' };
|
|
62
|
+
if (apiKey) {
|
|
63
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
64
|
+
headers['x-api-key'] = apiKey;
|
|
65
|
+
}
|
|
66
|
+
const res = await fetch(`${base}/models`, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
67
|
+
if (!res.ok) return {};
|
|
68
|
+
return parseModelList(await res.json());
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The windows of a profile's models, from the cache when it is fresh and from the profile's own
|
|
76
|
+
* list when it is not. A failed read keeps the stale answer, because an old window only sizes a
|
|
77
|
+
* session slightly wrong, while an empty one would hand the tool a template window it may not
|
|
78
|
+
* have. Never throws.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolveProfileWindows(stateDir, cacheKey, baseURL, apiKey, { ttlMs = DEFAULT_TTL_MS, now = Date.now() } = {}) {
|
|
81
|
+
const cache = readWindowsCache(stateDir);
|
|
82
|
+
const entry = cache[cacheKey] || {};
|
|
83
|
+
const fresh = entry.at && now - entry.at < ttlMs;
|
|
84
|
+
if (fresh) return entry.models || {};
|
|
85
|
+
const models = await fetchProfileWindows(baseURL, apiKey);
|
|
86
|
+
if (Object.keys(models).length) {
|
|
87
|
+
cache[cacheKey] = { at: now, baseURL, models };
|
|
88
|
+
writeWindowsCache(stateDir, cache);
|
|
89
|
+
return models;
|
|
90
|
+
}
|
|
91
|
+
return entry.models || {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function windowsCachePath(stateDir) {
|
|
95
|
+
return path.join(stateDir, 'model-windows.json');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readWindowsCache(stateDir) {
|
|
99
|
+
try {
|
|
100
|
+
const d = JSON.parse(fs.readFileSync(windowsCachePath(stateDir), 'utf8'));
|
|
101
|
+
return d && typeof d === 'object' && !Array.isArray(d) ? d : {};
|
|
102
|
+
} catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function writeWindowsCache(stateDir, cache) {
|
|
108
|
+
const p = windowsCachePath(stateDir);
|
|
109
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
110
|
+
try {
|
|
111
|
+
fs.writeFileSync(tmp, JSON.stringify(cache), 'utf8');
|
|
112
|
+
fs.renameSync(tmp, p);
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
try { fs.rmSync(tmp, { force: true }); } catch {}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The windows the Codex CLI itself cached for its signed-in account. It is the fallback for a
|
|
122
|
+
* profile that lists no limits: those numbers came from the provider the CLI uses, so they are
|
|
123
|
+
* better than the template and never a guess. Returns {} when the tool keeps no list.
|
|
124
|
+
*/
|
|
125
|
+
export function readLocalCodexWindows(sources = {}) {
|
|
126
|
+
const codexHome = sources.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
127
|
+
try {
|
|
128
|
+
const d = JSON.parse(fs.readFileSync(path.join(codexHome, 'models_cache.json'), 'utf8'));
|
|
129
|
+
const models = Array.isArray(d?.models) ? d.models : [];
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const m of models) {
|
|
132
|
+
const id = m && typeof m.slug === 'string' ? m.slug : '';
|
|
133
|
+
const context = n(m?.max_context_window) || n(m?.context_window);
|
|
134
|
+
const compact = context && n(m.context_window) < context ? n(m.context_window) : 0;
|
|
135
|
+
if (id && context) out[id] = { context, compact };
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
} catch {
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
}
|