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.
- package/CHANGELOG.md +27 -0
- package/README.md +27 -20
- package/README.vi.md +26 -16
- 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/update.test.mjs +92 -9
- package/tests/windows.test.mjs +124 -0
- package/toolvocab.mjs +272 -0
- package/ui.html +32 -76
- package/update.mjs +29 -8
- 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
|
});
|
package/tests/update.test.mjs
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
// The update itself:
|
|
2
|
-
// Every remote here is a local bare
|
|
1
|
+
// The update itself: npm is asked first and installs the version the registry names; a git
|
|
2
|
+
// checkout is the fallback, and pulls fast-forward only. Every remote here is a local bare
|
|
3
|
+
// repository or a local HTTP server.
|
|
3
4
|
import { test } from 'node:test';
|
|
4
5
|
import assert from 'node:assert/strict';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import http from 'node:http';
|
|
7
8
|
import os from 'node:os';
|
|
8
9
|
import path from 'node:path';
|
|
9
|
-
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
10
12
|
import { applyUpdate } from '../update.mjs';
|
|
11
13
|
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
// Records every command in the order it is asked. git runs for real, because the checkout has to
|
|
17
|
+
// move before the fallback can be observed. npm's install is recorded and not run: the version a
|
|
18
|
+
// fixture registry names is a number from the test, not one the real registry serves, and a real
|
|
19
|
+
// install would put a package on the machine running the suite.
|
|
20
|
+
const recording = (calls) => async (cmd, args, opts) => {
|
|
21
|
+
calls.push([cmd, ...args]);
|
|
22
|
+
if (cmd === 'npm' || cmd === 'cmd.exe') return '';
|
|
23
|
+
const { stdout } = await execFileAsync(cmd, args, { encoding: 'utf8', ...opts });
|
|
24
|
+
return stdout;
|
|
25
|
+
};
|
|
26
|
+
|
|
12
27
|
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
13
28
|
const ID = ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false'];
|
|
14
29
|
|
|
@@ -50,14 +65,14 @@ function fixture(t) {
|
|
|
50
65
|
test('a checkout behind its upstream pulls and reports the new version', async (t) => {
|
|
51
66
|
const fx = fixture(t);
|
|
52
67
|
fx.release('1.0.1');
|
|
53
|
-
const r = await applyUpdate({ root: fx.install });
|
|
68
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
54
69
|
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
55
70
|
assert.equal(JSON.parse(fs.readFileSync(path.join(fx.install, 'package.json'), 'utf8')).version, '1.0.1');
|
|
56
71
|
});
|
|
57
72
|
|
|
58
73
|
test('a checkout level with its upstream does nothing', async (t) => {
|
|
59
74
|
const fx = fixture(t);
|
|
60
|
-
const r = await applyUpdate({ root: fx.install });
|
|
75
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
61
76
|
assert.equal(r.updated, false);
|
|
62
77
|
assert.match(r.reason, /latest/i);
|
|
63
78
|
});
|
|
@@ -66,7 +81,7 @@ test('a gateway that runs older code than its checkout restarts into it, with no
|
|
|
66
81
|
const fx = fixture(t);
|
|
67
82
|
fx.release('1.0.1');
|
|
68
83
|
git(fx.install, 'pull', '-q', '--ff-only');
|
|
69
|
-
const r = await applyUpdate({ root: fx.install, running: '1.0.0' });
|
|
84
|
+
const r = await applyUpdate({ root: fx.install, running: '1.0.0', registryUrl: await registry(t, '1.0.1') });
|
|
70
85
|
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
71
86
|
});
|
|
72
87
|
|
|
@@ -75,7 +90,7 @@ test('a checkout with local edits is left alone, even when upstream moved', asyn
|
|
|
75
90
|
fx.release('1.0.1');
|
|
76
91
|
const before = fx.head();
|
|
77
92
|
fs.appendFileSync(path.join(fx.install, 'package.json'), ' ');
|
|
78
|
-
const r = await applyUpdate({ root: fx.install });
|
|
93
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
79
94
|
assert.equal(r.updated, false);
|
|
80
95
|
assert.match(r.reason, /local changes/i);
|
|
81
96
|
assert.equal(fx.head(), before);
|
|
@@ -85,7 +100,7 @@ test('a checkout that is only ahead of upstream does nothing', async (t) => {
|
|
|
85
100
|
const fx = fixture(t);
|
|
86
101
|
fs.writeFileSync(path.join(fx.install, 'local.txt'), 'x');
|
|
87
102
|
commit(fx.install, 'local work');
|
|
88
|
-
const r = await applyUpdate({ root: fx.install });
|
|
103
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') });
|
|
89
104
|
assert.equal(r.updated, false);
|
|
90
105
|
});
|
|
91
106
|
|
|
@@ -95,7 +110,7 @@ test('a diverged checkout fails and is never merged', async (t) => {
|
|
|
95
110
|
commit(fx.install, 'local work');
|
|
96
111
|
fx.release('1.0.1');
|
|
97
112
|
const before = fx.head();
|
|
98
|
-
await assert.rejects(applyUpdate({ root: fx.install }), /fast-forward|diverged/i);
|
|
113
|
+
await assert.rejects(applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0') }), /fast-forward|diverged/i);
|
|
99
114
|
assert.equal(fx.head(), before);
|
|
100
115
|
});
|
|
101
116
|
|
|
@@ -159,3 +174,71 @@ test('an unreachable registry is not an update', async (t) => {
|
|
|
159
174
|
assert.equal(r.updated, false);
|
|
160
175
|
assert.match(r.reason, /registry/i);
|
|
161
176
|
});
|
|
177
|
+
|
|
178
|
+
// ---- npm first, and the checkout only when npm cannot serve it ----
|
|
179
|
+
|
|
180
|
+
test('a release on npm is installed before the checkout is asked at all', async (t) => {
|
|
181
|
+
const fx = fixture(t);
|
|
182
|
+
fx.release('1.0.1');
|
|
183
|
+
const calls = [];
|
|
184
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
|
|
185
|
+
|
|
186
|
+
const order = calls.map(c => c[0]);
|
|
187
|
+
assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
|
|
188
|
+
assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
|
|
189
|
+
// npm put the release in the global prefix, which is not this directory, so the checkout is what
|
|
190
|
+
// moved -- and that is the version this gateway runs, which is the only reason the pull happened.
|
|
191
|
+
assert.match(r.reason, /Pulled 1 commit/);
|
|
192
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a checkout level with its upstream runs no git pull when npm had nothing newer', async (t) => {
|
|
196
|
+
const fx = fixture(t);
|
|
197
|
+
const calls = [];
|
|
198
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.0'), run: recording(calls) });
|
|
199
|
+
|
|
200
|
+
assert.equal(calls.some(c => c[0] === 'npm' && c.includes('install')), false, 'no install when npm is not newer');
|
|
201
|
+
assert.equal(calls.some(c => c.includes('pull')), false, 'nothing to pull');
|
|
202
|
+
assert.match(r.reason, /latest/i);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('npm holding a release the checkout cannot take is said plainly, not hidden by "latest"', async (t) => {
|
|
206
|
+
const fx = fixture(t);
|
|
207
|
+
// The checkout is level, and the registry is a version ahead of it: the two answers disagree.
|
|
208
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.4.0'), run: recording([]) });
|
|
209
|
+
|
|
210
|
+
assert.equal(r.updated, false);
|
|
211
|
+
assert.match(r.reason, /npm installed v1\.4\.0, but not into/);
|
|
212
|
+
assert.match(r.reason, /latest/i, 'the checkout answer is kept as well');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a checkout is still updated by git when the registry cannot be reached', async (t) => {
|
|
216
|
+
const fx = fixture(t);
|
|
217
|
+
fx.release('1.0.1');
|
|
218
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: 'http://127.0.0.1:9/' });
|
|
219
|
+
|
|
220
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an npm install that fails does not stop a checkout from updating', async (t) => {
|
|
224
|
+
const fx = fixture(t);
|
|
225
|
+
fx.release('1.0.1');
|
|
226
|
+
const logs = [];
|
|
227
|
+
// npm refuses the version a fixture registry names, the way a real registry would.
|
|
228
|
+
const run = async (cmd, args, opts) => {
|
|
229
|
+
if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
|
|
230
|
+
return recording([])(cmd, args, opts);
|
|
231
|
+
};
|
|
232
|
+
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
|
|
233
|
+
|
|
234
|
+
assert.deepEqual({ updated: r.updated, from: r.from, to: r.to }, { updated: true, from: '1.0.0', to: '1.0.1' });
|
|
235
|
+
assert.match(r.reason, /Pulled 1 commit/);
|
|
236
|
+
// The npm failure is said out loud, not swallowed into a success nobody can explain.
|
|
237
|
+
assert.match(logs.join('\n'), /npm could not install it here/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('an npm install behind the registry still fails loudly when there is no checkout', async (t) => {
|
|
241
|
+
const root = npmInstall(t, '1.0.0');
|
|
242
|
+
const run = async () => { throw new Error('npm install failed: EACCES'); };
|
|
243
|
+
await assert.rejects(applyUpdate({ root, registryUrl: await registry(t, '1.0.1'), run }), /npm install failed/);
|
|
244
|
+
});
|