llm-switcher 1.6.5 → 1.6.7
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 +12 -0
- package/compaction.mjs +72 -0
- package/docs/goal-check.md +75 -0
- package/formats.mjs +107 -35
- package/goalcheck-call.mjs +208 -0
- package/goalcheck.mjs +498 -0
- package/maintenance/gateway-upstream-watch/REPOSITORIES.md +64 -0
- package/maintenance/gateway-upstream-watch/SKILL.md +18 -0
- package/maintenance/gateway-upstream-watch/prompt.vi.md +11 -0
- package/maintenance/gateway-upstream-watch/references/repos.json +570 -0
- package/maintenance/gateway-upstream-watch/watch.py +222 -0
- package/package.json +1 -1
- package/preflight-check.mjs +188 -0
- package/proxy.mjs +393 -53
- package/shim.mjs +13 -2
- package/state.mjs +5 -0
- package/tests/codex-daemon.test.mjs +1 -0
- package/tests/compaction.e2e.test.mjs +79 -0
- package/tests/gateway-contract.test.mjs +59 -0
- package/tests/gateway.e2e.test.mjs +47 -0
- package/tests/goalcheck.e2e.test.mjs +316 -0
- package/tests/goalcheck.test.mjs +312 -0
- package/tests/preflight.test.mjs +119 -0
- package/tests/tool-repair.test.mjs +119 -2
- package/tests/update.test.mjs +2 -2
- package/tests/upstream-stream.test.mjs +30 -0
- package/toolvocab.mjs +59 -11
- package/ui.html +676 -1
- package/upstream-stream.mjs +37 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
GOAL_CHECK_DEFAULTS,
|
|
6
|
+
goalCheckPolicy,
|
|
7
|
+
validateGoalCheckPatch,
|
|
8
|
+
redactGoalCheck,
|
|
9
|
+
isGoalEvaluatorRequest,
|
|
10
|
+
extractStoppingCondition,
|
|
11
|
+
buildBoundedEvidence,
|
|
12
|
+
buildJevQuestions,
|
|
13
|
+
combineVerdicts,
|
|
14
|
+
parseEvaluatorVerdict,
|
|
15
|
+
redactSecrets
|
|
16
|
+
} from '../goalcheck.mjs';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
resolveJevTarget,
|
|
20
|
+
extractScoreFromAnswer
|
|
21
|
+
} from '../goalcheck-call.mjs';
|
|
22
|
+
|
|
23
|
+
test('goalcheck: policy defaults and bounded values', () => {
|
|
24
|
+
const p = goalCheckPolicy({});
|
|
25
|
+
assert.equal(p.enabled, false);
|
|
26
|
+
assert.equal(p.backend, 'jev');
|
|
27
|
+
assert.equal(p.model, 'typesafe/jev-latest');
|
|
28
|
+
assert.equal(p.completeMin, 0.90);
|
|
29
|
+
assert.equal(p.evidenceMin, 0.85);
|
|
30
|
+
assert.equal(p.unfinishedMax, 0.10);
|
|
31
|
+
assert.equal(p.timeoutMs, 8000);
|
|
32
|
+
assert.equal(p.totalTimeoutMs, 25000);
|
|
33
|
+
assert.equal(p.maxStateChars, 60000);
|
|
34
|
+
|
|
35
|
+
// Clamping
|
|
36
|
+
const clamped = goalCheckPolicy({
|
|
37
|
+
goalCheck: {
|
|
38
|
+
completeMin: 1.5,
|
|
39
|
+
evidenceMin: -0.2,
|
|
40
|
+
unfinishedMax: 2.0,
|
|
41
|
+
timeoutMs: 100, // min 500
|
|
42
|
+
totalTimeoutMs: 200000, // max 120000
|
|
43
|
+
maxStateChars: 100 // min 500
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
assert.equal(clamped.completeMin, 1.0);
|
|
47
|
+
assert.equal(clamped.evidenceMin, 0.0);
|
|
48
|
+
assert.equal(clamped.unfinishedMax, 1.0);
|
|
49
|
+
assert.equal(clamped.timeoutMs, 500);
|
|
50
|
+
assert.equal(clamped.totalTimeoutMs, 120000);
|
|
51
|
+
assert.equal(clamped.maxStateChars, 500);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('goalcheck: patch validation', () => {
|
|
55
|
+
assert.match(validateGoalCheckPatch(null), /must be an object/);
|
|
56
|
+
assert.match(validateGoalCheckPatch({ unknownField: true }), /unknown goal check field/);
|
|
57
|
+
assert.match(validateGoalCheckPatch({ enabled: 'yes' }), /enabled must be a boolean/);
|
|
58
|
+
assert.match(validateGoalCheckPatch({ completeMin: 1.2 }), /completeMin must be a number from 0 to 1/);
|
|
59
|
+
assert.match(validateGoalCheckPatch({ completeMin: -0.1 }), /completeMin must be a number from 0 to 1/);
|
|
60
|
+
assert.match(validateGoalCheckPatch({ timeoutMs: 100 }), /timeoutMs must be an integer from 500 to 60000/);
|
|
61
|
+
assert.match(validateGoalCheckPatch({ baseURL: 'ftp://bad' }), /baseURL must start with http/);
|
|
62
|
+
|
|
63
|
+
// While disabled, empty model or url is allowed
|
|
64
|
+
assert.equal(validateGoalCheckPatch({ enabled: false, model: '' }), '');
|
|
65
|
+
|
|
66
|
+
// Enabling without model is rejected
|
|
67
|
+
assert.match(validateGoalCheckPatch({ enabled: true, model: '' }), /model cannot be empty/);
|
|
68
|
+
|
|
69
|
+
// Valid patch
|
|
70
|
+
assert.equal(validateGoalCheckPatch({
|
|
71
|
+
enabled: true,
|
|
72
|
+
model: 'typesafe/jev-latest',
|
|
73
|
+
baseURL: 'https://api.example.com/v1',
|
|
74
|
+
completeMin: 0.88
|
|
75
|
+
}), '');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('goalcheck: redaction', () => {
|
|
79
|
+
const policy = { enabled: true, apiKey: 'secret-key-123', model: 'typesafe/jev-latest' };
|
|
80
|
+
const redacted = redactGoalCheck(policy);
|
|
81
|
+
assert.equal(redacted.apiKey, '__LLM_SWITCHER_KEEP_KEY__');
|
|
82
|
+
assert.equal(redacted.hasApiKey, true);
|
|
83
|
+
|
|
84
|
+
const noKey = redactGoalCheck({ enabled: false, apiKey: '' });
|
|
85
|
+
assert.equal(noKey.apiKey, '');
|
|
86
|
+
assert.equal(noKey.hasApiKey, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('goalcheck: evaluator request detection', () => {
|
|
90
|
+
const validEvaluatorPayload = {
|
|
91
|
+
system: 'You are evaluating a stop-condition hook in Claude Code. Read the conversation transcript carefully...',
|
|
92
|
+
messages: [
|
|
93
|
+
{ role: 'user', content: 'Fix the bug in auth.js' },
|
|
94
|
+
{ role: 'assistant', content: 'Fixed in commit 123' },
|
|
95
|
+
{
|
|
96
|
+
role: 'user',
|
|
97
|
+
content: 'Based on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.\n\nCondition: Fix the bug in auth.js'
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
tools: []
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const res = isGoalEvaluatorRequest('anthropic', validEvaluatorPayload, { 'user-agent': 'claude-cli/2.1.295' });
|
|
104
|
+
assert.equal(res.isEvaluator, true);
|
|
105
|
+
assert.equal(res.isSupported, true);
|
|
106
|
+
assert.equal(res.condition, 'Fix the bug in auth.js');
|
|
107
|
+
|
|
108
|
+
// Normal request without stop-hook system prompt
|
|
109
|
+
const normalPayload = {
|
|
110
|
+
system: 'You are Claude, a helpful AI assistant.',
|
|
111
|
+
messages: [{ role: 'user', content: 'What is the goal of AI?' }]
|
|
112
|
+
};
|
|
113
|
+
assert.deepEqual(isGoalEvaluatorRequest('anthropic', normalPayload), { isEvaluator: false, isSupported: false });
|
|
114
|
+
|
|
115
|
+
// Non-anthropic client
|
|
116
|
+
assert.deepEqual(isGoalEvaluatorRequest('responses', validEvaluatorPayload), { isEvaluator: false, isSupported: false });
|
|
117
|
+
|
|
118
|
+
// Evaluator template but with execution tools -> recognized but unsupported shape
|
|
119
|
+
const toolPayload = { ...validEvaluatorPayload, tools: [{ name: 'bash', description: 'run' }] };
|
|
120
|
+
const toolRes = isGoalEvaluatorRequest('anthropic', toolPayload);
|
|
121
|
+
assert.equal(toolRes.isEvaluator, true);
|
|
122
|
+
assert.equal(toolRes.isSupported, false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('goalcheck: extract stopping condition', () => {
|
|
126
|
+
const payload = {
|
|
127
|
+
messages: [
|
|
128
|
+
{ role: 'user', content: 'Earlier chat' },
|
|
129
|
+
{ role: 'user', content: 'Based on transcript...\n\nCondition: Ensure all unit tests pass with 0 failures.' }
|
|
130
|
+
]
|
|
131
|
+
};
|
|
132
|
+
assert.equal(extractStoppingCondition(payload), 'Ensure all unit tests pass with 0 failures.');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('goalcheck: bounded evidence building and secret redaction', () => {
|
|
136
|
+
const payload = {
|
|
137
|
+
messages: [
|
|
138
|
+
{ role: 'user', content: 'Please deploy using key sk-1234567890abcdef1234' },
|
|
139
|
+
{
|
|
140
|
+
role: 'assistant',
|
|
141
|
+
content: [
|
|
142
|
+
{ type: 'text', text: 'Running test suite' },
|
|
143
|
+
{ type: 'tool_use', id: 'call_1', name: 'bash', input: { command: 'npm test' } }
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
role: 'user',
|
|
148
|
+
content: [
|
|
149
|
+
{ type: 'tool_result', tool_use_id: 'call_1', content: 'Pass: 167 tests', is_error: false }
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
role: 'user',
|
|
154
|
+
content: 'Based on transcript...\n\nCondition: Tests must pass'
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const ev = buildBoundedEvidence(payload, 5000);
|
|
160
|
+
assert.equal(ev.stateTooLarge, false);
|
|
161
|
+
assert.ok(ev.state.includes('STOPPING CONDITION:\n\nTests must pass'));
|
|
162
|
+
assert.ok(ev.state.includes('[Tool Call call_1]: bash({"command":"npm test"})'));
|
|
163
|
+
assert.ok(ev.state.includes('[Tool Result call_1]: is_error=false\nPass: 167 tests'));
|
|
164
|
+
// Secret should be redacted
|
|
165
|
+
assert.ok(!ev.state.includes('sk-1234567890abcdef1234'));
|
|
166
|
+
assert.ok(ev.state.includes('[REDACTED_SECRET]'));
|
|
167
|
+
|
|
168
|
+
// Test size overflow
|
|
169
|
+
const evSmall = buildBoundedEvidence(payload, 100);
|
|
170
|
+
assert.equal(evSmall.stateTooLarge, true);
|
|
171
|
+
assert.ok(evSmall.chars > 100);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('goalcheck: parseEvaluatorVerdict', () => {
|
|
175
|
+
assert.deepEqual(parseEvaluatorVerdict('{"ok": true, "reason": "all fixed"}'), {
|
|
176
|
+
ok: true,
|
|
177
|
+
reason: 'all fixed',
|
|
178
|
+
impossible: false
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(parseEvaluatorVerdict('```json\n{"ok": false, "reason": "test failed", "impossible": true}\n```'), {
|
|
182
|
+
ok: false,
|
|
183
|
+
reason: 'test failed',
|
|
184
|
+
impossible: true
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(parseEvaluatorVerdict('malformed non-json text'), {
|
|
188
|
+
malformed: true,
|
|
189
|
+
raw: 'malformed non-json text'
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('goalcheck: combineVerdicts decision matrix', () => {
|
|
194
|
+
const thresholds = { completeMin: 0.90, evidenceMin: 0.85, unfinishedMax: 0.10 };
|
|
195
|
+
|
|
196
|
+
// 1. Native rejected
|
|
197
|
+
const nativeFalse = combineVerdicts({
|
|
198
|
+
nativeVerdict: { ok: false, reason: 'missing file' },
|
|
199
|
+
jevResult: { ok: true, scores: { complete: 0.99, evidence: 0.99, unfinished: 0.01 } },
|
|
200
|
+
thresholds
|
|
201
|
+
});
|
|
202
|
+
assert.equal(nativeFalse.ok, false);
|
|
203
|
+
assert.equal(nativeFalse.reason, 'missing file');
|
|
204
|
+
assert.equal(nativeFalse.verdictType, 'Blocked');
|
|
205
|
+
|
|
206
|
+
// 2. Native impossible
|
|
207
|
+
const nativeImp = combineVerdicts({
|
|
208
|
+
nativeVerdict: { ok: false, impossible: true, reason: 'unsolvable' },
|
|
209
|
+
jevResult: null,
|
|
210
|
+
thresholds
|
|
211
|
+
});
|
|
212
|
+
assert.equal(nativeImp.ok, false);
|
|
213
|
+
assert.equal(nativeImp.impossible, true);
|
|
214
|
+
assert.equal(nativeImp.verdictType, 'Blocked');
|
|
215
|
+
|
|
216
|
+
// 3. Native unavailable / malformed
|
|
217
|
+
const nativeBad = combineVerdicts({
|
|
218
|
+
nativeVerdict: { malformed: true },
|
|
219
|
+
jevResult: { ok: true },
|
|
220
|
+
thresholds
|
|
221
|
+
});
|
|
222
|
+
assert.equal(nativeBad.ok, false);
|
|
223
|
+
assert.equal(nativeBad.verdictType, 'Unavailable');
|
|
224
|
+
|
|
225
|
+
// 4. Native ok: true AND Jev passes all 3 thresholds
|
|
226
|
+
const bothPass = combineVerdicts({
|
|
227
|
+
nativeVerdict: { ok: true, reason: 'tests passed' },
|
|
228
|
+
jevResult: { ok: true, scores: { complete: 0.95, evidence: 0.92, unfinished: 0.04 } },
|
|
229
|
+
thresholds
|
|
230
|
+
});
|
|
231
|
+
assert.equal(bothPass.ok, true);
|
|
232
|
+
assert.equal(bothPass.verdictType, 'Allowed');
|
|
233
|
+
assert.equal(bothPass.reason, 'tests passed');
|
|
234
|
+
|
|
235
|
+
// 5. Native ok: true BUT Jev complete fails
|
|
236
|
+
const completeFail = combineVerdicts({
|
|
237
|
+
nativeVerdict: { ok: true, reason: 'audit pass claimed' },
|
|
238
|
+
jevResult: { ok: true, scores: { complete: 0.14, evidence: 0.88, unfinished: 0.63 } },
|
|
239
|
+
thresholds
|
|
240
|
+
});
|
|
241
|
+
assert.equal(completeFail.ok, false);
|
|
242
|
+
assert.equal(completeFail.verdictType, 'Blocked');
|
|
243
|
+
assert.match(completeFail.reason, /complete=0.14 < min 0.90/);
|
|
244
|
+
assert.match(completeFail.reason, /unfinished=0.63 > max 0.10/);
|
|
245
|
+
|
|
246
|
+
// 6. Native ok: true BUT Jev state too large
|
|
247
|
+
const stateLarge = combineVerdicts({
|
|
248
|
+
nativeVerdict: { ok: true, reason: 'done' },
|
|
249
|
+
jevResult: { stateTooLarge: true, chars: 70000, maxChars: 60000 },
|
|
250
|
+
thresholds
|
|
251
|
+
});
|
|
252
|
+
assert.equal(stateLarge.ok, false);
|
|
253
|
+
assert.equal(stateLarge.verdictType, 'Insufficient evidence');
|
|
254
|
+
|
|
255
|
+
// 7. Native ok: true BUT Jev HTTP/network error
|
|
256
|
+
const jevErr = combineVerdicts({
|
|
257
|
+
nativeVerdict: { ok: true, reason: 'done' },
|
|
258
|
+
jevResult: { ok: false, reason: 'http-502', message: 'HTTP 502 Bad Gateway' },
|
|
259
|
+
thresholds
|
|
260
|
+
});
|
|
261
|
+
assert.equal(jevErr.ok, false);
|
|
262
|
+
assert.equal(jevErr.verdictType, 'Unavailable');
|
|
263
|
+
|
|
264
|
+
// 8. Native ok: true BUT Jev score NaN or missing
|
|
265
|
+
const jevNaN = combineVerdicts({
|
|
266
|
+
nativeVerdict: { ok: true, reason: 'done' },
|
|
267
|
+
jevResult: { ok: true, scores: { complete: Number.NaN, evidence: 0.9, unfinished: 0.05 } },
|
|
268
|
+
thresholds
|
|
269
|
+
});
|
|
270
|
+
assert.equal(jevNaN.ok, false);
|
|
271
|
+
assert.equal(jevNaN.verdictType, 'Unavailable');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('goalcheck-call: target resolution and score extraction', () => {
|
|
275
|
+
const profiles = {
|
|
276
|
+
'intact-claude': {
|
|
277
|
+
baseURL: 'https://intact.example.com/v1',
|
|
278
|
+
apiKey: 'intact-key-xyz'
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// From sourceProfile
|
|
283
|
+
const t1 = resolveJevTarget({ sourceProfile: 'intact-claude', model: 'typesafe/jev-latest' }, profiles);
|
|
284
|
+
assert.equal(t1.url, 'https://intact.example.com/v1/systemone');
|
|
285
|
+
assert.equal(t1.apiKey, 'intact-key-xyz');
|
|
286
|
+
assert.equal(t1.model, 'typesafe/jev-latest');
|
|
287
|
+
|
|
288
|
+
// Override baseURL and key
|
|
289
|
+
const t2 = resolveJevTarget({
|
|
290
|
+
baseURL: 'https://custom.provider.com/api',
|
|
291
|
+
apiKey: 'custom-key',
|
|
292
|
+
model: 'typesafe/jev-custom'
|
|
293
|
+
}, profiles);
|
|
294
|
+
assert.equal(t2.url, 'https://custom.provider.com/api/systemone');
|
|
295
|
+
assert.equal(t2.apiKey, 'custom-key');
|
|
296
|
+
assert.equal(t2.model, 'typesafe/jev-custom');
|
|
297
|
+
|
|
298
|
+
// Full decisionEndpoint override
|
|
299
|
+
const t3 = resolveJevTarget({
|
|
300
|
+
decisionEndpoint: 'https://alpha.decision.org/judge',
|
|
301
|
+
apiKey: 'k'
|
|
302
|
+
});
|
|
303
|
+
assert.equal(t3.url, 'https://alpha.decision.org/judge');
|
|
304
|
+
|
|
305
|
+
// Score extraction
|
|
306
|
+
assert.equal(extractScoreFromAnswer({ noul: 0.85 }), 0.85);
|
|
307
|
+
assert.equal(extractScoreFromAnswer({ score: 0.72 }), 0.72);
|
|
308
|
+
assert.equal(extractScoreFromAnswer({ probabilities: { yes: 0.93 } }), 0.93);
|
|
309
|
+
assert.equal(extractScoreFromAnswer({ choice: 'yes', confidence: 0.89 }), 0.89);
|
|
310
|
+
assert.equal(extractScoreFromAnswer({ choice: 'no', confidence: 0.80 }), 0.20);
|
|
311
|
+
assert.ok(Number.isNaN(extractScoreFromAnswer({ invalid: true })));
|
|
312
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
11
|
+
const preflightScript = path.join(root, 'preflight-check.mjs');
|
|
12
|
+
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
13
|
+
|
|
14
|
+
function runPreflight(tool, dir) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const cp = spawn(process.execPath, [preflightScript, tool, dir], {
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
18
|
+
});
|
|
19
|
+
let stdout = '';
|
|
20
|
+
let stderr = '';
|
|
21
|
+
cp.stdout.on('data', b => stdout += b);
|
|
22
|
+
cp.stderr.on('data', b => stderr += b);
|
|
23
|
+
cp.on('close', status => resolve({ status, stdout, stderr }));
|
|
24
|
+
cp.on('error', reject);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('preflight-check: verifies available models, warns on dropped models and invalid keys', async t => {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-preflight-'));
|
|
30
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
31
|
+
|
|
32
|
+
let mockStatus = 200;
|
|
33
|
+
let mockModels = [
|
|
34
|
+
{ id: 'zencore/space-bunny-free' },
|
|
35
|
+
{ id: 'zencore/muse-spark-1.3-contributor-free' },
|
|
36
|
+
{ id: 'codex/gpt-6-astra' }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const mockUpstream = http.createServer((req, res) => {
|
|
40
|
+
if (mockStatus !== 200) {
|
|
41
|
+
res.statusCode = mockStatus;
|
|
42
|
+
return res.end('{"error":{"message":"Invalid API key"}}');
|
|
43
|
+
}
|
|
44
|
+
res.setHeader('content-type', 'application/json');
|
|
45
|
+
res.end(JSON.stringify({ data: mockModels }));
|
|
46
|
+
});
|
|
47
|
+
await listen(mockUpstream);
|
|
48
|
+
t.after(() => mockUpstream.close());
|
|
49
|
+
|
|
50
|
+
const port = mockUpstream.address().port;
|
|
51
|
+
const config = {
|
|
52
|
+
activeProfiles: { claude: 'mock-prof', codex: 'mock-prof' },
|
|
53
|
+
profiles: {
|
|
54
|
+
'mock-prof': {
|
|
55
|
+
name: 'Mock Provider',
|
|
56
|
+
baseURL: `http://127.0.0.1:${port}/v1`,
|
|
57
|
+
apiKey: 'test-key',
|
|
58
|
+
defaultModels: {
|
|
59
|
+
sonnet: 'zencore/space-bunny-free',
|
|
60
|
+
opus: 'zencore/space-bunny-free',
|
|
61
|
+
haiku: 'zencore/muse-spark-1.3-contributor-free'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config, null, 2));
|
|
68
|
+
fs.writeFileSync(path.join(dir, 'route-claude.txt'), 'claude -> mock-prof | 127.0.0.1 | zencore/space-bunny-free\n');
|
|
69
|
+
|
|
70
|
+
// Case 1: All models available -> verified ✓
|
|
71
|
+
const r1 = await runPreflight('claude', dir);
|
|
72
|
+
assert.equal(r1.status, 0);
|
|
73
|
+
assert.match(r1.stdout, /verified ✓/);
|
|
74
|
+
assert.doesNotMatch(r1.stdout, /CẢNH BÁO/);
|
|
75
|
+
|
|
76
|
+
// Case 2: Consecutive call hits cache
|
|
77
|
+
const r2 = await runPreflight('claude', dir);
|
|
78
|
+
assert.equal(r2.status, 0);
|
|
79
|
+
assert.match(r2.stdout, /verified ✓/);
|
|
80
|
+
|
|
81
|
+
// Clear cache for next test cases
|
|
82
|
+
fs.rmSync(path.join(dir, 'preflight-cache.json'), { force: true });
|
|
83
|
+
|
|
84
|
+
// Case 3: One model was dropped/deprecated (e.g. haiku set to dropped-model)
|
|
85
|
+
config.profiles['mock-prof'].defaultModels.haiku = 'zencore/old-dropped-model';
|
|
86
|
+
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config, null, 2));
|
|
87
|
+
|
|
88
|
+
const r3 = await runPreflight('claude', dir);
|
|
89
|
+
assert.equal(r3.status, 0);
|
|
90
|
+
assert.match(r3.stdout, /CẢNH BÁO/);
|
|
91
|
+
assert.match(r3.stdout, /old-dropped-model/);
|
|
92
|
+
assert.match(r3.stdout, /slot haiku/);
|
|
93
|
+
assert.match(r3.stdout, /switch ui/);
|
|
94
|
+
|
|
95
|
+
fs.rmSync(path.join(dir, 'preflight-cache.json'), { force: true });
|
|
96
|
+
|
|
97
|
+
// Case 4: API key rejected (401)
|
|
98
|
+
mockStatus = 401;
|
|
99
|
+
const r4 = await runPreflight('claude', dir);
|
|
100
|
+
assert.equal(r4.status, 0);
|
|
101
|
+
assert.match(r4.stdout, /API key cho profile 'mock-prof' không hợp lệ/);
|
|
102
|
+
assert.match(r4.stdout, /HTTP 401/);
|
|
103
|
+
|
|
104
|
+
fs.rmSync(path.join(dir, 'preflight-cache.json'), { force: true });
|
|
105
|
+
|
|
106
|
+
// Case 5: Endpoint unreachable (bad port)
|
|
107
|
+
config.profiles['mock-prof'].baseURL = 'http://127.0.0.1:1';
|
|
108
|
+
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config, null, 2));
|
|
109
|
+
|
|
110
|
+
const r5 = await runPreflight('claude', dir);
|
|
111
|
+
assert.equal(r5.status, 0);
|
|
112
|
+
assert.match(r5.stdout, /Không thể kết nối tới endpoint/);
|
|
113
|
+
|
|
114
|
+
// Case 6: Tool is off (no route file) -> exits quietly
|
|
115
|
+
fs.rmSync(path.join(dir, 'route-claude.txt'), { force: true });
|
|
116
|
+
const r6 = await runPreflight('claude', dir);
|
|
117
|
+
assert.equal(r6.status, 0);
|
|
118
|
+
assert.equal(r6.stdout.trim(), '');
|
|
119
|
+
});
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// declared. Sources for every name here are collected in docs/tool-vocabulary.md.
|
|
3
3
|
import test from 'node:test';
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
|
-
import { createToolVocab, NULL_TOOL_VOCAB, tokenize, sanitizeToolName } from '../toolvocab.mjs';
|
|
5
|
+
import { createToolVocab, NULL_TOOL_VOCAB, tokenize, sanitizeToolName, toolNameMapForIR } from '../toolvocab.mjs';
|
|
6
6
|
import {
|
|
7
|
-
anthropicToIR, responsesToIR, irToChatBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
|
|
7
|
+
anthropicToIR, responsesToIR, irToChatBody, irToAnthropicBody, irToVertexBody, buildAnthropicMessage, buildChatMessage, buildResponsesMessage,
|
|
8
8
|
createAnthropicStream, createChatStream, createResponsesStream
|
|
9
9
|
} from '../formats.mjs';
|
|
10
10
|
|
|
@@ -17,6 +17,123 @@ const claudeTools = () => anthropicToIR({
|
|
|
17
17
|
]
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const consoleTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
|
|
21
|
+
const networkTool = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_network_requests';
|
|
22
|
+
|
|
23
|
+
function toolSearchHistory(extraTools = []) {
|
|
24
|
+
return anthropicToIR({
|
|
25
|
+
model: 'claude-opus-5-5', system: `Inspect errors with ${consoleTool} and ${networkTool}.`,
|
|
26
|
+
messages: [
|
|
27
|
+
{ role: 'user', content: 'Inspect console errors.' },
|
|
28
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'search1', name: 'ToolSearch', input: { query: `select:${consoleTool},${networkTool}`, max_results: 5 } }] },
|
|
29
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'search1', content: [
|
|
30
|
+
{ type: 'tool_reference', tool_name: consoleTool }, { type: 'tool_reference', tool_name: networkTool }
|
|
31
|
+
] }] }
|
|
32
|
+
],
|
|
33
|
+
tools: [
|
|
34
|
+
{ name: 'ToolSearch', description: `Find tools such as ${consoleTool}.`, input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'number' } } } },
|
|
35
|
+
...[consoleTool, networkTool, ...extraTools].map(name => ({ name, input_schema: { type: 'object', properties: { pageSize: { type: 'integer' } } } }))
|
|
36
|
+
]
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('ToolSearch references, query history and instructions agree with declared aliases across upstream formats', () => {
|
|
41
|
+
const ir = toolSearchHistory();
|
|
42
|
+
const original = JSON.stringify(ir);
|
|
43
|
+
for (const [emit, model, opts] of [
|
|
44
|
+
[irToChatBody, 'zencore/space-bunny-free', {}],
|
|
45
|
+
[irToChatBody, 'zencore/space-bunny-free', { thinkingMode: 'off' }],
|
|
46
|
+
[irToChatBody, 'zencore/space-bunny-free', { thinkingMode: 'native' }],
|
|
47
|
+
[irToAnthropicBody, 'claude-opus-5-5', {}],
|
|
48
|
+
[irToVertexBody, 'gemini-3.8-flash', {}]
|
|
49
|
+
]) {
|
|
50
|
+
const body = emit(ir, model, opts);
|
|
51
|
+
const serialized = JSON.stringify(body);
|
|
52
|
+
const names = toolNameMapForIR(ir, { allowHyphens: emit !== irToVertexBody });
|
|
53
|
+
for (const name of [consoleTool, networkTool]) {
|
|
54
|
+
assert.ok(serialized.includes(names.name(name)), 'model sees the declared alias');
|
|
55
|
+
assert.ok(!serialized.includes(name), 'original no longer competes with the alias');
|
|
56
|
+
}
|
|
57
|
+
assert.equal(JSON.stringify(ir), original, 'conversion never mutates caller history');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('ToolSearch instructions use collision-resolved aliases instead of the reserved original name', () => {
|
|
62
|
+
const reserved = sanitizeToolName(consoleTool);
|
|
63
|
+
const ir = toolSearchHistory([reserved]);
|
|
64
|
+
const names = toolNameMapForIR(ir);
|
|
65
|
+
assert.notEqual(names.name(consoleTool), reserved);
|
|
66
|
+
const body = irToChatBody(ir, 'zencore/space-bunny-free');
|
|
67
|
+
assert.equal(body.tools.find(t => t.function.name === reserved).function.name, reserved);
|
|
68
|
+
const references = body.messages.find(m => m.role === 'tool').content.split('\n').map(JSON.parse);
|
|
69
|
+
assert.equal(references[0].tool_name, names.name(consoleTool));
|
|
70
|
+
assert.ok(body.messages[0].content.includes(names.name(consoleTool)));
|
|
71
|
+
const notice = irToChatBody({ ...ir, system: '' }, 'x').messages[0].content;
|
|
72
|
+
assert.ok(notice.includes(names.name(consoleTool)), 'notice uses the collision-resolved declaration too');
|
|
73
|
+
const vocab = createToolVocab(ir.tools);
|
|
74
|
+
const response = buildAnthropicMessage({ model: 'x', tools: [{ name: 'ToolSearch', args: JSON.stringify({ query: `select:${names.name(consoleTool)},${reserved}` }) }], vocab });
|
|
75
|
+
assert.equal(response.content[0].input.query, `select:${consoleTool},${reserved}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('only structured references and ToolSearch queries change; caller data, schemas and unknown tools survive', () => {
|
|
79
|
+
const ir = toolSearchHistory();
|
|
80
|
+
const unknown = 'mcp__not_declared__inspect';
|
|
81
|
+
ir.messages[0].content = `User data mentions ${consoleTool}.`;
|
|
82
|
+
ir.messages[1].toolCalls.push({ id: 'data1', name: consoleTool, args: { pageSize: 0, query: consoleTool } });
|
|
83
|
+
ir.messages.push({ role: 'tool', toolCallId: 'data1', content: `Log contains ${consoleTool}.` });
|
|
84
|
+
ir.messages[2].toolResultParts.push({ type: 'tool_reference', tool_name: unknown });
|
|
85
|
+
ir.tools[1].parameters.properties.query = { type: 'string', const: consoleTool };
|
|
86
|
+
const body = irToChatBody(ir, 'x');
|
|
87
|
+
assert.equal(body.messages.find(m => m.role === 'user').content, ir.messages[0].content);
|
|
88
|
+
assert.equal(body.messages.find(m => m.tool_call_id === 'data1').content, `Log contains ${consoleTool}.`);
|
|
89
|
+
const calls = body.messages.find(m => m.tool_calls).tool_calls;
|
|
90
|
+
assert.deepEqual(JSON.parse(calls[1].function.arguments), { pageSize: 0, query: consoleTool });
|
|
91
|
+
assert.equal(JSON.parse(calls[0].function.arguments).max_results, 5);
|
|
92
|
+
assert.ok(body.messages.find(m => m.tool_call_id === 'search1').content.includes(unknown));
|
|
93
|
+
assert.equal(body.tools[1].function.parameters.properties.query.const, consoleTool);
|
|
94
|
+
const vocab = createToolVocab(ir.tools);
|
|
95
|
+
assert.equal(vocab.name(unknown), unknown);
|
|
96
|
+
const malformed = '{"query":"select:';
|
|
97
|
+
assert.equal(vocab.args('ToolSearch', malformed), malformed);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('fragmented streamed ToolSearch queries restore original names before the client executes them', () => {
|
|
101
|
+
const ir = toolSearchHistory();
|
|
102
|
+
const vocab = createToolVocab(ir.tools);
|
|
103
|
+
const args = JSON.stringify({ query: `select:${sanitizeToolName(consoleTool)},${sanitizeToolName(networkTool)}`, max_results: 5 });
|
|
104
|
+
for (const create of [createAnthropicStream, createChatStream, createResponsesStream]) {
|
|
105
|
+
const events = [];
|
|
106
|
+
const renderer = create((event, data) => events.push({ event, data }), 'x', { vocab });
|
|
107
|
+
renderer.start();
|
|
108
|
+
renderer.tool({ index: 0, id: 'search2', name: 'ToolSearch', args: '' });
|
|
109
|
+
for (let i = 0; i < args.length; i += 7) renderer.tool({ index: 0, args: args.slice(i, i + 7) });
|
|
110
|
+
renderer.finish('tool_calls', { hasTools: true });
|
|
111
|
+
let restored;
|
|
112
|
+
if (create === createAnthropicStream) restored = events.filter(e => e.data?.delta?.type === 'input_json_delta').map(e => e.data.delta.partial_json).join('');
|
|
113
|
+
else if (create === createChatStream) restored = events.flatMap(e => e.data?.choices?.[0]?.delta?.tool_calls || []).map(c => c.function?.arguments || '').join('');
|
|
114
|
+
else restored = events.find(e => e.event === 'response.output_item.done').data.item.arguments;
|
|
115
|
+
assert.deepEqual(JSON.parse(restored), { query: `select:${consoleTool},${networkTool}`, max_results: 5 });
|
|
116
|
+
}
|
|
117
|
+
const argsUnrelated = JSON.stringify({ query: sanitizeToolName(consoleTool) });
|
|
118
|
+
assert.equal(vocab.args(consoleTool, argsUnrelated), argsUnrelated, 'other tools keep their arguments');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('repairing one parallel Chat ToolSearch call never flushes another unfinished query', () => {
|
|
122
|
+
const vocab = createToolVocab(toolSearchHistory().tools);
|
|
123
|
+
const events = [];
|
|
124
|
+
const renderer = createChatStream((event, data) => events.push(data), 'x', { vocab });
|
|
125
|
+
renderer.start();
|
|
126
|
+
renderer.tool({ index: 0, name: 'ToolSearch', args: '{"query":"select:' });
|
|
127
|
+
renderer.tool({ index: 1, name: 'ToolSearch', args: JSON.stringify({ query: `select:${sanitizeToolName(networkTool)}` }) });
|
|
128
|
+
renderer.tool({ index: 0, args: `${sanitizeToolName(consoleTool)}"}` });
|
|
129
|
+
renderer.finish('tool_calls');
|
|
130
|
+
for (const [index, original] of [[0, consoleTool], [1, networkTool]]) {
|
|
131
|
+
const args = events.flatMap(e => e.choices?.[0]?.delta?.tool_calls || [])
|
|
132
|
+
.filter(c => c.index === index).map(c => c.function?.arguments || '').join('');
|
|
133
|
+
assert.equal(JSON.parse(args).query, `select:${original}`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
20
137
|
// A model that answers `read` when the caller declared `Read` ends the turn with
|
|
21
138
|
// "No such tool available: read". The name comes back in the caller's words.
|
|
22
139
|
test('a name the model reached for comes back in the caller own words', () => {
|
package/tests/update.test.mjs
CHANGED
|
@@ -184,7 +184,7 @@ test('a release on npm is installed before the checkout is asked at all', async
|
|
|
184
184
|
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run: recording(calls) });
|
|
185
185
|
|
|
186
186
|
const order = calls.map(c => c[0]);
|
|
187
|
-
assert.deepEqual(order.slice(0, 2), ['npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
|
|
187
|
+
assert.deepEqual(order.slice(0, 2), [process.platform === 'win32' ? 'cmd.exe' : 'npm', 'git'], `npm first, then the checkout: ${order.join(' ')}`);
|
|
188
188
|
assert.deepEqual(calls[0].slice(-3), ['install', '-g', 'llm-switcher@1.0.1']);
|
|
189
189
|
// npm put the release in the global prefix, which is not this directory, so the checkout is what
|
|
190
190
|
// moved -- and that is the version this gateway runs, which is the only reason the pull happened.
|
|
@@ -226,7 +226,7 @@ test('an npm install that fails does not stop a checkout from updating', async (
|
|
|
226
226
|
const logs = [];
|
|
227
227
|
// npm refuses the version a fixture registry names, the way a real registry would.
|
|
228
228
|
const run = async (cmd, args, opts) => {
|
|
229
|
-
if (cmd === 'npm') throw new Error('npm install failed: notarget No matching version found');
|
|
229
|
+
if (cmd === 'npm' || cmd === 'cmd.exe') throw new Error('npm install failed: notarget No matching version found');
|
|
230
230
|
return recording([])(cmd, args, opts);
|
|
231
231
|
};
|
|
232
232
|
const r = await applyUpdate({ root: fx.install, registryUrl: await registry(t, '1.0.1'), run, logger: m => logs.push(m) });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readUpstreamPayloads } from '../upstream-stream.mjs';
|
|
4
|
+
|
|
5
|
+
async function collect(text, widths = [1, 3, 7]) {
|
|
6
|
+
const bytes = Buffer.from(text); let offset = 0, i = 0;
|
|
7
|
+
const body = new ReadableStream({ pull(controller) {
|
|
8
|
+
if (offset === bytes.length) return controller.close();
|
|
9
|
+
const end = Math.min(bytes.length, offset + widths[i++ % widths.length]);
|
|
10
|
+
controller.enqueue(bytes.subarray(offset, end)); offset = end;
|
|
11
|
+
} });
|
|
12
|
+
const values = [];
|
|
13
|
+
for await (const value of readUpstreamPayloads({ body })) values.push(value);
|
|
14
|
+
return values;
|
|
15
|
+
}
|
|
16
|
+
test('multiline SSE joins data fields across CRLF, byte chunks and UTF-8', async () => {
|
|
17
|
+
const text = ': ping\r\nevent: message\r\nid: 1\r\nretry: 1000\r\ndata: {"choices":\r\ndata: [{"delta":{"content":"giữ ngữ cảnh"}}]}\r\n\r\ndata: [DONE]\r\n\r\n';
|
|
18
|
+
assert.deepEqual(await collect(text), [{ choices: [{ delta: { content: 'giữ ngữ cảnh' } }] }]);
|
|
19
|
+
});
|
|
20
|
+
test('Vertex JSON lines and final SSE event without delimiter both survive', async () => {
|
|
21
|
+
assert.deepEqual(await collect('{"one":1}\n{"two":2}'), [{ one: 1 }, { two: 2 }]);
|
|
22
|
+
assert.deepEqual(await collect('data: {"one":1}'), [{ one: 1 }]);
|
|
23
|
+
});
|
|
24
|
+
test('malformed JSON is surfaced and stopping consumption cancels the upstream', async () => {
|
|
25
|
+
await assert.rejects(collect('data: {"partial":\n\n'), SyntaxError);
|
|
26
|
+
let canceled = false;
|
|
27
|
+
const body = new ReadableStream({ start(c) { c.enqueue(Buffer.from('data: {"one":1}\n\n')); }, cancel() { canceled = true; } });
|
|
28
|
+
for await (const item of readUpstreamPayloads({ body })) { assert.equal(item.one, 1); break; }
|
|
29
|
+
assert.equal(canceled, true);
|
|
30
|
+
});
|