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/tests/state.test.mjs
CHANGED
|
@@ -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
|
|
823
|
-
assert.equal(
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
assert.equal(
|
|
101
|
-
assert.equal(
|
|
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('
|
|
109
|
+
test('the 1M switch is gone from the slots, the card and the payload', () => {
|
|
105
110
|
const slots = functionBody('renderModelSlots');
|
|
106
|
-
assert.
|
|
107
|
-
assert.match(slots, /
|
|
108
|
-
assert.match(slots, /
|
|
109
|
-
assert.doesNotMatch(html,
|
|
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
|
+
});
|