@llumiverse/core 1.0.0-dev.20260224.234313Z → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ import {
2
+ AIModel,
3
+ Completion,
4
+ CompletionChunkObject,
5
+ DriverOptions,
6
+ EmbeddingsOptions,
7
+ EmbeddingsResult,
8
+ ExecutionOptions,
9
+ LlumiverseErrorContext,
10
+ ModelSearchPayload,
11
+ LlumiverseError,
12
+ } from '@llumiverse/common';
13
+ import { beforeEach, describe, expect, it } from 'vitest';
14
+ import { AbstractDriver } from './Driver.js';
15
+
16
+ // Simple test driver implementation
17
+ class TestDriver extends AbstractDriver<DriverOptions, string> {
18
+ provider = 'test-provider';
19
+
20
+ async requestTextCompletion(_prompt: string, _options: ExecutionOptions): Promise<Completion> {
21
+ throw new Error('Not implemented');
22
+ }
23
+
24
+ async requestTextCompletionStream(_prompt: string, _options: ExecutionOptions): Promise<AsyncIterable<CompletionChunkObject>> {
25
+ throw new Error('Not implemented');
26
+ }
27
+
28
+ async listModels(_params?: ModelSearchPayload): Promise<AIModel[]> {
29
+ return [];
30
+ }
31
+
32
+ async validateConnection(): Promise<boolean> {
33
+ return true;
34
+ }
35
+
36
+ async generateEmbeddings(_options: EmbeddingsOptions): Promise<EmbeddingsResult> {
37
+ throw new Error('Not implemented');
38
+ }
39
+ }
40
+
41
+ describe('AbstractDriver Error Formatting', () => {
42
+ let driver: TestDriver;
43
+ const mockContext: LlumiverseErrorContext = {
44
+ provider: 'test-provider',
45
+ model: 'test-model',
46
+ operation: 'execute',
47
+ };
48
+
49
+ beforeEach(() => {
50
+ driver = new TestDriver({});
51
+ });
52
+
53
+ describe('isRetryableError', () => {
54
+ describe('HTTP status codes', () => {
55
+ it('should mark 429 as retryable (rate limit)', () => {
56
+ expect(driver['isRetryableError'](429, 'Rate limit exceeded')).toBe(true);
57
+ });
58
+
59
+ it('should mark 408 as retryable (timeout)', () => {
60
+ expect(driver['isRetryableError'](408, 'Request timeout')).toBe(true);
61
+ });
62
+
63
+ it('should mark 529 as retryable (overloaded)', () => {
64
+ expect(driver['isRetryableError'](529, 'Service overloaded')).toBe(true);
65
+ });
66
+
67
+ it('should mark 5xx as retryable (server errors)', () => {
68
+ expect(driver['isRetryableError'](500, 'Internal server error')).toBe(true);
69
+ expect(driver['isRetryableError'](502, 'Bad gateway')).toBe(true);
70
+ expect(driver['isRetryableError'](503, 'Service unavailable')).toBe(true);
71
+ expect(driver['isRetryableError'](504, 'Gateway timeout')).toBe(true);
72
+ });
73
+
74
+ it('should mark 4xx as not retryable (except 429, 408)', () => {
75
+ expect(driver['isRetryableError'](400, 'Bad request')).toBe(false);
76
+ expect(driver['isRetryableError'](401, 'Unauthorized')).toBe(false);
77
+ expect(driver['isRetryableError'](403, 'Forbidden')).toBe(false);
78
+ expect(driver['isRetryableError'](404, 'Not found')).toBe(false);
79
+ });
80
+
81
+ it('should mark 2xx and 3xx as not retryable', () => {
82
+ expect(driver['isRetryableError'](200, 'OK')).toBe(false);
83
+ expect(driver['isRetryableError'](301, 'Moved permanently')).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe('message-based detection', () => {
88
+ it('should detect rate limit in message', () => {
89
+ expect(driver['isRetryableError'](undefined, 'Rate limit exceeded')).toBe(true);
90
+ expect(driver['isRetryableError'](undefined, 'You have hit the rate limit')).toBe(true);
91
+ expect(driver['isRetryableError'](undefined, 'RATE_LIMIT_EXCEEDED')).toBe(true);
92
+ });
93
+
94
+ it('should detect timeout in message', () => {
95
+ expect(driver['isRetryableError'](undefined, 'Request timeout')).toBe(true);
96
+ expect(driver['isRetryableError'](undefined, 'Connection timed out')).toBe(true);
97
+ expect(driver['isRetryableError'](undefined, 'TIMEOUT_ERROR')).toBe(true);
98
+ });
99
+
100
+ it('should detect retry in message', () => {
101
+ expect(driver['isRetryableError'](undefined, 'Please retry later')).toBe(true);
102
+ expect(driver['isRetryableError'](undefined, 'Retry the request')).toBe(true);
103
+ });
104
+
105
+ it('should detect overload in message', () => {
106
+ expect(driver['isRetryableError'](undefined, 'Service overloaded')).toBe(true);
107
+ expect(driver['isRetryableError'](undefined, 'Server is overload')).toBe(true);
108
+ expect(driver['isRetryableError'](undefined, 'System overloaded')).toBe(true);
109
+ });
110
+
111
+ it('should detect resource exhausted in message', () => {
112
+ expect(driver['isRetryableError'](undefined, 'Resource exhausted')).toBe(true);
113
+ expect(driver['isRetryableError'](undefined, 'Resources exhausted')).toBe(true);
114
+ });
115
+
116
+ it('should detect throttle in message', () => {
117
+ expect(driver['isRetryableError'](undefined, 'Request throttled')).toBe(true);
118
+ expect(driver['isRetryableError'](undefined, 'Throttling exception')).toBe(true);
119
+ });
120
+
121
+ it('should detect status codes in message', () => {
122
+ expect(driver['isRetryableError'](undefined, 'Error 429: Too many requests')).toBe(true);
123
+ expect(driver['isRetryableError'](undefined, 'HTTP 529 error')).toBe(true);
124
+ });
125
+
126
+ it('should mark unknown messages as undefined (let consumer decide)', () => {
127
+ expect(driver['isRetryableError'](undefined, 'Invalid API key')).toBeUndefined();
128
+ expect(driver['isRetryableError'](undefined, 'Bad request')).toBeUndefined();
129
+ expect(driver['isRetryableError'](undefined, 'Model not found')).toBeUndefined();
130
+ });
131
+ });
132
+ });
133
+
134
+ describe('formatLlumiverseError', () => {
135
+ it('should format error with status code', () => {
136
+ const originalError = new Error('Rate limit exceeded');
137
+ (originalError as any).status = 429;
138
+
139
+ const formatted = driver['formatLlumiverseError'](originalError, mockContext);
140
+
141
+ expect(formatted).toBeInstanceOf(LlumiverseError);
142
+ expect(formatted.code).toBe(429);
143
+ expect(formatted.retryable).toBe(true);
144
+ expect(formatted.message).toContain('[test-provider]');
145
+ expect(formatted.message).toContain('Rate limit exceeded');
146
+ expect(formatted.context).toEqual(mockContext);
147
+ expect(formatted.originalError).toBe(originalError);
148
+ });
149
+
150
+ it('should extract status from statusCode property', () => {
151
+ const originalError = new Error('Server error');
152
+ (originalError as any).statusCode = 500;
153
+
154
+ const formatted = driver['formatLlumiverseError'](originalError, mockContext);
155
+
156
+ expect(formatted.code).toBe(500);
157
+ expect(formatted.retryable).toBe(true);
158
+ });
159
+
160
+ it('should extract status from code property', () => {
161
+ const originalError = new Error('Timeout');
162
+ (originalError as any).code = 408;
163
+
164
+ const formatted = driver['formatLlumiverseError'](originalError, mockContext);
165
+
166
+ expect(formatted.code).toBe(408);
167
+ expect(formatted.retryable).toBe(true);
168
+ });
169
+
170
+ it('should use undefined when no status code found', () => {
171
+ const originalError = new Error('Generic error');
172
+
173
+ const formatted = driver['formatLlumiverseError'](originalError, mockContext);
174
+
175
+ expect(formatted.code).toBeUndefined();
176
+ expect(formatted.retryable).toBeUndefined(); // Unknown retryability
177
+ });
178
+
179
+ it('should handle non-Error objects', () => {
180
+ const originalError = 'String error message';
181
+
182
+ const formatted = driver['formatLlumiverseError'](originalError, mockContext);
183
+
184
+ expect(formatted.message).toContain('String error message');
185
+ expect(formatted.originalError).toBe(originalError);
186
+ });
187
+
188
+ it('should preserve provider in message', () => {
189
+ const error = new Error('Test error');
190
+
191
+ const formatted = driver['formatLlumiverseError'](error, mockContext);
192
+
193
+ expect(formatted.message).toMatch(/^\[test-provider\]/);
194
+ });
195
+
196
+ it('should determine retryability based on status and message', () => {
197
+ // Retryable by status
198
+ const retryableError = new Error('Error');
199
+ (retryableError as any).status = 429;
200
+ const formatted1 = driver['formatLlumiverseError'](retryableError, mockContext);
201
+ expect(formatted1.retryable).toBe(true);
202
+
203
+ // Not retryable by status
204
+ const nonRetryableError = new Error('Error');
205
+ (nonRetryableError as any).status = 400;
206
+ const formatted2 = driver['formatLlumiverseError'](nonRetryableError, mockContext);
207
+ expect(formatted2.retryable).toBe(false);
208
+
209
+ // Retryable by message
210
+ const messageRetryable = new Error('Rate limit exceeded');
211
+ const formatted3 = driver['formatLlumiverseError'](messageRetryable, mockContext);
212
+ expect(formatted3.retryable).toBe(true);
213
+ });
214
+ });
215
+
216
+ describe('driver override capability', () => {
217
+ class CustomDriver extends TestDriver {
218
+ public formatLlumiverseError(
219
+ error: unknown,
220
+ context: LlumiverseErrorContext
221
+ ): LlumiverseError {
222
+ // Custom logic: check for specific error type
223
+ if ((error as any).type === 'custom_retryable') {
224
+ return new LlumiverseError(
225
+ `[${this.provider}] Custom retryable error`,
226
+ true,
227
+ context,
228
+ error,
229
+ undefined,
230
+ 'CUSTOM_ERROR'
231
+ );
232
+ }
233
+ // Fall back to default
234
+ return super.formatLlumiverseError(error, context);
235
+ }
236
+ }
237
+
238
+ it('should allow drivers to override error formatting', () => {
239
+ const customDriver = new CustomDriver({});
240
+ const customError = { type: 'custom_retryable', message: 'Custom error' };
241
+
242
+ const formatted = customDriver['formatLlumiverseError'](customError, mockContext);
243
+
244
+ expect(formatted.name).toBe('CUSTOM_ERROR');
245
+ expect(formatted.code).toBeUndefined();
246
+ expect(formatted.retryable).toBe(true);
247
+ expect(formatted.message).toContain('Custom retryable error');
248
+ });
249
+
250
+ it('should fall back to default for non-custom errors', () => {
251
+ const customDriver = new CustomDriver({});
252
+ const regularError = new Error('Regular error');
253
+ (regularError as any).status = 500;
254
+
255
+ const formatted = customDriver['formatLlumiverseError'](regularError, mockContext);
256
+
257
+ expect(formatted.code).toBe(500);
258
+ expect(formatted.retryable).toBe(true);
259
+ });
260
+ });
261
+ });
package/src/Driver.ts CHANGED
@@ -4,8 +4,6 @@
4
4
  * (eg: OpenAI, HuggingFace, etc.)
5
5
  */
6
6
 
7
- import { DefaultCompletionStream, FallbackCompletionStream } from "./CompletionStream.js";
8
- import { formatTextPrompt } from "./formatters/index.js";
9
7
  import {
10
8
  AIModel,
11
9
  Completion,
@@ -17,15 +15,19 @@ import {
17
15
  EmbeddingsResult,
18
16
  ExecutionOptions,
19
17
  ExecutionResponse,
18
+ LlumiverseErrorContext,
20
19
  Logger,
21
- Modalities,
22
20
  ModelSearchPayload,
23
21
  PromptOptions,
24
22
  PromptSegment,
23
+ Providers,
25
24
  TrainingJob,
26
25
  TrainingOptions,
27
- TrainingPromptOptions
26
+ TrainingPromptOptions,
27
+ LlumiverseError
28
28
  } from "@llumiverse/common";
29
+ import { DefaultCompletionStream, FallbackCompletionStream } from "./CompletionStream.js";
30
+ import { formatTextPrompt } from "./formatters/index.js";
29
31
  import { validateResult } from "./validation.js";
30
32
 
31
33
  // Helper to create logger methods that support both message-only and object-first signatures
@@ -119,7 +121,7 @@ export abstract class AbstractDriver<OptionsT extends DriverOptions = DriverOpti
119
121
  options: OptionsT;
120
122
  logger: Logger;
121
123
 
122
- abstract provider: string; // the provider name
124
+ abstract provider: Providers | string; // the provider name
123
125
 
124
126
  constructor(opts: OptionsT) {
125
127
  this.options = opts;
@@ -165,8 +167,15 @@ export abstract class AbstractDriver<OptionsT extends DriverOptions = DriverOpti
165
167
  async execute(segments: PromptSegment[], options: ExecutionOptions): Promise<ExecutionResponse<PromptT>> {
166
168
  const prompt = await this.createPrompt(segments, options);
167
169
  return this._execute(prompt, options).catch((error: any) => {
168
- (error as any).prompt = prompt;
169
- throw error;
170
+ // Don't wrap if already a LlumiverseError
171
+ if (LlumiverseError.isLlumiverseError(error)) {
172
+ throw error;
173
+ }
174
+ throw this.formatLlumiverseError(error, {
175
+ provider: this.provider,
176
+ model: options.model,
177
+ operation: 'execute',
178
+ });
170
179
  });
171
180
  }
172
181
 
@@ -189,8 +198,17 @@ export abstract class AbstractDriver<OptionsT extends DriverOptions = DriverOpti
189
198
  const execution_time = Date.now() - start;
190
199
  return { ...result, prompt, execution_time };
191
200
  } catch (error) {
192
- (error as any).prompt = prompt;
193
- throw error;
201
+ // Don't wrap if already a LlumiverseError
202
+ if (LlumiverseError.isLlumiverseError(error)) {
203
+ throw error;
204
+ }
205
+ // Log the original error for debugging
206
+ this.logger.error({ err: error, data: { provider: this.provider, model: options.model, operation: 'execute', prompt } }, `Error during execution in provider ${this.provider}:`);
207
+ throw this.formatLlumiverseError(error, {
208
+ provider: this.provider,
209
+ model: options.model,
210
+ operation: 'execute',
211
+ });
194
212
  }
195
213
  }
196
214
 
@@ -200,9 +218,10 @@ export abstract class AbstractDriver<OptionsT extends DriverOptions = DriverOpti
200
218
 
201
219
  // by default no stream is supported. we block and we return all at once
202
220
  async stream(segments: PromptSegment[], options: ExecutionOptions): Promise<CompletionStream<PromptT>> {
221
+ this.logger.info(options, `Executing prompt with provider ${this.provider} with options: ${JSON.stringify(options)}`);
203
222
  const prompt = await this.createPrompt(segments, options);
204
223
  const canStream = await this.canStream(options);
205
- if (options.output_modality === Modalities.text && canStream) {
224
+ if (canStream) {
206
225
  return new DefaultCompletionStream(this, prompt, options);
207
226
  } else if (this.isImageModel(options.model)) {
208
227
  return new FallbackCompletionStream(this, prompt, options);
@@ -267,6 +286,98 @@ export abstract class AbstractDriver<OptionsT extends DriverOptions = DriverOpti
267
286
  return undefined;
268
287
  }
269
288
 
289
+ /**
290
+ * Format an error into LlumiverseError. Override in driver implementations
291
+ * to provide provider-specific error parsing.
292
+ *
293
+ * The default implementation uses common patterns:
294
+ * - Status 429, 408: retryable (rate limit, timeout)
295
+ * - Status 529: retryable (overloaded)
296
+ * - Status 5xx: retryable (server errors)
297
+ * - Status 4xx (except above): not retryable (client errors)
298
+ * - Error messages containing "rate limit", "timeout", etc.: retryable
299
+ *
300
+ * @param error - The error to format
301
+ * @param context - Context about where the error occurred
302
+ * @returns A standardized LlumiverseError
303
+ */
304
+ public formatLlumiverseError(
305
+ error: unknown,
306
+ context: LlumiverseErrorContext
307
+ ): LlumiverseError {
308
+ // Extract status code from common locations (only if numeric)
309
+ let code: number | undefined;
310
+ const rawCode = (error as any)?.status
311
+ || (error as any)?.statusCode
312
+ || (error as any)?.code;
313
+
314
+ if (typeof rawCode === 'number') {
315
+ code = rawCode;
316
+ }
317
+
318
+ // Extract error name if available
319
+ const errorName = (error as any)?.name;
320
+
321
+ // Extract message
322
+ const message = error instanceof Error
323
+ ? error.message
324
+ : String(error);
325
+
326
+ // Determine retryability
327
+ const retryable = this.isRetryableError(code, message);
328
+
329
+ return new LlumiverseError(
330
+ `[${this.provider}] ${message}`,
331
+ retryable,
332
+ context,
333
+ error,
334
+ code,
335
+ errorName
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Determine if an error is retryable based on status code and message.
341
+ * Can be overridden by drivers for provider-specific logic.
342
+ *
343
+ * @param statusCode - The HTTP status code (if available)
344
+ * @param message - The error message
345
+ * @returns True if retryable, false if not retryable, undefined if unknown
346
+ */
347
+ protected isRetryableError(statusCode: number | undefined, message: string): boolean | undefined {
348
+ // Numeric status codes
349
+ if (statusCode !== undefined) {
350
+ if (statusCode === 429 || statusCode === 408) return true; // Rate limit, timeout
351
+ if (statusCode === 529) return true; // Overloaded
352
+ if (statusCode >= 500 && statusCode < 600) return true; // Server errors
353
+ return false; // 4xx client errors not retryable
354
+ }
355
+
356
+ // Message-based detection for non-HTTP errors
357
+ const lowerMessage = message.toLowerCase();
358
+
359
+ // Rate limit variations
360
+ if (lowerMessage.includes('rate') && lowerMessage.includes('limit')) return true;
361
+
362
+ // Timeout variations (timeout, timed out, time out)
363
+ if (lowerMessage.includes('timeout')) return true;
364
+ if (lowerMessage.includes('timed') && lowerMessage.includes('out')) return true;
365
+ if (lowerMessage.includes('time') && lowerMessage.includes('out')) return true;
366
+
367
+ // Resource exhausted variations
368
+ if (lowerMessage.includes('resource') && lowerMessage.includes('exhaust')) return true;
369
+
370
+ // Other retryable patterns
371
+ if (lowerMessage.includes('retry')) return true;
372
+ if (lowerMessage.includes('overload')) return true;
373
+ if (lowerMessage.includes('throttl')) return true;
374
+ if (lowerMessage.includes('429')) return true;
375
+ if (lowerMessage.includes('529')) return true;
376
+
377
+ // Unknown errors - let consumer decide retry strategy
378
+ return undefined;
379
+ }
380
+
270
381
  abstract requestTextCompletion(prompt: PromptT, options: ExecutionOptions): Promise<Completion>;
271
382
 
272
383
  abstract requestTextCompletionStream(prompt: PromptT, options: ExecutionOptions): Promise<AsyncIterable<CompletionChunkObject>>;
@@ -529,3 +529,69 @@ function truncateLargeTextInternal(obj: unknown, maxChars: number): unknown {
529
529
 
530
530
  return obj;
531
531
  }
532
+
533
+ const HEARTBEAT_OPEN_TAG = '<heartbeat>';
534
+ const HEARTBEAT_CLOSE_TAG = '</heartbeat>';
535
+ const HEARTBEAT_PLACEHOLDER = '[Heartbeat removed from conversation history]';
536
+
537
+ /**
538
+ * Strip heartbeat status messages from conversation to reduce context bloat.
539
+ *
540
+ * Heartbeat messages are periodic workstream status updates injected by the
541
+ * workstream management system. They are wrapped with `<heartbeat>...</heartbeat>`
542
+ * tags at the point of injection.
543
+ *
544
+ * This function recursively walks the conversation and replaces any string
545
+ * wrapped in heartbeat tags with a short placeholder.
546
+ *
547
+ * @param obj The conversation object to strip heartbeats from
548
+ * @param options Optional settings for turn-based stripping (default keepForTurns: 1)
549
+ * @returns A new object with old heartbeat messages replaced
550
+ */
551
+ export function stripHeartbeatsFromConversation(obj: unknown, options?: StripOptions): unknown {
552
+ const { keepForTurns = 1 } = options ?? {};
553
+ const currentTurn = options?.currentTurn ?? getConversationMeta(obj).turnNumber;
554
+
555
+ // If keepForTurns is Infinity, never strip
556
+ if (keepForTurns === Infinity) {
557
+ return obj;
558
+ }
559
+
560
+ // Keep heartbeats if we haven't exceeded the turn threshold
561
+ if (keepForTurns > 0 && currentTurn < keepForTurns) {
562
+ return obj;
563
+ }
564
+
565
+ return stripHeartbeatsInternal(obj);
566
+ }
567
+
568
+ function stripHeartbeatsInternal(obj: unknown): unknown {
569
+ if (obj === null || obj === undefined) return obj;
570
+
571
+ // Replace heartbeat-tagged strings with placeholder
572
+ if (typeof obj === 'string') {
573
+ if (obj.startsWith(HEARTBEAT_OPEN_TAG) && obj.endsWith(HEARTBEAT_CLOSE_TAG)) {
574
+ return HEARTBEAT_PLACEHOLDER;
575
+ }
576
+ return obj;
577
+ }
578
+
579
+ if (Array.isArray(obj)) {
580
+ return obj.map(item => stripHeartbeatsInternal(item));
581
+ }
582
+
583
+ if (typeof obj === 'object') {
584
+ const result: Record<string, unknown> = {};
585
+ for (const [key, value] of Object.entries(obj)) {
586
+ // Preserve metadata
587
+ if (key === META_KEY) {
588
+ result[key] = value;
589
+ } else {
590
+ result[key] = stripHeartbeatsInternal(value);
591
+ }
592
+ }
593
+ return result;
594
+ }
595
+
596
+ return obj;
597
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
+ export * from "@llumiverse/common";
2
+ export * from "./conversation-utils.js";
1
3
  export * from "./Driver.js";
2
4
  export * from "./json.js";
3
5
  export * from "./stream.js";
4
- export * from "./conversation-utils.js";
5
- export * from "@llumiverse/common";
package/src/validation.ts CHANGED
@@ -47,7 +47,7 @@ function parseCompletionAsJson(data: CompletionResult[]) {
47
47
  }
48
48
 
49
49
 
50
- export function validateResult(data: CompletionResult[], schema: Object): CompletionResult[] {
50
+ export function validateResult(data: CompletionResult[], schema: object): CompletionResult[] {
51
51
  let json;
52
52
  if (Array.isArray(data)) {
53
53
  const jsonResults = data.filter(r => r.type === "json");
@@ -68,7 +68,7 @@ export function validateResult(data: CompletionResult[], schema: Object): Comple
68
68
  const valid = validate(json);
69
69
 
70
70
  if (!valid && validate.errors) {
71
- let errors = [];
71
+ const errors = [];
72
72
 
73
73
  for (const e of validate.errors) {
74
74
  const path = e.instancePath.split("/").slice(1);