genai-lite 0.10.0 → 0.11.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 CHANGED
@@ -13,7 +13,7 @@ A lightweight, portable Node.js/TypeScript library providing a unified interface
13
13
  - ⚡ **Lightweight** - Minimal dependencies, focused functionality
14
14
  - 🛡️ **Provider Normalization** - Consistent responses across different AI APIs
15
15
  - 🧠 **Local Reasoning Toggle** - Turn thinking on/off for detected GGUF models (Qwen 3.5-class, Gemma 4, and more) with vendor-tuned sampling defaults
16
- - 🔁 **Built-in Reliability** - Automatic retries with backoff/`Retry-After`, per-request timeouts, and abort signals; image generation supports cancellation too (including server-side cancel for local diffusion)
16
+ - 🔁 **Built-in Reliability** - Automatic retries with backoff/`Retry-After`, per-request timeouts, and abort signals — for both LLM and image generation (local diffusion additionally gets server-side cancel and is never blind-retried)
17
17
  - 🎨 **Configurable Model Presets** - Built-in presets with full customization options
18
18
  - 🎭 **Template Engine** - Sophisticated templating with conditionals and variable substitution
19
19
  - 📊 **Configurable Logging** - Debug mode, custom loggers (pino, winston), and silent mode for tests
@@ -35,6 +35,7 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
35
35
  settings: ResolvedImageGenerationSettings;
36
36
  apiKey: string | null;
37
37
  signal?: AbortSignal;
38
+ timeoutMs?: number;
38
39
  }): Promise<ImageGenerationResponse>;
39
40
  /**
40
41
  * Builds the request payload for genai-electron
@@ -43,7 +43,10 @@ class GenaiElectronImageAdapter {
43
43
  * Generates images using genai-electron's async API with progress polling
44
44
  */
45
45
  async generate(config) {
46
- const { request, resolvedPrompt, settings, signal } = config;
46
+ const { request, resolvedPrompt, settings, signal, timeoutMs } = config;
47
+ // Per-request override of the construction-time timeout; applies to both
48
+ // the POST and the poll-loop budget
49
+ const effectiveTimeout = timeoutMs ?? this.timeout;
47
50
  let generationId;
48
51
  try {
49
52
  if (signal?.aborted) {
@@ -58,10 +61,10 @@ class GenaiElectronImageAdapter {
58
61
  steps: payload.steps,
59
62
  });
60
63
  // Start generation (returns immediately with ID)
61
- generationId = await this.startGeneration(payload, signal);
64
+ generationId = await this.startGeneration(payload, signal, effectiveTimeout);
62
65
  this.logger.info(`GenaiElectron Image API: Generation started with ID: ${generationId}`);
63
66
  // Poll for completion
64
- const result = await this.pollForCompletion(generationId, settings.diffusion?.onProgress, signal);
67
+ const result = await this.pollForCompletion(generationId, settings.diffusion?.onProgress, signal, effectiveTimeout);
65
68
  this.logger.info(`GenaiElectron Image API: Generation complete (${result.timeTaken}ms)`);
66
69
  // Convert to ImageGenerationResponse
67
70
  return this.convertToResponse(result, request);
@@ -104,7 +107,7 @@ class GenaiElectronImageAdapter {
104
107
  * so both surface as AbortError — classification comes from adapter-side
105
108
  * state (timer-fired flag vs signal.aborted), with the caller's abort winning.
106
109
  */
107
- async startGeneration(payload, signal) {
110
+ async startGeneration(payload, signal, effectiveTimeout) {
108
111
  const url = `${this.baseURL}/v1/images/generations`;
109
112
  const controller = new AbortController();
110
113
  let timedOut = false;
@@ -120,7 +123,7 @@ class GenaiElectronImageAdapter {
120
123
  const timeoutId = setTimeout(() => {
121
124
  timedOut = true;
122
125
  controller.abort();
123
- }, this.timeout);
126
+ }, effectiveTimeout);
124
127
  try {
125
128
  const response = await fetch(url, {
126
129
  method: 'POST',
@@ -141,7 +144,7 @@ class GenaiElectronImageAdapter {
141
144
  throw this.createAbortError('Image generation request was aborted before it started');
142
145
  }
143
146
  if (timedOut) {
144
- throw this.createTimeoutError(`Request timeout after ${this.timeout}ms (connecting to ${this.baseURL})`);
147
+ throw this.createTimeoutError(`Request timeout after ${effectiveTimeout}ms (connecting to ${this.baseURL})`);
145
148
  }
146
149
  }
147
150
  throw error;
@@ -154,7 +157,7 @@ class GenaiElectronImageAdapter {
154
157
  /**
155
158
  * Polls for generation completion with progress updates
156
159
  */
157
- async pollForCompletion(generationId, onProgress, signal) {
160
+ async pollForCompletion(generationId, onProgress, signal, effectiveTimeout) {
158
161
  const url = `${this.baseURL}/v1/images/generations/${generationId}`;
159
162
  const startTime = Date.now();
160
163
  while (true) {
@@ -163,8 +166,8 @@ class GenaiElectronImageAdapter {
163
166
  throw this.createAbortError(`Image generation request was aborted (ID: ${generationId})`);
164
167
  }
165
168
  // Check overall timeout
166
- if (Date.now() - startTime > this.timeout) {
167
- throw this.createTimeoutError(`Generation timeout after ${this.timeout}ms (ID: ${generationId})`);
169
+ if (Date.now() - startTime > effectiveTimeout) {
170
+ throw this.createTimeoutError(`Generation timeout after ${effectiveTimeout}ms (ID: ${generationId})`);
168
171
  }
169
172
  // Fetch status
170
173
  let response;
@@ -343,6 +346,17 @@ class GenaiElectronImageAdapter {
343
346
  errorCode = types_1.ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED;
344
347
  errorType = 'rate_limit_error';
345
348
  }
349
+ else if (error.code === 'NOT_FOUND') {
350
+ // Server JSON code from a poll GET/DELETE whose generation is gone —
351
+ // typically expired from the registry (default TTL 300s). Without this
352
+ // branch the 404 status maps to MODEL_NOT_FOUND ("model not found"),
353
+ // which is misleading for an expired generation.
354
+ errorMessage =
355
+ 'Generation not found on the server — it likely expired from the ' +
356
+ `registry before polling completed. ${error.message}`;
357
+ errorCode = types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR;
358
+ errorType = 'server_error';
359
+ }
346
360
  else if (error.code === 'SERVER_NOT_RUNNING') {
347
361
  errorMessage = `Image generation server is not running (connecting to ${this.baseURL})`;
348
362
  errorCode = types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR;
@@ -371,6 +385,9 @@ class GenaiElectronImageAdapter {
371
385
  enhancedError.code = errorCode;
372
386
  enhancedError.type = errorType;
373
387
  enhancedError.status = mapped.status;
388
+ if (mapped.retryAfterMs !== undefined) {
389
+ enhancedError.retryAfterMs = mapped.retryAfterMs;
390
+ }
374
391
  enhancedError.providerId = this.id;
375
392
  enhancedError.modelId = request.modelId;
376
393
  return enhancedError;
@@ -20,5 +20,6 @@ export declare class MockImageAdapter implements ImageProviderAdapter {
20
20
  settings: ResolvedImageGenerationSettings;
21
21
  apiKey: string | null;
22
22
  signal?: AbortSignal;
23
+ timeoutMs?: number;
23
24
  }): Promise<ImageGenerationResponse>;
24
25
  }
@@ -36,6 +36,7 @@ export declare class OpenAIImageAdapter implements ImageProviderAdapter {
36
36
  settings: ResolvedImageGenerationSettings;
37
37
  apiKey: string | null;
38
38
  signal?: AbortSignal;
39
+ timeoutMs?: number;
39
40
  }): Promise<ImageGenerationResponse>;
40
41
  /**
41
42
  * Validates prompt length for the given model
@@ -59,7 +59,7 @@ class OpenAIImageAdapter {
59
59
  * Generates images using OpenAI's API
60
60
  */
61
61
  async generate(config) {
62
- const { request, resolvedPrompt, settings, apiKey, signal } = config;
62
+ const { request, resolvedPrompt, settings, apiKey, signal, timeoutMs } = config;
63
63
  if (!apiKey) {
64
64
  throw new Error('OpenAI API key is required but was not provided');
65
65
  }
@@ -102,14 +102,18 @@ class OpenAIImageAdapter {
102
102
  n: params.n,
103
103
  isGptImageModel,
104
104
  });
105
- // Make API call (per-request abort signal when provided)
106
- const response = await client.images.generate(params, signal ? { signal } : undefined);
105
+ // Make API call (per-request abort signal and timeout override when provided)
106
+ const requestOptions = {
107
+ ...(signal && { signal }),
108
+ ...(timeoutMs !== undefined && { timeout: timeoutMs }),
109
+ };
110
+ const response = await client.images.generate(params, Object.keys(requestOptions).length > 0 ? requestOptions : undefined);
107
111
  if (!response.data || response.data.length === 0) {
108
112
  throw new Error('OpenAI API returned no images in response');
109
113
  }
110
114
  this.logger.info(`OpenAI Image API call successful, generated ${response.data.length} images`);
111
115
  // Process response
112
- return await this.processResponse(response, request, isGptImageModel, signal);
116
+ return await this.processResponse(response, request, isGptImageModel, signal, timeoutMs);
113
117
  }
114
118
  catch (error) {
115
119
  this.logger.error('OpenAI Image API error:', error);
@@ -199,7 +203,7 @@ class OpenAIImageAdapter {
199
203
  /**
200
204
  * Processes the OpenAI API response and converts to ImageGenerationResponse
201
205
  */
202
- async processResponse(response, request, isGptImageModel, signal) {
206
+ async processResponse(response, request, isGptImageModel, signal, timeoutMs) {
203
207
  const images = [];
204
208
  for (let i = 0; i < response.data.length; i++) {
205
209
  const item = response.data[i];
@@ -222,9 +226,17 @@ class OpenAIImageAdapter {
222
226
  else {
223
227
  // dall-e-2/dall-e-3: can be url or b64_json
224
228
  if (item.url) {
225
- // Fetch image from URL
229
+ // Fetch image from URL. The SDK timeout only covers images.generate,
230
+ // so bound this fetch with the same per-request budget when one was
231
+ // given (AbortSignal.timeout carries a TimeoutError reason, mapping
232
+ // to REQUEST_TIMEOUT; a caller abort still maps to REQUEST_ABORTED)
226
233
  imageUrl = item.url;
227
- const imageResponse = await fetch(item.url, signal ? { signal } : undefined);
234
+ const fetchSignal = timeoutMs !== undefined
235
+ ? signal
236
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
237
+ : AbortSignal.timeout(timeoutMs)
238
+ : signal;
239
+ const imageResponse = await fetch(item.url, fetchSignal ? { signal: fetchSignal } : undefined);
228
240
  if (!imageResponse.ok) {
229
241
  throw new Error(`Failed to fetch image from URL: ${imageResponse.statusText}`);
230
242
  }
@@ -297,6 +309,9 @@ class OpenAIImageAdapter {
297
309
  enhancedError.code = mapped.errorCode;
298
310
  enhancedError.type = mapped.errorType;
299
311
  enhancedError.status = mapped.status;
312
+ if (mapped.retryAfterMs !== undefined) {
313
+ enhancedError.retryAfterMs = mapped.retryAfterMs;
314
+ }
300
315
  enhancedError.providerId = this.id;
301
316
  enhancedError.modelId = request.modelId;
302
317
  return enhancedError;
@@ -17,6 +17,7 @@ export declare class ImageService {
17
17
  private requestValidator;
18
18
  private settingsResolver;
19
19
  private modelResolver;
20
+ private retryOptions;
20
21
  constructor(getApiKey: ApiKeyProvider, options?: ImageServiceOptions);
21
22
  /**
22
23
  * Generates images based on the request
@@ -26,6 +27,16 @@ export declare class ImageService {
26
27
  * @returns Promise resolving to response or error
27
28
  */
28
29
  generateImage(request: ImageGenerationRequest | ImageGenerationRequestWithPreset, options?: GenerateImageOptions): Promise<ImageGenerationResponse | ImageFailureResponse>;
30
+ /**
31
+ * Builds a failure envelope from a thrown error.
32
+ *
33
+ * Adapters throw errors stamped with an ADAPTER_ERROR_CODES code plus
34
+ * type/status/retryAfterMs — that classification is propagated. Anything else
35
+ * (e.g. a failing ApiKeyProvider whose error carries a foreign `.code` like
36
+ * ENOENT) keeps the generic fallback so unrelated codes don't leak into the
37
+ * API surface.
38
+ */
39
+ private buildFailureEnvelope;
29
40
  /**
30
41
  * Gets list of supported image providers
31
42
  *
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ImageService = void 0;
13
13
  const defaultLogger_1 = require("../logging/defaultLogger");
14
14
  const types_1 = require("../llm/clients/types");
15
+ const withRetry_1 = require("../shared/services/withRetry");
15
16
  const config_1 = require("./config");
16
17
  const image_presets_json_1 = __importDefault(require("../config/image-presets.json"));
17
18
  const PresetManager_1 = require("../shared/services/PresetManager");
@@ -33,6 +34,7 @@ const ADAPTER_ERROR_CODE_VALUES = new Set(Object.values(types_1.ADAPTER_ERROR_CO
33
34
  class ImageService {
34
35
  constructor(getApiKey, options = {}) {
35
36
  this.getApiKey = getApiKey;
37
+ this.retryOptions = options.retry;
36
38
  // Initialize logger - custom logger takes precedence over logLevel
37
39
  this.logger = options.logger ?? (0, defaultLogger_1.createDefaultLogger)(options.logLevel);
38
40
  // Initialize helper services
@@ -136,44 +138,58 @@ class ImageService {
136
138
  },
137
139
  };
138
140
  }
139
- // Generate images
141
+ // Generate images. Only providers marked retryable in config are ever
142
+ // retried — genai-electron's POST-then-poll is not idempotent (a blind
143
+ // retry after the POST would start a second GPU generation), and custom
144
+ // adapters without a config entry get the safe default (no retries).
140
145
  this.logger.info(`ImageService: Calling adapter for provider: ${providerId}`);
141
- const response = await adapter.generate({
142
- request: fullRequest,
143
- resolvedPrompt,
144
- settings: resolvedSettings,
145
- apiKey,
146
+ const providerRetryable = (0, config_1.getImageProviderById)(providerId)?.retryable === true;
147
+ const retryOnTimeout = this.retryOptions?.retryOnTimeout ?? true;
148
+ const retryableCodes = new Set([
149
+ types_1.ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
150
+ types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR,
151
+ ...(retryOnTimeout ? [types_1.ADAPTER_ERROR_CODES.REQUEST_TIMEOUT] : []),
152
+ ]);
153
+ return await (0, withRetry_1.withRetry)(async () => {
154
+ try {
155
+ const response = await adapter.generate({
156
+ request: fullRequest,
157
+ resolvedPrompt,
158
+ settings: resolvedSettings,
159
+ apiKey,
160
+ signal: options?.signal,
161
+ timeoutMs: options?.timeoutMs,
162
+ });
163
+ this.logger.info('ImageService: Image generation completed successfully');
164
+ return response;
165
+ }
166
+ catch (error) {
167
+ this.logger.error('ImageService: Error during image generation:', error);
168
+ return this.buildFailureEnvelope(error, providerId, modelId);
169
+ }
170
+ }, (result) => {
171
+ if (result.object !== 'error' || !providerRetryable) {
172
+ return { retry: false };
173
+ }
174
+ const code = String(result.error.code);
175
+ const status = result.error.status;
176
+ const retry = retryableCodes.has(code) ||
177
+ (code === types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR &&
178
+ typeof status === 'number' &&
179
+ (status === 408 || status === 409 || status >= 500));
180
+ return { retry, retryAfterMs: result.error.retryAfterMs };
181
+ }, {
182
+ ...this.retryOptions,
183
+ ...(options?.maxRetries !== undefined && { maxRetries: options.maxRetries }),
146
184
  signal: options?.signal,
185
+ logger: this.logger,
186
+ label: `${providerId}/${modelId}`,
147
187
  });
148
- this.logger.info('ImageService: Image generation completed successfully');
149
- return response;
150
188
  }
151
189
  catch (error) {
190
+ // Errors thrown outside the adapter call (e.g. a failing ApiKeyProvider)
152
191
  this.logger.error('ImageService: Error during image generation:', error);
153
- // Adapters throw errors stamped with an ADAPTER_ERROR_CODES code plus
154
- // type/status — propagate that classification. Anything else caught here
155
- // (e.g. a failing ApiKeyProvider whose error carries a foreign `.code`
156
- // like ENOENT) keeps the generic fallback so unrelated codes don't leak
157
- // into the API surface.
158
- const thrown = error;
159
- const isAdapterError = typeof thrown?.code === 'string' && ADAPTER_ERROR_CODE_VALUES.has(thrown.code);
160
- return {
161
- object: 'error',
162
- providerId: providerId,
163
- modelId: modelId,
164
- error: {
165
- message: error instanceof Error
166
- ? error.message
167
- : 'An unknown error occurred during image generation',
168
- code: isAdapterError ? thrown.code : 'PROVIDER_ERROR',
169
- type: isAdapterError && typeof thrown.type === 'string'
170
- ? thrown.type
171
- : 'server_error',
172
- ...(isAdapterError &&
173
- typeof thrown.status === 'number' && { status: thrown.status }),
174
- providerError: error,
175
- },
176
- };
192
+ return this.buildFailureEnvelope(error, providerId, modelId);
177
193
  }
178
194
  }
179
195
  catch (error) {
@@ -192,6 +208,38 @@ class ImageService {
192
208
  };
193
209
  }
194
210
  }
211
+ /**
212
+ * Builds a failure envelope from a thrown error.
213
+ *
214
+ * Adapters throw errors stamped with an ADAPTER_ERROR_CODES code plus
215
+ * type/status/retryAfterMs — that classification is propagated. Anything else
216
+ * (e.g. a failing ApiKeyProvider whose error carries a foreign `.code` like
217
+ * ENOENT) keeps the generic fallback so unrelated codes don't leak into the
218
+ * API surface.
219
+ */
220
+ buildFailureEnvelope(error, providerId, modelId) {
221
+ const thrown = error;
222
+ const isAdapterError = typeof thrown?.code === 'string' && ADAPTER_ERROR_CODE_VALUES.has(thrown.code);
223
+ return {
224
+ object: 'error',
225
+ providerId,
226
+ modelId,
227
+ error: {
228
+ message: error instanceof Error
229
+ ? error.message
230
+ : 'An unknown error occurred during image generation',
231
+ code: isAdapterError ? thrown.code : 'PROVIDER_ERROR',
232
+ type: isAdapterError && typeof thrown.type === 'string'
233
+ ? thrown.type
234
+ : 'server_error',
235
+ ...(isAdapterError &&
236
+ typeof thrown.status === 'number' && { status: thrown.status }),
237
+ ...(isAdapterError &&
238
+ typeof thrown.retryAfterMs === 'number' && { retryAfterMs: thrown.retryAfterMs }),
239
+ providerError: error,
240
+ },
241
+ };
242
+ }
195
243
  /**
196
244
  * Gets list of supported image providers
197
245
  *
@@ -22,6 +22,8 @@ exports.SUPPORTED_IMAGE_PROVIDERS = [
22
22
  id: 'openai-images',
23
23
  displayName: 'OpenAI Images',
24
24
  description: 'DALL-E and GPT-Image models from OpenAI',
25
+ // Single atomic POST per generation — safe to auto-retry transient failures
26
+ retryable: true,
25
27
  capabilities: {
26
28
  supportsMultipleImages: true,
27
29
  supportsB64Json: true,
@@ -128,6 +130,9 @@ exports.SUPPORTED_IMAGE_PROVIDERS = [
128
130
  id: 'genai-electron-images',
129
131
  displayName: 'Local Diffusion (genai-electron)',
130
132
  description: 'Local stable-diffusion models via genai-electron wrapper',
133
+ // POST-then-poll: a blind retry of a mid-poll failure would start a second
134
+ // GPU generation, so the service-level retry layer must never touch this
135
+ retryable: false,
131
136
  capabilities: {
132
137
  supportsMultipleImages: true,
133
138
  supportsB64Json: true,
@@ -38,14 +38,17 @@ class GeminiClientAdapter {
38
38
  * @returns Promise resolving to success or failure response
39
39
  */
40
40
  async sendMessage(request, apiKey, options) {
41
- // The SDK enforces httpOptions.timeout by aborting an internal controller and
42
- // wraps any fetch rejection in a plain Error (no name/status/code), so timeouts
43
- // and caller aborts cannot be classified from the error object. The adapter owns
44
- // the timeout instead and classifies from its own state in the catch block.
41
+ // The SDK enforces httpOptions.timeout by aborting the same internal controller
42
+ // the caller's abortSignal funnels into, so timeout and caller abort surface as
43
+ // identical AbortErrors and cannot be distinguished from the error object. The
44
+ // adapter owns the timeout instead and classifies from its own state in the
45
+ // catch block.
45
46
  let timedOut = false;
46
47
  let timeoutHandle;
47
48
  try {
48
- // Initialize Gemini client (note: @google/genai performs no automatic retries)
49
+ // Initialize Gemini client. The SDK's internal retry layer is opt-in via
50
+ // httpOptions.retryOptions — it must stay unset here because LLMService's
51
+ // withRetry owns retrying (SDK retries would multiply attempts).
49
52
  const genAI = new genai_1.GoogleGenAI({ apiKey });
50
53
  // Format the request for Gemini API
51
54
  const { contents, generationConfig, safetySettings, systemInstruction } = this.formatInternalRequestToGemini(request);
@@ -91,11 +91,25 @@ class MistralClientAdapter {
91
91
  this.logger.warn(`Mistral does not support JSON schema validation. ` +
92
92
  `Using json_object mode - schema validation will be client-side only.`);
93
93
  }
94
- // Make the API call (per-request transport options: timeout + abort signal)
95
- const transportOptions = {
96
- ...(options?.timeoutMs !== undefined && { timeoutMs: options.timeoutMs }),
97
- ...(options?.signal && { fetchOptions: { signal: options.signal } }),
98
- };
94
+ // Make the API call (per-request transport options: timeout + abort signal).
95
+ // The SDK ignores timeoutMs whenever a signal is supplied, so when both are
96
+ // set they must be composed into one signal. AbortSignal.timeout carries a
97
+ // TimeoutError reason, which the SDK maps to RequestTimeoutError (vs a
98
+ // caller abort's AbortError -> RequestAbortedError), so classification in
99
+ // errorUtils keeps working.
100
+ const transportOptions = {};
101
+ if (options?.signal && options?.timeoutMs !== undefined) {
102
+ transportOptions.signal = AbortSignal.any([
103
+ options.signal,
104
+ AbortSignal.timeout(options.timeoutMs),
105
+ ]);
106
+ }
107
+ else if (options?.signal) {
108
+ transportOptions.signal = options.signal;
109
+ }
110
+ else if (options?.timeoutMs !== undefined) {
111
+ transportOptions.timeoutMs = options.timeoutMs;
112
+ }
99
113
  const completion = Object.keys(transportOptions).length > 0
100
114
  ? await mistral.chat.complete(requestOptions, transportOptions)
101
115
  : await mistral.chat.complete(requestOptions);
@@ -28,7 +28,8 @@ export declare function extractRetryAfterMs(error: any): number | undefined;
28
28
  *
29
29
  * This utility handles:
30
30
  * - Common HTTP status codes (401, 402, 404, 429, 4xx, 5xx)
31
- * - Network connection errors (ENOTFOUND, ECONNREFUSED, timeouts)
31
+ * - Network connection errors (ENOTFOUND, ECONNREFUSED, timeouts), including
32
+ * undici's `TypeError: fetch failed` wrapper carrying the failure on `cause`
32
33
  * - Generic JavaScript errors
33
34
  *
34
35
  * Individual adapters can further refine the mappings for provider-specific cases,
@@ -50,12 +50,37 @@ function extractRetryAfterMs(error) {
50
50
  }
51
51
  return undefined;
52
52
  }
53
+ /**
54
+ * Matches an error against known error-class names. The openai/anthropic SDK
55
+ * error classes never assign `this.name` (instances report name "Error"), so
56
+ * the constructor name is checked as well.
57
+ */
58
+ function matchesErrorName(e, names) {
59
+ return !!e && (names.includes(e.name) || names.includes(e.constructor?.name));
60
+ }
61
+ /**
62
+ * Matches the error shapes produced for connection-level failures (DNS lookup,
63
+ * connection refused, connect timeout). Wrappers keep the real failure on
64
+ * `cause`, sometimes nested (undici's `TypeError: fetch failed` inside
65
+ * anthropic/openai `APIConnectionError`), so the cause chain is walked too.
66
+ */
67
+ function isConnectionFailure(e, depth = 0) {
68
+ if (!e || depth > 4)
69
+ return false;
70
+ return (e.code === 'ENOTFOUND' ||
71
+ e.code === 'ECONNREFUSED' ||
72
+ e.code === 'ETIMEDOUT' ||
73
+ matchesErrorName(e, ['ConnectTimeoutError', 'ConnectionError', 'APIConnectionError']) ||
74
+ (typeof e.type === 'string' && e.type.includes('timeout')) ||
75
+ isConnectionFailure(e.cause, depth + 1));
76
+ }
53
77
  /**
54
78
  * Maps common error patterns to standardized error codes and types
55
79
  *
56
80
  * This utility handles:
57
81
  * - Common HTTP status codes (401, 402, 404, 429, 4xx, 5xx)
58
- * - Network connection errors (ENOTFOUND, ECONNREFUSED, timeouts)
82
+ * - Network connection errors (ENOTFOUND, ECONNREFUSED, timeouts), including
83
+ * undici's `TypeError: fetch failed` wrapper carrying the failure on `cause`
59
84
  * - Generic JavaScript errors
60
85
  *
61
86
  * Individual adapters can further refine the mappings for provider-specific cases,
@@ -72,29 +97,37 @@ function getCommonMappedErrorDetails(error, providerMessageOverride) {
72
97
  let status;
73
98
  const retryAfterMs = extractRetryAfterMs(error);
74
99
  // Handle user-initiated aborts and client-side timeouts first — the SDKs raise
75
- // these as named errors (openai: APIUserAbortError/APIConnectionTimeoutError;
76
- // fetch/undici: AbortError/TimeoutError DOMExceptions)
77
- if (error &&
78
- (error.name === 'APIUserAbortError' ||
79
- error.name === 'AbortError' ||
80
- (error instanceof DOMException && error.name === 'AbortError'))) {
100
+ // these as typed errors (openai/anthropic: APIUserAbortError/
101
+ // APIConnectionTimeoutError, matched by constructor name since those classes
102
+ // never set this.name; Speakeasy/mistral: RequestAbortedError/
103
+ // RequestTimeoutError; fetch/undici: AbortError/TimeoutError DOMExceptions)
104
+ if (matchesErrorName(error, ['APIUserAbortError', 'AbortError', 'RequestAbortedError'])) {
81
105
  return {
82
106
  errorCode: types_1.ADAPTER_ERROR_CODES.REQUEST_ABORTED,
83
107
  errorMessage: providerMessageOverride || error.message || 'Request was aborted',
84
108
  errorType: 'abort_error',
85
109
  };
86
110
  }
87
- if (error &&
88
- (error.name === 'APIConnectionTimeoutError' || error.name === 'TimeoutError')) {
111
+ if (matchesErrorName(error, [
112
+ 'APIConnectionTimeoutError',
113
+ 'TimeoutError',
114
+ 'RequestTimeoutError',
115
+ ])) {
89
116
  return {
90
117
  errorCode: types_1.ADAPTER_ERROR_CODES.REQUEST_TIMEOUT,
91
118
  errorMessage: providerMessageOverride || error.message || 'Request timed out',
92
119
  errorType: 'timeout_error',
93
120
  };
94
121
  }
95
- // Handle API errors with HTTP status codes
96
- if (error && typeof error.status === 'number') {
97
- const httpStatus = error.status;
122
+ // Handle API errors with HTTP status codes — SDKs expose the status as either
123
+ // `status` (openai/anthropic) or `statusCode` (Speakeasy/mistral MistralError)
124
+ const numericStatus = error && typeof error.status === 'number'
125
+ ? error.status
126
+ : error && typeof error.statusCode === 'number'
127
+ ? error.statusCode
128
+ : undefined;
129
+ if (numericStatus !== undefined) {
130
+ const httpStatus = numericStatus;
98
131
  status = httpStatus;
99
132
  errorMessage = providerMessageOverride || error.message || `HTTP ${httpStatus} error`;
100
133
  // Map common HTTP status codes
@@ -143,15 +176,20 @@ function getCommonMappedErrorDetails(error, providerMessageOverride) {
143
176
  }
144
177
  }
145
178
  }
146
- // Handle network connection errors
147
- else if (error && (error.code === 'ENOTFOUND' ||
148
- error.code === 'ECONNREFUSED' ||
149
- error.code === 'ETIMEDOUT' ||
150
- error.name === 'ConnectTimeoutError' ||
151
- (error.type && error.type.includes('timeout')))) {
179
+ // Handle network connection errors — either directly (Node error shapes) or
180
+ // wrapped with the real failure on `cause` (undici's `TypeError: fetch failed`,
181
+ // anthropic/openai APIConnectionError, Speakeasy ConnectionError)
182
+ else if (isConnectionFailure(error)) {
152
183
  errorCode = types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR;
153
184
  errorType = 'connection_error';
154
185
  errorMessage = providerMessageOverride || error.message || 'Network connection failed';
186
+ // Surface the underlying cause when the top-level message is generic
187
+ // (undici's "fetch failed", Speakeasy's ConnectionError wrapper)
188
+ if (typeof error?.cause?.message === 'string' &&
189
+ error.cause.message &&
190
+ !errorMessage.includes(error.cause.message)) {
191
+ errorMessage = `${errorMessage}: ${error.cause.message}`;
192
+ }
155
193
  }
156
194
  // Handle generic JavaScript errors
157
195
  else if (error instanceof Error) {
@@ -5,6 +5,7 @@
5
5
  * Based on the design specification in docs/devlog/2025-10-22-genai-lite-image-api-design.md
6
6
  */
7
7
  import type { Logger, LogLevel } from '../logging/types';
8
+ import type { RetryPolicy } from '../shared/services/withRetry';
8
9
  /**
9
10
  * Image provider ID type - represents a unique identifier for an image generation provider
10
11
  */
@@ -253,6 +254,8 @@ export interface ImageFailureResponse {
253
254
  type?: string;
254
255
  /** HTTP status code reported by the provider, when available */
255
256
  status?: number;
257
+ /** Provider-suggested wait before retrying, in ms (from a Retry-After header) */
258
+ retryAfterMs?: number;
256
259
  /** Parameter that caused the error (if applicable) */
257
260
  param?: string;
258
261
  /** Original provider error (for debugging) */
@@ -309,6 +312,13 @@ export interface ImageProviderInfo {
309
312
  capabilities: ImageProviderCapabilities;
310
313
  /** Available models from this provider */
311
314
  models?: ImageModelInfo[];
315
+ /**
316
+ * Whether transient failures from this provider are safe to auto-retry.
317
+ * Omit/false for providers whose generate() has non-idempotent side effects
318
+ * (e.g. genai-electron's POST-then-poll — a blind retry after the POST would
319
+ * start a second GPU generation).
320
+ */
321
+ retryable?: boolean;
312
322
  }
313
323
  /**
314
324
  * Preset configuration for image generation
@@ -363,6 +373,8 @@ export interface ImageProviderAdapter {
363
373
  apiKey: string | null;
364
374
  /** Abort signal for request-side cancellation (optional) */
365
375
  signal?: AbortSignal;
376
+ /** Per-request timeout override in ms; adapters fall back to their construction-time default when undefined */
377
+ timeoutMs?: number;
366
378
  }): Promise<ImageGenerationResponse>;
367
379
  /**
368
380
  * Optional method to get available models from this provider
@@ -398,6 +410,16 @@ export interface ImageServiceOptions {
398
410
  logLevel?: LogLevel;
399
411
  /** Custom logger implementation. If provided, logLevel is ignored. */
400
412
  logger?: Logger;
413
+ /**
414
+ * Retry policy for transient failures (rate limits, 5xx, network, timeouts)
415
+ * from retry-safe providers only. Defaults: maxRetries 2, initialDelayMs 500,
416
+ * maxDelayMs 10000, backoffFactor 2, retryOnTimeout true. Set maxRetries: 0
417
+ * to disable. Providers marked retryable: false (e.g. genai-electron, where a
418
+ * blind retry would start a second GPU generation) are never retried.
419
+ */
420
+ retry?: Partial<RetryPolicy> & {
421
+ retryOnTimeout?: boolean;
422
+ };
401
423
  }
402
424
  /**
403
425
  * Per-call options for ImageService.generateImage
@@ -410,6 +432,17 @@ export interface GenerateImageOptions {
410
432
  * requests surface as REQUEST_ABORTED / abort_error failures.
411
433
  */
412
434
  signal?: AbortSignal;
435
+ /**
436
+ * Per-request timeout in ms. Overrides the adapter's construction-time
437
+ * default (60s OpenAI Images, 120s genai-electron). Timeouts surface as
438
+ * REQUEST_TIMEOUT / timeout_error failures.
439
+ */
440
+ timeoutMs?: number;
441
+ /**
442
+ * Per-request retry cap (overrides the service-level retry.maxRetries;
443
+ * ignored for providers marked retryable: false).
444
+ */
445
+ maxRetries?: number;
413
446
  }
414
447
  /**
415
448
  * Result from createPrompt utility
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genai-lite",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A lightweight, portable toolkit for interacting with various Generative AI APIs.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -18,6 +18,9 @@
18
18
  },
19
19
  "author": "Luigi Acerbi <luigi.acerbi@gmail.com>",
20
20
  "license": "MIT",
21
+ "engines": {
22
+ "node": ">=20.0.0"
23
+ },
21
24
  "funding": {
22
25
  "type": "github",
23
26
  "url": "https://github.com/sponsors/lacerbi"
@@ -43,9 +46,9 @@
43
46
  "test:e2e:reasoning": "npm run build && jest --config jest.e2e.config.js reasoning.e2e.test.ts"
44
47
  },
45
48
  "dependencies": {
46
- "@anthropic-ai/sdk": "^0.72.1",
47
- "@google/genai": "^1.0.1",
48
- "@mistralai/mistralai": "^1.11.0",
49
+ "@anthropic-ai/sdk": "^0.110.0",
50
+ "@google/genai": "^2.10.0",
51
+ "@mistralai/mistralai": "^1.15.1",
49
52
  "js-tiktoken": "^1.0.20",
50
53
  "openai": "^6.7.0"
51
54
  },