tlc-claude-code 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/lib/code-gate/first-commit-audit.js +138 -0
- package/server/lib/code-gate/first-commit-audit.test.js +203 -0
- package/server/lib/code-gate/multi-model-reviewer.js +172 -0
- package/server/lib/code-gate/multi-model-reviewer.test.js +217 -0
- package/server/lib/infra/infra-generator.js +331 -0
- package/server/lib/infra/infra-generator.test.js +146 -0
- package/server/lib/llm/adapters/api-adapter.js +95 -0
- package/server/lib/llm/adapters/api-adapter.test.js +81 -0
- package/server/lib/llm/adapters/codex-adapter.js +85 -0
- package/server/lib/llm/adapters/codex-adapter.test.js +54 -0
- package/server/lib/llm/adapters/gemini-adapter.js +100 -0
- package/server/lib/llm/adapters/gemini-adapter.test.js +54 -0
- package/server/lib/llm/index.js +109 -0
- package/server/lib/llm/index.test.js +147 -0
- package/server/lib/llm/provider-executor.js +168 -0
- package/server/lib/llm/provider-executor.test.js +244 -0
- package/server/lib/llm/provider-registry.js +104 -0
- package/server/lib/llm/provider-registry.test.js +157 -0
- package/server/lib/llm/review-service.js +222 -0
- package/server/lib/llm/review-service.test.js +220 -0
- package/server/lib/shame/shame-registry.js +224 -0
- package/server/lib/shame/shame-registry.test.js +202 -0
- package/server/lib/standards/cleanup-dry-run.js +254 -0
- package/server/lib/standards/cleanup-dry-run.test.js +220 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Service Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Unified API: createLLMService(config) → { review, execute, health }
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
createLLMService,
|
|
10
|
+
} = require('./index.js');
|
|
11
|
+
|
|
12
|
+
describe('LLM Service', () => {
|
|
13
|
+
const mockDeps = {
|
|
14
|
+
healthCheck: vi.fn().mockResolvedValue({ available: true }),
|
|
15
|
+
spawn: vi.fn().mockReturnValue({
|
|
16
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('{"findings": [], "summary": "Clean"}')); }) },
|
|
17
|
+
stderr: { on: vi.fn() },
|
|
18
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
19
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('createLLMService', () => {
|
|
24
|
+
it('creates service with config', () => {
|
|
25
|
+
const service = createLLMService({
|
|
26
|
+
providers: {
|
|
27
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
28
|
+
},
|
|
29
|
+
}, mockDeps);
|
|
30
|
+
|
|
31
|
+
expect(service).toBeDefined();
|
|
32
|
+
expect(service.review).toBeDefined();
|
|
33
|
+
expect(service.execute).toBeDefined();
|
|
34
|
+
expect(service.health).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('creates service with zero config (auto-detect)', () => {
|
|
38
|
+
const service = createLLMService({}, mockDeps);
|
|
39
|
+
expect(service).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('review() returns structured findings', async () => {
|
|
43
|
+
const service = createLLMService({
|
|
44
|
+
providers: {
|
|
45
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
46
|
+
},
|
|
47
|
+
}, mockDeps);
|
|
48
|
+
|
|
49
|
+
const result = await service.review('diff content');
|
|
50
|
+
expect(result).toMatchObject({
|
|
51
|
+
findings: expect.any(Array),
|
|
52
|
+
summary: expect.any(String),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('execute() returns raw response', async () => {
|
|
57
|
+
const service = createLLMService({
|
|
58
|
+
providers: {
|
|
59
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['code-gen'] },
|
|
60
|
+
},
|
|
61
|
+
}, mockDeps);
|
|
62
|
+
|
|
63
|
+
const result = await service.execute('Write a function');
|
|
64
|
+
expect(result).toMatchObject({
|
|
65
|
+
response: expect.any(String),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('health() returns provider statuses', async () => {
|
|
70
|
+
const service = createLLMService({
|
|
71
|
+
providers: {
|
|
72
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
73
|
+
},
|
|
74
|
+
}, mockDeps);
|
|
75
|
+
|
|
76
|
+
const status = await service.health();
|
|
77
|
+
expect(status.providers).toBeDefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back through providers on failure', async () => {
|
|
81
|
+
const failThenSucceed = vi.fn()
|
|
82
|
+
.mockReturnValueOnce({
|
|
83
|
+
stdout: { on: vi.fn() },
|
|
84
|
+
stderr: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('err')); }) },
|
|
85
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
86
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(1); }),
|
|
87
|
+
})
|
|
88
|
+
.mockReturnValueOnce({
|
|
89
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('{"findings": [], "summary": "OK"}')); }) },
|
|
90
|
+
stderr: { on: vi.fn() },
|
|
91
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
92
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const service = createLLMService({
|
|
96
|
+
providers: {
|
|
97
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
98
|
+
gemini: { type: 'cli', command: 'gemini', capabilities: ['review'] },
|
|
99
|
+
},
|
|
100
|
+
}, { ...mockDeps, spawn: failThenSucceed });
|
|
101
|
+
|
|
102
|
+
const result = await service.review('diff');
|
|
103
|
+
expect(result.findings).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('respects multi-model config', () => {
|
|
107
|
+
const service = createLLMService({
|
|
108
|
+
providers: {
|
|
109
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
110
|
+
gemini: { type: 'cli', command: 'gemini', capabilities: ['review'] },
|
|
111
|
+
},
|
|
112
|
+
multiModel: true,
|
|
113
|
+
}, mockDeps);
|
|
114
|
+
|
|
115
|
+
expect(service).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('works with single provider', () => {
|
|
119
|
+
const service = createLLMService({
|
|
120
|
+
providers: {
|
|
121
|
+
codex: { type: 'cli', command: 'codex', capabilities: ['review'] },
|
|
122
|
+
},
|
|
123
|
+
}, mockDeps);
|
|
124
|
+
|
|
125
|
+
expect(service).toBeDefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('exports clean public API', () => {
|
|
129
|
+
const service = createLLMService({}, mockDeps);
|
|
130
|
+
const keys = Object.keys(service);
|
|
131
|
+
expect(keys).toContain('review');
|
|
132
|
+
expect(keys).toContain('execute');
|
|
133
|
+
expect(keys).toContain('health');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('config validation catches bad provider references', () => {
|
|
137
|
+
// Should not throw, just log warning or skip bad providers
|
|
138
|
+
const service = createLLMService({
|
|
139
|
+
providers: {
|
|
140
|
+
invalid: { type: 'unknown', capabilities: ['review'] },
|
|
141
|
+
},
|
|
142
|
+
}, mockDeps);
|
|
143
|
+
|
|
144
|
+
expect(service).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Executor
|
|
3
|
+
*
|
|
4
|
+
* Actually execute LLM requests through any provider.
|
|
5
|
+
* The bridge between "provider detected" and "review completed."
|
|
6
|
+
* Supports CLI (spawn) and API (HTTP) providers.
|
|
7
|
+
*
|
|
8
|
+
* @module llm/provider-executor
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Strip ANSI escape codes from output */
|
|
12
|
+
function stripAnsi(str) {
|
|
13
|
+
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Execute a CLI provider by spawning the process
|
|
18
|
+
* @param {string} prompt - The prompt to send
|
|
19
|
+
* @param {Object} options - Execution options
|
|
20
|
+
* @param {string} options.command - CLI command to run
|
|
21
|
+
* @param {string[]} options.args - CLI arguments
|
|
22
|
+
* @param {Function} options.spawn - Spawn function (injectable)
|
|
23
|
+
* @param {number} options.timeout - Timeout in ms
|
|
24
|
+
* @returns {Promise<{response: string}>}
|
|
25
|
+
*/
|
|
26
|
+
function executeCliProvider(prompt, options = {}) {
|
|
27
|
+
const { command, args = [], spawn, timeout } = options;
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const proc = spawn(command, args);
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
let settled = false;
|
|
34
|
+
let timer;
|
|
35
|
+
|
|
36
|
+
proc.stdout.on('data', (data) => {
|
|
37
|
+
stdout += data.toString();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
proc.stderr.on('data', (data) => {
|
|
41
|
+
stderr += data.toString();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
proc.on('close', (code) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
if (timer) clearTimeout(timer);
|
|
48
|
+
|
|
49
|
+
if (code !== 0) {
|
|
50
|
+
reject(new Error('Provider exited with exit code ' + code + ': ' + stderr));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
resolve({ response: stripAnsi(stdout) });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Write prompt to stdin
|
|
58
|
+
proc.stdin.write(prompt);
|
|
59
|
+
proc.stdin.end();
|
|
60
|
+
|
|
61
|
+
// Timeout handling
|
|
62
|
+
if (timeout) {
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
if (proc.kill) proc.kill();
|
|
67
|
+
reject(new Error('CLI provider timeout after ' + timeout + 'ms'));
|
|
68
|
+
}, timeout);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Execute an API provider via HTTP POST
|
|
75
|
+
* @param {string} prompt - The prompt to send
|
|
76
|
+
* @param {Object} options - Execution options
|
|
77
|
+
* @param {string} options.url - API endpoint URL
|
|
78
|
+
* @param {string} options.model - Model name
|
|
79
|
+
* @param {string} options.apiKey - API key
|
|
80
|
+
* @param {Function} options.fetch - Fetch function (injectable)
|
|
81
|
+
* @param {number} options.timeout - Timeout in ms
|
|
82
|
+
* @returns {Promise<{response: string, model: string, tokens: number}>}
|
|
83
|
+
*/
|
|
84
|
+
async function executeApiProvider(prompt, options = {}) {
|
|
85
|
+
const { url, model, apiKey, timeout } = options;
|
|
86
|
+
const fetchFn = options.fetch || globalThis.fetch;
|
|
87
|
+
|
|
88
|
+
const body = {
|
|
89
|
+
model,
|
|
90
|
+
messages: [{ role: 'user', content: prompt }],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
94
|
+
if (apiKey) {
|
|
95
|
+
headers['Authorization'] = 'Bearer ' + apiKey;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let fetchPromise = fetchFn(url, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers,
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (timeout) {
|
|
105
|
+
fetchPromise = Promise.race([
|
|
106
|
+
fetchPromise,
|
|
107
|
+
new Promise((_, reject) =>
|
|
108
|
+
setTimeout(() => reject(new Error('API provider timeout after ' + timeout + 'ms')), timeout)
|
|
109
|
+
),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resp = await fetchPromise;
|
|
114
|
+
|
|
115
|
+
if (!resp.ok) {
|
|
116
|
+
throw new Error('API returned status ' + resp.status);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const data = await resp.json();
|
|
120
|
+
const content = data.choices?.[0]?.message?.content || '';
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
response: content,
|
|
124
|
+
model: data.model || model,
|
|
125
|
+
tokens: data.usage?.total_tokens || 0,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a unified executor with injectable dependencies
|
|
131
|
+
* @param {Object} deps - Dependencies
|
|
132
|
+
* @param {Function} deps.spawn - Spawn function
|
|
133
|
+
* @param {Function} deps.fetch - Fetch function
|
|
134
|
+
* @returns {Object} Executor with execute method
|
|
135
|
+
*/
|
|
136
|
+
function createExecutor(deps = {}) {
|
|
137
|
+
return {
|
|
138
|
+
execute: async (prompt, provider) => {
|
|
139
|
+
const start = Date.now();
|
|
140
|
+
|
|
141
|
+
let result;
|
|
142
|
+
if (provider.type === 'api') {
|
|
143
|
+
result = await executeApiProvider(prompt, {
|
|
144
|
+
...provider,
|
|
145
|
+
fetch: deps.fetch,
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
result = await executeCliProvider(prompt, {
|
|
149
|
+
...provider,
|
|
150
|
+
spawn: deps.spawn,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
response: result.response,
|
|
156
|
+
model: provider.model || provider.command || 'unknown',
|
|
157
|
+
latency: Date.now() - start,
|
|
158
|
+
tokens: result.tokens || 0,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
executeCliProvider,
|
|
166
|
+
executeApiProvider,
|
|
167
|
+
createExecutor,
|
|
168
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Executor Tests
|
|
3
|
+
*
|
|
4
|
+
* Actually execute LLM requests through any provider.
|
|
5
|
+
* The bridge between "provider detected" and "review completed."
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
executeCliProvider,
|
|
11
|
+
executeApiProvider,
|
|
12
|
+
createExecutor,
|
|
13
|
+
} = require('./provider-executor.js');
|
|
14
|
+
|
|
15
|
+
describe('Provider Executor', () => {
|
|
16
|
+
describe('executeCliProvider', () => {
|
|
17
|
+
it('executes CLI provider via spawn', async () => {
|
|
18
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
19
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('review result')); }) },
|
|
20
|
+
stderr: { on: vi.fn() },
|
|
21
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
22
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const result = await executeCliProvider('Review this code', {
|
|
26
|
+
command: 'codex',
|
|
27
|
+
args: [],
|
|
28
|
+
spawn: mockSpawn,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
32
|
+
expect(result.response).toContain('review result');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('passes prompt as stdin to CLI', async () => {
|
|
36
|
+
let writtenData = '';
|
|
37
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
38
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('ok')); }) },
|
|
39
|
+
stderr: { on: vi.fn() },
|
|
40
|
+
stdin: { write: vi.fn((d) => { writtenData = d; }), end: vi.fn() },
|
|
41
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await executeCliProvider('my prompt text', {
|
|
45
|
+
command: 'codex',
|
|
46
|
+
args: [],
|
|
47
|
+
spawn: mockSpawn,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(writtenData).toContain('my prompt text');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('captures stdout as response', async () => {
|
|
54
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
55
|
+
stdout: { on: vi.fn((ev, cb) => {
|
|
56
|
+
if (ev === 'data') {
|
|
57
|
+
cb(Buffer.from('chunk1'));
|
|
58
|
+
cb(Buffer.from('chunk2'));
|
|
59
|
+
}
|
|
60
|
+
}) },
|
|
61
|
+
stderr: { on: vi.fn() },
|
|
62
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
63
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const result = await executeCliProvider('prompt', {
|
|
67
|
+
command: 'codex',
|
|
68
|
+
args: [],
|
|
69
|
+
spawn: mockSpawn,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.response).toBe('chunk1chunk2');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles CLI timeout (kills process)', async () => {
|
|
76
|
+
const killFn = vi.fn();
|
|
77
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
78
|
+
stdout: { on: vi.fn() },
|
|
79
|
+
stderr: { on: vi.fn() },
|
|
80
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
81
|
+
on: vi.fn(), // never calls close
|
|
82
|
+
kill: killFn,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
executeCliProvider('prompt', {
|
|
87
|
+
command: 'codex',
|
|
88
|
+
args: [],
|
|
89
|
+
spawn: mockSpawn,
|
|
90
|
+
timeout: 50,
|
|
91
|
+
})
|
|
92
|
+
).rejects.toThrow(/timeout/i);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles provider exit code != 0', async () => {
|
|
96
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
97
|
+
stdout: { on: vi.fn() },
|
|
98
|
+
stderr: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('error occurred')); }) },
|
|
99
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
100
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(1); }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
executeCliProvider('prompt', {
|
|
105
|
+
command: 'codex',
|
|
106
|
+
args: [],
|
|
107
|
+
spawn: mockSpawn,
|
|
108
|
+
})
|
|
109
|
+
).rejects.toThrow(/exit code 1/i);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('handles empty response', async () => {
|
|
113
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
114
|
+
stdout: { on: vi.fn() },
|
|
115
|
+
stderr: { on: vi.fn() },
|
|
116
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
117
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = await executeCliProvider('prompt', {
|
|
121
|
+
command: 'codex',
|
|
122
|
+
args: [],
|
|
123
|
+
spawn: mockSpawn,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.response).toBe('');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('respects provider-specific args from config', async () => {
|
|
130
|
+
let spawnedArgs = [];
|
|
131
|
+
const mockSpawn = vi.fn().mockImplementation((cmd, args) => {
|
|
132
|
+
spawnedArgs = args;
|
|
133
|
+
return {
|
|
134
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('ok')); }) },
|
|
135
|
+
stderr: { on: vi.fn() },
|
|
136
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
137
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await executeCliProvider('prompt', {
|
|
142
|
+
command: 'codex',
|
|
143
|
+
args: ['--model', 'gpt-4o', '--quiet'],
|
|
144
|
+
spawn: mockSpawn,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(spawnedArgs).toContain('--model');
|
|
148
|
+
expect(spawnedArgs).toContain('gpt-4o');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('strips ANSI codes from CLI output', async () => {
|
|
152
|
+
const ansiOutput = '\x1b[31mred text\x1b[0m normal';
|
|
153
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
154
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from(ansiOutput)); }) },
|
|
155
|
+
stderr: { on: vi.fn() },
|
|
156
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
157
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await executeCliProvider('prompt', {
|
|
161
|
+
command: 'codex',
|
|
162
|
+
args: [],
|
|
163
|
+
spawn: mockSpawn,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.response).not.toContain('\x1b[');
|
|
167
|
+
expect(result.response).toContain('red text');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('executeApiProvider', () => {
|
|
172
|
+
it('executes API provider via HTTP POST', async () => {
|
|
173
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
174
|
+
ok: true,
|
|
175
|
+
json: () => Promise.resolve({
|
|
176
|
+
choices: [{ message: { content: 'review response' } }],
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const result = await executeApiProvider('prompt', {
|
|
181
|
+
url: 'http://localhost:4000/v1/chat/completions',
|
|
182
|
+
model: 'gpt-4o',
|
|
183
|
+
apiKey: 'test-key',
|
|
184
|
+
fetch: mockFetch,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result.response).toBe('review response');
|
|
188
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
189
|
+
expect.any(String),
|
|
190
|
+
expect.objectContaining({ method: 'POST' })
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('handles API timeout', async () => {
|
|
195
|
+
const mockFetch = vi.fn().mockImplementation(() =>
|
|
196
|
+
new Promise((resolve) => setTimeout(resolve, 10000))
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
await expect(
|
|
200
|
+
executeApiProvider('prompt', {
|
|
201
|
+
url: 'http://localhost:4000/v1/chat/completions',
|
|
202
|
+
model: 'gpt-4o',
|
|
203
|
+
fetch: mockFetch,
|
|
204
|
+
timeout: 50,
|
|
205
|
+
})
|
|
206
|
+
).rejects.toThrow(/timeout/i);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('createExecutor', () => {
|
|
211
|
+
it('returns standardized { response, model, latency } format', async () => {
|
|
212
|
+
const mockSpawn = vi.fn().mockReturnValue({
|
|
213
|
+
stdout: { on: vi.fn((ev, cb) => { if (ev === 'data') cb(Buffer.from('result')); }) },
|
|
214
|
+
stderr: { on: vi.fn() },
|
|
215
|
+
stdin: { write: vi.fn(), end: vi.fn() },
|
|
216
|
+
on: vi.fn((ev, cb) => { if (ev === 'close') cb(0); }),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const executor = createExecutor({ spawn: mockSpawn });
|
|
220
|
+
const result = await executor.execute('prompt', {
|
|
221
|
+
type: 'cli',
|
|
222
|
+
command: 'codex',
|
|
223
|
+
args: [],
|
|
224
|
+
model: 'gpt-4o',
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(result).toMatchObject({
|
|
228
|
+
response: expect.any(String),
|
|
229
|
+
model: 'gpt-4o',
|
|
230
|
+
latency: expect.any(Number),
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('works with injectable spawn/fetch for testing', () => {
|
|
235
|
+
const executor = createExecutor({
|
|
236
|
+
spawn: vi.fn(),
|
|
237
|
+
fetch: vi.fn(),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(executor).toBeDefined();
|
|
241
|
+
expect(executor.execute).toBeDefined();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Registry
|
|
3
|
+
*
|
|
4
|
+
* Runtime registry of available LLM providers with health status.
|
|
5
|
+
* Tracks which providers are available and routes by capability.
|
|
6
|
+
*
|
|
7
|
+
* @module llm/provider-registry
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a provider registry
|
|
12
|
+
* @param {Object} options - Registry options
|
|
13
|
+
* @param {Function} options.healthCheck - Health check function(provider) → { available, version }
|
|
14
|
+
* @param {number} options.cacheTTL - Health cache TTL in ms (default 30000)
|
|
15
|
+
* @returns {Object} Registry instance
|
|
16
|
+
*/
|
|
17
|
+
function createRegistry(options = {}) {
|
|
18
|
+
const { healthCheck, cacheTTL = 30000 } = options;
|
|
19
|
+
const providers = new Map();
|
|
20
|
+
const healthCache = new Map();
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
/**
|
|
24
|
+
* Register a provider
|
|
25
|
+
* @param {Object} provider - Provider config
|
|
26
|
+
*/
|
|
27
|
+
register(provider) {
|
|
28
|
+
providers.set(provider.name, { ...provider, status: 'unknown' });
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* List all registered providers
|
|
33
|
+
* @returns {Array} Provider list
|
|
34
|
+
*/
|
|
35
|
+
list() {
|
|
36
|
+
return Array.from(providers.values());
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check health of a specific provider
|
|
41
|
+
* @param {string} name - Provider name
|
|
42
|
+
* @returns {Promise<Object>} Health status
|
|
43
|
+
*/
|
|
44
|
+
async checkHealth(name) {
|
|
45
|
+
const provider = providers.get(name);
|
|
46
|
+
if (!provider) return { available: false, error: 'unknown provider' };
|
|
47
|
+
|
|
48
|
+
// Check cache
|
|
49
|
+
const cached = healthCache.get(name);
|
|
50
|
+
if (cached && (Date.now() - cached.timestamp) < cacheTTL) {
|
|
51
|
+
return cached.status;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const status = await healthCheck(provider);
|
|
55
|
+
provider.status = status.available ? 'available' : 'unavailable';
|
|
56
|
+
|
|
57
|
+
healthCache.set(name, { status, timestamp: Date.now() });
|
|
58
|
+
|
|
59
|
+
return status;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get providers by capability
|
|
64
|
+
* @param {string} capability - Capability name (e.g., 'review')
|
|
65
|
+
* @returns {Array} Matching providers
|
|
66
|
+
*/
|
|
67
|
+
getByCapability(capability) {
|
|
68
|
+
return Array.from(providers.values())
|
|
69
|
+
.filter(p => p.capabilities && p.capabilities.includes(capability));
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get best available provider for a capability
|
|
74
|
+
* @param {string} capability - Capability name
|
|
75
|
+
* @returns {Promise<Object|null>} Best provider or null
|
|
76
|
+
*/
|
|
77
|
+
async getBestProvider(capability) {
|
|
78
|
+
const candidates = this.getByCapability(capability)
|
|
79
|
+
.sort((a, b) => (a.priority || 99) - (b.priority || 99));
|
|
80
|
+
|
|
81
|
+
for (const provider of candidates) {
|
|
82
|
+
const status = await this.checkHealth(provider.name);
|
|
83
|
+
if (status.available) return provider;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load providers from config object
|
|
91
|
+
* @param {Object} config - Config with providers map
|
|
92
|
+
*/
|
|
93
|
+
loadFromConfig(config) {
|
|
94
|
+
const providerMap = config.providers || {};
|
|
95
|
+
for (const [name, providerConfig] of Object.entries(providerMap)) {
|
|
96
|
+
this.register({ name, ...providerConfig });
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
createRegistry,
|
|
104
|
+
};
|