portos-ai-toolkit 0.1.0 → 0.3.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/README.md +4 -0
- package/package.json +5 -1
- package/src/server/errorDetection.js +305 -0
- package/src/server/errorDetection.test.js +180 -0
- package/src/server/index.d.ts +1 -1
- package/src/server/index.js +52 -6
- package/src/server/providerStatus.js +313 -0
- package/src/server/providerStatus.test.js +368 -0
- package/src/server/providers.js +6 -0
- package/src/server/routes/providerStatus.js +50 -0
- package/src/server/runner.js +143 -19
- package/src/shared/constants.js +31 -0
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ npm install portos-ai-toolkit
|
|
|
16
16
|
- **React Components**: Ready-to-use React components and hooks for AI provider management
|
|
17
17
|
- **Express Routes**: Pre-built Express route handlers for provider, prompt, and run management
|
|
18
18
|
|
|
19
|
+
## Screenshot
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
19
23
|
## Usage
|
|
20
24
|
|
|
21
25
|
### Server-side
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portos-ai-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared AI provider, model, and prompt template patterns for PortOS-style applications",
|
|
5
5
|
"author": "Adam Eivy <adam@eivy.com> (https://atomantic.com)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"express": "^4.21.2 || ^5.2.1",
|
|
41
41
|
"socket.io": "^4.8.3",
|
|
42
|
+
"socket.io-client": "^4.8.3",
|
|
42
43
|
"react": "^18.3.1",
|
|
43
44
|
"react-dom": "^18.3.1"
|
|
44
45
|
},
|
|
@@ -49,6 +50,9 @@
|
|
|
49
50
|
"socket.io": {
|
|
50
51
|
"optional": true
|
|
51
52
|
},
|
|
53
|
+
"socket.io-client": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
52
56
|
"react": {
|
|
53
57
|
"optional": true
|
|
54
58
|
},
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Detection Utility
|
|
3
|
+
*
|
|
4
|
+
* Detects and categorizes errors from AI provider responses,
|
|
5
|
+
* particularly rate limits and usage limits that require fallback handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Error categories and their characteristics
|
|
10
|
+
*/
|
|
11
|
+
export const ERROR_CATEGORIES = {
|
|
12
|
+
RATE_LIMIT: 'rate-limit',
|
|
13
|
+
USAGE_LIMIT: 'usage-limit',
|
|
14
|
+
AUTH_ERROR: 'auth-error',
|
|
15
|
+
MODEL_NOT_FOUND: 'model-not-found',
|
|
16
|
+
NETWORK_ERROR: 'network-error',
|
|
17
|
+
TIMEOUT: 'timeout',
|
|
18
|
+
QUOTA_EXCEEDED: 'quota-exceeded',
|
|
19
|
+
UNKNOWN: 'unknown'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Error patterns for detection
|
|
24
|
+
* NOTE: Order matters! More specific patterns should come before general ones.
|
|
25
|
+
*/
|
|
26
|
+
const ERROR_PATTERNS = [
|
|
27
|
+
// Quota exceeded (billing issues - check before usage limit since "quota" could match both)
|
|
28
|
+
{
|
|
29
|
+
pattern: /billing|payment|credit|insufficient funds/i,
|
|
30
|
+
category: ERROR_CATEGORIES.QUOTA_EXCEEDED,
|
|
31
|
+
requiresFallback: true,
|
|
32
|
+
actionable: true,
|
|
33
|
+
suggestedFix: 'Check billing status and add credits to the provider account'
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Rate limiting (temporary, short wait)
|
|
37
|
+
{
|
|
38
|
+
pattern: /API Error: 429|rate.?limit|too many requests/i,
|
|
39
|
+
category: ERROR_CATEGORIES.RATE_LIMIT,
|
|
40
|
+
requiresFallback: false, // Usually temporary
|
|
41
|
+
actionable: false,
|
|
42
|
+
suggestedFix: 'Wait and retry - temporary rate limiting'
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// Usage limits (longer wait, fallback recommended)
|
|
46
|
+
{
|
|
47
|
+
pattern: /(?:hit your usage limit|You've hit your limit|usage limit|Upgrade to Pro)/i,
|
|
48
|
+
category: ERROR_CATEGORIES.USAGE_LIMIT,
|
|
49
|
+
requiresFallback: true,
|
|
50
|
+
actionable: true,
|
|
51
|
+
suggestedFix: 'Provider usage limit reached. Using fallback provider or wait for limit reset.',
|
|
52
|
+
extractWaitTime: true
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Authentication errors
|
|
56
|
+
{
|
|
57
|
+
pattern: /unauthorized|invalid.?api.?key|authentication|forbidden|401|403/i,
|
|
58
|
+
category: ERROR_CATEGORIES.AUTH_ERROR,
|
|
59
|
+
requiresFallback: true,
|
|
60
|
+
actionable: true,
|
|
61
|
+
suggestedFix: 'Check API key configuration for this provider'
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// Model not found
|
|
65
|
+
{
|
|
66
|
+
pattern: /model.*(not found|does not exist|unavailable)|invalid model/i,
|
|
67
|
+
category: ERROR_CATEGORIES.MODEL_NOT_FOUND,
|
|
68
|
+
requiresFallback: true,
|
|
69
|
+
actionable: true,
|
|
70
|
+
suggestedFix: 'Check model name and availability in provider settings'
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// Network errors
|
|
74
|
+
{
|
|
75
|
+
pattern: /ECONNREFUSED|ENOTFOUND|network error|connection refused|timeout|ETIMEDOUT/i,
|
|
76
|
+
category: ERROR_CATEGORIES.NETWORK_ERROR,
|
|
77
|
+
requiresFallback: false, // Often temporary
|
|
78
|
+
actionable: false,
|
|
79
|
+
suggestedFix: 'Check network connectivity and provider endpoint URL'
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Timeout
|
|
83
|
+
{
|
|
84
|
+
pattern: /timed out|timeout exceeded|SIGTERM/i,
|
|
85
|
+
category: ERROR_CATEGORIES.TIMEOUT,
|
|
86
|
+
requiresFallback: false,
|
|
87
|
+
actionable: false,
|
|
88
|
+
suggestedFix: 'Consider increasing timeout or reducing prompt complexity'
|
|
89
|
+
}
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Wait time extraction patterns
|
|
94
|
+
*/
|
|
95
|
+
const WAIT_TIME_PATTERNS = [
|
|
96
|
+
// "resets 6am (America/Los_Angeles)" - extract timezone-aware time
|
|
97
|
+
/resets?\s+(\d{1,2}(?:am|pm)?)\s*\(([^)]+)\)/i,
|
|
98
|
+
// "try again in X day(s) X hour(s) X minute(s)"
|
|
99
|
+
/try again in\s+((?:\d+\s*(?:day|hour|minute|second)s?\s*)+)/i,
|
|
100
|
+
// "wait X minutes/hours/days"
|
|
101
|
+
/wait\s+((?:\d+\s*(?:day|hour|minute|second)s?\s*)+)/i,
|
|
102
|
+
// "in X hours", "in X minutes"
|
|
103
|
+
/in\s+(\d+)\s*(day|hour|minute|second)s?/i,
|
|
104
|
+
// Specific time format "1 day 1 hour 33 minutes"
|
|
105
|
+
/(\d+\s*day(?:s)?)?[,\s]*(\d+\s*hour(?:s)?)?[,\s]*(\d+\s*min(?:ute)?(?:s)?)?/i
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract wait time from error message
|
|
110
|
+
* @param {string} text - Error text to search
|
|
111
|
+
* @returns {string|null} - Human-readable wait time or null
|
|
112
|
+
*/
|
|
113
|
+
export function extractWaitTime(text) {
|
|
114
|
+
if (!text) return null;
|
|
115
|
+
|
|
116
|
+
// Try each pattern
|
|
117
|
+
for (const pattern of WAIT_TIME_PATTERNS) {
|
|
118
|
+
const match = text.match(pattern);
|
|
119
|
+
if (match) {
|
|
120
|
+
// Clean up and return the matched time string
|
|
121
|
+
const timeStr = match.slice(1).filter(Boolean).join(' ').trim();
|
|
122
|
+
if (timeStr && timeStr !== ' ') {
|
|
123
|
+
return timeStr;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Try to find any time-related content
|
|
129
|
+
const generalMatch = text.match(/(\d+)\s*(day|hour|min|sec)(?:ute)?(?:s)?/gi);
|
|
130
|
+
if (generalMatch) {
|
|
131
|
+
return generalMatch.join(' ');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Analyze error text and categorize it
|
|
139
|
+
*
|
|
140
|
+
* @param {string} errorText - The error message or output to analyze
|
|
141
|
+
* @param {number} exitCode - Optional exit code from CLI process
|
|
142
|
+
* @returns {Object} - Error analysis result
|
|
143
|
+
*/
|
|
144
|
+
export function analyzeError(errorText, exitCode = null) {
|
|
145
|
+
if (!errorText && exitCode === 0) {
|
|
146
|
+
return {
|
|
147
|
+
hasError: false,
|
|
148
|
+
category: null,
|
|
149
|
+
message: null,
|
|
150
|
+
waitTime: null,
|
|
151
|
+
requiresFallback: false,
|
|
152
|
+
actionable: false,
|
|
153
|
+
suggestedFix: null
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const text = String(errorText || '');
|
|
158
|
+
|
|
159
|
+
// Check each pattern
|
|
160
|
+
for (const errorPattern of ERROR_PATTERNS) {
|
|
161
|
+
if (errorPattern.pattern.test(text)) {
|
|
162
|
+
const result = {
|
|
163
|
+
hasError: true,
|
|
164
|
+
category: errorPattern.category,
|
|
165
|
+
message: extractErrorMessage(text),
|
|
166
|
+
waitTime: errorPattern.extractWaitTime ? extractWaitTime(text) : null,
|
|
167
|
+
requiresFallback: errorPattern.requiresFallback,
|
|
168
|
+
actionable: errorPattern.actionable,
|
|
169
|
+
suggestedFix: errorPattern.suggestedFix
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If we have an error but couldn't categorize it
|
|
177
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
178
|
+
return {
|
|
179
|
+
hasError: true,
|
|
180
|
+
category: ERROR_CATEGORIES.UNKNOWN,
|
|
181
|
+
message: extractErrorMessage(text) || `Process exited with code ${exitCode}`,
|
|
182
|
+
waitTime: null,
|
|
183
|
+
requiresFallback: false,
|
|
184
|
+
actionable: false,
|
|
185
|
+
suggestedFix: null
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
hasError: false,
|
|
191
|
+
category: null,
|
|
192
|
+
message: null,
|
|
193
|
+
waitTime: null,
|
|
194
|
+
requiresFallback: false,
|
|
195
|
+
actionable: false,
|
|
196
|
+
suggestedFix: null
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Extract the most relevant error message from text
|
|
202
|
+
* @param {string} text - Full error text
|
|
203
|
+
* @returns {string} - Extracted error message
|
|
204
|
+
*/
|
|
205
|
+
function extractErrorMessage(text) {
|
|
206
|
+
if (!text) return '';
|
|
207
|
+
|
|
208
|
+
// Try to find common error message patterns
|
|
209
|
+
const patterns = [
|
|
210
|
+
/Error:\s*(.+?)(?:\n|$)/i,
|
|
211
|
+
/error":\s*"([^"]+)"/i,
|
|
212
|
+
/message":\s*"([^"]+)"/i,
|
|
213
|
+
/failed:\s*(.+?)(?:\n|$)/i
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const pattern of patterns) {
|
|
217
|
+
const match = text.match(pattern);
|
|
218
|
+
if (match) {
|
|
219
|
+
return match[1].trim();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Return first meaningful line
|
|
224
|
+
const lines = text.split('\n').filter(line => line.trim());
|
|
225
|
+
return lines[0]?.substring(0, 200) || text.substring(0, 200);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Check if an HTTP status code indicates rate limiting
|
|
230
|
+
* @param {number} statusCode - HTTP status code
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
*/
|
|
233
|
+
export function isRateLimitStatus(statusCode) {
|
|
234
|
+
return statusCode === 429;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if an HTTP status code indicates auth error
|
|
239
|
+
* @param {number} statusCode - HTTP status code
|
|
240
|
+
* @returns {boolean}
|
|
241
|
+
*/
|
|
242
|
+
export function isAuthErrorStatus(statusCode) {
|
|
243
|
+
return statusCode === 401 || statusCode === 403;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Analyze an HTTP response for errors
|
|
248
|
+
* @param {Object} response - HTTP response object with status and body
|
|
249
|
+
* @returns {Object} - Error analysis result
|
|
250
|
+
*/
|
|
251
|
+
export function analyzeHttpError(response) {
|
|
252
|
+
const { status, statusText, body } = response;
|
|
253
|
+
|
|
254
|
+
if (status >= 200 && status < 300) {
|
|
255
|
+
return {
|
|
256
|
+
hasError: false,
|
|
257
|
+
category: null,
|
|
258
|
+
message: null,
|
|
259
|
+
waitTime: null,
|
|
260
|
+
requiresFallback: false,
|
|
261
|
+
actionable: false,
|
|
262
|
+
suggestedFix: null
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check status code first
|
|
267
|
+
if (isRateLimitStatus(status)) {
|
|
268
|
+
return {
|
|
269
|
+
hasError: true,
|
|
270
|
+
category: ERROR_CATEGORIES.RATE_LIMIT,
|
|
271
|
+
message: `Rate limit exceeded (${status})`,
|
|
272
|
+
waitTime: extractWaitTime(body),
|
|
273
|
+
requiresFallback: false,
|
|
274
|
+
actionable: false,
|
|
275
|
+
suggestedFix: 'Wait and retry - temporary rate limiting'
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isAuthErrorStatus(status)) {
|
|
280
|
+
return {
|
|
281
|
+
hasError: true,
|
|
282
|
+
category: ERROR_CATEGORIES.AUTH_ERROR,
|
|
283
|
+
message: `Authentication failed (${status})`,
|
|
284
|
+
waitTime: null,
|
|
285
|
+
requiresFallback: true,
|
|
286
|
+
actionable: true,
|
|
287
|
+
suggestedFix: 'Check API key configuration for this provider'
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Analyze body for more specific errors
|
|
292
|
+
if (body) {
|
|
293
|
+
return analyzeError(body);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
hasError: true,
|
|
298
|
+
category: ERROR_CATEGORIES.UNKNOWN,
|
|
299
|
+
message: statusText || `HTTP ${status}`,
|
|
300
|
+
waitTime: null,
|
|
301
|
+
requiresFallback: false,
|
|
302
|
+
actionable: false,
|
|
303
|
+
suggestedFix: null
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
analyzeError,
|
|
4
|
+
analyzeHttpError,
|
|
5
|
+
extractWaitTime,
|
|
6
|
+
ERROR_CATEGORIES
|
|
7
|
+
} from './errorDetection.js';
|
|
8
|
+
|
|
9
|
+
describe('Error Detection', () => {
|
|
10
|
+
describe('analyzeError', () => {
|
|
11
|
+
it('should detect rate limit errors', () => {
|
|
12
|
+
const result = analyzeError('API Error: 429 Too Many Requests');
|
|
13
|
+
expect(result.hasError).toBe(true);
|
|
14
|
+
expect(result.category).toBe(ERROR_CATEGORIES.RATE_LIMIT);
|
|
15
|
+
expect(result.requiresFallback).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should detect rate limit from "rate limit" text', () => {
|
|
19
|
+
const result = analyzeError('Rate limit exceeded. Please try again later.');
|
|
20
|
+
expect(result.hasError).toBe(true);
|
|
21
|
+
expect(result.category).toBe(ERROR_CATEGORIES.RATE_LIMIT);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should detect usage limit errors', () => {
|
|
25
|
+
const result = analyzeError("You've hit your usage limit. Upgrade to Pro or try again in 1 day 1 hour 33 minutes");
|
|
26
|
+
expect(result.hasError).toBe(true);
|
|
27
|
+
expect(result.category).toBe(ERROR_CATEGORIES.USAGE_LIMIT);
|
|
28
|
+
expect(result.requiresFallback).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should extract wait time from usage limit errors', () => {
|
|
32
|
+
const result = analyzeError("You've hit your usage limit. Upgrade to Pro or try again in 1 day 1 hour 33 minutes");
|
|
33
|
+
expect(result.waitTime).toBeTruthy();
|
|
34
|
+
expect(result.waitTime).toContain('day');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should detect authentication errors', () => {
|
|
38
|
+
const result = analyzeError('Error: 401 Unauthorized - Invalid API key');
|
|
39
|
+
expect(result.hasError).toBe(true);
|
|
40
|
+
expect(result.category).toBe(ERROR_CATEGORIES.AUTH_ERROR);
|
|
41
|
+
expect(result.requiresFallback).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should detect model not found errors', () => {
|
|
45
|
+
const result = analyzeError('Error: model "claude-9" does not exist');
|
|
46
|
+
expect(result.hasError).toBe(true);
|
|
47
|
+
expect(result.category).toBe(ERROR_CATEGORIES.MODEL_NOT_FOUND);
|
|
48
|
+
expect(result.requiresFallback).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should detect network errors', () => {
|
|
52
|
+
const result = analyzeError('Error: ECONNREFUSED 127.0.0.1:8080');
|
|
53
|
+
expect(result.hasError).toBe(true);
|
|
54
|
+
expect(result.category).toBe(ERROR_CATEGORIES.NETWORK_ERROR);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should detect timeout errors', () => {
|
|
58
|
+
const result = analyzeError('Process timed out after 300000ms');
|
|
59
|
+
expect(result.hasError).toBe(true);
|
|
60
|
+
expect(result.category).toBe(ERROR_CATEGORIES.TIMEOUT);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should detect quota exceeded errors', () => {
|
|
64
|
+
const result = analyzeError('Error: Billing quota exceeded. Please add credits.');
|
|
65
|
+
expect(result.hasError).toBe(true);
|
|
66
|
+
expect(result.category).toBe(ERROR_CATEGORIES.QUOTA_EXCEEDED);
|
|
67
|
+
expect(result.requiresFallback).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should return unknown for unrecognized errors with exit code', () => {
|
|
71
|
+
const result = analyzeError('Some unknown error occurred', 1);
|
|
72
|
+
expect(result.hasError).toBe(true);
|
|
73
|
+
expect(result.category).toBe(ERROR_CATEGORIES.UNKNOWN);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return no error for success', () => {
|
|
77
|
+
const result = analyzeError('', 0);
|
|
78
|
+
expect(result.hasError).toBe(false);
|
|
79
|
+
expect(result.category).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle null/undefined input', () => {
|
|
83
|
+
const result = analyzeError(null, 0);
|
|
84
|
+
expect(result.hasError).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('extractWaitTime', () => {
|
|
89
|
+
it('should extract "X day X hour X minutes" format', () => {
|
|
90
|
+
const result = extractWaitTime('try again in 1 day 2 hours 30 minutes');
|
|
91
|
+
expect(result).toBeTruthy();
|
|
92
|
+
expect(result).toContain('day');
|
|
93
|
+
expect(result).toContain('hour');
|
|
94
|
+
expect(result).toContain('min');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should extract "in X hours" format', () => {
|
|
98
|
+
const result = extractWaitTime('Please wait, available in 3 hours');
|
|
99
|
+
expect(result).toBeTruthy();
|
|
100
|
+
expect(result).toMatch(/3\s*hour/i);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should extract "wait X minutes" format', () => {
|
|
104
|
+
const result = extractWaitTime('Wait 5 minutes before retrying');
|
|
105
|
+
expect(result).toBeTruthy();
|
|
106
|
+
expect(result).toMatch(/5\s*min/i);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should return null for no time found', () => {
|
|
110
|
+
const result = extractWaitTime('No time information here');
|
|
111
|
+
expect(result).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should handle null input', () => {
|
|
115
|
+
const result = extractWaitTime(null);
|
|
116
|
+
expect(result).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('analyzeHttpError', () => {
|
|
121
|
+
it('should detect 429 rate limit', () => {
|
|
122
|
+
const result = analyzeHttpError({
|
|
123
|
+
status: 429,
|
|
124
|
+
statusText: 'Too Many Requests',
|
|
125
|
+
body: ''
|
|
126
|
+
});
|
|
127
|
+
expect(result.hasError).toBe(true);
|
|
128
|
+
expect(result.category).toBe(ERROR_CATEGORIES.RATE_LIMIT);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should detect 401 auth error', () => {
|
|
132
|
+
const result = analyzeHttpError({
|
|
133
|
+
status: 401,
|
|
134
|
+
statusText: 'Unauthorized',
|
|
135
|
+
body: ''
|
|
136
|
+
});
|
|
137
|
+
expect(result.hasError).toBe(true);
|
|
138
|
+
expect(result.category).toBe(ERROR_CATEGORIES.AUTH_ERROR);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should detect 403 auth error', () => {
|
|
142
|
+
const result = analyzeHttpError({
|
|
143
|
+
status: 403,
|
|
144
|
+
statusText: 'Forbidden',
|
|
145
|
+
body: ''
|
|
146
|
+
});
|
|
147
|
+
expect(result.hasError).toBe(true);
|
|
148
|
+
expect(result.category).toBe(ERROR_CATEGORIES.AUTH_ERROR);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return no error for 200 status', () => {
|
|
152
|
+
const result = analyzeHttpError({
|
|
153
|
+
status: 200,
|
|
154
|
+
statusText: 'OK',
|
|
155
|
+
body: ''
|
|
156
|
+
});
|
|
157
|
+
expect(result.hasError).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should analyze body for more specific errors', () => {
|
|
161
|
+
const result = analyzeHttpError({
|
|
162
|
+
status: 400,
|
|
163
|
+
statusText: 'Bad Request',
|
|
164
|
+
body: 'Error: model "invalid-model" does not exist'
|
|
165
|
+
});
|
|
166
|
+
expect(result.hasError).toBe(true);
|
|
167
|
+
expect(result.category).toBe(ERROR_CATEGORIES.MODEL_NOT_FOUND);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should extract wait time from 429 response body', () => {
|
|
171
|
+
const result = analyzeHttpError({
|
|
172
|
+
status: 429,
|
|
173
|
+
statusText: 'Too Many Requests',
|
|
174
|
+
body: 'Rate limit exceeded. Try again in 5 minutes.'
|
|
175
|
+
});
|
|
176
|
+
expect(result.hasError).toBe(true);
|
|
177
|
+
expect(result.waitTime).toBeTruthy();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/server/index.d.ts
CHANGED
package/src/server/index.js
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
import { createProviderService } from './providers.js';
|
|
7
7
|
import { createRunnerService } from './runner.js';
|
|
8
8
|
import { createPromptsService } from './prompts.js';
|
|
9
|
+
import { createProviderStatusService } from './providerStatus.js';
|
|
9
10
|
import { createProvidersRoutes } from './routes/providers.js';
|
|
10
11
|
import { createRunsRoutes } from './routes/runs.js';
|
|
11
12
|
import { createPromptsRoutes } from './routes/prompts.js';
|
|
13
|
+
import { createProviderStatusRoutes } from './routes/providerStatus.js';
|
|
12
14
|
|
|
13
15
|
export * from './validation.js';
|
|
14
|
-
export
|
|
15
|
-
export {
|
|
16
|
+
export * from './errorDetection.js';
|
|
17
|
+
export { createProviderService, createRunnerService, createPromptsService, createProviderStatusService };
|
|
18
|
+
export { createProvidersRoutes, createRunsRoutes, createPromptsRoutes, createProviderStatusRoutes };
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* Create a complete AI toolkit instance with services and routes
|
|
@@ -21,6 +24,7 @@ export function createAIToolkit(config = {}) {
|
|
|
21
24
|
const {
|
|
22
25
|
dataDir = './data',
|
|
23
26
|
providersFile = 'providers.json',
|
|
27
|
+
statusFile = 'provider-status.json',
|
|
24
28
|
runsDir = 'runs',
|
|
25
29
|
promptsDir = 'prompts',
|
|
26
30
|
screenshotsDir = './data/screenshots',
|
|
@@ -36,7 +40,11 @@ export function createAIToolkit(config = {}) {
|
|
|
36
40
|
hooks = {},
|
|
37
41
|
|
|
38
42
|
// Runner config
|
|
39
|
-
maxConcurrentRuns = 5
|
|
43
|
+
maxConcurrentRuns = 5,
|
|
44
|
+
|
|
45
|
+
// Provider status config
|
|
46
|
+
enableProviderStatus = true,
|
|
47
|
+
defaultFallbackPriority = ['claude-code', 'codex', 'lmstudio', 'local-lm-studio', 'ollama', 'gemini-cli']
|
|
40
48
|
} = config;
|
|
41
49
|
|
|
42
50
|
// Create services
|
|
@@ -46,12 +54,39 @@ export function createAIToolkit(config = {}) {
|
|
|
46
54
|
sampleFile: sampleProvidersFile
|
|
47
55
|
});
|
|
48
56
|
|
|
57
|
+
// Create provider status service if enabled
|
|
58
|
+
let providerStatusService = null;
|
|
59
|
+
if (enableProviderStatus) {
|
|
60
|
+
providerStatusService = createProviderStatusService({
|
|
61
|
+
dataDir,
|
|
62
|
+
statusFile,
|
|
63
|
+
defaultFallbackPriority,
|
|
64
|
+
onStatusChange: (eventData) => {
|
|
65
|
+
// Emit Socket.IO event if io is configured
|
|
66
|
+
io?.emit('provider:status:changed', eventData);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Initialize status service
|
|
71
|
+
providerStatusService.init().catch(err => {
|
|
72
|
+
console.error(`❌ Failed to initialize provider status: ${err.message}`);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
const runnerService = createRunnerService({
|
|
50
77
|
dataDir,
|
|
51
78
|
runsDir,
|
|
52
79
|
screenshotsDir,
|
|
53
80
|
providerService,
|
|
54
|
-
|
|
81
|
+
providerStatusService,
|
|
82
|
+
hooks: {
|
|
83
|
+
...hooks,
|
|
84
|
+
// Add hook to emit provider error events
|
|
85
|
+
onProviderError: (providerId, errorAnalysis, output) => {
|
|
86
|
+
io?.emit('provider:error', { providerId, errorAnalysis });
|
|
87
|
+
hooks.onProviderError?.(providerId, errorAnalysis, output);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
55
90
|
maxConcurrentRuns
|
|
56
91
|
});
|
|
57
92
|
|
|
@@ -70,19 +105,27 @@ export function createAIToolkit(config = {}) {
|
|
|
70
105
|
const runsRouter = createRunsRoutes(runnerService, { asyncHandler, io });
|
|
71
106
|
const promptsRouter = createPromptsRoutes(promptsService, { asyncHandler });
|
|
72
107
|
|
|
108
|
+
// Create provider status routes if enabled
|
|
109
|
+
let providerStatusRouter = null;
|
|
110
|
+
if (providerStatusService) {
|
|
111
|
+
providerStatusRouter = createProviderStatusRoutes(providerStatusService, { asyncHandler });
|
|
112
|
+
}
|
|
113
|
+
|
|
73
114
|
return {
|
|
74
115
|
// Services
|
|
75
116
|
services: {
|
|
76
117
|
providers: providerService,
|
|
77
118
|
runner: runnerService,
|
|
78
|
-
prompts: promptsService
|
|
119
|
+
prompts: promptsService,
|
|
120
|
+
providerStatus: providerStatusService
|
|
79
121
|
},
|
|
80
122
|
|
|
81
123
|
// Routes
|
|
82
124
|
routes: {
|
|
83
125
|
providers: providersRouter,
|
|
84
126
|
runs: runsRouter,
|
|
85
|
-
prompts: promptsRouter
|
|
127
|
+
prompts: promptsRouter,
|
|
128
|
+
providerStatus: providerStatusRouter
|
|
86
129
|
},
|
|
87
130
|
|
|
88
131
|
// Convenience method to mount all routes
|
|
@@ -90,6 +133,9 @@ export function createAIToolkit(config = {}) {
|
|
|
90
133
|
app.use(`${basePath}/providers`, providersRouter);
|
|
91
134
|
app.use(`${basePath}/runs`, runsRouter);
|
|
92
135
|
app.use(`${basePath}/prompts`, promptsRouter);
|
|
136
|
+
if (providerStatusRouter) {
|
|
137
|
+
app.use(`${basePath}/providers/status`, providerStatusRouter);
|
|
138
|
+
}
|
|
93
139
|
}
|
|
94
140
|
};
|
|
95
141
|
}
|