@loxia-labs/loxia-autopilot-one 1.0.1

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 (80) hide show
  1. package/LICENSE +267 -0
  2. package/README.md +509 -0
  3. package/bin/cli.js +117 -0
  4. package/package.json +94 -0
  5. package/scripts/install-scanners.js +236 -0
  6. package/src/analyzers/CSSAnalyzer.js +297 -0
  7. package/src/analyzers/ConfigValidator.js +690 -0
  8. package/src/analyzers/ESLintAnalyzer.js +320 -0
  9. package/src/analyzers/JavaScriptAnalyzer.js +261 -0
  10. package/src/analyzers/PrettierFormatter.js +247 -0
  11. package/src/analyzers/PythonAnalyzer.js +266 -0
  12. package/src/analyzers/SecurityAnalyzer.js +729 -0
  13. package/src/analyzers/TypeScriptAnalyzer.js +247 -0
  14. package/src/analyzers/codeCloneDetector/analyzer.js +344 -0
  15. package/src/analyzers/codeCloneDetector/detector.js +203 -0
  16. package/src/analyzers/codeCloneDetector/index.js +160 -0
  17. package/src/analyzers/codeCloneDetector/parser.js +199 -0
  18. package/src/analyzers/codeCloneDetector/reporter.js +148 -0
  19. package/src/analyzers/codeCloneDetector/scanner.js +59 -0
  20. package/src/core/agentPool.js +1474 -0
  21. package/src/core/agentScheduler.js +2147 -0
  22. package/src/core/contextManager.js +709 -0
  23. package/src/core/messageProcessor.js +732 -0
  24. package/src/core/orchestrator.js +548 -0
  25. package/src/core/stateManager.js +877 -0
  26. package/src/index.js +631 -0
  27. package/src/interfaces/cli.js +549 -0
  28. package/src/interfaces/webServer.js +2162 -0
  29. package/src/modules/fileExplorer/controller.js +280 -0
  30. package/src/modules/fileExplorer/index.js +37 -0
  31. package/src/modules/fileExplorer/middleware.js +92 -0
  32. package/src/modules/fileExplorer/routes.js +125 -0
  33. package/src/modules/fileExplorer/types.js +44 -0
  34. package/src/services/aiService.js +1232 -0
  35. package/src/services/apiKeyManager.js +164 -0
  36. package/src/services/benchmarkService.js +366 -0
  37. package/src/services/budgetService.js +539 -0
  38. package/src/services/contextInjectionService.js +247 -0
  39. package/src/services/conversationCompactionService.js +637 -0
  40. package/src/services/errorHandler.js +810 -0
  41. package/src/services/fileAttachmentService.js +544 -0
  42. package/src/services/modelRouterService.js +366 -0
  43. package/src/services/modelsService.js +322 -0
  44. package/src/services/qualityInspector.js +796 -0
  45. package/src/services/tokenCountingService.js +536 -0
  46. package/src/tools/agentCommunicationTool.js +1344 -0
  47. package/src/tools/agentDelayTool.js +485 -0
  48. package/src/tools/asyncToolManager.js +604 -0
  49. package/src/tools/baseTool.js +800 -0
  50. package/src/tools/browserTool.js +920 -0
  51. package/src/tools/cloneDetectionTool.js +621 -0
  52. package/src/tools/dependencyResolverTool.js +1215 -0
  53. package/src/tools/fileContentReplaceTool.js +875 -0
  54. package/src/tools/fileSystemTool.js +1107 -0
  55. package/src/tools/fileTreeTool.js +853 -0
  56. package/src/tools/imageTool.js +901 -0
  57. package/src/tools/importAnalyzerTool.js +1060 -0
  58. package/src/tools/jobDoneTool.js +248 -0
  59. package/src/tools/seekTool.js +956 -0
  60. package/src/tools/staticAnalysisTool.js +1778 -0
  61. package/src/tools/taskManagerTool.js +2873 -0
  62. package/src/tools/terminalTool.js +2304 -0
  63. package/src/tools/webTool.js +1430 -0
  64. package/src/types/agent.js +519 -0
  65. package/src/types/contextReference.js +972 -0
  66. package/src/types/conversation.js +730 -0
  67. package/src/types/toolCommand.js +747 -0
  68. package/src/utilities/attachmentValidator.js +292 -0
  69. package/src/utilities/configManager.js +582 -0
  70. package/src/utilities/constants.js +722 -0
  71. package/src/utilities/directoryAccessManager.js +535 -0
  72. package/src/utilities/fileProcessor.js +307 -0
  73. package/src/utilities/logger.js +436 -0
  74. package/src/utilities/tagParser.js +1246 -0
  75. package/src/utilities/toolConstants.js +317 -0
  76. package/web-ui/build/index.html +15 -0
  77. package/web-ui/build/logo.png +0 -0
  78. package/web-ui/build/logo2.png +0 -0
  79. package/web-ui/build/static/index-CjkkcnFA.js +344 -0
  80. package/web-ui/build/static/index-Dy2bYbOa.css +1 -0
@@ -0,0 +1,1232 @@
1
+ /**
2
+ * AIService - Manages communication with Azure backend API, model routing, rate limiting
3
+ *
4
+ * Purpose:
5
+ * - Backend API communication
6
+ * - Model selection and routing
7
+ * - Rate limiting enforcement
8
+ * - Conversation compactization
9
+ * - Token usage tracking
10
+ * - Request/response transformation
11
+ */
12
+
13
+ import {
14
+ MODELS,
15
+ MODEL_PROVIDERS,
16
+ MODEL_ROUTING,
17
+ HTTP_STATUS,
18
+ ERROR_TYPES,
19
+ SYSTEM_DEFAULTS
20
+ } from '../utilities/constants.js';
21
+
22
+ class AIService {
23
+ constructor(config, logger, budgetService, errorHandler) {
24
+ this.config = config;
25
+ this.logger = logger;
26
+ this.budgetService = budgetService;
27
+ this.errorHandler = errorHandler;
28
+
29
+ this.baseUrl = config.backend?.baseUrl || 'https://api.loxia.ai';
30
+ this.timeout = config.backend?.timeout || 60000;
31
+ this.retryAttempts = config.backend?.retryAttempts || 3;
32
+
33
+ // Rate limiting
34
+ this.rateLimiters = new Map();
35
+ this.requestQueue = new Map();
36
+
37
+ // Circuit breaker
38
+ this.circuitBreaker = {
39
+ failures: 0,
40
+ lastFailureTime: null,
41
+ isOpen: false,
42
+ threshold: 5,
43
+ timeout: 30000 // 30 seconds
44
+ };
45
+
46
+ // Model specifications
47
+ this.modelSpecs = this._initializeModelSpecs();
48
+
49
+ // Conversation managers for multi-model support
50
+ this.conversationManagers = new Map();
51
+
52
+ // API Key Manager reference (will be set by LoxiaSystem)
53
+ this.apiKeyManager = null;
54
+
55
+ // Agent Pool reference (will be set by LoxiaSystem)
56
+ this.agentPool = null;
57
+ }
58
+
59
+ /**
60
+ * Send message to backend API
61
+ * @param {string} model - Target model name
62
+ * @param {string|Array} messages - Message content or conversation history
63
+ * @param {Object} options - Additional options (agentId, systemPrompt, etc.)
64
+ * @returns {Promise<Object>} API response with content and metadata
65
+ */
66
+ async sendMessage(model, messages, options = {}) {
67
+ const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
68
+
69
+ try {
70
+ // Check circuit breaker
71
+ if (this._isCircuitBreakerOpen()) {
72
+ throw new Error('Service temporarily unavailable - circuit breaker is open');
73
+ }
74
+
75
+ // Skip local model validation - let Azure backend handle it
76
+
77
+ // Check rate limits
78
+ await this._checkRateLimit(model);
79
+
80
+ // Format messages for specific model
81
+ const formattedMessages = this._formatMessagesForModel(messages, model, options);
82
+
83
+ // Prepare request payload
84
+ const payload = {
85
+ model, // Use original model name - let Azure backend handle it
86
+ messages: formattedMessages,
87
+ options: {
88
+ max_tokens: this.modelSpecs[model]?.maxTokens || 4000,
89
+ temperature: options.temperature || 0.7,
90
+ stream: options.stream || false
91
+ },
92
+ metadata: {
93
+ requestId,
94
+ agentId: options.agentId,
95
+ timestamp: new Date().toISOString()
96
+ }
97
+ };
98
+
99
+ // Add system prompt if provided
100
+ if (options.systemPrompt) {
101
+ payload.system = options.systemPrompt;
102
+ }
103
+
104
+ this.logger.info(`Sending message to model: ${model}`, {
105
+ requestId,
106
+ agentId: options.agentId,
107
+ messageCount: Array.isArray(messages) ? messages.length : 1,
108
+ maxTokens: payload.max_tokens
109
+ });
110
+
111
+ // Make API request
112
+ const response = await this._makeAPIRequest('/chat/completions', payload, requestId, {
113
+ ...options,
114
+ sessionId: options.sessionId, // Pass session ID for API key retrieval
115
+ platformProvided: options.platformProvided || false
116
+ });
117
+
118
+ // Track usage
119
+ if (response.usage) {
120
+ await this.trackUsage(options.agentId, model, {
121
+ prompt_tokens: response.usage.prompt_tokens || 0,
122
+ completion_tokens: response.usage.completion_tokens || 0,
123
+ total_tokens: response.usage.total_tokens || 0
124
+ });
125
+ }
126
+
127
+ // Reset circuit breaker on success
128
+ this._resetCircuitBreaker();
129
+
130
+ return {
131
+ content: response.choices[0]?.message?.content || '',
132
+ model: response.model,
133
+ tokenUsage: response.usage,
134
+ requestId,
135
+ finishReason: response.choices[0]?.finish_reason
136
+ };
137
+
138
+ } catch (error) {
139
+ // Handle circuit breaker
140
+ this._recordFailure();
141
+
142
+ this.logger.error(`AI service request failed: ${error.message}`, {
143
+ requestId,
144
+ model,
145
+ agentId: options.agentId,
146
+ error: error.stack
147
+ });
148
+
149
+ // Handle specific error types
150
+ await this.handleHttpError(error, { requestId, model, agentId: options.agentId });
151
+
152
+ throw error;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Route model selection based on task and context
158
+ * @param {string} task - Task type (coding, analysis, quick-tasks, creative)
159
+ * @param {Object} context - Additional context for routing
160
+ * @returns {Promise<string>} Selected model name
161
+ */
162
+ async routeModel(task, context = {}) {
163
+ try {
164
+ // Get available models for task
165
+ const availableModels = MODEL_ROUTING[task.toUpperCase()] || MODEL_ROUTING.FALLBACK;
166
+
167
+ // Check model availability and health
168
+ const healthyModels = [];
169
+ for (const model of availableModels) {
170
+ const isHealthy = await this._checkModelHealth(model);
171
+ if (isHealthy) {
172
+ healthyModels.push(model);
173
+ }
174
+ }
175
+
176
+ if (healthyModels.length === 0) {
177
+ this.logger.warn(`No healthy models available for task: ${task}, using fallback`);
178
+ return MODEL_ROUTING.FALLBACK[0];
179
+ }
180
+
181
+ // Select model based on context
182
+ let selectedModel = healthyModels[0]; // Default to first healthy model
183
+
184
+ // Prefer model based on context
185
+ if (context.preferredModel && healthyModels.includes(context.preferredModel)) {
186
+ selectedModel = context.preferredModel;
187
+ } else if (context.complexity === 'high' && healthyModels.includes(MODELS.ANTHROPIC_OPUS)) {
188
+ selectedModel = MODELS.ANTHROPIC_OPUS;
189
+ } else if (context.speed === 'fast' && healthyModels.includes(MODELS.ANTHROPIC_HAIKU)) {
190
+ selectedModel = MODELS.ANTHROPIC_HAIKU;
191
+ }
192
+
193
+ this.logger.info(`Model routed for task: ${task}`, {
194
+ selectedModel,
195
+ availableModels: healthyModels.length,
196
+ context
197
+ });
198
+
199
+ return selectedModel;
200
+
201
+ } catch (error) {
202
+ this.logger.error(`Model routing failed: ${error.message}`, { task, context });
203
+ return MODEL_ROUTING.FALLBACK[0];
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Compactize conversation for specific model context window
209
+ * @param {Array} messages - Message history
210
+ * @param {string} targetModel - Target model name
211
+ * @returns {Promise<Array>} Compactized messages
212
+ */
213
+ async compactizeConversation(messages, targetModel) {
214
+ const modelSpec = this.modelSpecs[targetModel];
215
+ if (!modelSpec) {
216
+ throw new Error(`Unknown model: ${targetModel}`);
217
+ }
218
+
219
+ const maxTokens = modelSpec.contextWindow * 0.8; // Use 80% of context window
220
+ let currentTokens = 0;
221
+ const compactizedMessages = [];
222
+
223
+ // Estimate tokens for each message
224
+ const messagesWithTokens = await Promise.all(
225
+ messages.map(async (msg) => ({
226
+ ...msg,
227
+ estimatedTokens: await this._estimateTokens(msg.content, targetModel)
228
+ }))
229
+ );
230
+
231
+ // Start from the most recent messages
232
+ const reversedMessages = [...messagesWithTokens].reverse();
233
+
234
+ for (const message of reversedMessages) {
235
+ if (currentTokens + message.estimatedTokens > maxTokens) {
236
+ // If we've exceeded the limit, summarize older messages
237
+ if (compactizedMessages.length === 0) {
238
+ // If even the latest message is too long, truncate it
239
+ const truncatedContent = await this._truncateMessage(message.content, maxTokens);
240
+ compactizedMessages.unshift({
241
+ ...message,
242
+ content: truncatedContent,
243
+ estimatedTokens: maxTokens
244
+ });
245
+ }
246
+ break;
247
+ }
248
+
249
+ compactizedMessages.unshift(message);
250
+ currentTokens += message.estimatedTokens;
251
+ }
252
+
253
+ // If we have remaining older messages, create a summary
254
+ const remainingMessages = messagesWithTokens.slice(0, -compactizedMessages.length);
255
+ if (remainingMessages.length > 0) {
256
+ const summary = await this._summarizeMessages(remainingMessages, targetModel);
257
+ compactizedMessages.unshift({
258
+ role: 'system',
259
+ content: `Previous conversation summary: ${summary}`,
260
+ timestamp: remainingMessages[0].timestamp,
261
+ type: 'summary',
262
+ estimatedTokens: await this._estimateTokens(summary, targetModel)
263
+ });
264
+ }
265
+
266
+ this.logger.info(`Conversation compactized for model: ${targetModel}`, {
267
+ originalMessages: messages.length,
268
+ compactizedMessages: compactizedMessages.length,
269
+ estimatedTokens: currentTokens,
270
+ maxTokens
271
+ });
272
+
273
+ return compactizedMessages;
274
+ }
275
+
276
+ /**
277
+ * Track token usage and costs
278
+ * @param {number} tokens - Number of tokens used
279
+ * @param {number} cost - Cost in dollars
280
+ * @returns {Promise<void>}
281
+ */
282
+ async trackUsage(agentId, model, tokenUsage, cost) {
283
+ try {
284
+ if (this.budgetService) {
285
+ this.budgetService.trackUsage(agentId, model, tokenUsage);
286
+ }
287
+ } catch (error) {
288
+ this.logger.error(`Usage tracking failed: ${error.message}`);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Handle HTTP errors with comprehensive error handling
294
+ * @param {Error} error - Error object
295
+ * @param {Object} context - Request context
296
+ * @returns {Promise<void>}
297
+ */
298
+ async handleHttpError(error, context) {
299
+ const errorType = this.errorHandler?.classifyError?.(error, context);
300
+
301
+ switch (error.status || error.code) {
302
+ case HTTP_STATUS.BAD_REQUEST:
303
+ this.logger.error('Bad request to AI service', { context, error: error.message });
304
+ throw new Error(`Invalid request: ${error.message}`);
305
+
306
+ case HTTP_STATUS.UNAUTHORIZED:
307
+ this.logger.error('Authentication failed with AI service', { context });
308
+ throw new Error('Authentication failed - check API credentials');
309
+
310
+ case HTTP_STATUS.FORBIDDEN:
311
+ this.logger.error('Access forbidden to AI service', { context });
312
+ throw new Error('Access forbidden - insufficient permissions');
313
+
314
+ case HTTP_STATUS.NOT_FOUND:
315
+ this.logger.error('AI service endpoint not found', { context });
316
+ throw new Error('Service endpoint not found');
317
+
318
+ case HTTP_STATUS.TOO_MANY_REQUESTS:
319
+ this.logger.warn('Rate limit exceeded', { context });
320
+ await this._handleRateLimit(context);
321
+ throw new Error('Rate limit exceeded - request queued for retry');
322
+
323
+ case HTTP_STATUS.INTERNAL_SERVER_ERROR:
324
+ case HTTP_STATUS.BAD_GATEWAY:
325
+ case HTTP_STATUS.SERVICE_UNAVAILABLE:
326
+ case HTTP_STATUS.GATEWAY_TIMEOUT:
327
+ this.logger.error('AI service unavailable', { context, status: error.status });
328
+ await this._handleServiceUnavailable(context);
329
+ throw new Error('AI service temporarily unavailable');
330
+
331
+ default:
332
+ this.logger.error('Unknown AI service error', { context, error: error.message });
333
+ throw new Error(`AI service error: ${error.message}`);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Set API key manager instance
339
+ * @param {ApiKeyManager} apiKeyManager - API key manager instance
340
+ */
341
+ setApiKeyManager(apiKeyManager) {
342
+ this.apiKeyManager = apiKeyManager;
343
+
344
+ this.logger?.info('API key manager set for AI service', {
345
+ hasManager: !!apiKeyManager
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Set agent pool reference
351
+ * @param {Object} agentPool - Agent pool instance
352
+ */
353
+ setAgentPool(agentPool) {
354
+ this.agentPool = agentPool;
355
+
356
+ this.logger?.info('Agent pool set for AI service', {
357
+ hasAgentPool: !!agentPool
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Generate image from text prompt using AI models
363
+ * @param {string} prompt - Text description of the image to generate
364
+ * @param {Object} options - Generation options
365
+ * @param {string} options.model - Model to use (e.g., 'flux-1.1-pro', 'dall-e-3')
366
+ * @param {string} options.size - Image size (e.g., '1024x1024', '512x512')
367
+ * @param {string} options.quality - Image quality ('standard' or 'hd')
368
+ * @param {string} options.responseFormat - Response format ('url' or 'b64_json')
369
+ * @param {string} options.sessionId - Session ID for API key retrieval
370
+ * @param {boolean} options.platformProvided - Whether to use platform model
371
+ * @returns {Promise<Object>} Generated image result with URL or base64 data
372
+ */
373
+ async generateImage(prompt, options = {}) {
374
+ const requestId = `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
375
+
376
+ try {
377
+ // Check circuit breaker
378
+ if (this._isCircuitBreakerOpen()) {
379
+ throw new Error('Service temporarily unavailable - circuit breaker is open');
380
+ }
381
+
382
+ // Validate prompt
383
+ if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) {
384
+ throw new Error('Image generation requires a non-empty text prompt');
385
+ }
386
+
387
+ // Default options
388
+ const model = options.model || 'flux-1.1-pro';
389
+ const size = options.size || '1024x1024';
390
+ const quality = options.quality || 'standard';
391
+ const responseFormat = options.responseFormat || 'url';
392
+
393
+ this.logger.info(`Generating image with model: ${model}`, {
394
+ requestId,
395
+ model,
396
+ size,
397
+ quality,
398
+ promptLength: prompt.length
399
+ });
400
+
401
+ // Prepare request payload
402
+ const payload = {
403
+ prompt,
404
+ model,
405
+ size,
406
+ quality,
407
+ response_format: responseFormat,
408
+ n: 1, // Generate 1 image
409
+ metadata: {
410
+ requestId,
411
+ timestamp: new Date().toISOString()
412
+ }
413
+ };
414
+
415
+ // Make API request to image generation endpoint
416
+ const response = await this._makeImageAPIRequest(payload, requestId, {
417
+ sessionId: options.sessionId,
418
+ platformProvided: options.platformProvided || false
419
+ });
420
+
421
+ // Reset circuit breaker on success
422
+ this._resetCircuitBreaker();
423
+
424
+ return {
425
+ url: response.data?.[0]?.url || null,
426
+ b64_json: response.data?.[0]?.b64_json || null,
427
+ model: response.model || model,
428
+ requestId,
429
+ revisedPrompt: response.data?.[0]?.revised_prompt || prompt
430
+ };
431
+
432
+ } catch (error) {
433
+ // Handle circuit breaker
434
+ this._recordFailure();
435
+
436
+ this.logger.error(`Image generation failed: ${error.message}`, {
437
+ requestId,
438
+ model: options.model,
439
+ error: error.stack
440
+ });
441
+
442
+ throw error;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Check service health for circuit breaker
448
+ * @returns {Promise<boolean>} Service health status
449
+ */
450
+ async checkServiceHealth() {
451
+ try {
452
+ const response = await this._makeAPIRequest('/health', {}, 'health-check');
453
+ return response.status === 'healthy';
454
+ } catch (error) {
455
+ return false;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Switch agent to different model
461
+ * @param {string} agentId - Agent identifier
462
+ * @param {string} newModel - New model name
463
+ * @returns {Promise<Object>} Switch result
464
+ */
465
+ async switchAgentModel(agentId, newModel) {
466
+ try {
467
+ if (!this._isValidModel(newModel)) {
468
+ throw new Error(`Invalid model: ${newModel}`);
469
+ }
470
+
471
+ // Get conversation manager for agent
472
+ let conversationManager = this.conversationManagers.get(agentId);
473
+ if (!conversationManager) {
474
+ // Create new conversation manager if it doesn't exist
475
+ conversationManager = new ConversationManager(agentId, this.logger);
476
+ this.conversationManagers.set(agentId, conversationManager);
477
+ }
478
+
479
+ // Switch model and return conversation
480
+ const modelConversation = await conversationManager.switchModel(newModel);
481
+
482
+ // CRITICAL FIX: Update agent's currentModel field in AgentPool
483
+ const agent = await this.agentPool?.getAgent(agentId);
484
+ if (agent) {
485
+ agent.currentModel = newModel;
486
+ await this.agentPool.persistAgentState(agentId);
487
+ }
488
+
489
+ this.logger.info(`Agent model switched: ${agentId}`, {
490
+ newModel,
491
+ messageCount: modelConversation.messages.length
492
+ });
493
+
494
+ return {
495
+ success: true,
496
+ agentId,
497
+ newModel,
498
+ conversation: modelConversation
499
+ };
500
+
501
+ } catch (error) {
502
+ this.logger.error(`Model switch failed: ${error.message}`, { agentId, newModel });
503
+ throw error;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Initialize model specifications
509
+ * @private
510
+ */
511
+ _initializeModelSpecs() {
512
+ const baseSpecs = {
513
+ // Anthropic Claude models
514
+ [MODELS.ANTHROPIC_SONNET]: {
515
+ provider: MODEL_PROVIDERS.ANTHROPIC,
516
+ contextWindow: 200000,
517
+ maxTokens: 8192, // Increased from 4096
518
+ costPer1kTokens: 0.015
519
+ },
520
+ [MODELS.ANTHROPIC_OPUS]: {
521
+ provider: MODEL_PROVIDERS.ANTHROPIC,
522
+ contextWindow: 200000,
523
+ maxTokens: 8192, // Increased from 4096
524
+ costPer1kTokens: 0.075
525
+ },
526
+ [MODELS.ANTHROPIC_HAIKU]: {
527
+ provider: MODEL_PROVIDERS.ANTHROPIC,
528
+ contextWindow: 200000,
529
+ maxTokens: 8192, // Increased from 4096
530
+ costPer1kTokens: 0.0025
531
+ },
532
+
533
+ // OpenAI models
534
+ [MODELS.GPT_4]: {
535
+ provider: MODEL_PROVIDERS.OPENAI,
536
+ contextWindow: 128000,
537
+ maxTokens: 8192, // Increased from 4096
538
+ costPer1kTokens: 0.03
539
+ },
540
+ [MODELS.GPT_4_MINI]: {
541
+ provider: MODEL_PROVIDERS.OPENAI,
542
+ contextWindow: 128000,
543
+ maxTokens: 16384,
544
+ costPer1kTokens: 0.0015
545
+ },
546
+ 'gpt-4o': {
547
+ provider: MODEL_PROVIDERS.OPENAI,
548
+ contextWindow: 128000,
549
+ maxTokens: 8192,
550
+ costPer1kTokens: 0.03
551
+ },
552
+ 'gpt-4o-mini': {
553
+ provider: MODEL_PROVIDERS.OPENAI,
554
+ contextWindow: 128000,
555
+ maxTokens: 16384,
556
+ costPer1kTokens: 0.0015
557
+ },
558
+ 'gpt-4-turbo': {
559
+ provider: MODEL_PROVIDERS.OPENAI,
560
+ contextWindow: 128000,
561
+ maxTokens: 8192,
562
+ costPer1kTokens: 0.03
563
+ },
564
+ 'gpt-3.5-turbo': {
565
+ provider: MODEL_PROVIDERS.OPENAI,
566
+ contextWindow: 16384,
567
+ maxTokens: 4096,
568
+ costPer1kTokens: 0.001
569
+ },
570
+
571
+ // DeepSeek models
572
+ [MODELS.DEEPSEEK_R1]: {
573
+ provider: MODEL_PROVIDERS.DEEPSEEK,
574
+ contextWindow: 128000,
575
+ maxTokens: 8192,
576
+ costPer1kTokens: 0.002
577
+ },
578
+
579
+ // Phi models
580
+ [MODELS.PHI_4]: {
581
+ provider: MODEL_PROVIDERS.PHI,
582
+ contextWindow: 16384,
583
+ maxTokens: 4096, // Increased from 2048
584
+ costPer1kTokens: 0.001
585
+ },
586
+
587
+ // Azure AI Foundry models
588
+ 'azure-ai-grok3': {
589
+ provider: 'AZURE',
590
+ contextWindow: 128000,
591
+ maxTokens: 8192, // Increased from 4096
592
+ costPer1kTokens: 0.01
593
+ },
594
+ 'azure-ai-deepseek-r1': {
595
+ provider: 'AZURE',
596
+ contextWindow: 128000,
597
+ maxTokens: 8192,
598
+ costPer1kTokens: 0.002
599
+ },
600
+ 'azure-openai-gpt-5': {
601
+ provider: 'AZURE',
602
+ contextWindow: 128000,
603
+ maxTokens: 8192,
604
+ costPer1kTokens: 0.03
605
+ },
606
+ 'azure-openai-gpt-4': {
607
+ provider: 'AZURE',
608
+ contextWindow: 128000,
609
+ maxTokens: 8192,
610
+ costPer1kTokens: 0.03
611
+ },
612
+ 'azure-openai-gpt-4o': {
613
+ provider: 'AZURE',
614
+ contextWindow: 128000,
615
+ maxTokens: 8192,
616
+ costPer1kTokens: 0.03
617
+ },
618
+
619
+ // Router model
620
+ 'autopilot-model-router': {
621
+ provider: 'AZURE',
622
+ contextWindow: 16384,
623
+ maxTokens: 2048,
624
+ costPer1kTokens: 0.001
625
+ }
626
+ };
627
+
628
+ // No need for prefixed models anymore - just return clean base specs
629
+ return baseSpecs;
630
+ }
631
+
632
+ /**
633
+ * Format messages for specific model
634
+ * @private
635
+ */
636
+ _formatMessagesForModel(messages, model, options) {
637
+ // Get model spec or use default
638
+ const modelSpec = this.modelSpecs[model] || { provider: 'AZURE' };
639
+
640
+ let formattedMessages;
641
+
642
+ if (typeof messages === 'string') {
643
+ // Single message
644
+ formattedMessages = [{
645
+ role: 'user',
646
+ content: messages
647
+ }];
648
+ } else {
649
+ // Message array
650
+ formattedMessages = messages.map(msg => this._formatSingleMessage(msg, model));
651
+ }
652
+
653
+ // Apply provider-specific formatting
654
+ switch (modelSpec.provider) {
655
+ case MODEL_PROVIDERS.ANTHROPIC:
656
+ return this._formatForAnthropic(formattedMessages);
657
+ case MODEL_PROVIDERS.OPENAI:
658
+ return this._formatForOpenAI(formattedMessages);
659
+ case MODEL_PROVIDERS.AZURE:
660
+ return this._formatForAzure(formattedMessages);
661
+ default:
662
+ return formattedMessages;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Format single message for model
668
+ * @private
669
+ */
670
+ _formatSingleMessage(message, model) {
671
+ return {
672
+ role: message.role || 'user',
673
+ content: message.content,
674
+ timestamp: message.timestamp
675
+ };
676
+ }
677
+
678
+ /**
679
+ * Format messages for Anthropic models
680
+ * @private
681
+ */
682
+ _formatForAnthropic(messages) {
683
+ return messages.map(msg => {
684
+ if (msg.role === 'system') {
685
+ return {
686
+ role: 'user',
687
+ content: `System: ${msg.content}`
688
+ };
689
+ }
690
+ return msg;
691
+ });
692
+ }
693
+
694
+ /**
695
+ * Format messages for OpenAI models
696
+ * @private
697
+ */
698
+ _formatForOpenAI(messages) {
699
+ // OpenAI supports system role natively
700
+ return messages;
701
+ }
702
+
703
+ /**
704
+ * Format messages for Azure models
705
+ * @private
706
+ */
707
+ _formatForAzure(messages) {
708
+ // Azure may have specific formatting requirements
709
+ return messages.map(msg => ({
710
+ ...msg,
711
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
712
+ }));
713
+ }
714
+
715
+ /**
716
+ * Check if model is valid
717
+ * @private
718
+ */
719
+ _isValidModel(model) {
720
+ this.logger.debug('Validating model', { model, modelType: typeof model });
721
+
722
+ // Check if model exists in our specs directly
723
+ if (this.modelSpecs[model] !== undefined) {
724
+ return true;
725
+ }
726
+
727
+ this.logger.warn('Model validation failed', {
728
+ model,
729
+ availableModels: Object.keys(this.modelSpecs)
730
+ });
731
+
732
+ return false;
733
+ }
734
+
735
+ /**
736
+ * Check model health status
737
+ * @private
738
+ */
739
+ async _checkModelHealth(model) {
740
+ // Implementation would check model-specific health endpoints
741
+ // For now, return true (assuming all models are healthy)
742
+ return true;
743
+ }
744
+
745
+ /**
746
+ * Estimate tokens for content
747
+ * @private
748
+ */
749
+ async _estimateTokens(content, model) {
750
+ // Rough estimation: 1 token ≈ 4 characters for most models
751
+ return Math.ceil(content.length / 4);
752
+ }
753
+
754
+ /**
755
+ * Truncate message to fit token limit
756
+ * @private
757
+ */
758
+ async _truncateMessage(content, maxTokens) {
759
+ const maxChars = maxTokens * 4; // Rough estimation
760
+ if (content.length <= maxChars) {
761
+ return content;
762
+ }
763
+
764
+ return content.substring(0, maxChars - 20) + '\n... [message truncated]';
765
+ }
766
+
767
+ /**
768
+ * Summarize messages for compactization
769
+ * @private
770
+ */
771
+ async _summarizeMessages(messages, model) {
772
+ const combinedContent = messages
773
+ .map(msg => `${msg.role}: ${msg.content}`)
774
+ .join('\n');
775
+
776
+ // This would use the AI service to create a summary
777
+ // For now, return a simple truncated version
778
+ const maxLength = 500;
779
+ if (combinedContent.length <= maxLength) {
780
+ return combinedContent;
781
+ }
782
+
783
+ return combinedContent.substring(0, maxLength) + '... [conversation summary truncated]';
784
+ }
785
+
786
+ /**
787
+ * Make API request with retry logic
788
+ * @private
789
+ */
790
+ async _makeAPIRequest(endpoint, payload, requestId, options = {}) {
791
+ // Make request directly to Azure backend (not through local proxy)
792
+ const azureBackendUrl = 'https://autopilot-api.azurewebsites.net/llm/chat';
793
+ let lastError;
794
+
795
+ // Get API keys from session-based storage
796
+ let apiKey = null;
797
+ let vendorApiKey = null;
798
+
799
+ // Log the state for debugging
800
+ this.logger?.info('🔑 API Key retrieval state', {
801
+ hasApiKeyManager: !!this.apiKeyManager,
802
+ sessionId: options.sessionId,
803
+ hasSessionId: !!options.sessionId,
804
+ optionsKeys: Object.keys(options),
805
+ model: payload.model
806
+ });
807
+
808
+ // First try to get from API key manager using session ID
809
+ if (this.apiKeyManager && options.sessionId) {
810
+ const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
811
+ platformProvided: options.platformProvided || false,
812
+ vendor: this._getVendorFromModel(payload.model)
813
+ });
814
+
815
+ apiKey = keys.loxiaApiKey;
816
+ vendorApiKey = keys.vendorApiKey;
817
+
818
+ this.logger?.debug('Retrieved API keys from session manager', {
819
+ sessionId: options.sessionId,
820
+ hasLoxiaKey: !!apiKey,
821
+ hasVendorKey: !!vendorApiKey,
822
+ vendor: this._getVendorFromModel(payload.model)
823
+ });
824
+ }
825
+
826
+ // Fallback to options (passed from frontend)
827
+ if (!apiKey && options.apiKey) {
828
+ apiKey = options.apiKey;
829
+ }
830
+
831
+ // Fallback to config if no API key from session or options
832
+ if (!apiKey && this.config.apiKey) {
833
+ apiKey = this.config.apiKey;
834
+ }
835
+
836
+ if (!apiKey) {
837
+ throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
838
+ }
839
+
840
+ // Use the model name from payload (already transformed)
841
+ const modelName = payload.model;
842
+
843
+ // Transform the payload to match the Azure backend API format
844
+ const azurePayload = {
845
+ conversationId: requestId,
846
+ message: payload.messages[payload.messages.length - 1]?.content || '',
847
+ messages: payload.messages,
848
+ model: modelName,
849
+ requestId,
850
+ options: payload.options || {},
851
+ platformProvided: options.platformProvided || false // Indicate if this is a platform model
852
+ };
853
+
854
+ // Add system prompt if provided
855
+ if (payload.system) {
856
+ azurePayload.systemPrompt = payload.system;
857
+ }
858
+
859
+ // Include appropriate API key based on model type
860
+ if (options.platformProvided) {
861
+ // Platform models use Loxia API key
862
+ azurePayload.apiKey = apiKey;
863
+ } else {
864
+ // Direct access models use vendor-specific keys
865
+ if (vendorApiKey) {
866
+ azurePayload.vendorApiKey = vendorApiKey;
867
+ }
868
+
869
+ // Also include custom API keys from options for backward compatibility
870
+ if (options.customApiKeys) {
871
+ azurePayload.customApiKeys = options.customApiKeys;
872
+ }
873
+
874
+ // Include Loxia API key as fallback
875
+ azurePayload.apiKey = apiKey;
876
+ }
877
+
878
+ for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
879
+ try {
880
+ this.logger.info('Making request to Azure backend', {
881
+ url: azureBackendUrl,
882
+ model: payload.model,
883
+ requestId,
884
+ attempt,
885
+ hasApiKey: !!apiKey
886
+ });
887
+
888
+ const response = await fetch(azureBackendUrl, {
889
+ method: 'POST',
890
+ headers: {
891
+ 'Content-Type': 'application/json',
892
+ 'Authorization': `Bearer ${apiKey}`,
893
+ 'X-Request-ID': requestId
894
+ },
895
+ body: JSON.stringify(azurePayload),
896
+ timeout: this.timeout
897
+ });
898
+
899
+ if (!response.ok) {
900
+ const errorText = await response.text();
901
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
902
+ error.status = response.status;
903
+ throw error;
904
+ }
905
+
906
+ const data = await response.json();
907
+
908
+ // Transform Azure backend response to match expected format
909
+ return {
910
+ choices: [{
911
+ message: {
912
+ content: data.content || '',
913
+ role: 'assistant'
914
+ },
915
+ finish_reason: data.finishReason || 'stop'
916
+ }],
917
+ usage: data.usage || {
918
+ prompt_tokens: 0,
919
+ completion_tokens: 0,
920
+ total_tokens: 0
921
+ },
922
+ model: data.model || payload.model,
923
+ id: data.requestId || requestId
924
+ };
925
+
926
+ } catch (error) {
927
+ lastError = error;
928
+
929
+ this.logger.warn('Request to Azure backend failed', {
930
+ attempt,
931
+ requestId,
932
+ error: error.message,
933
+ status: error.status
934
+ });
935
+
936
+ if (attempt < this.retryAttempts) {
937
+ const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
938
+ this.logger.warn(`Retrying in ${delay}ms`, { attempt, requestId });
939
+ await new Promise(resolve => setTimeout(resolve, delay));
940
+ }
941
+ }
942
+ }
943
+
944
+ // If all attempts failed, return a mock response with error info
945
+ this.logger.error('All attempts to reach backend failed, using mock response', {
946
+ requestId,
947
+ error: lastError.message
948
+ });
949
+
950
+ // Check if this is an API key error that should stop retries
951
+ const isApiKeyError = lastError.message && lastError.message.includes('No API key configured');
952
+
953
+ // For API key errors, throw to stop the autonomous loop
954
+ if (isApiKeyError) {
955
+ throw new Error(`API Configuration Error: ${lastError.message}`);
956
+ }
957
+
958
+ // For other errors, return a short error message without including the original content
959
+ return {
960
+ choices: [{
961
+ message: {
962
+ content: `I apologize, but I'm unable to connect to the AI service at the moment. Error: ${lastError.message}`,
963
+ role: 'assistant'
964
+ },
965
+ finish_reason: 'stop'
966
+ }],
967
+ usage: {
968
+ prompt_tokens: 0,
969
+ completion_tokens: 0,
970
+ total_tokens: 0
971
+ },
972
+ model: payload.model,
973
+ id: `error-${requestId}`,
974
+ error: lastError.message
975
+ };
976
+ }
977
+
978
+
979
+ /**
980
+ * Make API request for image generation
981
+ * @private
982
+ */
983
+ async _makeImageAPIRequest(payload, requestId, options = {}) {
984
+ // Image generation endpoint on Azure backend (CORRECTED)
985
+ const azureImageUrl = 'https://autopilot-api.azurewebsites.net/llm/generate-image';
986
+ let lastError;
987
+
988
+ // Get API keys from session-based storage
989
+ let apiKey = null;
990
+ let vendorApiKey = null;
991
+
992
+ this.logger?.info('🖼️ Image API request state', {
993
+ hasApiKeyManager: !!this.apiKeyManager,
994
+ sessionId: options.sessionId,
995
+ hasSessionId: !!options.sessionId,
996
+ model: payload.model
997
+ });
998
+
999
+ // First try to get from API key manager using session ID
1000
+ if (this.apiKeyManager && options.sessionId) {
1001
+ const keys = this.apiKeyManager.getKeysForRequest(options.sessionId, {
1002
+ platformProvided: options.platformProvided || false,
1003
+ vendor: this._getVendorFromModel(payload.model)
1004
+ });
1005
+
1006
+ apiKey = keys.loxiaApiKey;
1007
+ vendorApiKey = keys.vendorApiKey;
1008
+
1009
+ this.logger?.debug('Retrieved API keys from session manager for image', {
1010
+ sessionId: options.sessionId,
1011
+ hasLoxiaKey: !!apiKey,
1012
+ hasVendorKey: !!vendorApiKey
1013
+ });
1014
+ }
1015
+
1016
+ // Fallback to options (passed from frontend)
1017
+ if (!apiKey && options.apiKey) {
1018
+ apiKey = options.apiKey;
1019
+ }
1020
+
1021
+ // Fallback to config if no API key from session or options
1022
+ if (!apiKey && this.config.apiKey) {
1023
+ apiKey = this.config.apiKey;
1024
+ }
1025
+
1026
+ if (!apiKey) {
1027
+ throw new Error('No API key configured. Please configure your Loxia API key in Settings.');
1028
+ }
1029
+
1030
+ // Transform the payload to match Azure backend format
1031
+ // Backend expects: prompt, model, size, quality, style, n, requestId
1032
+ const azurePayload = {
1033
+ prompt: payload.prompt,
1034
+ model: payload.model || 'azure-openai-dalle3', // Backend default
1035
+ size: payload.size,
1036
+ quality: payload.quality,
1037
+ style: payload.style || 'vivid', // Backend default
1038
+ n: payload.n || 1,
1039
+ requestId
1040
+ };
1041
+
1042
+ // API key is sent via Authorization header, not in body
1043
+
1044
+ for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
1045
+ try {
1046
+ this.logger.info('Making image request to Azure backend', {
1047
+ url: azureImageUrl,
1048
+ model: payload.model,
1049
+ requestId,
1050
+ attempt,
1051
+ hasApiKey: !!apiKey
1052
+ });
1053
+
1054
+ const response = await fetch(azureImageUrl, {
1055
+ method: 'POST',
1056
+ headers: {
1057
+ 'Content-Type': 'application/json',
1058
+ 'Authorization': `Bearer ${apiKey}`,
1059
+ 'X-Request-ID': requestId
1060
+ },
1061
+ body: JSON.stringify(azurePayload),
1062
+ timeout: this.timeout * 2 // Image generation may take longer
1063
+ });
1064
+
1065
+ if (!response.ok) {
1066
+ const errorText = await response.text();
1067
+ const error = new Error(`HTTP ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
1068
+ error.status = response.status;
1069
+ throw error;
1070
+ }
1071
+
1072
+ const data = await response.json();
1073
+
1074
+ // Backend returns: { images: [...], usage: {...}, model, requestId, created }
1075
+ // Transform to match our expected format
1076
+ return {
1077
+ data: data.images || [],
1078
+ model: data.model || payload.model,
1079
+ created: data.created || Date.now(),
1080
+ usage: data.usage
1081
+ };
1082
+
1083
+ } catch (error) {
1084
+ lastError = error;
1085
+
1086
+ this.logger.warn('Image request to Azure backend failed', {
1087
+ attempt,
1088
+ requestId,
1089
+ error: error.message,
1090
+ status: error.status
1091
+ });
1092
+
1093
+ if (attempt < this.retryAttempts) {
1094
+ const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
1095
+ this.logger.warn(`Retrying in ${delay}ms`, { attempt, requestId });
1096
+ await new Promise(resolve => setTimeout(resolve, delay));
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // If all attempts failed, throw error
1102
+ this.logger.error('All image generation attempts failed', {
1103
+ requestId,
1104
+ error: lastError.message
1105
+ });
1106
+
1107
+ throw new Error(`Image generation failed: ${lastError.message}`);
1108
+ }
1109
+
1110
+ /**
1111
+ * Check rate limits
1112
+ * @private
1113
+ */
1114
+ async _checkRateLimit(model) {
1115
+ // Implementation would check model-specific rate limits
1116
+ // For now, just add a small delay
1117
+ await new Promise(resolve => setTimeout(resolve, 100));
1118
+ }
1119
+
1120
+ /**
1121
+ * Handle rate limit exceeded
1122
+ * @private
1123
+ */
1124
+ async _handleRateLimit(context) {
1125
+ const delay = 60000; // 1 minute delay for rate limits
1126
+ this.logger.info(`Rate limit exceeded, waiting ${delay}ms`, context);
1127
+ await new Promise(resolve => setTimeout(resolve, delay));
1128
+ }
1129
+
1130
+ /**
1131
+ * Handle service unavailable
1132
+ * @private
1133
+ */
1134
+ async _handleServiceUnavailable(context) {
1135
+ this._recordFailure();
1136
+ const delay = 30000; // 30 second delay for service issues
1137
+ this.logger.info(`Service unavailable, waiting ${delay}ms`, context);
1138
+ await new Promise(resolve => setTimeout(resolve, delay));
1139
+ }
1140
+
1141
+ /**
1142
+ * Check if circuit breaker is open
1143
+ * @private
1144
+ */
1145
+ _isCircuitBreakerOpen() {
1146
+ if (!this.circuitBreaker.isOpen) {
1147
+ return false;
1148
+ }
1149
+
1150
+ const timeSinceLastFailure = Date.now() - this.circuitBreaker.lastFailureTime;
1151
+ if (timeSinceLastFailure > this.circuitBreaker.timeout) {
1152
+ this.circuitBreaker.isOpen = false;
1153
+ this.circuitBreaker.failures = 0;
1154
+ return false;
1155
+ }
1156
+
1157
+ return true;
1158
+ }
1159
+
1160
+ /**
1161
+ * Record failure for circuit breaker
1162
+ * @private
1163
+ */
1164
+ _recordFailure() {
1165
+ this.circuitBreaker.failures++;
1166
+ this.circuitBreaker.lastFailureTime = Date.now();
1167
+
1168
+ if (this.circuitBreaker.failures >= this.circuitBreaker.threshold) {
1169
+ this.circuitBreaker.isOpen = true;
1170
+ this.logger.warn('Circuit breaker opened due to repeated failures');
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Reset circuit breaker on success
1176
+ * @private
1177
+ */
1178
+ _resetCircuitBreaker() {
1179
+ if (this.circuitBreaker.failures > 0) {
1180
+ this.circuitBreaker.failures = 0;
1181
+ this.circuitBreaker.isOpen = false;
1182
+ this.logger.info('Circuit breaker reset - service recovered');
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Extract vendor name from model name
1188
+ * @param {string} model - Model name
1189
+ * @returns {string|null} Vendor name
1190
+ * @private
1191
+ */
1192
+ _getVendorFromModel(model) {
1193
+ if (!model) return null;
1194
+
1195
+ const modelName = model.toLowerCase();
1196
+
1197
+ if (modelName.includes('anthropic') || modelName.includes('claude')) {
1198
+ return 'anthropic';
1199
+ } else if (modelName.includes('openai') || modelName.includes('gpt')) {
1200
+ return 'openai';
1201
+ } else if (modelName.includes('deepseek')) {
1202
+ return 'deepseek';
1203
+ } else if (modelName.includes('phi')) {
1204
+ return 'microsoft';
1205
+ }
1206
+
1207
+ return null;
1208
+ }
1209
+ }
1210
+
1211
+ /**
1212
+ * ConversationManager - Handles multi-model conversation state
1213
+ */
1214
+ class ConversationManager {
1215
+ constructor(agentId, logger) {
1216
+ this.agentId = agentId;
1217
+ this.logger = logger;
1218
+ this.conversations = new Map();
1219
+ }
1220
+
1221
+ async switchModel(newModel) {
1222
+ // Implementation would handle model switching logic
1223
+ // For now, return empty conversation
1224
+ return {
1225
+ messages: [],
1226
+ model: newModel,
1227
+ lastUpdated: new Date().toISOString()
1228
+ };
1229
+ }
1230
+ }
1231
+
1232
+ export default AIService;