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.
@@ -797,6 +797,7 @@ test('A5: migration splits shared auto profile into -claude and -codex halves wi
797
797
  name: 'Shared Profile',
798
798
  apiKey: 'secret-key',
799
799
  defaultModels: { opus: 'opus-model', sonnet: 'sonnet-model', subagent: 'sub-model' },
800
+ // The retired 1M flags are in an old config: the split must not carry them into a half.
800
801
  model1M: { opus: true, sonnet: false }
801
802
  }
802
803
  }
@@ -819,10 +820,8 @@ test('A5: migration splits shared auto profile into -claude and -codex halves wi
819
820
  assert.equal(codex.defaultModels.main, 'opus-model');
820
821
  assert.equal(codex.defaultModels.review, 'sonnet-model');
821
822
  assert.equal(codex.defaultModels.subagent, 'sub-model');
822
- assert.equal(claude.model1M.opus, true);
823
- assert.equal(claude.model1M.sonnet, false);
824
- assert.equal(codex.model1M.main, true);
825
- assert.equal(codex.model1M.review, false);
823
+ assert.equal(claude.model1M, undefined, 'the retired 1M flags do not survive the split');
824
+ assert.equal(codex.model1M, undefined);
826
825
  });
827
826
 
828
827
  test('A5: R7 row 4 splits profile with Claude/no-tool fields active for responses with slot fallbacks', () => {
@@ -881,7 +880,7 @@ test('A5: migration preserves legacy "default" key on both halves during split',
881
880
  assert.equal(res.config.profiles['combo-codex'].defaultModels.default, 'chat-default');
882
881
  });
883
882
 
884
- test('A5: migration never fills slot whose value is empty string or false', () => {
883
+ test('A5: migration never fills slot whose value is empty string', () => {
885
884
  const raw = {
886
885
  activeProfile: 'combo',
887
886
  profiles: {
@@ -895,7 +894,7 @@ test('A5: migration never fills slot whose value is empty string or false', () =
895
894
  const res = migrateConfigInMemory(raw);
896
895
  const codex = res.config.profiles['combo-codex'];
897
896
  assert.equal(codex.defaultModels.main, '');
898
- assert.equal(codex.model1M.main, false);
897
+ assert.equal(codex.model1M, undefined, 'and the retired flags do not come along');
899
898
  });
900
899
 
901
900
  test('A5: migration splits renamed command-word profile as <key>-profile-claude and <key>-profile-codex', () => {
@@ -1610,7 +1609,7 @@ test('each routed tool gets a route line; a tool that is off gets an empty one',
1610
1609
  assert.match(r.on.claude, /\bcl\b/, 'the line names the profile that serves claude');
1611
1610
  assert.match(r.on.claude, /intact\.example\.io/, 'and the host the traffic goes to');
1612
1611
  assert.match(r.on.claude, /gemini-3\.8-flash/, 'and the model it maps to');
1613
- assert.match(r.on.claude, /1M/, 'and the 1M window, because that changes what the session costs');
1612
+ assert.doesNotMatch(r.on.claude, /1M/, 'and no window: the tool sizes the session from the model');
1614
1613
  assert.match(r.on.codex, /\bcx\b/);
1615
1614
  assert.match(r.on.codex, /other\.example\.io/);
1616
1615
  assert.doesNotMatch(r.on.codex, /intact\.example\.io/, 'one tool never shows the other tool route');
@@ -0,0 +1,280 @@
1
+ // The tool-name layer: a model answers in the vocabulary it knows, the caller only runs the tools it
2
+ // declared. Sources for every name here are collected in docs/tool-vocabulary.md.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { createToolVocab, NULL_TOOL_VOCAB, tokenize } from '../toolvocab.mjs';
6
+ import {
7
+ anthropicToIR, responsesToIR, irToChatBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
8
+ createAnthropicStream, createChatStream, createResponsesStream
9
+ } from '../formats.mjs';
10
+
11
+ const claudeTools = () => anthropicToIR({
12
+ model: 'claude-opus-4-6', messages: [{ role: 'user', content: 'go' }],
13
+ tools: [
14
+ { name: 'Read', input_schema: { properties: { file_path: { type: 'string' }, offset: { type: 'number' } } } },
15
+ { name: 'Bash', input_schema: { properties: { command: { type: 'string' } } } },
16
+ { name: 'Edit', input_schema: { properties: { file_path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' } } } }
17
+ ]
18
+ });
19
+
20
+ // A model that answers `read` when the caller declared `Read` ends the turn with
21
+ // "No such tool available: read". The name comes back in the caller's words.
22
+ test('a name the model reached for comes back in the caller own words', () => {
23
+ const vocab = createToolVocab(claudeTools().tools);
24
+ assert.equal(vocab.name('read'), 'Read');
25
+ assert.equal(vocab.name('shell'), 'Bash');
26
+ assert.equal(vocab.name('str_replace_editor'), 'Edit');
27
+ assert.equal(vocab.name('read_file'), 'Read', 'the name Codex is trained on');
28
+ assert.equal(vocab.name('run_shell_command'), 'Bash', 'the name Gemini is trained on');
29
+ assert.equal(vocab.name('execute_command'), 'Bash');
30
+ });
31
+
32
+ test('a name the caller already declared is never touched', () => {
33
+ const vocab = createToolVocab(claudeTools().tools);
34
+ for (const own of ['Read', 'Bash', 'Edit']) assert.equal(vocab.name(own), own);
35
+ assert.equal(vocab.name('WebFetch'), 'WebFetch', 'a name it did not declare and that says nothing');
36
+ assert.equal(vocab.name('totally_unknown'), 'totally_unknown');
37
+ });
38
+
39
+ // Two declared tools of the same kind: which one the model meant is not knowable, and the wrong one
40
+ // runs. Claude Code on Windows declares both Bash and PowerShell.
41
+ test('two tools of the same kind are left alone rather than one being chosen', () => {
42
+ const ir = anthropicToIR({
43
+ model: 'x', messages: [],
44
+ tools: [
45
+ { name: 'Bash', input_schema: { properties: {} } },
46
+ { name: 'PowerShell', input_schema: { properties: {} } },
47
+ { name: 'Read', input_schema: { properties: {} } }
48
+ ]
49
+ });
50
+ const vocab = createToolVocab(ir.tools);
51
+ assert.equal(vocab.name('shell'), 'shell', 'no shell tool was renamed');
52
+ assert.equal(vocab.name('read'), 'Read', 'the unambiguous one still is');
53
+ });
54
+
55
+ // mcp__github__create_issue becoming a local Write produces a call the caller cannot detect as wrong:
56
+ // it runs, and it writes a file instead of opening an issue.
57
+ test('a namespaced name never leaves its namespace', () => {
58
+ const ir = anthropicToIR({
59
+ model: 'x', messages: [],
60
+ tools: [
61
+ { name: 'Write', input_schema: { properties: {} } },
62
+ { name: 'mcp__github__create_issue', input_schema: { properties: {} } }
63
+ ]
64
+ });
65
+ const vocab = createToolVocab(ir.tools);
66
+ assert.equal(vocab.name('mcp__github__create_issue'), 'mcp__github__create_issue');
67
+ assert.equal(vocab.name('mcp__github__write_file'), 'mcp__github__write_file');
68
+ });
69
+
70
+ // Who runs the tool decides whether a name may move at all. Anthropic runs a server tool and its
71
+ // result reaches the caller without the caller executing anything; handing it back as a client tool
72
+ // moves the work to the user's machine, where nothing can see that it happened.
73
+ test('a tool the provider runs is neither renamed nor offered as a client tool', () => {
74
+ const ir = anthropicToIR({
75
+ model: 'claude-opus-4-6', messages: [],
76
+ tools: [
77
+ { name: 'Read', input_schema: { properties: { file_path: { type: 'string' } } } },
78
+ { name: 'Bash', input_schema: { properties: { command: { type: 'string' } } } },
79
+ { type: 'web_search_20260209', name: 'web_search' }
80
+ ]
81
+ });
82
+ assert.deepEqual(ir.tools.map(t => [t.name, t.side]), [
83
+ ['Read', 'client'], ['Bash', 'client'], ['web_search', 'provider']
84
+ ]);
85
+ const vocab = createToolVocab(ir.tools);
86
+ assert.equal(vocab.name('web_search'), 'web_search', 'a server tool is never a rename target');
87
+ });
88
+
89
+ // The schema is the caller's to declare, so the argument keys are matched against it rather than
90
+ // against a table of what each tool is called.
91
+ test('arguments are renamed only against the caller own schema, and a falsy value survives', () => {
92
+ const vocab = createToolVocab(claudeTools().tools);
93
+ const read = vocab.args('read', JSON.stringify({ path: 'a.ts', offset: 0 }));
94
+ assert.deepEqual(JSON.parse(read), { file_path: 'a.ts', offset: 0 }, 'offset 0 is a value, not an absence');
95
+
96
+ // A tool whose schema already has the key: nothing is touched.
97
+ const bash = vocab.args('shell', JSON.stringify({ command: 'ls' }));
98
+ assert.deepEqual(JSON.parse(bash), { command: 'ls' });
99
+
100
+ // A key the schema does not name stays: the caller may still read it.
101
+ const edit = vocab.args('Edit', JSON.stringify({ file_path: 'a', note: 'keep me' }));
102
+ assert.deepEqual(JSON.parse(edit), { file_path: 'a', note: 'keep me' });
103
+
104
+ // Arguments cut off by the upstream are passed through, not rewritten.
105
+ const broken = vocab.args('read', '{"path": "a.t');
106
+ assert.equal(broken, '{"path": "a.t');
107
+ });
108
+
109
+ // The whole turn, non-streaming, through the emitter the caller reads.
110
+ test('a non-streaming answer hands the caller its own tool name and its own arguments', () => {
111
+ const ir = claudeTools();
112
+ const vocab = createToolVocab(ir.tools);
113
+ const body = buildAnthropicMessage({
114
+ model: 'claude-opus-4-6', text: [], think: [], finish: 'tool_calls',
115
+ tools: [{ id: 't1', name: 'read', args: '{"path":"a.ts","offset":0}' }], vocab
116
+ });
117
+ const use = body.content.find(c => c.type === 'tool_use');
118
+ assert.equal(use.name, 'Read');
119
+ assert.deepEqual(use.input, { file_path: 'a.ts', offset: 0 });
120
+ assert.equal(body.stop_reason, 'tool_use');
121
+
122
+ const chat = buildChatMessage({ model: 'm', text: [], tools: [{ id: 't1', name: 'shell', args: '{"command":"ls"}' }], vocab });
123
+ const call = chat.choices[0].message.tool_calls[0].function;
124
+ assert.equal(call.name, 'Bash');
125
+ assert.equal(call.arguments, '{"command":"ls"}');
126
+
127
+ const codex = buildResponsesMessage({ model: 'm', text: [], tools: [{ id: 'c1', name: 'read', args: '{"path":"a"}' }], vocab });
128
+ assert.equal(codex.output.find(i => i.type === 'function_call').name, 'Read');
129
+ });
130
+
131
+ // With no declared tools there is nothing to map, so the layer costs nothing and changes nothing.
132
+ test('a request that declared no tools is passed through untouched', () => {
133
+ const vocab = createToolVocab([]);
134
+ assert.equal(vocab.name('read'), 'read');
135
+ assert.equal(vocab.args('read', '{"path":"a"}'), '{"path":"a"}');
136
+ const body = buildAnthropicMessage({ model: 'm', text: [], tools: [{ id: 't', name: 'read', args: '{}' }], vocab });
137
+ assert.equal(body.content[0].name, 'read');
138
+ assert.equal(NULL_TOOL_VOCAB.name('x'), 'x');
139
+ });
140
+
141
+ // Streaming: the name arrives first and is fixed at once. The arguments arrive in pieces, so a call
142
+ // whose keys are renamed is held back and handed over whole, before the block closes.
143
+ test('a streamed answer renames the name at once and holds the arguments only when it must', () => {
144
+ const vocab = createToolVocab(claudeTools().tools);
145
+
146
+ const plain = [];
147
+ const a = createAnthropicStream((e, d) => plain.push([e, d]), 'm', { vocab });
148
+ a.start();
149
+ a.tool({ index: 0, id: 't1', name: 'Bash', args: '{"command":' });
150
+ a.tool({ index: 0, args: '"ls"}' });
151
+ a.finish('tool_calls', {});
152
+ assert.deepEqual(plain.find(([e]) => e === 'content_block_start')[1].content_block.name, 'Bash');
153
+ // A call the caller already names is not buffered at all: every fragment goes straight out.
154
+ assert.deepEqual(
155
+ plain.filter(([, d]) => d.delta?.type === 'input_json_delta').map(([, d]) => d.delta.partial_json),
156
+ ['{"command":', '"ls"}']
157
+ );
158
+
159
+ const fixed = [];
160
+ const b = createAnthropicStream((e, d) => fixed.push([e, d]), 'm', { vocab });
161
+ b.start();
162
+ b.tool({ index: 0, id: 't1', name: 'read', args: '{"path"' });
163
+ b.tool({ index: 0, args: ':"a.ts","offset":0}' });
164
+ b.finish('tool_calls', {});
165
+ assert.equal(fixed.find(([e]) => e === 'content_block_start')[1].content_block.name, 'Read');
166
+ const deltas = fixed.filter(([, d]) => d.delta?.type === 'input_json_delta').map(([, d]) => d.delta.partial_json);
167
+ assert.deepEqual(deltas, ['{"file_path":"a.ts","offset":0}'], 'one delta, with the caller own key');
168
+ // The block never closes with arguments still pending.
169
+ const stopAt = fixed.findIndex(([e, d]) => e === 'content_block_stop' && d.type === 'content_block_stop');
170
+ const lastArgAt = fixed.findIndex(([, d]) => d.delta?.type === 'input_json_delta');
171
+ assert.ok(lastArgAt < stopAt, 'the arguments arrive before the block stops');
172
+ });
173
+
174
+ // Codex runs a tool when the whole item arrives, so a renamed call carries its repaired arguments
175
+ // with that item and streams no argument deltas of its own.
176
+ test('a streamed Codex call is repaired in the item Codex runs', () => {
177
+ const ir = responsesToIR({
178
+ model: 'gpt-5.6-sol', input: 'go',
179
+ tools: [{ type: 'function', name: 'Read', parameters: { properties: { file_path: { type: 'string' } } } },
180
+ { type: 'function', name: 'Bash', parameters: { properties: { command: { type: 'string' } } } }]
181
+ });
182
+ const vocab = createToolVocab(ir.tools);
183
+ const events = [];
184
+ const r = createResponsesStream((e, d) => events.push([e, d]), 'm', { vocab, toolMeta: ir.toolMeta });
185
+ r.start();
186
+ r.tool({ index: 0, id: 'c1', name: 'read', args: '{"path":"a.ts"' });
187
+ r.tool({ index: 0, args: '}' });
188
+ r.finish('tool_calls', {});
189
+ const added = events.find(([e]) => e === 'response.output_item.added')[1].item;
190
+ const done = events.find(([e]) => e === 'response.output_item.done')[1].item;
191
+ assert.equal(added.name, 'Read');
192
+ assert.equal(added.arguments, '', 'nothing is streamed for a call whose keys are renamed');
193
+ assert.equal(done.arguments, '{"file_path":"a.ts"}');
194
+ assert.equal(events.some(([e]) => e === 'response.function_call_arguments.delta'), false);
195
+ });
196
+
197
+ test('tokenize reads a name the way the model that coined it saw it', () => {
198
+ assert.deepEqual(tokenize('TodoWrite'), ['Todo', 'Write']);
199
+ assert.deepEqual(tokenize('str_replace_editor'), ['str', 'replace', 'editor']);
200
+ assert.deepEqual(tokenize('mcp__fs__read'), ['mcp', 'fs', 'read']);
201
+ assert.deepEqual(tokenize('filePath'), ['file', 'Path']);
202
+ });
203
+
204
+ // ---- steering the model instead of repairing every call ----
205
+ //
206
+ // A model answers with the tool names it was trained on, whatever the caller declared. Naming the
207
+ // caller's names in the prompt is cheaper than repairing each such call, but only where the caller
208
+ // has not already named them: a second list for a prompt that already carries one is a second thing
209
+ // for the model to weigh, and this is where the collision would come from.
210
+
211
+ function noticeIn(system, toolNames, opts = {}) {
212
+ const ir = anthropicToIR({
213
+ model: 'gpt-5.6-sol', messages: [{ role: 'user', content: 'go' }],
214
+ ...(system ? { system } : {}),
215
+ tools: toolNames.map(n => ({ name: n, input_schema: { properties: {} } }))
216
+ });
217
+ const body = irToChatBody(ir, 'gpt-5.6-sol', opts);
218
+ const sys = body.messages.find(m => m.role === 'system');
219
+ return sys ? sys.content : '';
220
+ }
221
+
222
+ test('a caller that never names its tools gets the list, and nothing else changes', () => {
223
+ const sys = noticeIn('', ['Read', 'Bash', 'Edit']);
224
+ assert.match(sys, /use exactly these names/i);
225
+ for (const n of ['Read', 'Bash', 'Edit']) assert.ok(sys.includes(n), `${n} is missing from the notice`);
226
+ });
227
+
228
+ // Claude Code names its tools at length in its own prompt. A second list would compete with it, so
229
+ // nothing is added and the caller's text is passed through byte for byte.
230
+ test('a prompt that already names every tool gets nothing added', () => {
231
+ const prompt = 'You are Claude Code. Use Read to view a file, Bash to run a command, Edit to change one.';
232
+ assert.equal(noticeIn(prompt, ['Read', 'Bash', 'Edit']), prompt);
233
+ });
234
+
235
+ test('a prompt that names some of them gets only the rest', () => {
236
+ const sys = noticeIn('Use Read to view a file.', ['Read', 'Bash']);
237
+ assert.match(sys, /^Use Read to view a file\./);
238
+ assert.ok(sys.includes('Bash'), 'the name the prompt did not carry is added');
239
+ assert.ok(!/Read, Bash/.test(sys), 'and the one it already had is not repeated');
240
+ });
241
+
242
+ test('one tool cannot be confused with another, so nothing is said', () => {
243
+ assert.equal(noticeIn('', ['Read']), '');
244
+ assert.equal(noticeIn('', []), '');
245
+ });
246
+
247
+ // A tool the provider runs is not in the list: the caller never executes it, so naming it would only
248
+ // invite the model to call something the gateway dropped.
249
+ test('a tool the provider runs is never named', () => {
250
+ const ir = anthropicToIR({
251
+ model: 'x', messages: [],
252
+ tools: [
253
+ { name: 'Read', input_schema: { properties: {} } },
254
+ { name: 'Bash', input_schema: { properties: {} } },
255
+ { type: 'web_search_20260209', name: 'web_search' }
256
+ ]
257
+ });
258
+ const sys = irToChatBody(ir, 'gpt-5.6-sol').messages.find(m => m.role === 'system').content;
259
+ assert.ok(!sys.includes('web_search'), `a server tool was named: ${sys}`);
260
+ });
261
+
262
+ // native never touches the system prompt, and that is a contract with the people who chose it; off
263
+ // sends no reasoning parameters at all, and a tool name is neither.
264
+ test('native and off add nothing, whatever the caller declared', () => {
265
+ assert.equal(noticeIn('', ['Read', 'Bash'], { thinkingMode: 'native' }), '');
266
+ assert.equal(noticeIn('', ['Read', 'Bash'], { thinkingMode: 'off' }), '');
267
+ });
268
+
269
+ // The notice and the think guide are two different instructions about two different things, and a
270
+ // non-reasoning model that was asked to think gets both.
271
+ test('the notice and the think guide coexist', () => {
272
+ const ir = anthropicToIR({
273
+ model: 'gpt-4o', messages: [{ role: 'user', content: 'go' }],
274
+ thinking: { type: 'enabled', budget_tokens: 2048 },
275
+ tools: [{ name: 'Read', input_schema: { properties: {} } }, { name: 'Bash', input_schema: { properties: {} } }]
276
+ });
277
+ const sys = irToChatBody(ir, 'gpt-4o').messages.find(m => m.role === 'system').content;
278
+ assert.ok(sys.includes('<think> and </think>'), `the think guide is missing: ${sys}`);
279
+ assert.match(sys, /use exactly these names/i);
280
+ });
package/tests/ui.test.mjs CHANGED
@@ -89,22 +89,29 @@ test('a profile without outFormat keeps auto routing through a save', () => {
89
89
  assert.match(html, /getElementById\('p-outformat'\)\.value = p\.outFormat \|\| '';/);
90
90
  });
91
91
 
92
- // The 1M box is locked only when the model's window is known and under 1M. An unknown window
93
- // only warns: many gateways list no limits at all.
94
- test('contextVerdict locks only a window known to be under 1M', () => {
95
- const verdict = new Function(functionBody('contextVerdict') + '\n }\n return contextVerdict;')();
96
- assert.equal(verdict(undefined).kind, 'unknown');
97
- assert.equal(verdict({ output: 32000 }).kind, 'unknown');
98
- assert.equal(verdict({ context: 200000 }).kind, 'small');
99
- assert.equal(verdict({ context: 200000 }).window, 200000);
100
- assert.equal(verdict({ input: 1000000 }).kind, 'ok');
101
- assert.equal(verdict({ context: 1048576 }).kind, 'ok');
92
+ // The window is the model's own, so the dashboard reports it and offers no switch. A model the
93
+ // gateway lists with no window says nothing, and a compact window below the whole one is the
94
+ // point the tool compresses at.
95
+ test('windowLine reports the window the gateway lists, and nothing when it lists none', () => {
96
+ // functionBody stops before the closing brace, so a helper it calls is closed here.
97
+ const source = `${functionBody('fmtTokens')}\n }\n${functionBody('windowLine')}\n }\n return windowLine;`;
98
+ const withLimits = limits => new Function('loadedLimits', source)(limits);
99
+ const line = withLimits({});
100
+ assert.equal(line(undefined), '');
101
+ assert.equal(line('bare'), '', 'a model with no limits says nothing');
102
+ const loaded = { bare: { output: 32000 }, big: { context: 1048576 }, codex: { context: 872000, compact: 272000 } };
103
+ const listed = withLimits(loaded);
104
+ assert.equal(listed('big'), '1M context');
105
+ assert.equal(listed('codex'), '872K context, compacts at 272K');
106
+ assert.equal(listed('bare'), '', 'limits without a window are not a window');
102
107
  });
103
108
 
104
- test('each 1M box names its slot and points at its note', () => {
109
+ test('the 1M switch is gone from the slots, the card and the payload', () => {
105
110
  const slots = functionBody('renderModelSlots');
106
- assert.match(slots, /aria-label="1M context for \$\{escapeHtml\(s\.name\)\}"/);
107
- assert.match(slots, /aria-describedby="p-\$\{s\.id\}-1m-note"/);
108
- assert.match(slots, /class="slot-1m-note" id="p-\$\{s\.id\}-1m-note" role="status"/);
109
- assert.doesNotMatch(html, /\.slot-1m-note:empty \{ display: none/);
111
+ assert.doesNotMatch(slots, /type="checkbox"/, 'a model slot offers no window switch');
112
+ assert.match(slots, /class="slot-note" id="p-\$\{s\.id\}-note" role="status"/);
113
+ assert.match(slots, /oninput="updateWindowNotes\(\)"/, 'typing a model refreshes its window');
114
+ assert.doesNotMatch(html, /id="warn-1m-callout"/);
115
+ assert.doesNotMatch(functionBody('collectCurrentSlotValues'), /model1M\s*[,}]/);
116
+ assert.doesNotMatch(html, /profilePayload\.model1M/);
110
117
  });
@@ -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
+ });