te.js 2.1.4 → 2.1.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.
- package/docs/configuration.md +24 -10
- package/docs/error-handling.md +134 -50
- package/lib/llm/client.js +34 -9
- package/package.json +1 -1
- package/server/ammo.js +84 -10
- package/server/errors/channels/base.js +31 -0
- package/server/errors/channels/console.js +64 -0
- package/server/errors/channels/index.js +111 -0
- package/server/errors/channels/log.js +27 -0
- package/server/errors/llm-cache.js +102 -0
- package/server/errors/llm-error-service.js +76 -15
- package/server/errors/llm-rate-limiter.js +72 -0
- package/server/handler.js +50 -12
- package/server/targets/registry.js +6 -6
- package/te.js +39 -11
- package/utils/errors-llm-config.js +137 -7
package/docs/configuration.md
CHANGED
|
@@ -110,15 +110,22 @@ These options configure the `tejas generate:docs` CLI command and the auto-docum
|
|
|
110
110
|
|
|
111
111
|
### Error handling (LLM-inferred errors)
|
|
112
112
|
|
|
113
|
-
When [LLM-inferred error codes and messages](./error-handling.md#llm-inferred-errors) are enabled, the **`errors.llm`** block configures the LLM used for inferring status code and message when you call `ammo.throw()` without explicit code or message. Unset values fall back to `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL`. You can also enable (and optionally set connection options) by calling **`app.withLLMErrors(config?)`** before `takeoff()` — e.g. `app.withLLMErrors()` to use env/config for baseURL, apiKey, model, or `app.withLLMErrors({ baseURL, apiKey, model, messageType })` to override in code.
|
|
114
|
-
|
|
115
|
-
| Config Key | Env Variable
|
|
116
|
-
| ------------------------ |
|
|
117
|
-
| `errors.llm.enabled` | `ERRORS_LLM_ENABLED`
|
|
118
|
-
| `errors.llm.baseURL` | `ERRORS_LLM_BASE_URL` or `LLM_BASE_URL`
|
|
119
|
-
| `errors.llm.apiKey` | `ERRORS_LLM_API_KEY` or `LLM_API_KEY`
|
|
120
|
-
| `errors.llm.model` | `ERRORS_LLM_MODEL` or `LLM_MODEL`
|
|
121
|
-
| `errors.llm.messageType` | `ERRORS_LLM_MESSAGE_TYPE` or `LLM_MESSAGE_TYPE`
|
|
113
|
+
When [LLM-inferred error codes and messages](./error-handling.md#llm-inferred-errors) are enabled, the **`errors.llm`** block configures the LLM used for inferring status code and message when you call `ammo.throw()` without explicit code or message. Unset values fall back to `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL`. You can also enable (and optionally set connection options) by calling **`app.withLLMErrors(config?)`** before `takeoff()` — e.g. `app.withLLMErrors()` to use env/config for baseURL, apiKey, model, or `app.withLLMErrors({ baseURL, apiKey, model, messageType, mode, ... })` to override in code.
|
|
114
|
+
|
|
115
|
+
| Config Key | Env Variable | Type | Default | Description |
|
|
116
|
+
| ------------------------ | ----------------------------------------------- | ---------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| `errors.llm.enabled` | `ERRORS_LLM_ENABLED` | boolean | `false` | Enable LLM-inferred error code and message for `ammo.throw()` and framework-caught errors. |
|
|
118
|
+
| `errors.llm.baseURL` | `ERRORS_LLM_BASE_URL` or `LLM_BASE_URL` | string | — | LLM provider endpoint (e.g. `https://api.openai.com/v1`). Required when enabled. |
|
|
119
|
+
| `errors.llm.apiKey` | `ERRORS_LLM_API_KEY` or `LLM_API_KEY` | string | — | LLM provider API key. Required when enabled. |
|
|
120
|
+
| `errors.llm.model` | `ERRORS_LLM_MODEL` or `LLM_MODEL` | string | — | LLM model name (e.g. `gpt-4o-mini`). Required when enabled. |
|
|
121
|
+
| `errors.llm.messageType` | `ERRORS_LLM_MESSAGE_TYPE` or `LLM_MESSAGE_TYPE` | `"endUser"` \| `"developer"` | `"endUser"` | Default tone for LLM-generated messages. `endUser` is safe for clients; `developer` includes technical detail. Overridable per `ammo.throw()` call. |
|
|
122
|
+
| `errors.llm.mode` | `ERRORS_LLM_MODE` or `LLM_MODE` | `"sync"` \| `"async"` | `"sync"` | `sync` blocks the HTTP response until the LLM returns. `async` sends an immediate 500 and runs the LLM in the background, dispatching the result to the configured channel. |
|
|
123
|
+
| `errors.llm.timeout` | `ERRORS_LLM_TIMEOUT` or `LLM_TIMEOUT` | number (ms) | `10000` | Maximum time in milliseconds to wait for an LLM response before aborting with a timeout error. |
|
|
124
|
+
| `errors.llm.channel` | `ERRORS_LLM_CHANNEL` or `LLM_CHANNEL` | `"console"` \| `"log"` \| `"both"` | `"console"` | Output channel for async mode results. `console` pretty-prints to the terminal; `log` appends JSONL to the log file; `both` does both. Only applies when `mode` is `async`. |
|
|
125
|
+
| `errors.llm.logFile` | `ERRORS_LLM_LOG_FILE` | string (path) | `"./errors.llm.log"` | Path for the JSONL log file used by the `log` and `both` channels. |
|
|
126
|
+
| `errors.llm.rateLimit` | `ERRORS_LLM_RATE_LIMIT` or `LLM_RATE_LIMIT` | number | `10` | Maximum number of LLM calls allowed per minute across all requests. When exceeded, a generic 500 is returned (sync) or dispatched with a `rateLimited` flag (async). Cached results do not count against this limit. |
|
|
127
|
+
| `errors.llm.cache` | `ERRORS_LLM_CACHE` | boolean | `true` | Cache LLM results by throw site (file + line) and error message. Repeated errors at the same location reuse the cached result without making another LLM call. |
|
|
128
|
+
| `errors.llm.cacheTTL` | `ERRORS_LLM_CACHE_TTL` | number (ms) | `3600000` | How long cached results are reused (default 1 hour). After expiry the same error will trigger a fresh LLM call. |
|
|
122
129
|
|
|
123
130
|
When enabled, the same behaviour applies whether you call `ammo.throw()` or the framework calls it when it catches an error — one mechanism, no separate config.
|
|
124
131
|
|
|
@@ -162,7 +169,14 @@ Create a `tejas.config.json` in your project root:
|
|
|
162
169
|
"enabled": true,
|
|
163
170
|
"baseURL": "https://api.openai.com/v1",
|
|
164
171
|
"model": "gpt-4o-mini",
|
|
165
|
-
"messageType": "endUser"
|
|
172
|
+
"messageType": "endUser",
|
|
173
|
+
"mode": "async",
|
|
174
|
+
"timeout": 10000,
|
|
175
|
+
"channel": "both",
|
|
176
|
+
"logFile": "./errors.llm.log",
|
|
177
|
+
"rateLimit": 10,
|
|
178
|
+
"cache": true,
|
|
179
|
+
"cacheTTL": 3600000
|
|
166
180
|
}
|
|
167
181
|
}
|
|
168
182
|
}
|
package/docs/error-handling.md
CHANGED
|
@@ -15,8 +15,8 @@ Tejas wraps all middleware and route handlers with built-in error catching. Any
|
|
|
15
15
|
```javascript
|
|
16
16
|
// ✅ No try-catch needed — Tejas handles errors automatically
|
|
17
17
|
target.register('/users/:id', async (ammo) => {
|
|
18
|
-
const user = await database.findUser(ammo.payload.id);
|
|
19
|
-
const posts = await database.getUserPosts(user.id);
|
|
18
|
+
const user = await database.findUser(ammo.payload.id); // If this throws, Tejas catches it
|
|
19
|
+
const posts = await database.getUserPosts(user.id); // Same here
|
|
20
20
|
ammo.fire({ user, posts });
|
|
21
21
|
});
|
|
22
22
|
```
|
|
@@ -30,8 +30,8 @@ app.get('/users/:id', async (req, res) => {
|
|
|
30
30
|
const user = await database.findUser(req.params.id);
|
|
31
31
|
res.json(user);
|
|
32
32
|
} catch (error) {
|
|
33
|
-
console.error(error);
|
|
34
|
-
res.status(500).json({ error: 'Internal Server Error' });
|
|
33
|
+
console.error(error); // 1. log
|
|
34
|
+
res.status(500).json({ error: 'Internal Server Error' }); // 2. send response
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
37
|
```
|
|
@@ -47,8 +47,8 @@ To see caught exceptions in your logs, enable exception logging:
|
|
|
47
47
|
```javascript
|
|
48
48
|
const app = new Tejas({
|
|
49
49
|
log: {
|
|
50
|
-
exceptions: true
|
|
51
|
-
}
|
|
50
|
+
exceptions: true, // Log all caught exceptions
|
|
51
|
+
},
|
|
52
52
|
});
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -89,6 +89,87 @@ ammo.throw({ messageType: 'developer' });
|
|
|
89
89
|
ammo.throw(caughtErr, { useLlm: false });
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
### Async mode
|
|
93
|
+
|
|
94
|
+
By default (`errors.llm.mode: 'sync'`), `ammo.throw()` blocks the HTTP response until the LLM returns. This adds LLM latency (typically 1–3 seconds) to every error response.
|
|
95
|
+
|
|
96
|
+
Set `errors.llm.mode` to `'async'` to respond immediately with a generic `500 Internal Server Error` and run the LLM inference in the background. The result is dispatched to the configured **channel** once ready — the client never waits.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# .env
|
|
100
|
+
ERRORS_LLM_MODE=async
|
|
101
|
+
ERRORS_LLM_CHANNEL=both # console + log file
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
// tejas.config.json
|
|
106
|
+
{
|
|
107
|
+
"errors": {
|
|
108
|
+
"llm": {
|
|
109
|
+
"enabled": true,
|
|
110
|
+
"mode": "async",
|
|
111
|
+
"channel": "both"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
In async mode:
|
|
118
|
+
|
|
119
|
+
- The HTTP response is always `500 Internal Server Error` regardless of what the LLM would infer. The LLM-inferred status and message are only visible in the channel.
|
|
120
|
+
- Developer insight (`devInsight`) is **always** included in the channel output, even in production — it never reaches the HTTP response.
|
|
121
|
+
- If the LLM call fails or times out in the background, it is silently swallowed. The HTTP response has already been sent.
|
|
122
|
+
|
|
123
|
+
### Output channels (async mode)
|
|
124
|
+
|
|
125
|
+
When `mode` is `async`, the LLM result is sent to the configured channel after the response. Set `errors.llm.channel`:
|
|
126
|
+
|
|
127
|
+
| Channel | Output |
|
|
128
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
129
|
+
| `"console"` (default) | Pretty-printed colored block in the terminal: timestamp, method+path, inferred status, message, dev insight. Shows `[CACHED]` or `[RATE LIMITED]` flags. |
|
|
130
|
+
| `"log"` | Appends a JSON line to `errors.llm.logFile` (default `./errors.llm.log`). Each entry contains all fields: timestamp, method, path, statusCode, message, devInsight, original error, code context snippets, cached, rateLimited. |
|
|
131
|
+
| `"both"` | Both console and log file. |
|
|
132
|
+
|
|
133
|
+
The log file uses **JSONL format** (one JSON object per line), so it can be read by log analysis tools or Radar.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
ERRORS_LLM_CHANNEL=log
|
|
137
|
+
ERRORS_LLM_LOG_FILE=./logs/llm-errors.log
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Rate limiting
|
|
141
|
+
|
|
142
|
+
Set `errors.llm.rateLimit` (default `10`) to cap the number of LLM calls per minute across all requests. This prevents a burst of errors from exhausting your API quota.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
ERRORS_LLM_RATE_LIMIT=20
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
When the rate limit is exceeded:
|
|
149
|
+
|
|
150
|
+
- **Sync mode**: responds immediately with `500 Internal Server Error` (no LLM call).
|
|
151
|
+
- **Async mode**: the channel still receives a dispatch with `rateLimited: true` so the error occurrence is recorded even though LLM enhancement was skipped.
|
|
152
|
+
|
|
153
|
+
Cached results do **not** count against the rate limit.
|
|
154
|
+
|
|
155
|
+
### Error caching
|
|
156
|
+
|
|
157
|
+
By default (`errors.llm.cache: true`), Tejas caches LLM results by throw site and error message. If the same error is thrown at the same file and line, the cached result is reused without making another LLM call.
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
ERRORS_LLM_CACHE=true
|
|
161
|
+
ERRORS_LLM_CACHE_TTL=3600000 # 1 hour (default)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The cache key is: `file:line:errorMessage`. After the TTL expires, the next occurrence triggers a fresh LLM call.
|
|
165
|
+
|
|
166
|
+
To effectively **only enhance new errors**, keep caching enabled with a long TTL. To re-evaluate errors more frequently, reduce the TTL.
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
// Only enhance errors once per 24 hours
|
|
170
|
+
app.withLLMErrors({ cache: true, cacheTTL: 86400000 });
|
|
171
|
+
```
|
|
172
|
+
|
|
92
173
|
---
|
|
93
174
|
|
|
94
175
|
## TejError Class
|
|
@@ -113,6 +194,7 @@ throw new TejError(404, 'Resource not found');
|
|
|
113
194
|
```
|
|
114
195
|
|
|
115
196
|
**Response:**
|
|
197
|
+
|
|
116
198
|
```
|
|
117
199
|
HTTP/1.1 404 Not Found
|
|
118
200
|
Content-Type: text/plain
|
|
@@ -148,7 +230,7 @@ ammo.throw(new TejError(400, 'Bad request'));
|
|
|
148
230
|
// When errors.llm.enabled: LLM infers code and message from context
|
|
149
231
|
ammo.throw(new Error('Something went wrong'));
|
|
150
232
|
ammo.throw('Validation failed');
|
|
151
|
-
ammo.throw();
|
|
233
|
+
ammo.throw(); // context still used when available
|
|
152
234
|
```
|
|
153
235
|
|
|
154
236
|
See [Ammo — throw()](./ammo.md#throw--send-error-response) for all signatures and the LLM-inferred row.
|
|
@@ -160,13 +242,13 @@ See [Ammo — throw()](./ammo.md#throw--send-error-response) for all signatures
|
|
|
160
242
|
```javascript
|
|
161
243
|
target.register('/users/:id', async (ammo) => {
|
|
162
244
|
const { id } = ammo.payload;
|
|
163
|
-
|
|
245
|
+
|
|
164
246
|
const user = await findUser(id);
|
|
165
|
-
|
|
247
|
+
|
|
166
248
|
if (!user) {
|
|
167
249
|
throw new TejError(404, 'User not found');
|
|
168
250
|
}
|
|
169
|
-
|
|
251
|
+
|
|
170
252
|
ammo.fire(user);
|
|
171
253
|
});
|
|
172
254
|
```
|
|
@@ -194,8 +276,8 @@ Errors are automatically caught by Tejas's handler. Enable logging:
|
|
|
194
276
|
```javascript
|
|
195
277
|
const app = new Tejas({
|
|
196
278
|
log: {
|
|
197
|
-
exceptions: true // Log all exceptions
|
|
198
|
-
}
|
|
279
|
+
exceptions: true, // Log all exceptions
|
|
280
|
+
},
|
|
199
281
|
});
|
|
200
282
|
```
|
|
201
283
|
|
|
@@ -207,18 +289,18 @@ Create middleware to customize error handling:
|
|
|
207
289
|
// middleware/error-handler.js
|
|
208
290
|
export const errorHandler = (ammo, next) => {
|
|
209
291
|
const originalThrow = ammo.throw.bind(ammo);
|
|
210
|
-
|
|
292
|
+
|
|
211
293
|
ammo.throw = (...args) => {
|
|
212
294
|
// Log errors
|
|
213
295
|
console.error('Error:', args);
|
|
214
|
-
|
|
296
|
+
|
|
215
297
|
// Send to error tracking service
|
|
216
298
|
errorTracker.capture(args[0]);
|
|
217
|
-
|
|
299
|
+
|
|
218
300
|
// Call original throw
|
|
219
301
|
originalThrow(...args);
|
|
220
302
|
};
|
|
221
|
-
|
|
303
|
+
|
|
222
304
|
next();
|
|
223
305
|
};
|
|
224
306
|
|
|
@@ -234,12 +316,12 @@ For APIs, return structured error objects:
|
|
|
234
316
|
// middleware/api-errors.js
|
|
235
317
|
export const apiErrorHandler = (ammo, next) => {
|
|
236
318
|
const originalThrow = ammo.throw.bind(ammo);
|
|
237
|
-
|
|
319
|
+
|
|
238
320
|
ammo.throw = (statusOrError, message) => {
|
|
239
321
|
let status = 500;
|
|
240
322
|
let errorMessage = 'Internal Server Error';
|
|
241
323
|
let errorCode = 'INTERNAL_ERROR';
|
|
242
|
-
|
|
324
|
+
|
|
243
325
|
if (typeof statusOrError === 'number') {
|
|
244
326
|
status = statusOrError;
|
|
245
327
|
errorMessage = message || getDefaultMessage(status);
|
|
@@ -249,16 +331,16 @@ export const apiErrorHandler = (ammo, next) => {
|
|
|
249
331
|
errorMessage = statusOrError.message;
|
|
250
332
|
errorCode = getErrorCode(status);
|
|
251
333
|
}
|
|
252
|
-
|
|
334
|
+
|
|
253
335
|
ammo.fire(status, {
|
|
254
336
|
error: {
|
|
255
337
|
code: errorCode,
|
|
256
338
|
message: errorMessage,
|
|
257
|
-
status
|
|
258
|
-
}
|
|
339
|
+
status,
|
|
340
|
+
},
|
|
259
341
|
});
|
|
260
342
|
};
|
|
261
|
-
|
|
343
|
+
|
|
262
344
|
next();
|
|
263
345
|
};
|
|
264
346
|
|
|
@@ -270,7 +352,7 @@ function getDefaultMessage(status) {
|
|
|
270
352
|
404: 'Not Found',
|
|
271
353
|
405: 'Method Not Allowed',
|
|
272
354
|
429: 'Too Many Requests',
|
|
273
|
-
500: 'Internal Server Error'
|
|
355
|
+
500: 'Internal Server Error',
|
|
274
356
|
};
|
|
275
357
|
return messages[status] || 'Unknown Error';
|
|
276
358
|
}
|
|
@@ -283,13 +365,14 @@ function getErrorCode(status) {
|
|
|
283
365
|
404: 'NOT_FOUND',
|
|
284
366
|
405: 'METHOD_NOT_ALLOWED',
|
|
285
367
|
429: 'RATE_LIMITED',
|
|
286
|
-
500: 'INTERNAL_ERROR'
|
|
368
|
+
500: 'INTERNAL_ERROR',
|
|
287
369
|
};
|
|
288
370
|
return codes[status] || 'UNKNOWN_ERROR';
|
|
289
371
|
}
|
|
290
372
|
```
|
|
291
373
|
|
|
292
374
|
**Response:**
|
|
375
|
+
|
|
293
376
|
```json
|
|
294
377
|
{
|
|
295
378
|
"error": {
|
|
@@ -307,10 +390,10 @@ For input validation, return detailed errors:
|
|
|
307
390
|
```javascript
|
|
308
391
|
target.register('/users', (ammo) => {
|
|
309
392
|
if (!ammo.POST) return ammo.notAllowed();
|
|
310
|
-
|
|
393
|
+
|
|
311
394
|
const { name, email, age } = ammo.payload;
|
|
312
395
|
const errors = [];
|
|
313
|
-
|
|
396
|
+
|
|
314
397
|
if (!name) errors.push({ field: 'name', message: 'Name is required' });
|
|
315
398
|
if (!email) errors.push({ field: 'email', message: 'Email is required' });
|
|
316
399
|
if (email && !isValidEmail(email)) {
|
|
@@ -319,17 +402,17 @@ target.register('/users', (ammo) => {
|
|
|
319
402
|
if (age && (isNaN(age) || age < 0)) {
|
|
320
403
|
errors.push({ field: 'age', message: 'Age must be a positive number' });
|
|
321
404
|
}
|
|
322
|
-
|
|
405
|
+
|
|
323
406
|
if (errors.length > 0) {
|
|
324
407
|
return ammo.fire(400, {
|
|
325
408
|
error: {
|
|
326
409
|
code: 'VALIDATION_ERROR',
|
|
327
410
|
message: 'Validation failed',
|
|
328
|
-
details: errors
|
|
329
|
-
}
|
|
411
|
+
details: errors,
|
|
412
|
+
},
|
|
330
413
|
});
|
|
331
414
|
}
|
|
332
|
-
|
|
415
|
+
|
|
333
416
|
// Process valid data...
|
|
334
417
|
});
|
|
335
418
|
```
|
|
@@ -380,16 +463,17 @@ While Tejas catches all errors automatically, you may want try-catch for:
|
|
|
380
463
|
|
|
381
464
|
`BodyParserError` is a subclass of `TejError` thrown automatically during request body parsing. You do not need to handle these yourself — they are caught by the framework and converted to appropriate HTTP responses.
|
|
382
465
|
|
|
383
|
-
| Status
|
|
384
|
-
|
|
466
|
+
| Status | Condition |
|
|
467
|
+
| ------- | -------------------------------------------------------------------------- |
|
|
385
468
|
| **400** | Malformed JSON, invalid URL-encoded data, or corrupted multipart form data |
|
|
386
|
-
| **408** | Body parsing timed out (exceeds `body.timeout`, default 30 seconds)
|
|
387
|
-
| **413** | Request body exceeds `body.max_size` (default 10 MB)
|
|
388
|
-
| **415** | Unsupported content type (not JSON, URL-encoded, or multipart)
|
|
469
|
+
| **408** | Body parsing timed out (exceeds `body.timeout`, default 30 seconds) |
|
|
470
|
+
| **413** | Request body exceeds `body.max_size` (default 10 MB) |
|
|
471
|
+
| **415** | Unsupported content type (not JSON, URL-encoded, or multipart) |
|
|
389
472
|
|
|
390
473
|
These limits are configured via [Configuration](./configuration.md) (`body.max_size`, `body.timeout`).
|
|
391
474
|
|
|
392
475
|
Supported content types:
|
|
476
|
+
|
|
393
477
|
- `application/json`
|
|
394
478
|
- `application/x-www-form-urlencoded`
|
|
395
479
|
- `multipart/form-data`
|
|
@@ -410,21 +494,21 @@ Once a response has been sent (`res.headersSent` is true), no further middleware
|
|
|
410
494
|
|
|
411
495
|
## Error Codes Reference
|
|
412
496
|
|
|
413
|
-
| Status | Name
|
|
414
|
-
|
|
415
|
-
| 400
|
|
416
|
-
| 401
|
|
417
|
-
| 403
|
|
418
|
-
| 404
|
|
419
|
-
| 405
|
|
420
|
-
| 409
|
|
421
|
-
| 413
|
|
422
|
-
| 422
|
|
423
|
-
| 429
|
|
424
|
-
| 500
|
|
425
|
-
| 502
|
|
426
|
-
| 503
|
|
427
|
-
| 504
|
|
497
|
+
| Status | Name | When to Use |
|
|
498
|
+
| ------ | --------------------- | --------------------------------- |
|
|
499
|
+
| 400 | Bad Request | Invalid input, malformed request |
|
|
500
|
+
| 401 | Unauthorized | Missing or invalid authentication |
|
|
501
|
+
| 403 | Forbidden | Authenticated but not authorized |
|
|
502
|
+
| 404 | Not Found | Resource doesn't exist |
|
|
503
|
+
| 405 | Method Not Allowed | HTTP method not supported |
|
|
504
|
+
| 409 | Conflict | Resource conflict (duplicate) |
|
|
505
|
+
| 413 | Payload Too Large | Request body too large |
|
|
506
|
+
| 422 | Unprocessable Entity | Valid syntax but semantic errors |
|
|
507
|
+
| 429 | Too Many Requests | Rate limit exceeded |
|
|
508
|
+
| 500 | Internal Server Error | Unexpected server errors |
|
|
509
|
+
| 502 | Bad Gateway | Upstream server error |
|
|
510
|
+
| 503 | Service Unavailable | Server temporarily unavailable |
|
|
511
|
+
| 504 | Gateway Timeout | Upstream server timeout |
|
|
428
512
|
|
|
429
513
|
## Best Practices
|
|
430
514
|
|
package/lib/llm/client.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
8
8
|
const DEFAULT_MODEL = 'gpt-4o-mini';
|
|
9
|
+
const DEFAULT_TIMEOUT = 10000;
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* OpenAI-compatible LLM provider. Exposes only constructor and analyze(prompt).
|
|
@@ -15,11 +16,16 @@ class LLMProvider {
|
|
|
15
16
|
this.baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, '');
|
|
16
17
|
this.model = options.model ?? DEFAULT_MODEL;
|
|
17
18
|
this.apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
19
|
+
this.timeout =
|
|
20
|
+
typeof options.timeout === 'number' && options.timeout > 0
|
|
21
|
+
? options.timeout
|
|
22
|
+
: DEFAULT_TIMEOUT;
|
|
18
23
|
this.options = options;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Send a prompt to the LLM and return the raw text response and usage.
|
|
28
|
+
* Aborts after this.timeout milliseconds and throws a clean error.
|
|
23
29
|
* @param {string} prompt
|
|
24
30
|
* @returns {Promise<{ content: string, usage: { prompt_tokens: number, completion_tokens: number, total_tokens: number } }>}
|
|
25
31
|
*/
|
|
@@ -34,25 +40,44 @@ class LLMProvider {
|
|
|
34
40
|
messages: [{ role: 'user', content: prompt }],
|
|
35
41
|
};
|
|
36
42
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
45
|
+
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(url, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers,
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.name === 'AbortError') {
|
|
56
|
+
throw new Error(`LLM request timed out after ${this.timeout}ms`);
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timeoutId);
|
|
61
|
+
}
|
|
42
62
|
|
|
43
63
|
if (!res.ok) {
|
|
44
64
|
const text = await res.text();
|
|
45
|
-
throw new Error(
|
|
65
|
+
throw new Error(
|
|
66
|
+
`LLM request failed (${res.status}): ${text.slice(0, 300)}`,
|
|
67
|
+
);
|
|
46
68
|
}
|
|
47
69
|
|
|
48
70
|
const data = await res.json();
|
|
49
71
|
const content = data.choices?.[0]?.message?.content ?? '';
|
|
50
|
-
const text =
|
|
72
|
+
const text =
|
|
73
|
+
typeof content === 'string' ? content : JSON.stringify(content);
|
|
51
74
|
const rawUsage = data.usage;
|
|
52
75
|
const usage = {
|
|
53
76
|
prompt_tokens: rawUsage?.prompt_tokens ?? 0,
|
|
54
77
|
completion_tokens: rawUsage?.completion_tokens ?? 0,
|
|
55
|
-
total_tokens:
|
|
78
|
+
total_tokens:
|
|
79
|
+
rawUsage?.total_tokens ??
|
|
80
|
+
(rawUsage?.prompt_tokens ?? 0) + (rawUsage?.completion_tokens ?? 0),
|
|
56
81
|
};
|
|
57
82
|
return { content: text, usage };
|
|
58
83
|
}
|
|
@@ -60,7 +85,7 @@ class LLMProvider {
|
|
|
60
85
|
|
|
61
86
|
/**
|
|
62
87
|
* Create an LLM provider from config.
|
|
63
|
-
* @param {object} config - { baseURL?, apiKey?, model? }
|
|
88
|
+
* @param {object} config - { baseURL?, apiKey?, model?, timeout? }
|
|
64
89
|
* @returns {LLMProvider}
|
|
65
90
|
*/
|
|
66
91
|
function createProvider(config) {
|
package/package.json
CHANGED
package/server/ammo.js
CHANGED
|
@@ -10,6 +10,11 @@ import TejError from './error.js';
|
|
|
10
10
|
import { getErrorsLlmConfig } from '../utils/errors-llm-config.js';
|
|
11
11
|
import { inferErrorFromContext } from './errors/llm-error-service.js';
|
|
12
12
|
import { captureCodeContext } from './errors/code-context.js';
|
|
13
|
+
import {
|
|
14
|
+
getChannels,
|
|
15
|
+
buildPayload,
|
|
16
|
+
dispatchToChannels,
|
|
17
|
+
} from './errors/channels/index.js';
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* Detect if the value is a throw() options object (per-call overrides).
|
|
@@ -367,22 +372,84 @@ class Ammo {
|
|
|
367
372
|
const llmEligible =
|
|
368
373
|
args.length === 0 ||
|
|
369
374
|
(!isStatusCode(args[0]) && !(args[0] instanceof TejError));
|
|
370
|
-
let throwOpts =
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
let throwOpts =
|
|
376
|
+
/** @type {{ useLlm?: boolean, messageType?: 'endUser'|'developer' } | null} */ (
|
|
377
|
+
null
|
|
378
|
+
);
|
|
379
|
+
if (
|
|
380
|
+
llmEligible &&
|
|
381
|
+
args.length > 0 &&
|
|
382
|
+
isThrowOptions(args[args.length - 1])
|
|
383
|
+
) {
|
|
384
|
+
throwOpts =
|
|
385
|
+
/** @type {{ useLlm?: boolean, messageType?: 'endUser'|'developer' } } */ (
|
|
386
|
+
args.pop()
|
|
387
|
+
);
|
|
373
388
|
}
|
|
374
389
|
|
|
375
|
-
const useLlm =
|
|
376
|
-
llmEnabled &&
|
|
377
|
-
llmEligible &&
|
|
378
|
-
throwOpts?.useLlm !== false;
|
|
390
|
+
const useLlm = llmEnabled && llmEligible && throwOpts?.useLlm !== false;
|
|
379
391
|
|
|
380
392
|
if (useLlm) {
|
|
381
|
-
//
|
|
393
|
+
// Capture the stack string SYNCHRONOUSLY before any async work or fire() call,
|
|
394
|
+
// because the call stack unwinds as soon as we await or respond.
|
|
382
395
|
const stack =
|
|
383
396
|
args[0] instanceof Error && args[0].stack
|
|
384
397
|
? args[0].stack
|
|
385
398
|
: new Error().stack;
|
|
399
|
+
const originalError =
|
|
400
|
+
args[0] !== undefined && args[0] !== null ? args[0] : undefined;
|
|
401
|
+
|
|
402
|
+
const { mode, channel, logFile } = getErrorsLlmConfig();
|
|
403
|
+
|
|
404
|
+
if (mode === 'async') {
|
|
405
|
+
// Respond immediately with a generic 500, then run LLM in the background.
|
|
406
|
+
this.fire(500, 'Internal Server Error');
|
|
407
|
+
|
|
408
|
+
// Fire-and-forget: capture context, call LLM, dispatch to channel.
|
|
409
|
+
const method = this.method;
|
|
410
|
+
const path = this.path;
|
|
411
|
+
captureCodeContext(stack)
|
|
412
|
+
.then((codeContext) => {
|
|
413
|
+
const context = {
|
|
414
|
+
codeContext,
|
|
415
|
+
method,
|
|
416
|
+
path,
|
|
417
|
+
// Always request devInsight in async mode — it goes to the channel, not the HTTP response.
|
|
418
|
+
includeDevInsight: true,
|
|
419
|
+
forceDevInsight: true,
|
|
420
|
+
...(throwOpts?.messageType && {
|
|
421
|
+
messageType: throwOpts.messageType,
|
|
422
|
+
}),
|
|
423
|
+
};
|
|
424
|
+
if (originalError !== undefined) context.error = originalError;
|
|
425
|
+
return inferErrorFromContext(context).then((result) => ({
|
|
426
|
+
result,
|
|
427
|
+
codeContext,
|
|
428
|
+
}));
|
|
429
|
+
})
|
|
430
|
+
.then(({ result, codeContext }) => {
|
|
431
|
+
const channels = getChannels(channel, logFile);
|
|
432
|
+
const payload = buildPayload({
|
|
433
|
+
method,
|
|
434
|
+
path,
|
|
435
|
+
originalError,
|
|
436
|
+
codeContext,
|
|
437
|
+
statusCode: result.statusCode,
|
|
438
|
+
message: result.message,
|
|
439
|
+
devInsight: result.devInsight,
|
|
440
|
+
cached: result.cached,
|
|
441
|
+
rateLimited: result.rateLimited,
|
|
442
|
+
});
|
|
443
|
+
return dispatchToChannels(channels, payload);
|
|
444
|
+
})
|
|
445
|
+
.catch(() => {
|
|
446
|
+
// Swallow background errors — the HTTP response has already been sent.
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Sync mode (default): block until LLM responds, then fire.
|
|
386
453
|
return captureCodeContext(stack)
|
|
387
454
|
.then((codeContext) => {
|
|
388
455
|
const context = {
|
|
@@ -390,9 +457,11 @@ class Ammo {
|
|
|
390
457
|
method: this.method,
|
|
391
458
|
path: this.path,
|
|
392
459
|
includeDevInsight: true,
|
|
393
|
-
...(throwOpts?.messageType && {
|
|
460
|
+
...(throwOpts?.messageType && {
|
|
461
|
+
messageType: throwOpts.messageType,
|
|
462
|
+
}),
|
|
394
463
|
};
|
|
395
|
-
if (
|
|
464
|
+
if (originalError !== undefined) context.error = originalError;
|
|
396
465
|
return inferErrorFromContext(context);
|
|
397
466
|
})
|
|
398
467
|
.then(({ statusCode, message, devInsight }) => {
|
|
@@ -402,6 +471,11 @@ class Ammo {
|
|
|
402
471
|
? { message, _dev: devInsight }
|
|
403
472
|
: message;
|
|
404
473
|
this.fire(statusCode, data);
|
|
474
|
+
})
|
|
475
|
+
.catch(() => {
|
|
476
|
+
// LLM call failed (network error, timeout, etc.) — fall back to generic 500
|
|
477
|
+
// so the client always gets a response and we don't trigger an infinite retry loop.
|
|
478
|
+
this.fire(500, 'Internal Server Error');
|
|
405
479
|
});
|
|
406
480
|
}
|
|
407
481
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for LLM error output channels.
|
|
3
|
+
* Subclasses implement dispatch() to send the LLM result wherever needed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} ChannelPayload
|
|
8
|
+
* @property {string} timestamp - ISO 8601 timestamp of when the error occurred
|
|
9
|
+
* @property {string} method - HTTP method (e.g. GET, POST)
|
|
10
|
+
* @property {string} path - Request path
|
|
11
|
+
* @property {number} statusCode - LLM-inferred HTTP status code
|
|
12
|
+
* @property {string} message - LLM-inferred message
|
|
13
|
+
* @property {string} [devInsight] - Developer insight from LLM (always included in async mode)
|
|
14
|
+
* @property {{ type: string, message: string } | null} error - Original error summary
|
|
15
|
+
* @property {{ snippets: Array<{ file: string, line: number, snippet: string }> }} codeContext - Source context
|
|
16
|
+
* @property {boolean} [cached] - Whether this result came from the cache
|
|
17
|
+
* @property {boolean} [rateLimited] - Whether LLM was skipped due to rate limiting
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class ErrorChannel {
|
|
21
|
+
/**
|
|
22
|
+
* Dispatch an LLM error result to this channel.
|
|
23
|
+
* @param {ChannelPayload} payload
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
async dispatch(payload) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`dispatch() must be implemented by ${this.constructor.name}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|