tlc-claude-code 1.4.4 → 1.4.6

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.
Files changed (72) hide show
  1. package/dashboard/dist/App.js +28 -2
  2. package/dashboard/dist/api/health-diagnostics.d.ts +26 -0
  3. package/dashboard/dist/api/health-diagnostics.js +85 -0
  4. package/dashboard/dist/api/health-diagnostics.test.d.ts +1 -0
  5. package/dashboard/dist/api/health-diagnostics.test.js +126 -0
  6. package/dashboard/dist/api/index.d.ts +5 -0
  7. package/dashboard/dist/api/index.js +5 -0
  8. package/dashboard/dist/api/notes-api.d.ts +18 -0
  9. package/dashboard/dist/api/notes-api.js +68 -0
  10. package/dashboard/dist/api/notes-api.test.d.ts +1 -0
  11. package/dashboard/dist/api/notes-api.test.js +113 -0
  12. package/dashboard/dist/api/safeFetch.d.ts +50 -0
  13. package/dashboard/dist/api/safeFetch.js +135 -0
  14. package/dashboard/dist/api/safeFetch.test.d.ts +1 -0
  15. package/dashboard/dist/api/safeFetch.test.js +215 -0
  16. package/dashboard/dist/api/tasks-api.d.ts +32 -0
  17. package/dashboard/dist/api/tasks-api.js +98 -0
  18. package/dashboard/dist/api/tasks-api.test.d.ts +1 -0
  19. package/dashboard/dist/api/tasks-api.test.js +383 -0
  20. package/dashboard/dist/components/BugsPane.d.ts +20 -0
  21. package/dashboard/dist/components/BugsPane.js +210 -0
  22. package/dashboard/dist/components/BugsPane.test.d.ts +1 -0
  23. package/dashboard/dist/components/BugsPane.test.js +256 -0
  24. package/dashboard/dist/components/HealthPane.d.ts +3 -1
  25. package/dashboard/dist/components/HealthPane.js +44 -6
  26. package/dashboard/dist/components/HealthPane.test.js +105 -2
  27. package/dashboard/dist/components/RouterPane.d.ts +4 -3
  28. package/dashboard/dist/components/RouterPane.js +60 -57
  29. package/dashboard/dist/components/RouterPane.test.js +150 -96
  30. package/dashboard/dist/components/UpdateBanner.d.ts +26 -0
  31. package/dashboard/dist/components/UpdateBanner.js +30 -0
  32. package/dashboard/dist/components/UpdateBanner.test.d.ts +1 -0
  33. package/dashboard/dist/components/UpdateBanner.test.js +96 -0
  34. package/dashboard/dist/components/ui/EmptyState.d.ts +14 -0
  35. package/dashboard/dist/components/ui/EmptyState.js +58 -0
  36. package/dashboard/dist/components/ui/EmptyState.test.d.ts +1 -0
  37. package/dashboard/dist/components/ui/EmptyState.test.js +97 -0
  38. package/dashboard/dist/components/ui/ErrorState.d.ts +17 -0
  39. package/dashboard/dist/components/ui/ErrorState.js +80 -0
  40. package/dashboard/dist/components/ui/ErrorState.test.d.ts +1 -0
  41. package/dashboard/dist/components/ui/ErrorState.test.js +166 -0
  42. package/dashboard/package.json +3 -0
  43. package/package.json +4 -1
  44. package/server/dashboard/index.html +284 -13
  45. package/server/dashboard/login.html +262 -0
  46. package/server/index.js +304 -0
  47. package/server/lib/api-provider.js +104 -186
  48. package/server/lib/api-provider.test.js +238 -336
  49. package/server/lib/cli-detector.js +90 -166
  50. package/server/lib/cli-detector.test.js +114 -269
  51. package/server/lib/cli-provider.js +142 -212
  52. package/server/lib/cli-provider.test.js +196 -349
  53. package/server/lib/debug.test.js +3 -3
  54. package/server/lib/devserver-router-api.js +54 -249
  55. package/server/lib/devserver-router-api.test.js +126 -426
  56. package/server/lib/introspect.js +309 -0
  57. package/server/lib/introspect.test.js +286 -0
  58. package/server/lib/model-router.js +107 -245
  59. package/server/lib/model-router.test.js +122 -313
  60. package/server/lib/output-schemas.js +146 -269
  61. package/server/lib/output-schemas.test.js +106 -307
  62. package/server/lib/plan-parser.js +59 -16
  63. package/server/lib/provider-interface.js +99 -153
  64. package/server/lib/provider-interface.test.js +228 -394
  65. package/server/lib/provider-queue.js +164 -158
  66. package/server/lib/provider-queue.test.js +186 -315
  67. package/server/lib/router-config.js +99 -221
  68. package/server/lib/router-config.test.js +83 -237
  69. package/server/lib/router-setup-command.js +94 -419
  70. package/server/lib/router-setup-command.test.js +96 -375
  71. package/server/lib/router-status-api.js +93 -0
  72. package/server/lib/router-status-api.test.js +270 -0
package/server/index.js CHANGED
@@ -12,6 +12,14 @@ const chokidar = require('chokidar');
12
12
  const { detectProject } = require('./lib/project-detector');
13
13
  const { parsePlan, parseBugs } = require('./lib/plan-parser');
14
14
  const { autoProvision, stopDatabase } = require('./lib/auto-database');
15
+ const {
16
+ createUserStore,
17
+ createAuthMiddleware,
18
+ generateJWT,
19
+ verifyJWT,
20
+ hashPassword,
21
+ verifyPassword,
22
+ } = require('./lib/auth-system');
15
23
 
16
24
  // Handle PGlite WASM crashes gracefully
17
25
  process.on('uncaughtException', (err) => {
@@ -48,6 +56,150 @@ const wss = new WebSocketServer({ server });
48
56
 
49
57
  // Middleware
50
58
  app.use(express.json());
59
+ const cookieParser = require('cookie-parser');
60
+ app.use(cookieParser());
61
+
62
+ // ============================================
63
+ // Authentication Setup
64
+ // ============================================
65
+ const userStore = createUserStore();
66
+ const JWT_SECRET = process.env.TLC_JWT_SECRET || 'tlc-dashboard-secret-change-in-production';
67
+ const AUTH_ENABLED = process.env.TLC_AUTH !== 'false';
68
+
69
+ // Initialize admin user from config or environment
70
+ async function initializeAuth() {
71
+ const tlcConfigPath = path.join(PROJECT_DIR, '.tlc.json');
72
+ let adminEmail = process.env.TLC_ADMIN_EMAIL || 'admin@localhost';
73
+ let adminPassword = process.env.TLC_ADMIN_PASSWORD;
74
+
75
+ // Try to read from .tlc.json
76
+ if (fs.existsSync(tlcConfigPath)) {
77
+ try {
78
+ const config = JSON.parse(fs.readFileSync(tlcConfigPath, 'utf-8'));
79
+ if (config.auth?.adminEmail) adminEmail = config.auth.adminEmail;
80
+ if (config.auth?.adminPassword) adminPassword = config.auth.adminPassword;
81
+ } catch (e) {
82
+ // Ignore parse errors
83
+ }
84
+ }
85
+
86
+ // Create admin user if password is set
87
+ if (adminPassword) {
88
+ try {
89
+ await userStore.createUser({
90
+ email: adminEmail,
91
+ password: adminPassword,
92
+ name: 'Admin',
93
+ role: 'admin',
94
+ });
95
+ console.log(`[TLC] Admin user initialized: ${adminEmail}`);
96
+ } catch (e) {
97
+ // User may already exist
98
+ if (!e.message.includes('already registered')) {
99
+ console.error('[TLC] Failed to create admin user:', e.message);
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // Auth middleware for protected routes
106
+ const authMiddleware = createAuthMiddleware({
107
+ userStore,
108
+ jwtSecret: JWT_SECRET,
109
+ requireAuth: true,
110
+ });
111
+
112
+ // Public paths that don't require auth
113
+ const publicPaths = ['/api/auth/login', '/api/auth/status', '/login.html', '/login'];
114
+
115
+ // Apply auth to API routes (except public paths)
116
+ app.use((req, res, next) => {
117
+ // Skip auth if disabled
118
+ if (!AUTH_ENABLED) return next();
119
+
120
+ // Allow public paths
121
+ if (publicPaths.some(p => req.path === p || req.path.startsWith(p))) {
122
+ return next();
123
+ }
124
+
125
+ // Allow static assets
126
+ if (req.path.match(/\.(js|css|png|jpg|ico|svg|woff|woff2)$/)) {
127
+ return next();
128
+ }
129
+
130
+ // Check for auth cookie or header
131
+ const token = req.cookies?.tlc_token || req.headers.authorization?.replace('Bearer ', '');
132
+
133
+ if (!token) {
134
+ // Redirect browser requests to login, return 401 for API
135
+ if (req.path.startsWith('/api/')) {
136
+ return res.status(401).json({ error: 'Authentication required' });
137
+ }
138
+ return res.redirect('/login.html');
139
+ }
140
+
141
+ // Verify token
142
+ const payload = verifyJWT(token, JWT_SECRET);
143
+ if (!payload) {
144
+ if (req.path.startsWith('/api/')) {
145
+ return res.status(401).json({ error: 'Invalid or expired token' });
146
+ }
147
+ return res.redirect('/login.html');
148
+ }
149
+
150
+ // Attach user to request
151
+ req.user = payload;
152
+ next();
153
+ });
154
+
155
+ // Auth routes
156
+ app.get('/api/auth/status', (req, res) => {
157
+ res.json({ authEnabled: AUTH_ENABLED });
158
+ });
159
+
160
+ app.post('/api/auth/login', async (req, res) => {
161
+ const { email, password } = req.body;
162
+
163
+ if (!email || !password) {
164
+ return res.status(400).json({ error: 'Email and password required' });
165
+ }
166
+
167
+ const user = await userStore.authenticate(email, password);
168
+ if (!user) {
169
+ return res.status(401).json({ error: 'Invalid credentials' });
170
+ }
171
+
172
+ // Generate JWT
173
+ const token = generateJWT(
174
+ { sub: user.id, email: user.email, role: user.role, name: user.name },
175
+ JWT_SECRET,
176
+ { expiresIn: 86400 * 7 } // 7 days
177
+ );
178
+
179
+ // Set cookie
180
+ res.cookie('tlc_token', token, {
181
+ httpOnly: true,
182
+ secure: process.env.NODE_ENV === 'production',
183
+ sameSite: 'lax',
184
+ maxAge: 86400 * 7 * 1000, // 7 days
185
+ });
186
+
187
+ res.json({ success: true, user: { email: user.email, name: user.name, role: user.role } });
188
+ });
189
+
190
+ app.post('/api/auth/logout', (req, res) => {
191
+ res.clearCookie('tlc_token');
192
+ res.json({ success: true });
193
+ });
194
+
195
+ app.get('/api/auth/me', (req, res) => {
196
+ if (!req.user) {
197
+ return res.status(401).json({ error: 'Not authenticated' });
198
+ }
199
+ res.json({ user: req.user });
200
+ });
201
+
202
+ // Serve static files (after auth middleware)
51
203
  app.use(express.static(path.join(__dirname, 'dashboard')));
52
204
 
53
205
  // Broadcast to all WebSocket clients
@@ -158,6 +310,7 @@ async function startApp() {
158
310
 
159
311
  // Run tests
160
312
  function runTests() {
313
+ broadcast('test-start', { timestamp: Date.now() });
161
314
  addLog('test', '--- Running tests ---', 'info');
162
315
 
163
316
  // Try to detect test command
@@ -202,6 +355,86 @@ function runTests() {
202
355
  }
203
356
 
204
357
  // API Routes
358
+
359
+ // Project info endpoint - returns real project data
360
+ app.get('/api/project', (req, res) => {
361
+ try {
362
+ const { execSync } = require('child_process');
363
+
364
+ // Get project name and description from package.json or .tlc.json
365
+ let projectName = 'Unknown Project';
366
+ let projectDesc = '';
367
+ let version = '';
368
+
369
+ const pkgPath = path.join(PROJECT_DIR, 'package.json');
370
+ if (fs.existsSync(pkgPath)) {
371
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
372
+ projectName = pkg.name || projectName;
373
+ projectDesc = pkg.description || '';
374
+ version = pkg.version || '';
375
+ }
376
+
377
+ const tlcPath = path.join(PROJECT_DIR, '.tlc.json');
378
+ if (fs.existsSync(tlcPath)) {
379
+ const tlc = JSON.parse(fs.readFileSync(tlcPath, 'utf-8'));
380
+ if (tlc.project) projectName = tlc.project;
381
+ if (tlc.description) projectDesc = tlc.description;
382
+ }
383
+
384
+ // Get git info
385
+ let branch = 'unknown';
386
+ let lastCommit = null;
387
+ try {
388
+ branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: PROJECT_DIR, encoding: 'utf-8' }).trim();
389
+ const commitInfo = execSync('git log -1 --pretty=format:"%h|%s|%ar"', { cwd: PROJECT_DIR, encoding: 'utf-8' }).trim();
390
+ const [hash, message, time] = commitInfo.split('|');
391
+ lastCommit = { hash, message, time };
392
+ } catch (e) {
393
+ // Not a git repo
394
+ }
395
+
396
+ // Get phase info
397
+ const plan = parsePlan(PROJECT_DIR);
398
+
399
+ // Count phases from roadmap
400
+ let totalPhases = 0;
401
+ let completedPhases = 0;
402
+ const roadmapPath = path.join(PROJECT_DIR, '.planning', 'ROADMAP.md');
403
+ if (fs.existsSync(roadmapPath)) {
404
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
405
+ const phases = content.match(/##\s+Phase\s+\d+/g) || [];
406
+ totalPhases = phases.length;
407
+ const completed = content.match(/##\s+Phase\s+\d+[^[]*\[x\]/gi) || [];
408
+ completedPhases = completed.length;
409
+ }
410
+
411
+ // Calculate progress
412
+ const tasksDone = plan.tasks?.filter(t => t.status === 'done' || t.status === 'complete').length || 0;
413
+ const tasksTotal = plan.tasks?.length || 0;
414
+ const progress = tasksTotal > 0 ? Math.round((tasksDone / tasksTotal) * 100) : 0;
415
+
416
+ res.json({
417
+ name: projectName,
418
+ description: projectDesc,
419
+ version,
420
+ branch,
421
+ lastCommit,
422
+ phase: plan.currentPhase,
423
+ phaseName: plan.currentPhaseName,
424
+ totalPhases,
425
+ completedPhases,
426
+ tasks: {
427
+ total: tasksTotal,
428
+ done: tasksDone,
429
+ progress
430
+ },
431
+ projectDir: PROJECT_DIR
432
+ });
433
+ } catch (err) {
434
+ res.status(500).json({ error: err.message });
435
+ }
436
+ });
437
+
205
438
  app.get('/api/status', (req, res) => {
206
439
  const bugs = parseBugs(PROJECT_DIR);
207
440
  const plan = parsePlan(PROJECT_DIR);
@@ -372,6 +605,32 @@ app.post('/api/playwright', (req, res) => {
372
605
  res.json({ success: true });
373
606
  });
374
607
 
608
+ // POST /api/tasks - Create a new task
609
+ app.post('/api/tasks', (req, res) => {
610
+ const { title, phase, owner } = req.body;
611
+
612
+ if (!title) {
613
+ return res.status(400).json({ error: 'Title required' });
614
+ }
615
+
616
+ const plan = parsePlan(PROJECT_DIR);
617
+ const taskId = `task-${Date.now()}`;
618
+ const task = {
619
+ id: taskId,
620
+ title,
621
+ status: 'pending',
622
+ phase: phase || plan.currentPhase || 1,
623
+ owner: owner || null,
624
+ createdAt: new Date().toISOString()
625
+ };
626
+
627
+ // Broadcast to all connected clients
628
+ broadcast('task-created', task);
629
+ addLog('app', `Task created: ${title}`, 'info');
630
+
631
+ res.status(201).json(task);
632
+ });
633
+
375
634
  app.post('/api/bug', (req, res) => {
376
635
  const { description, url, screenshot, severity, images } = req.body;
377
636
 
@@ -570,6 +829,11 @@ app.post('/api/agents', (req, res) => {
570
829
  });
571
830
 
572
831
  const agent = registry.getAgent(agentId);
832
+
833
+ // Broadcast agent creation
834
+ broadcast('agent-created', formatAgent(agent));
835
+ addLog('app', `Agent registered: ${name}`, 'info');
836
+
573
837
  res.status(201).json({ success: true, agent: formatAgent(agent) });
574
838
  } catch (err) {
575
839
  res.status(500).json({ success: false, error: err.message });
@@ -604,6 +868,9 @@ app.patch('/api/agents/:id', (req, res) => {
604
868
  });
605
869
  }
606
870
 
871
+ // Broadcast agent update
872
+ broadcast('agent-updated', formatAgent(agent));
873
+
607
874
  res.json({ success: true, agent: formatAgent(agent) });
608
875
  } catch (err) {
609
876
  res.status(500).json({ success: false, error: err.message });
@@ -758,6 +1025,34 @@ async function shutdown() {
758
1025
  process.on('SIGINT', shutdown);
759
1026
  process.on('SIGTERM', shutdown);
760
1027
 
1028
+ // Get health data for broadcasting
1029
+ function getHealthData() {
1030
+ const os = require('os');
1031
+ const memUsed = process.memoryUsage().heapUsed;
1032
+ const memTotal = os.totalmem();
1033
+ const loadAvg = os.loadavg()[0];
1034
+ const cpuCount = os.cpus().length;
1035
+ const cpuPercent = Math.round((loadAvg / cpuCount) * 100);
1036
+
1037
+ return {
1038
+ status: appProcess ? 'healthy' : 'degraded',
1039
+ memory: memUsed,
1040
+ cpu: Math.min(cpuPercent, 100),
1041
+ uptime: process.uptime(),
1042
+ appRunning: appProcess !== null,
1043
+ appPort: appPort
1044
+ };
1045
+ }
1046
+
1047
+ // Periodic health broadcast
1048
+ let healthInterval = null;
1049
+ function startHealthBroadcast() {
1050
+ if (healthInterval) clearInterval(healthInterval);
1051
+ healthInterval = setInterval(() => {
1052
+ broadcast('health-update', getHealthData());
1053
+ }, 30000); // Every 30 seconds
1054
+ }
1055
+
761
1056
  // Start server
762
1057
  async function main() {
763
1058
  console.log(`
@@ -771,13 +1066,22 @@ async function main() {
771
1066
  TLC Dev Server
772
1067
  `);
773
1068
 
1069
+ // Initialize authentication
1070
+ await initializeAuth();
1071
+
774
1072
  server.listen(TLC_PORT, () => {
775
1073
  console.log(` Dashboard: http://localhost:${TLC_PORT}`);
776
1074
  console.log(` Share: http://${getLocalIP()}:${TLC_PORT}`);
1075
+ if (AUTH_ENABLED) {
1076
+ console.log(` Auth: ENABLED (set TLC_AUTH=false to disable)`);
1077
+ } else {
1078
+ console.log(` Auth: DISABLED`);
1079
+ }
777
1080
  console.log('');
778
1081
  });
779
1082
 
780
1083
  setupWatchers();
1084
+ startHealthBroadcast();
781
1085
 
782
1086
  if (PROXY_ONLY) {
783
1087
  // In proxy-only mode, just set the app port for proxying
@@ -1,186 +1,104 @@
1
- /**
2
- * API Provider - Provider implementation for REST API endpoints
3
- *
4
- * Supports OpenAI-compatible endpoints:
5
- * - DeepSeek
6
- * - Mistral
7
- * - Any OpenAI-compatible API
8
- */
9
-
10
- import { createProvider, PROVIDER_TYPES } from './provider-interface.js';
11
-
12
- /**
13
- * API pricing per 1K tokens (USD)
14
- */
15
- export const API_PRICING = {
16
- deepseek: { input: 0.0001, output: 0.0002 },
17
- 'deepseek-coder': { input: 0.0001, output: 0.0002 },
18
- mistral: { input: 0.0002, output: 0.0006 },
19
- 'mistral-large': { input: 0.002, output: 0.006 },
20
- groq: { input: 0.0001, output: 0.0001 },
21
- default: { input: 0.001, output: 0.002 },
22
- };
23
-
24
- /**
25
- * Calculate cost from token usage
26
- * @param {Object} tokenUsage - { input, output }
27
- * @param {Object} pricing - { input, output } per 1K tokens
28
- * @returns {number|null} Cost in USD
29
- */
30
- export function calculateCost(tokenUsage, pricing) {
31
- if (!tokenUsage || !pricing) return null;
32
-
33
- const inputCost = (tokenUsage.input * pricing.input) / 1000;
34
- const outputCost = (tokenUsage.output * pricing.output) / 1000;
35
-
36
- return inputCost + outputCost;
37
- }
38
-
39
- /**
40
- * Parse API response
41
- * @param {Object} response - API response
42
- * @returns {Object} Parsed result
43
- */
44
- export function parseResponse(response) {
45
- const content = response.choices?.[0]?.message?.content || '';
46
- const usage = response.usage || {};
47
-
48
- let parsed = null;
49
- try {
50
- parsed = JSON.parse(content);
51
- } catch (e) {
52
- // Not JSON, that's ok
53
- }
54
-
55
- return {
56
- raw: content,
57
- parsed,
58
- tokenUsage: {
59
- input: usage.prompt_tokens || 0,
60
- output: usage.completion_tokens || 0,
61
- },
62
- };
63
- }
64
-
65
- /**
66
- * Call an OpenAI-compatible API
67
- * @param {Object} params - Parameters
68
- * @param {string} params.baseUrl - API base URL
69
- * @param {string} params.model - Model name
70
- * @param {string} params.prompt - The prompt
71
- * @param {string} params.apiKey - API key
72
- * @param {Object} [params.outputSchema] - JSON schema for output
73
- * @param {number} [params.maxRetries=3] - Max retry attempts
74
- * @param {number} [params.retryDelay=1000] - Retry delay in ms
75
- * @returns {Promise<Object>} ProviderResult
76
- */
77
- export async function callAPI({
78
- baseUrl,
79
- model,
80
- prompt,
81
- apiKey,
82
- outputSchema,
83
- maxRetries = 3,
84
- retryDelay = 1000,
85
- }) {
86
- const url = `${baseUrl}/v1/chat/completions`;
87
-
88
- const body = {
89
- model,
90
- messages: [{ role: 'user', content: prompt }],
91
- };
92
-
93
- if (outputSchema) {
94
- body.response_format = {
95
- type: 'json_schema',
96
- json_schema: {
97
- name: 'response',
98
- schema: outputSchema,
99
- },
100
- };
101
- }
102
-
103
- let lastError = null;
104
-
105
- for (let attempt = 0; attempt < maxRetries; attempt++) {
106
- try {
107
- const response = await fetch(url, {
108
- method: 'POST',
109
- headers: {
110
- 'Content-Type': 'application/json',
111
- 'Authorization': `Bearer ${apiKey}`,
112
- },
113
- body: JSON.stringify(body),
114
- });
115
-
116
- // Handle rate limiting
117
- if (response.status === 429) {
118
- const retryAfter = parseInt(response.headers.get('Retry-After') || '1', 10);
119
- await new Promise(r => setTimeout(r, retryAfter * 1000 || retryDelay));
120
- continue;
121
- }
122
-
123
- if (!response.ok) {
124
- const errorBody = await response.json().catch(() => ({}));
125
- lastError = new Error(errorBody.error?.message || response.statusText);
126
- continue;
127
- }
128
-
129
- const data = await response.json();
130
- const parsed = parseResponse(data);
131
-
132
- // Get pricing for cost calculation
133
- const pricing = API_PRICING[model] || API_PRICING.default;
134
- const cost = calculateCost(parsed.tokenUsage, pricing);
135
-
136
- return {
137
- ...parsed,
138
- exitCode: 0,
139
- cost,
140
- };
141
- } catch (err) {
142
- lastError = err;
143
-
144
- if (attempt < maxRetries - 1) {
145
- await new Promise(r => setTimeout(r, retryDelay));
146
- }
147
- }
148
- }
149
-
150
- return {
151
- raw: '',
152
- parsed: null,
153
- exitCode: 1,
154
- error: lastError?.message || 'API call failed',
155
- tokenUsage: null,
156
- cost: null,
157
- };
158
- }
159
-
160
- /**
161
- * Create an API provider instance
162
- * @param {Object} config - Provider configuration
163
- * @returns {Object} Provider instance
164
- */
165
- export function createAPIProvider(config) {
166
- const runner = async (prompt, opts) => {
167
- if (!config.apiKey && !process.env[`${config.name.toUpperCase()}_API_KEY`]) {
168
- throw new Error(`API key not configured for ${config.name}`);
169
- }
170
-
171
- return callAPI({
172
- baseUrl: config.baseUrl,
173
- model: config.model || config.name,
174
- prompt,
175
- apiKey: config.apiKey || process.env[`${config.name.toUpperCase()}_API_KEY`],
176
- outputSchema: opts.outputSchema,
177
- });
178
- };
179
-
180
- return createProvider({
181
- ...config,
182
- type: PROVIDER_TYPES.API,
183
- devserverOnly: config.devserverOnly ?? true,
184
- runner,
185
- });
186
- }
1
+ /**
2
+ * API Provider - Provider implementation for REST API endpoints
3
+ * Phase 33, Task 4
4
+ */
5
+
6
+ export function calculateCost(tokenUsage, pricing) {
7
+ const inputCost = (tokenUsage.input / 1000) * pricing.inputPer1k;
8
+ const outputCost = (tokenUsage.output / 1000) * pricing.outputPer1k;
9
+ return inputCost + outputCost;
10
+ }
11
+
12
+ export class APIProvider {
13
+ constructor(config) {
14
+ this.name = config.name;
15
+ this.baseUrl = config.baseUrl;
16
+ this.model = config.model;
17
+ this.apiKey = config.apiKey;
18
+ this.pricing = config.pricing || { inputPer1k: 0, outputPer1k: 0 };
19
+ this.maxRetries = config.maxRetries || 3;
20
+ this._fetch = null;
21
+ }
22
+
23
+ async run(prompt, options = {}) {
24
+ const fetch = this._fetch || globalThis.fetch;
25
+ const url = this.baseUrl + '/v1/chat/completions';
26
+
27
+ const body = {
28
+ model: this.model,
29
+ messages: [{ role: 'user', content: prompt }],
30
+ };
31
+
32
+ if (options.outputSchema) {
33
+ body.response_format = {
34
+ type: 'json_schema',
35
+ json_schema: {
36
+ name: 'response',
37
+ schema: options.outputSchema,
38
+ },
39
+ };
40
+ }
41
+
42
+ let lastError;
43
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
44
+ try {
45
+ const response = await fetch(url, {
46
+ method: 'POST',
47
+ headers: {
48
+ 'Content-Type': 'application/json',
49
+ Authorization: 'Bearer ' + this.apiKey,
50
+ },
51
+ body: JSON.stringify(body),
52
+ });
53
+
54
+ if (response.status === 429) {
55
+ // Rate limited, wait and retry
56
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
57
+ continue;
58
+ }
59
+
60
+ if (!response.ok) {
61
+ const error = await response.json();
62
+ throw new Error(error.error?.message || 'API error');
63
+ }
64
+
65
+ const data = await response.json();
66
+ return this._parseResponse(data);
67
+ } catch (err) {
68
+ lastError = err;
69
+ if (err.message.includes('network') || err.message.includes('Network')) {
70
+ throw err;
71
+ }
72
+ }
73
+ }
74
+
75
+ throw lastError || new Error('Max retries exceeded');
76
+ }
77
+
78
+ _parseResponse(data) {
79
+ const content = data.choices?.[0]?.message?.content || '';
80
+ const usage = data.usage || {};
81
+
82
+ let parsed = null;
83
+ try {
84
+ parsed = JSON.parse(content);
85
+ } catch {
86
+ // Not JSON
87
+ }
88
+
89
+ const tokenUsage = {
90
+ input: usage.prompt_tokens || 0,
91
+ output: usage.completion_tokens || 0,
92
+ };
93
+
94
+ return {
95
+ raw: content,
96
+ parsed,
97
+ exitCode: 0,
98
+ tokenUsage,
99
+ cost: calculateCost(tokenUsage, this.pricing),
100
+ };
101
+ }
102
+ }
103
+
104
+ export default { APIProvider, calculateCost };