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.
@@ -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
+ };