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,79 @@
|
|
|
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
|
+
import { openResponsesWs } from './ws-client.mjs';
|
|
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
|
+
async function until(check) { const deadline = Date.now() + 6000; while (Date.now() < deadline) { if (await check()) return; await new Promise(r => setTimeout(r, 20)); } throw new Error('timeout'); }
|
|
13
|
+
async function fixture(t) {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-compaction-'));
|
|
15
|
+
const received = []; let finish = 'stop', truncate = false;
|
|
16
|
+
const up = http.createServer((req, res) => {
|
|
17
|
+
let raw = ''; req.on('data', b => raw += b); req.on('end', () => {
|
|
18
|
+
if (req.method === 'GET') return res.end('{"data":[]}');
|
|
19
|
+
const body = JSON.parse(raw); received.push(body);
|
|
20
|
+
const summary = 'KEEP_DECISION_7391';
|
|
21
|
+
if (body.stream) {
|
|
22
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
23
|
+
res.write(`data: {"choices":\ndata: [{"index":0,"delta":{"content":"${summary}"}}]}\n\n`);
|
|
24
|
+
if (!truncate) res.write(`data: {"choices":[{"index":0,"delta":{},"finish_reason":"${finish}"}]}\n\ndata: [DONE]\n\n`);
|
|
25
|
+
res.end();
|
|
26
|
+
} else { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { content: summary }, finish_reason: finish }] })); }
|
|
27
|
+
});
|
|
28
|
+
}); await listen(up);
|
|
29
|
+
const reserve = http.createServer(); await listen(reserve); const port = reserve.address().port; await new Promise(r => reserve.close(r));
|
|
30
|
+
const config = path.join(dir, 'config.json'); fs.writeFileSync(config, JSON.stringify({ port, profiles: {
|
|
31
|
+
p1: { name: 'Offline', mode: 'convert', outFormat: 'openai-chat', baseURL: `http://127.0.0.1:${up.address().port}/v1`, apiKey: 'fixture-only' }
|
|
32
|
+
} }));
|
|
33
|
+
const child = spawn(process.execPath, [path.join(root, 'proxy.mjs')], { env: { ...process.env,
|
|
34
|
+
LLM_SWITCHER_CONFIG: config, LLM_SWITCHER_STATE_DIR: dir, LLM_SWITCHER_PORT: '', LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
35
|
+
let logs = ''; child.stdout.on('data', b => logs += b); child.stderr.on('data', b => logs += b);
|
|
36
|
+
t.after(async () => {
|
|
37
|
+
if (child.exitCode === null) { const done = new Promise(r => child.once('exit', r)); child.kill(); await done; }
|
|
38
|
+
up.closeAllConnections?.(); await new Promise(r => up.close(r));
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok);
|
|
42
|
+
const send = body => fetch(`http://127.0.0.1:${port}/v1/responses`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1' }, body: JSON.stringify(body) });
|
|
43
|
+
return { port, received, send, failure: (reason, cut = false) => { finish = reason; truncate = cut; }, logs: () => logs };
|
|
44
|
+
}
|
|
45
|
+
const trigger = stream => ({ model: 'm', stream, input: [{ role: 'user', content: 'ORIGINAL_HISTORY' }, { type: 'compaction_trigger' }] });
|
|
46
|
+
const events = text => text.split('\n').filter(l => l.startsWith('data: {')).map(l => JSON.parse(l.slice(6)));
|
|
47
|
+
|
|
48
|
+
test('HTTP compaction produces one item, replays it, and can compact the replay again', { timeout: 20000 }, async t => {
|
|
49
|
+
const f = await fixture(t);
|
|
50
|
+
for (const stream of [false, true]) {
|
|
51
|
+
const first = await f.send(trigger(stream)); assert.equal(first.status, 200, f.logs());
|
|
52
|
+
const response = stream ? events(await first.text()).find(e => e.type === 'response.completed').response : await first.json();
|
|
53
|
+
assert.equal(response.output.length, 1); assert.equal(response.output[0].type, 'compaction');
|
|
54
|
+
const replay = await f.send({ model: 'm', input: [response.output[0], { role: 'user', content: 'continue' }] });
|
|
55
|
+
assert.equal(replay.status, 200, await replay.text());
|
|
56
|
+
assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*continue/);
|
|
57
|
+
const again = await f.send({ ...trigger(false), input: [response.output[0], { type: 'compaction_trigger' }] });
|
|
58
|
+
assert.equal(again.status, 200, await again.text());
|
|
59
|
+
assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391/);
|
|
60
|
+
assert.doesNotMatch(JSON.stringify(f.received.at(-1)), /encrypted_content|compaction_trigger/);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
test('HTTP compaction rejects token-limit and prematurely ended summaries', { timeout: 20000 }, async t => {
|
|
64
|
+
const f = await fixture(t);
|
|
65
|
+
f.failure('length'); assert.equal((await f.send(trigger(false))).status, 502);
|
|
66
|
+
f.failure('stop', true); const r = await f.send(trigger(true)); assert.equal(r.status, 502);
|
|
67
|
+
assert.doesNotMatch(await r.text(), /encrypted_content/);
|
|
68
|
+
});
|
|
69
|
+
test('WS compaction replays the compacted state through previous_response_id', { timeout: 20000 }, async t => {
|
|
70
|
+
const f = await fixture(t); const ws = await openResponsesWs(f.port, { 'x-profile': 'p1' }); t.after(ws.close);
|
|
71
|
+
ws.send({ type: 'response.create', ...trigger(true) });
|
|
72
|
+
await until(() => ws.messages.some(m => ['response.completed', 'response.failed'].includes(m.type)));
|
|
73
|
+
const completed = ws.messages.find(m => m.type === 'response.completed'); assert.ok(completed, JSON.stringify(ws.messages));
|
|
74
|
+
assert.equal(ws.messages.filter(m => m.type === 'response.output_item.done' && m.item.type === 'compaction').length, 1);
|
|
75
|
+
ws.send({ type: 'response.create', model: 'm', previous_response_id: completed.response.id, input: [{ role: 'user', content: 'NEXT_TURN' }] });
|
|
76
|
+
await until(() => f.received.length === 2);
|
|
77
|
+
assert.match(JSON.stringify(f.received.at(-1).messages), /KEEP_DECISION_7391.*NEXT_TURN/);
|
|
78
|
+
assert.doesNotMatch(JSON.stringify(f.received.at(-1).messages), /ORIGINAL_HISTORY/);
|
|
79
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { anthropicToIR, responsesToIR, irToChatBody, irToVertexBody, irToAnthropicBody } from '../formats.mjs';
|
|
4
|
+
import { createToolVocab } from '../toolvocab.mjs';
|
|
5
|
+
|
|
6
|
+
const prefix = 'llm-gateway-compact-v1:';
|
|
7
|
+
const compact = summary => ({ type: 'compaction', encrypted_content: prefix + Buffer.from(JSON.stringify({ summary })).toString('base64url') });
|
|
8
|
+
|
|
9
|
+
test('converted Responses replays gateway compaction and refuses unknown ciphertext', () => {
|
|
10
|
+
const body = irToChatBody(responsesToIR({ input: [compact('KEEP_DECISION'), { role: 'user', content: 'continue' }] }), 'm');
|
|
11
|
+
assert.match(JSON.stringify(body.messages), /KEEP_DECISION/);
|
|
12
|
+
assert.doesNotMatch(JSON.stringify(body.messages), /llm-gateway-compact/);
|
|
13
|
+
assert.throws(() => responsesToIR({ input: [{ type: 'compaction', encrypted_content: 'opaque-native-ciphertext' }] }), /compaction/i);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('explicit true and false strict survive Responses to Chat', () => {
|
|
17
|
+
for (const strict of [true, false]) {
|
|
18
|
+
const ir = responsesToIR({ input: 'go', tools: [{ type: 'function', name: 'f', strict,
|
|
19
|
+
parameters: { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false } }] });
|
|
20
|
+
assert.equal(irToChatBody(ir, 'm').tools[0].function.strict, strict);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('Vertex tool names remain unique and reverse to the correct declaration, history and choice', () => {
|
|
25
|
+
const names = ['mcp__server-a__read', 'mcp__server_a__read'];
|
|
26
|
+
for (const order of [names, [...names].reverse()]) {
|
|
27
|
+
const ir = anthropicToIR({ tools: order.map(name => ({ name, input_schema: { type: 'object' } })), tool_choice: { type: 'tool', name: names[0] },
|
|
28
|
+
messages: [{ role: 'assistant', content: names.map((name, i) => ({ type: 'tool_use', id: `c${i}`, name, input: {} })) },
|
|
29
|
+
{ role: 'user', content: names.map((_, i) => ({ type: 'tool_result', tool_use_id: `c${i}`, content: 'ok' })) }] });
|
|
30
|
+
const body = irToVertexBody(ir, 'gemini-2.5-pro');
|
|
31
|
+
const emitted = body.tools[0].functionDeclarations.map(t => t.name);
|
|
32
|
+
assert.equal(new Set(emitted).size, 2);
|
|
33
|
+
const vocab = createToolVocab(ir.tools, { allowHyphens: false });
|
|
34
|
+
assert.deepEqual(emitted.map(n => vocab.name(n)), order);
|
|
35
|
+
const calls = body.contents.find(c => c.role === 'model').parts.map(p => p.functionCall.name);
|
|
36
|
+
assert.deepEqual(calls.map(n => vocab.name(n)), names);
|
|
37
|
+
assert.equal(vocab.name(body.toolConfig.functionCallingConfig.allowedFunctionNames[0]), names[0]);
|
|
38
|
+
assert.equal(emitted[order.indexOf(names[1])], names[1], 'valid original stays reserved');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('tool-result image reaches Chat, Anthropic and Vertex without becoming text', () => {
|
|
43
|
+
const ir = anthropicToIR({ messages: [
|
|
44
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'image-call', name: 'screen', input: {} }] },
|
|
45
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'image-call', content: [
|
|
46
|
+
{ type: 'text', text: 'screenshot' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }
|
|
47
|
+
] }] }
|
|
48
|
+
] });
|
|
49
|
+
const chat = irToChatBody(ir, 'm');
|
|
50
|
+
const tool = chat.messages.find(m => m.role === 'tool');
|
|
51
|
+
assert.equal(tool.content, 'screenshot');
|
|
52
|
+
const image = chat.messages.find(m => m.role === 'user' && Array.isArray(m.content));
|
|
53
|
+
assert.equal(image.content.find(p => p.type === 'image_url').image_url.url, 'data:image/png;base64,iVBORw0KGgo=');
|
|
54
|
+
const anthropic = irToAnthropicBody(ir, 'm');
|
|
55
|
+
assert.equal(anthropic.messages.at(-1).content[0].content[1].source.data, 'iVBORw0KGgo=');
|
|
56
|
+
const vertex = irToVertexBody(ir, 'gemini-2.5-pro');
|
|
57
|
+
assert.match(JSON.stringify(vertex.contents), /"inlineData":\{"mimeType":"image\/png","data":"iVBORw0KGgo="\}/);
|
|
58
|
+
assert.doesNotMatch(tool.content, /omitted|base64/);
|
|
59
|
+
});
|
|
@@ -95,6 +95,19 @@ function startUpstream() {
|
|
|
95
95
|
return res.end(JSON.stringify({ id: 'c1', object: 'chat.completion', model: json.model, choices: [{ index: 0, message: { role: 'assistant', content: 'converted ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }));
|
|
96
96
|
}
|
|
97
97
|
if (req.url.startsWith('/chat/v1/chat/completions')) {
|
|
98
|
+
if (lastUser.includes('TOOL_ALIAS_REPLAY')) {
|
|
99
|
+
const reference = json.messages.find(m => m.role === 'tool');
|
|
100
|
+
const name = JSON.parse(reference.content).tool_name;
|
|
101
|
+
if (!json.tools.some(t => t.function.name === name)) {
|
|
102
|
+
return sse(res, [{ error: { message: `upstream called undeclared tool '${name}'`, type: 'server_error' } }]);
|
|
103
|
+
}
|
|
104
|
+
return sse(res, [
|
|
105
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'console1', type: 'function', function: { name, arguments: '{"pageSize":0}' } }] } }] },
|
|
106
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 1, id: 'search2', type: 'function', function: { name: 'ToolSearch', arguments: '{"query":"select:' } }] } }] },
|
|
107
|
+
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 1, function: { arguments: `${name}","max_results":5}` } }] } }] },
|
|
108
|
+
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 10, completion_tokens: 15 } }
|
|
109
|
+
], { raw: ['data: [DONE]\n\n'] });
|
|
110
|
+
}
|
|
98
111
|
if (lastUser.includes('RATE_LIMIT')) {
|
|
99
112
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
|
|
100
113
|
return res.end(JSON.stringify({ error: { message: 'slow down' } }));
|
|
@@ -650,6 +663,40 @@ test('Bifrost stays off for a model that is not a Claude Code account', async ()
|
|
|
650
663
|
assert.equal(received.at(-1).url, '/intact/v1/chat/completions');
|
|
651
664
|
});
|
|
652
665
|
|
|
666
|
+
test('ToolSearch replay calls declared upstream aliases and restores original tool names and queries to Claude', async () => {
|
|
667
|
+
const name = 'mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages';
|
|
668
|
+
const res = await post('/v1/messages', {
|
|
669
|
+
model: 'claude-opus-4-6', max_tokens: 256, stream: true, system: `Use ${name} to inspect errors.`,
|
|
670
|
+
tools: [
|
|
671
|
+
{ name: 'ToolSearch', input_schema: { type: 'object', properties: { query: { type: 'string' }, max_results: { type: 'integer' } } } },
|
|
672
|
+
{ name, input_schema: { type: 'object', properties: { pageSize: { type: 'integer' } } } }
|
|
673
|
+
],
|
|
674
|
+
messages: [
|
|
675
|
+
{ role: 'user', content: 'Inspect errors.' },
|
|
676
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'search1', name: 'ToolSearch', input: { query: `select:${name}` } }] },
|
|
677
|
+
{ role: 'user', content: [
|
|
678
|
+
{ type: 'tool_result', tool_use_id: 'search1', content: [{ type: 'tool_reference', tool_name: name }] },
|
|
679
|
+
{ type: 'text', text: 'TOOL_ALIAS_REPLAY' }
|
|
680
|
+
] }
|
|
681
|
+
]
|
|
682
|
+
});
|
|
683
|
+
assert.equal(res.status, 200);
|
|
684
|
+
const events = parseSSE(await res.text());
|
|
685
|
+
assertValidAnthropicEvents(events);
|
|
686
|
+
assert.ok(events.some(e => e.event === 'message_stop'));
|
|
687
|
+
assert.ok(!events.some(e => e.event === 'error'));
|
|
688
|
+
const starts = events.filter(e => e.data?.content_block?.type === 'tool_use');
|
|
689
|
+
assert.deepEqual(starts.map(e => e.data.content_block.name), [name, 'ToolSearch']);
|
|
690
|
+
const args = start => JSON.parse(events.filter(e => e.data?.index === start.data.index && e.data?.delta?.type === 'input_json_delta').map(e => e.data.delta.partial_json).join(''));
|
|
691
|
+
assert.deepEqual(args(starts[0]), { pageSize: 0 });
|
|
692
|
+
assert.deepEqual(args(starts[1]), { query: `select:${name}`, max_results: 5 });
|
|
693
|
+
const up = received.at(-1).body;
|
|
694
|
+
const alias = up.tools.find(t => t.function.name !== 'ToolSearch').function.name;
|
|
695
|
+
assert.equal(alias.length, 64);
|
|
696
|
+
assert.ok(!JSON.stringify(up.messages).includes(name));
|
|
697
|
+
assert.equal(JSON.parse(up.messages.find(m => m.tool_calls).tool_calls[0].function.arguments).query, `select:${alias}`);
|
|
698
|
+
});
|
|
699
|
+
|
|
653
700
|
test('Bifrost stays off for a client that is not Claude Code', async () => {
|
|
654
701
|
await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
|
|
655
702
|
{ 'x-llm-profile': 'intactcc', 'user-agent': 'some-sdk/1.0' });
|
|
@@ -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
|
+
});
|