llm-switcher 1.6.6 → 1.6.8
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/docs/goal-check.md +75 -0
- package/goalcheck-call.mjs +208 -0
- package/goalcheck.mjs +498 -0
- package/package.json +1 -1
- package/preflight-check.mjs +188 -0
- package/proxy.mjs +341 -2
- package/shim.mjs +13 -2
- package/state.mjs +5 -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/ui.test.mjs +35 -0
- package/ui.html +1629 -31
|
@@ -0,0 +1,316 @@
|
|
|
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 listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
12
|
+
|
|
13
|
+
async function until(check, timeoutMs = 6000) {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
while (Date.now() < deadline) {
|
|
16
|
+
if (await check()) return;
|
|
17
|
+
await new Promise(r => setTimeout(r, 25));
|
|
18
|
+
}
|
|
19
|
+
throw new Error('timeout waiting for condition');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fixture(t) {
|
|
23
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-goalcheck-'));
|
|
24
|
+
const adminToken = 'admin-secret-token-123';
|
|
25
|
+
fs.writeFileSync(path.join(dir, 'admin.token'), adminToken, { mode: 0o600 });
|
|
26
|
+
|
|
27
|
+
let nativeVerdict = { ok: true, reason: 'looks complete' };
|
|
28
|
+
let jevScores = { complete: 0.14, evidence: 0.84, unfinished: 0.63 };
|
|
29
|
+
let jevStatus = 200;
|
|
30
|
+
|
|
31
|
+
// Mock native upstream (Anthropic format)
|
|
32
|
+
const nativeUp = http.createServer((req, res) => {
|
|
33
|
+
let raw = '';
|
|
34
|
+
req.on('data', b => raw += b);
|
|
35
|
+
req.on('end', () => {
|
|
36
|
+
if (req.method !== 'POST' || !raw.trim()) {
|
|
37
|
+
res.setHeader('content-type', 'application/json');
|
|
38
|
+
return res.end('{"data":[]}');
|
|
39
|
+
}
|
|
40
|
+
const body = JSON.parse(raw);
|
|
41
|
+
const isStream = Boolean(body.stream);
|
|
42
|
+
const respText = JSON.stringify(nativeVerdict);
|
|
43
|
+
|
|
44
|
+
if (isStream) {
|
|
45
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
46
|
+
res.write(`event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-haiku","content":[],"usage":{"input_tokens":10,"output_tokens":5}}}\n\n`);
|
|
47
|
+
res.write(`event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n`);
|
|
48
|
+
res.write(`event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":${JSON.stringify(respText)}}}\n\n`);
|
|
49
|
+
res.write(`event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n`);
|
|
50
|
+
res.write(`event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n`);
|
|
51
|
+
res.write(`event: message_stop\ndata: {"type":"message_stop"}\n\n`);
|
|
52
|
+
res.end();
|
|
53
|
+
} else {
|
|
54
|
+
res.setHeader('content-type', 'application/json');
|
|
55
|
+
res.end(JSON.stringify({
|
|
56
|
+
id: 'msg_1',
|
|
57
|
+
type: 'message',
|
|
58
|
+
role: 'assistant',
|
|
59
|
+
model: 'claude-3-haiku',
|
|
60
|
+
content: [{ type: 'text', text: respText }],
|
|
61
|
+
stop_reason: 'end_turn'
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
await listen(nativeUp);
|
|
67
|
+
|
|
68
|
+
// Mock Jev decision upstream
|
|
69
|
+
let jevRequests = [];
|
|
70
|
+
const jevUp = http.createServer((req, res) => {
|
|
71
|
+
let raw = '';
|
|
72
|
+
req.on('data', b => raw += b);
|
|
73
|
+
req.on('end', () => {
|
|
74
|
+
if (req.method === 'POST') {
|
|
75
|
+
jevRequests.push({ url: req.url, headers: req.headers, body: JSON.parse(raw) });
|
|
76
|
+
}
|
|
77
|
+
if (jevStatus !== 200) {
|
|
78
|
+
res.statusCode = jevStatus;
|
|
79
|
+
return res.end('error');
|
|
80
|
+
}
|
|
81
|
+
res.setHeader('content-type', 'application/json');
|
|
82
|
+
res.end(JSON.stringify({
|
|
83
|
+
model: 'typesafe/jev-latest',
|
|
84
|
+
answers: {
|
|
85
|
+
complete: { type: 'noul', noul: jevScores.complete },
|
|
86
|
+
evidence: { type: 'noul', noul: jevScores.evidence },
|
|
87
|
+
unfinished: { type: 'noul', noul: jevScores.unfinished }
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
await listen(jevUp);
|
|
93
|
+
|
|
94
|
+
const reserve = http.createServer();
|
|
95
|
+
await listen(reserve);
|
|
96
|
+
const port = reserve.address().port;
|
|
97
|
+
await new Promise(r => reserve.close(r));
|
|
98
|
+
|
|
99
|
+
const configPath = path.join(dir, 'config.json');
|
|
100
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
101
|
+
port,
|
|
102
|
+
activeProfile: 'p1',
|
|
103
|
+
activeProfiles: { claude: 'p1' },
|
|
104
|
+
profiles: {
|
|
105
|
+
p1: {
|
|
106
|
+
name: 'NativeMock',
|
|
107
|
+
mode: 'direct',
|
|
108
|
+
tool: 'claude',
|
|
109
|
+
inFormat: 'auto',
|
|
110
|
+
outFormat: 'anthropic',
|
|
111
|
+
baseURL: `http://127.0.0.1:${nativeUp.address().port}`,
|
|
112
|
+
apiKey: 'sk-native-key'
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
goalCheck: {
|
|
116
|
+
enabled: false,
|
|
117
|
+
backend: 'jev',
|
|
118
|
+
baseURL: `http://127.0.0.1:${jevUp.address().port}`,
|
|
119
|
+
apiKey: 'jev-test-key',
|
|
120
|
+
model: 'typesafe/jev-latest',
|
|
121
|
+
completeMin: 0.90,
|
|
122
|
+
evidenceMin: 0.85,
|
|
123
|
+
unfinishedMax: 0.10
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
const child = spawn(process.execPath, [path.join(root, 'proxy.mjs')], {
|
|
128
|
+
env: {
|
|
129
|
+
...process.env,
|
|
130
|
+
LLM_SWITCHER_CONFIG: configPath,
|
|
131
|
+
LLM_SWITCHER_STATE_DIR: dir,
|
|
132
|
+
LLM_SWITCHER_PORT: String(port)
|
|
133
|
+
},
|
|
134
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let logs = '';
|
|
138
|
+
child.stdout.on('data', b => logs += b);
|
|
139
|
+
child.stderr.on('data', b => logs += b);
|
|
140
|
+
|
|
141
|
+
t.after(async () => {
|
|
142
|
+
if (child.exitCode === null) {
|
|
143
|
+
const done = new Promise(r => child.once('exit', r));
|
|
144
|
+
child.kill();
|
|
145
|
+
await done;
|
|
146
|
+
}
|
|
147
|
+
nativeUp.closeAllConnections?.();
|
|
148
|
+
jevUp.closeAllConnections?.();
|
|
149
|
+
await Promise.all([
|
|
150
|
+
new Promise(r => nativeUp.close(r)),
|
|
151
|
+
new Promise(r => jevUp.close(r))
|
|
152
|
+
]);
|
|
153
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok);
|
|
157
|
+
|
|
158
|
+
const adminHeaders = {
|
|
159
|
+
'x-llm-switcher-token': adminToken,
|
|
160
|
+
'content-type': 'application/json'
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const sendAdmin = (path, method = 'GET', body = null) => fetch(`http://127.0.0.1:${port}${path}`, {
|
|
164
|
+
method,
|
|
165
|
+
headers: adminHeaders,
|
|
166
|
+
body: body ? JSON.stringify(body) : undefined
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const sendClaude = (body, stream = false) => fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: {
|
|
172
|
+
'content-type': 'application/json',
|
|
173
|
+
'user-agent': 'claude-cli/2.1.295'
|
|
174
|
+
},
|
|
175
|
+
body: JSON.stringify({ ...body, stream })
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
port,
|
|
180
|
+
sendAdmin,
|
|
181
|
+
sendClaude,
|
|
182
|
+
setNativeVerdict: v => { nativeVerdict = v; },
|
|
183
|
+
setJevScores: s => { jevScores = s; },
|
|
184
|
+
setJevStatus: st => { jevStatus = st; },
|
|
185
|
+
jevRequests,
|
|
186
|
+
logs: () => logs
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const evaluatorRequest = {
|
|
191
|
+
model: 'claude-3-haiku-20240307',
|
|
192
|
+
system: 'You are evaluating a stop-condition hook in Claude Code. Read the conversation transcript carefully, then judge whether the user-provided condition is satisfied.',
|
|
193
|
+
messages: [
|
|
194
|
+
{ role: 'user', content: 'Please fix the failing tests in repo.' },
|
|
195
|
+
{ role: 'assistant', content: 'I have edited the code and reports.' },
|
|
196
|
+
{
|
|
197
|
+
role: 'user',
|
|
198
|
+
content: 'Based on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.\n\nCondition: Fix all failing tests.'
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
tools: []
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
test('goalcheck e2e: admin API endpoints and probe', async t => {
|
|
205
|
+
const f = await fixture(t);
|
|
206
|
+
|
|
207
|
+
// 1. GET /api/goal-check
|
|
208
|
+
const r1 = await f.sendAdmin('/api/goal-check');
|
|
209
|
+
assert.equal(r1.status, 200);
|
|
210
|
+
const data1 = await r1.json();
|
|
211
|
+
assert.equal(data1.goalCheck.enabled, false);
|
|
212
|
+
assert.equal(data1.goalCheck.apiKey, '__LLM_SWITCHER_KEEP_KEY__');
|
|
213
|
+
assert.equal(data1.goalCheck.hasApiKey, true);
|
|
214
|
+
assert.equal(data1.status.configured, true);
|
|
215
|
+
|
|
216
|
+
// 2. POST /api/goal-check/test (Probe)
|
|
217
|
+
const probeRes = await f.sendAdmin('/api/goal-check/test', 'POST', {
|
|
218
|
+
model: 'typesafe/jev-latest'
|
|
219
|
+
});
|
|
220
|
+
assert.equal(probeRes.status, 200);
|
|
221
|
+
const probeData = await probeRes.json();
|
|
222
|
+
assert.equal(probeData.ok, true);
|
|
223
|
+
assert.ok(probeData.message.includes('Connected successfully'));
|
|
224
|
+
|
|
225
|
+
// 3. POST /api/goal-check (enable feature)
|
|
226
|
+
const updateRes = await f.sendAdmin('/api/goal-check', 'POST', {
|
|
227
|
+
enabled: true,
|
|
228
|
+
completeMin: 0.90
|
|
229
|
+
});
|
|
230
|
+
assert.equal(updateRes.status, 200);
|
|
231
|
+
const updateData = await updateRes.json();
|
|
232
|
+
assert.equal(updateData.goalCheck.enabled, true);
|
|
233
|
+
assert.equal(updateData.status.enabled, true);
|
|
234
|
+
|
|
235
|
+
// 4. GET /api/goal-check/logs
|
|
236
|
+
const logsRes = await f.sendAdmin('/api/goal-check/logs');
|
|
237
|
+
assert.equal(logsRes.status, 200);
|
|
238
|
+
const logsData = await logsRes.json();
|
|
239
|
+
assert.ok(Array.isArray(logsData.logs));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('goalcheck e2e: live interception, veto, and allow behaviors', async t => {
|
|
243
|
+
const f = await fixture(t);
|
|
244
|
+
|
|
245
|
+
// Case A: Feature is OFF -> passes through untouched
|
|
246
|
+
f.setNativeVerdict({ ok: true, reason: 'native pass' });
|
|
247
|
+
const offRes = await f.sendClaude(evaluatorRequest, false);
|
|
248
|
+
assert.equal(offRes.status, 200);
|
|
249
|
+
const offJson = await offRes.json();
|
|
250
|
+
const offVerdict = JSON.parse(offJson.content[0].text);
|
|
251
|
+
assert.equal(offVerdict.ok, true);
|
|
252
|
+
assert.equal(offVerdict.reason, 'native pass');
|
|
253
|
+
assert.equal(f.jevRequests.length, 0); // Jev was not called!
|
|
254
|
+
|
|
255
|
+
// Enable Goal Check
|
|
256
|
+
await f.sendAdmin('/api/goal-check', 'POST', { enabled: true });
|
|
257
|
+
|
|
258
|
+
// Case B: Feature is ON -> Native passes, but Jev has low complete score (0.14) -> VETOED!
|
|
259
|
+
f.setJevScores({ complete: 0.14, evidence: 0.84, unfinished: 0.63 });
|
|
260
|
+
f.setNativeVerdict({ ok: true, reason: 'audit report PASS' });
|
|
261
|
+
|
|
262
|
+
// Test Non-streaming JSON
|
|
263
|
+
const vetoRes = await f.sendClaude(evaluatorRequest, false);
|
|
264
|
+
assert.equal(vetoRes.status, 200);
|
|
265
|
+
const vetoJson = await vetoRes.json();
|
|
266
|
+
const vetoVerdict = JSON.parse(vetoJson.content[0].text);
|
|
267
|
+
assert.equal(vetoVerdict.ok, false);
|
|
268
|
+
assert.match(vetoVerdict.reason, /Jev has not confirmed full completion/);
|
|
269
|
+
assert.match(vetoVerdict.reason, /complete=0.14/);
|
|
270
|
+
assert.equal(f.jevRequests.length, 1); // Jev was called!
|
|
271
|
+
|
|
272
|
+
// Test SSE streaming: verify valid event stream and vetoed result
|
|
273
|
+
const sseRes = await f.sendClaude(evaluatorRequest, true);
|
|
274
|
+
assert.equal(sseRes.status, 200);
|
|
275
|
+
assert.ok(sseRes.headers.get('content-type').includes('text/event-stream'));
|
|
276
|
+
const sseText = await sseRes.text();
|
|
277
|
+
assert.ok(sseText.includes('event: message_start'));
|
|
278
|
+
assert.ok(sseText.includes('event: content_block_delta'));
|
|
279
|
+
assert.ok(sseText.includes('event: message_stop'));
|
|
280
|
+
assert.ok(!sseText.includes('\\"ok\\":true')); // NEVER sends ok:true!
|
|
281
|
+
assert.ok(sseText.includes('\\"ok\\":false'));
|
|
282
|
+
|
|
283
|
+
// Case C: Feature is ON -> Native passes AND Jev scores pass all thresholds -> ALLOWED!
|
|
284
|
+
f.setJevScores({ complete: 0.95, evidence: 0.90, unfinished: 0.05 });
|
|
285
|
+
const allowRes = await f.sendClaude(evaluatorRequest, false);
|
|
286
|
+
assert.equal(allowRes.status, 200);
|
|
287
|
+
const allowJson = await allowRes.json();
|
|
288
|
+
const allowVerdict = JSON.parse(allowJson.content[0].text);
|
|
289
|
+
assert.equal(allowVerdict.ok, true);
|
|
290
|
+
assert.equal(allowVerdict.reason, 'audit report PASS');
|
|
291
|
+
|
|
292
|
+
// Case D: Native rejects (ok: false) -> preserves native rejection
|
|
293
|
+
f.setNativeVerdict({ ok: false, reason: 'test command exited 1' });
|
|
294
|
+
const rejectRes = await f.sendClaude(evaluatorRequest, false);
|
|
295
|
+
assert.equal(rejectRes.status, 200);
|
|
296
|
+
const rejectJson = await rejectRes.json();
|
|
297
|
+
const rejectVerdict = JSON.parse(rejectJson.content[0].text);
|
|
298
|
+
assert.equal(rejectVerdict.ok, false);
|
|
299
|
+
assert.equal(rejectVerdict.reason, 'test command exited 1');
|
|
300
|
+
|
|
301
|
+
// Case E: Jev unavailable (HTTP 502) -> fails closed
|
|
302
|
+
f.setNativeVerdict({ ok: true, reason: 'claimed done' });
|
|
303
|
+
f.setJevStatus(502);
|
|
304
|
+
const failRes = await f.sendClaude(evaluatorRequest, false);
|
|
305
|
+
assert.equal(failRes.status, 200);
|
|
306
|
+
const failJson = await failRes.json();
|
|
307
|
+
const failVerdict = JSON.parse(failJson.content[0].text);
|
|
308
|
+
assert.equal(failVerdict.ok, false);
|
|
309
|
+
assert.match(failVerdict.reason, /Goal Check verification unavailable/);
|
|
310
|
+
|
|
311
|
+
// Check logs endpoint recorded the checks
|
|
312
|
+
const logsRes = await f.sendAdmin('/api/goal-check/logs');
|
|
313
|
+
const logsData = await logsRes.json();
|
|
314
|
+
assert.ok(logsData.logs.length >= 4);
|
|
315
|
+
assert.equal(logsData.logs[0].verdictType, 'Unavailable');
|
|
316
|
+
});
|
|
@@ -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
|
+
});
|