llm-switcher 1.3.8 → 1.4.1

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.
@@ -0,0 +1,124 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import http from 'node:http';
7
+ import {
8
+ parseModelWindows, parseModelList, fetchProfileWindows,
9
+ resolveProfileWindows, readWindowsCache, readLocalCodexWindows
10
+ } from '../windows.mjs';
11
+
12
+ const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'lsw-windows-'));
13
+
14
+ // A Codex model publishes both windows; intact names the threshold itself. Neither is guessed.
15
+ test('a list entry gives the whole window and the window to compress at', () => {
16
+ assert.deepEqual(parseModelWindows({ id: 'codex/gpt-6-astra', context_length: 872000, compact_window: 272000 }),
17
+ { context: 872000, compact: 272000 });
18
+ assert.deepEqual(parseModelWindows({ id: 'local/g', context_window: 272000, max_context_window: 872000 }),
19
+ { context: 872000, compact: 272000 }, 'the smaller of two Codex windows is the threshold');
20
+ assert.deepEqual(parseModelWindows({ id: 'groq/q', context_window: 131072 }), { context: 131072, compact: 0 },
21
+ 'one window is the whole window, not a threshold');
22
+ assert.deepEqual(parseModelWindows({ id: 'or/r', context_length: 262144, top_provider: { context_length: 262144, max_completion_tokens: 32768 } }),
23
+ { context: 262144, compact: 0 });
24
+ assert.deepEqual(parseModelWindows({ id: 'bare' }), null, 'a model with no limits says nothing');
25
+ assert.deepEqual(parseModelWindows({ id: 'zero', context_length: 0, compact_window: 0 }), null);
26
+ });
27
+
28
+ test('parseModelList keys the windows by the id a profile maps, and drops the nameless', () => {
29
+ const out = parseModelList({ data: [{ id: 'models/big', context_length: 1048576 }, { id: 'bare' }, 'plain'] });
30
+ assert.deepEqual(Object.keys(out), ['big'], 'a bare string carries no window, and a models/ prefix is dropped');
31
+ assert.equal(out.big.context, 1048576);
32
+ assert.deepEqual(parseModelList({}), {});
33
+ assert.deepEqual(parseModelList({ models: [{ id: 'gemini', inputTokenLimit: 1000000 }] }), {});
34
+ });
35
+
36
+ test('a profile list is read over HTTP, and a failure gives no window rather than a wrong one', async () => {
37
+ const seen = [];
38
+ const sink = http.createServer((req, res) => {
39
+ seen.push(req.headers.authorization);
40
+ if (req.url === '/v1/models') {
41
+ res.writeHead(200, { 'content-type': 'application/json' });
42
+ return res.end(JSON.stringify({ data: [{ id: 'm', context_length: 872000, compact_window: 272000 }] }));
43
+ }
44
+ res.writeHead(404); res.end();
45
+ });
46
+ await new Promise(r => sink.listen(0, '127.0.0.1', r));
47
+ const base = `http://127.0.0.1:${sink.address().port}/v1`;
48
+ try {
49
+ assert.deepEqual(await fetchProfileWindows(base, 'sk-x'), { m: { context: 872000, compact: 272000 } });
50
+ assert.ok(seen[0]?.startsWith('Bearer '), 'the profile key travels to the profile own list');
51
+ assert.deepEqual(await fetchProfileWindows(`${base}/gone`, 'sk-x'), {}, 'a 404 is not a window');
52
+ assert.deepEqual(await fetchProfileWindows('http://127.0.0.1:9/v1', 'sk-x'), {}, 'an unreachable host is not a window');
53
+ assert.deepEqual(await fetchProfileWindows('', 'sk-x'), {});
54
+ } finally {
55
+ sink.close();
56
+ }
57
+ });
58
+
59
+ // The cache exists so /v1/models costs nothing on the hot path, and so a profile whose list cannot
60
+ // be read keeps the last windows it did see.
61
+ test('windows are cached for an hour, and a failed refresh keeps the stale answer', async () => {
62
+ const dir = tmpDir();
63
+ let hits = 0;
64
+ const sink = http.createServer((req, res) => {
65
+ hits++;
66
+ res.writeHead(200, { 'content-type': 'application/json' });
67
+ res.end(JSON.stringify({ data: [{ id: 'm', context_length: hits === 1 ? 872000 : 400000 }] }));
68
+ });
69
+ await new Promise(r => sink.listen(0, '127.0.0.1', r));
70
+ const base = `http://127.0.0.1:${sink.address().port}/v1`;
71
+ const key = 'p@' + base;
72
+ try {
73
+ const fresh = await resolveProfileWindows(dir, key, base, 'k', { now: 1_000 });
74
+ assert.equal(fresh.m.context, 872000);
75
+ assert.equal(hits, 1);
76
+ // Inside the hour: the cache answers and the list is not read again.
77
+ assert.equal((await resolveProfileWindows(dir, key, base, 'k', { now: 1_000 + 60_000 })).m.context, 872000);
78
+ assert.equal(hits, 1);
79
+ // Past the hour the list is read again, and the answer moves with it.
80
+ assert.equal((await resolveProfileWindows(dir, key, base, 'k', { now: 1_000 + 3_600_000 })).m.context, 400000);
81
+ assert.equal(hits, 2);
82
+ // A list that cannot be read keeps what the last good read found.
83
+ sink.closeAllConnections?.();
84
+ await new Promise(r => sink.close(r));
85
+ assert.equal((await resolveProfileWindows(dir, key, base, 'k', { now: 1_000 + 7_200_000 })).m.context, 400000);
86
+ assert.equal(readWindowsCache(dir)[key].models.m.context, 400000, 'and the cache still holds it');
87
+ } finally {
88
+ sink.close();
89
+ fs.rmSync(dir, { recursive: true, force: true });
90
+ }
91
+ });
92
+
93
+ test('an empty cache file reads as no windows instead of throwing', () => {
94
+ const dir = tmpDir();
95
+ try {
96
+ assert.deepEqual(readWindowsCache(dir), {});
97
+ fs.writeFileSync(path.join(dir, 'model-windows.json'), 'not json');
98
+ assert.deepEqual(readWindowsCache(dir), {}, 'a broken cache is a cache miss, never a crash');
99
+ fs.writeFileSync(path.join(dir, 'model-windows.json'), '[1,2]');
100
+ assert.deepEqual(readWindowsCache(dir), {}, 'a shape that is not a map is a cache miss too');
101
+ } finally {
102
+ fs.rmSync(dir, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ // The Codex CLI keeps the numbers for its own account, so a profile that lists no limits at least
107
+ // gets the provider's real windows instead of the template's.
108
+ test('the Codex CLI own cache is a fallback, not a source of truth', () => {
109
+ const home = tmpDir();
110
+ try {
111
+ assert.deepEqual(readLocalCodexWindows({ codexHome: home }), {}, 'no cache file, no windows');
112
+ fs.writeFileSync(path.join(home, 'models_cache.json'), JSON.stringify({ models: [
113
+ { slug: 'gpt-6-astra', context_window: 272000, max_context_window: 872000 },
114
+ { slug: 'flat', context_window: 400000 },
115
+ { slug: 'bare' }
116
+ ] }));
117
+ assert.deepEqual(readLocalCodexWindows({ codexHome: home }), {
118
+ 'gpt-6-astra': { context: 872000, compact: 272000 },
119
+ flat: { context: 400000, compact: 0 }
120
+ });
121
+ } finally {
122
+ fs.rmSync(home, { recursive: true, force: true });
123
+ }
124
+ });
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-1m-note { font-size: 0.72rem; color: var(--faint); }
556
- /* Kept in the page when empty: a live region added later is often not read. */
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 & 1M -->
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
- <div style="display:flex; justify-content:space-between; align-items:center;">
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="update1mWarnings()" 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)}">
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-1m-note" id="p-${s.id}-1m-note" role="status" aria-live="polite"></div>
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
- update1mWarnings();
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
- currentProfileBuffer.model1M = model1M;
1704
- return { defaultModels, model1M };
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
- function update1mWarnings() {
1722
- const cfg = getActiveSlotConfig(renderedSlotFormat);
1723
- let any1m = false;
1724
- for (const s of cfg.slots) {
1725
- const chk = document.getElementById('p-' + s.id + '-1m');
1726
- const note = document.getElementById('p-' + s.id + '-1m-note');
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
- if (!chk) continue;
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
- const has1m = flagForSlot(p.model1M, s.id);
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
- update1mWarnings();
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
- update1mWarnings();
2446
+ updateWindowNotes();
2491
2447
  }
2492
2448
 
2493
2449
  function onComboKeydown(e) {