genai-lite 0.9.1 → 0.10.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
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)
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
@@ -5,7 +5,7 @@
5
5
  * Supports stable-diffusion.cpp through HTTP wrapper with async polling for progress.
6
6
  *
7
7
  * Provider ID: 'genai-electron-images'
8
- * Default endpoint: http://localhost:8081
8
+ * Default endpoint: http://127.0.0.1:8081
9
9
  * Configure via: GENAI_ELECTRON_IMAGE_BASE_URL environment variable
10
10
  *
11
11
  * This adapter uses genai-electron's async image generation API which:
@@ -34,6 +34,7 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
34
34
  resolvedPrompt: string;
35
35
  settings: ResolvedImageGenerationSettings;
36
36
  apiKey: string | null;
37
+ signal?: AbortSignal;
37
38
  }): Promise<ImageGenerationResponse>;
38
39
  /**
39
40
  * Builds the request payload for genai-electron
@@ -41,12 +42,24 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
41
42
  private buildRequestPayload;
42
43
  /**
43
44
  * Starts generation and returns the generation ID
45
+ *
46
+ * The adapter's timeout and the caller's abort signal share one controller,
47
+ * so both surface as AbortError — classification comes from adapter-side
48
+ * state (timer-fired flag vs signal.aborted), with the caller's abort winning.
44
49
  */
45
50
  private startGeneration;
46
51
  /**
47
52
  * Polls for generation completion with progress updates
48
53
  */
49
54
  private pollForCompletion;
55
+ /**
56
+ * Best-effort cancellation of a server-side generation
57
+ * (DELETE /v1/images/generations/:id, available since genai-electron 0.6.0).
58
+ *
59
+ * All failures are swallowed: 404/409 just mean there is nothing left to
60
+ * cancel, and cleanup must never mask the caller's primary error.
61
+ */
62
+ private cancelGeneration;
50
63
  /**
51
64
  * Converts genai-electron result to ImageGenerationResponse
52
65
  */
@@ -55,6 +68,16 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
55
68
  * Creates an HTTP error with context
56
69
  */
57
70
  private createHttpError;
71
+ /**
72
+ * Creates a timeout-typed error: name 'TimeoutError' is recognized by the
73
+ * shared error mapping and classified as REQUEST_TIMEOUT / timeout_error
74
+ */
75
+ private createTimeoutError;
76
+ /**
77
+ * Creates an abort-typed error: name 'AbortError' is recognized by the
78
+ * shared error mapping and classified as REQUEST_ABORTED / abort_error
79
+ */
80
+ private createAbortError;
58
81
  /**
59
82
  * Creates a generation error from genai-electron error codes
60
83
  */
@@ -64,7 +87,7 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
64
87
  */
65
88
  private handleError;
66
89
  /**
67
- * Sleep helper for polling
90
+ * Sleep helper for polling; rejects immediately when the signal aborts
68
91
  */
69
92
  private sleep;
70
93
  }
@@ -6,7 +6,7 @@
6
6
  * Supports stable-diffusion.cpp through HTTP wrapper with async polling for progress.
7
7
  *
8
8
  * Provider ID: 'genai-electron-images'
9
- * Default endpoint: http://localhost:8081
9
+ * Default endpoint: http://127.0.0.1:8081
10
10
  * Configure via: GENAI_ELECTRON_IMAGE_BASE_URL environment variable
11
11
  *
12
12
  * This adapter uses genai-electron's async image generation API which:
@@ -18,6 +18,7 @@
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.GenaiElectronImageAdapter = void 0;
20
20
  const errorUtils_1 = require("../../shared/adapters/errorUtils");
21
+ const types_1 = require("../../llm/clients/types");
21
22
  const defaultLogger_1 = require("../../logging/defaultLogger");
22
23
  /**
23
24
  * Adapter for genai-electron's local diffusion image generation
@@ -33,7 +34,7 @@ class GenaiElectronImageAdapter {
33
34
  supportsNegativePrompt: true, // full diffusion support
34
35
  defaultModelId: 'sdxl',
35
36
  };
36
- this.baseURL = config?.baseURL || 'http://localhost:8081';
37
+ this.baseURL = config?.baseURL || 'http://127.0.0.1:8081';
37
38
  this.timeout = config?.timeout || 120000; // 120 seconds for diffusion
38
39
  this.pollInterval = 500; // Poll every 500ms
39
40
  this.logger = config?.logger ?? (0, defaultLogger_1.createDefaultLogger)();
@@ -42,8 +43,12 @@ class GenaiElectronImageAdapter {
42
43
  * Generates images using genai-electron's async API with progress polling
43
44
  */
44
45
  async generate(config) {
45
- const { request, resolvedPrompt, settings } = config;
46
+ const { request, resolvedPrompt, settings, signal } = config;
47
+ let generationId;
46
48
  try {
49
+ if (signal?.aborted) {
50
+ throw this.createAbortError('Image generation request was aborted before it started');
51
+ }
47
52
  // Build request payload
48
53
  const payload = this.buildRequestPayload(resolvedPrompt, request, settings);
49
54
  this.logger.debug(`GenaiElectron Image API: Starting generation`, {
@@ -53,16 +58,22 @@ class GenaiElectronImageAdapter {
53
58
  steps: payload.steps,
54
59
  });
55
60
  // Start generation (returns immediately with ID)
56
- const generationId = await this.startGeneration(payload);
61
+ generationId = await this.startGeneration(payload, signal);
57
62
  this.logger.info(`GenaiElectron Image API: Generation started with ID: ${generationId}`);
58
63
  // Poll for completion
59
- const result = await this.pollForCompletion(generationId, settings.diffusion?.onProgress);
64
+ const result = await this.pollForCompletion(generationId, settings.diffusion?.onProgress, signal);
60
65
  this.logger.info(`GenaiElectron Image API: Generation complete (${result.timeTaken}ms)`);
61
66
  // Convert to ImageGenerationResponse
62
67
  return this.convertToResponse(result, request);
63
68
  }
64
69
  catch (error) {
65
70
  this.logger.error('GenaiElectron Image API error:', error);
71
+ // Best-effort server-side cancellation when a started generation is being
72
+ // abandoned (caller abort, or client-side poll timeout — cancel frees the
73
+ // GPU; the DELETE is cleanup, not a reclassification)
74
+ if (generationId && (signal?.aborted || error?.name === 'TimeoutError')) {
75
+ await this.cancelGeneration(generationId);
76
+ }
66
77
  throw this.handleError(error, request);
67
78
  }
68
79
  }
@@ -88,12 +99,28 @@ class GenaiElectronImageAdapter {
88
99
  }
89
100
  /**
90
101
  * Starts generation and returns the generation ID
102
+ *
103
+ * The adapter's timeout and the caller's abort signal share one controller,
104
+ * so both surface as AbortError — classification comes from adapter-side
105
+ * state (timer-fired flag vs signal.aborted), with the caller's abort winning.
91
106
  */
92
- async startGeneration(payload) {
107
+ async startGeneration(payload, signal) {
93
108
  const url = `${this.baseURL}/v1/images/generations`;
94
- // Create abort controller for timeout
95
109
  const controller = new AbortController();
96
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
110
+ let timedOut = false;
111
+ const abortFromCaller = () => controller.abort();
112
+ if (signal) {
113
+ if (signal.aborted) {
114
+ controller.abort();
115
+ }
116
+ else {
117
+ signal.addEventListener('abort', abortFromCaller, { once: true });
118
+ }
119
+ }
120
+ const timeoutId = setTimeout(() => {
121
+ timedOut = true;
122
+ controller.abort();
123
+ }, this.timeout);
97
124
  try {
98
125
  const response = await fetch(url, {
99
126
  method: 'POST',
@@ -101,7 +128,6 @@ class GenaiElectronImageAdapter {
101
128
  body: JSON.stringify(payload),
102
129
  signal: controller.signal,
103
130
  });
104
- clearTimeout(timeoutId);
105
131
  if (!response.ok) {
106
132
  const errorText = await response.text();
107
133
  throw this.createHttpError(response.status, errorText, url);
@@ -110,27 +136,47 @@ class GenaiElectronImageAdapter {
110
136
  return data.id;
111
137
  }
112
138
  catch (error) {
113
- clearTimeout(timeoutId);
114
- // Handle AbortError
115
139
  if (error.name === 'AbortError') {
116
- throw new Error(`Request timeout after ${this.timeout}ms (connecting to ${this.baseURL})`);
140
+ if (signal?.aborted) {
141
+ throw this.createAbortError('Image generation request was aborted before it started');
142
+ }
143
+ if (timedOut) {
144
+ throw this.createTimeoutError(`Request timeout after ${this.timeout}ms (connecting to ${this.baseURL})`);
145
+ }
117
146
  }
118
147
  throw error;
119
148
  }
149
+ finally {
150
+ clearTimeout(timeoutId);
151
+ signal?.removeEventListener('abort', abortFromCaller);
152
+ }
120
153
  }
121
154
  /**
122
155
  * Polls for generation completion with progress updates
123
156
  */
124
- async pollForCompletion(generationId, onProgress) {
157
+ async pollForCompletion(generationId, onProgress, signal) {
125
158
  const url = `${this.baseURL}/v1/images/generations/${generationId}`;
126
159
  const startTime = Date.now();
127
160
  while (true) {
161
+ // Check caller abort (before timeout — user intent wins)
162
+ if (signal?.aborted) {
163
+ throw this.createAbortError(`Image generation request was aborted (ID: ${generationId})`);
164
+ }
128
165
  // Check overall timeout
129
166
  if (Date.now() - startTime > this.timeout) {
130
- throw new Error(`Generation timeout after ${this.timeout}ms (ID: ${generationId})`);
167
+ throw this.createTimeoutError(`Generation timeout after ${this.timeout}ms (ID: ${generationId})`);
131
168
  }
132
169
  // Fetch status
133
- const response = await fetch(url);
170
+ let response;
171
+ try {
172
+ response = await fetch(url, signal ? { signal } : undefined);
173
+ }
174
+ catch (error) {
175
+ if (error.name === 'AbortError' && signal?.aborted) {
176
+ throw this.createAbortError(`Image generation request was aborted (ID: ${generationId})`);
177
+ }
178
+ throw error;
179
+ }
134
180
  if (!response.ok) {
135
181
  const errorText = await response.text();
136
182
  throw this.createHttpError(response.status, errorText, url);
@@ -157,8 +203,36 @@ class GenaiElectronImageAdapter {
157
203
  const error = state.error || { message: 'Unknown error', code: 'UNKNOWN_ERROR' };
158
204
  throw this.createGenerationError(error.message, error.code);
159
205
  }
206
+ // Handle out-of-band cancellation (terminal status since genai-electron 0.6.0)
207
+ if (state.status === 'cancelled') {
208
+ const cancelError = new Error(`Image generation was cancelled on the server (ID: ${generationId})`);
209
+ cancelError.code = 'GENERATION_CANCELLED';
210
+ throw cancelError;
211
+ }
160
212
  // Wait before next poll
161
- await this.sleep(this.pollInterval);
213
+ await this.sleep(this.pollInterval, signal);
214
+ }
215
+ }
216
+ /**
217
+ * Best-effort cancellation of a server-side generation
218
+ * (DELETE /v1/images/generations/:id, available since genai-electron 0.6.0).
219
+ *
220
+ * All failures are swallowed: 404/409 just mean there is nothing left to
221
+ * cancel, and cleanup must never mask the caller's primary error.
222
+ */
223
+ async cancelGeneration(generationId) {
224
+ const url = `${this.baseURL}/v1/images/generations/${generationId}`;
225
+ const controller = new AbortController();
226
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
227
+ try {
228
+ await fetch(url, { method: 'DELETE', signal: controller.signal });
229
+ this.logger.debug(`GenaiElectron Image API: Requested cancellation of generation ${generationId}`);
230
+ }
231
+ catch (error) {
232
+ this.logger.debug(`GenaiElectron Image API: Best-effort cancellation failed for ${generationId}:`, error);
233
+ }
234
+ finally {
235
+ clearTimeout(timeoutId);
162
236
  }
163
237
  }
164
238
  /**
@@ -198,11 +272,15 @@ class GenaiElectronImageAdapter {
198
272
  */
199
273
  createHttpError(status, errorText, url) {
200
274
  let errorMessage = `HTTP ${status} error`;
275
+ let serverCode;
201
276
  try {
202
277
  const errorData = JSON.parse(errorText);
203
278
  if (errorData.error?.message) {
204
279
  errorMessage = errorData.error.message;
205
280
  }
281
+ if (typeof errorData.error?.code === 'string') {
282
+ serverCode = errorData.error.code;
283
+ }
206
284
  }
207
285
  catch {
208
286
  // Not JSON, use raw text
@@ -213,6 +291,27 @@ class GenaiElectronImageAdapter {
213
291
  const error = new Error(`${errorMessage} (${url})`);
214
292
  error.status = status;
215
293
  error.url = url;
294
+ if (serverCode) {
295
+ error.code = serverCode;
296
+ }
297
+ return error;
298
+ }
299
+ /**
300
+ * Creates a timeout-typed error: name 'TimeoutError' is recognized by the
301
+ * shared error mapping and classified as REQUEST_TIMEOUT / timeout_error
302
+ */
303
+ createTimeoutError(message) {
304
+ const error = new Error(message);
305
+ error.name = 'TimeoutError';
306
+ return error;
307
+ }
308
+ /**
309
+ * Creates an abort-typed error: name 'AbortError' is recognized by the
310
+ * shared error mapping and classified as REQUEST_ABORTED / abort_error
311
+ */
312
+ createAbortError(message) {
313
+ const error = new Error(message);
314
+ error.name = 'AbortError';
216
315
  return error;
217
316
  }
218
317
  /**
@@ -231,22 +330,33 @@ class GenaiElectronImageAdapter {
231
330
  const mapped = (0, errorUtils_1.getCommonMappedErrorDetails)(error);
232
331
  // Enhance error message with context
233
332
  let errorMessage = mapped.errorMessage;
333
+ let errorCode = mapped.errorCode;
334
+ let errorType = mapped.errorType;
234
335
  // Special handling for genai-electron specific errors
235
- if (error.code === 'SERVER_BUSY') {
336
+ if (error.code === 'GENERATION_CANCELLED') {
337
+ errorMessage = error.message;
338
+ errorCode = types_1.ADAPTER_ERROR_CODES.REQUEST_ABORTED;
339
+ errorType = 'abort_error';
340
+ }
341
+ else if (error.code === 'SERVER_BUSY') {
236
342
  errorMessage = 'Image generation server is busy. Wait for current generation to complete.';
237
- error.type = 'rate_limit_error';
343
+ errorCode = types_1.ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED;
344
+ errorType = 'rate_limit_error';
238
345
  }
239
346
  else if (error.code === 'SERVER_NOT_RUNNING') {
240
347
  errorMessage = `Image generation server is not running (connecting to ${this.baseURL})`;
241
- error.type = 'connection_error';
348
+ errorCode = types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR;
349
+ errorType = 'connection_error';
242
350
  }
243
351
  else if (error.code === 'BACKEND_ERROR') {
244
352
  errorMessage = `Diffusion backend error: ${error.message}`;
245
- error.type = 'server_error';
353
+ errorCode = types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR;
354
+ errorType = 'server_error';
246
355
  }
247
356
  else if (error.code === 'IO_ERROR') {
248
357
  errorMessage = `Image I/O error: ${error.message}`;
249
- error.type = 'server_error';
358
+ errorCode = types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR;
359
+ errorType = 'server_error';
250
360
  }
251
361
  // Add baseURL context for network errors
252
362
  if (mapped.errorCode === 'NETWORK_ERROR') {
@@ -258,18 +368,32 @@ class GenaiElectronImageAdapter {
258
368
  }
259
369
  // Create enhanced error with all details
260
370
  const enhancedError = new Error(errorMessage);
261
- enhancedError.code = mapped.errorCode;
262
- enhancedError.type = mapped.errorType;
371
+ enhancedError.code = errorCode;
372
+ enhancedError.type = errorType;
263
373
  enhancedError.status = mapped.status;
264
374
  enhancedError.providerId = this.id;
265
375
  enhancedError.modelId = request.modelId;
266
376
  return enhancedError;
267
377
  }
268
378
  /**
269
- * Sleep helper for polling
379
+ * Sleep helper for polling; rejects immediately when the signal aborts
270
380
  */
271
- sleep(ms) {
272
- return new Promise((resolve) => setTimeout(resolve, ms));
381
+ sleep(ms, signal) {
382
+ return new Promise((resolve, reject) => {
383
+ if (signal?.aborted) {
384
+ reject(this.createAbortError('Image generation request was aborted'));
385
+ return;
386
+ }
387
+ const onAbort = () => {
388
+ clearTimeout(timer);
389
+ reject(this.createAbortError('Image generation request was aborted'));
390
+ };
391
+ const timer = setTimeout(() => {
392
+ signal?.removeEventListener('abort', onAbort);
393
+ resolve();
394
+ }, ms);
395
+ signal?.addEventListener('abort', onAbort, { once: true });
396
+ });
273
397
  }
274
398
  }
275
399
  exports.GenaiElectronImageAdapter = GenaiElectronImageAdapter;
@@ -19,5 +19,6 @@ export declare class MockImageAdapter implements ImageProviderAdapter {
19
19
  resolvedPrompt: string;
20
20
  settings: ResolvedImageGenerationSettings;
21
21
  apiKey: string | null;
22
+ signal?: AbortSignal;
22
23
  }): Promise<ImageGenerationResponse>;
23
24
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.MockImageAdapter = void 0;
10
+ const types_1 = require("../../llm/clients/types");
10
11
  /**
11
12
  * Mock adapter for testing image generation
12
13
  */
@@ -26,7 +27,13 @@ class MockImageAdapter {
26
27
  * Generates mock images
27
28
  */
28
29
  async generate(config) {
29
- const { request, resolvedPrompt } = config;
30
+ const { request, resolvedPrompt, signal } = config;
31
+ if (signal?.aborted) {
32
+ const error = new Error('Image generation request was aborted');
33
+ error.code = types_1.ADAPTER_ERROR_CODES.REQUEST_ABORTED;
34
+ error.type = 'abort_error';
35
+ throw error;
36
+ }
30
37
  const count = request.count || 1;
31
38
  // Create mock image data (1x1 PNG)
32
39
  const mockImageBuffer = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
@@ -35,6 +35,7 @@ export declare class OpenAIImageAdapter implements ImageProviderAdapter {
35
35
  resolvedPrompt: string;
36
36
  settings: ResolvedImageGenerationSettings;
37
37
  apiKey: string | null;
38
+ signal?: AbortSignal;
38
39
  }): Promise<ImageGenerationResponse>;
39
40
  /**
40
41
  * 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 } = config;
62
+ const { request, resolvedPrompt, settings, apiKey, signal } = config;
63
63
  if (!apiKey) {
64
64
  throw new Error('OpenAI API key is required but was not provided');
65
65
  }
@@ -102,14 +102,14 @@ class OpenAIImageAdapter {
102
102
  n: params.n,
103
103
  isGptImageModel,
104
104
  });
105
- // Make API call
106
- const response = await client.images.generate(params);
105
+ // Make API call (per-request abort signal when provided)
106
+ const response = await client.images.generate(params, signal ? { signal } : undefined);
107
107
  if (!response.data || response.data.length === 0) {
108
108
  throw new Error('OpenAI API returned no images in response');
109
109
  }
110
110
  this.logger.info(`OpenAI Image API call successful, generated ${response.data.length} images`);
111
111
  // Process response
112
- return await this.processResponse(response, request, isGptImageModel);
112
+ return await this.processResponse(response, request, isGptImageModel, signal);
113
113
  }
114
114
  catch (error) {
115
115
  this.logger.error('OpenAI Image API error:', error);
@@ -199,7 +199,7 @@ class OpenAIImageAdapter {
199
199
  /**
200
200
  * Processes the OpenAI API response and converts to ImageGenerationResponse
201
201
  */
202
- async processResponse(response, request, isGptImageModel) {
202
+ async processResponse(response, request, isGptImageModel, signal) {
203
203
  const images = [];
204
204
  for (let i = 0; i < response.data.length; i++) {
205
205
  const item = response.data[i];
@@ -224,7 +224,7 @@ class OpenAIImageAdapter {
224
224
  if (item.url) {
225
225
  // Fetch image from URL
226
226
  imageUrl = item.url;
227
- const imageResponse = await fetch(item.url);
227
+ const imageResponse = await fetch(item.url, signal ? { signal } : undefined);
228
228
  if (!imageResponse.ok) {
229
229
  throw new Error(`Failed to fetch image from URL: ${imageResponse.statusText}`);
230
230
  }
@@ -5,7 +5,7 @@
5
5
  * manages presets, validates requests, and handles settings resolution.
6
6
  */
7
7
  import type { ApiKeyProvider } from '../types';
8
- import type { ImageGenerationRequest, ImageGenerationRequestWithPreset, ImageGenerationResponse, ImageFailureResponse, ImageProviderInfo, ImageModelInfo, ImageProviderId, ImagePreset, ImageServiceOptions } from '../types/image';
8
+ import type { ImageGenerationRequest, ImageGenerationRequestWithPreset, ImageGenerationResponse, ImageFailureResponse, ImageProviderInfo, ImageModelInfo, ImageProviderId, ImagePreset, ImageServiceOptions, GenerateImageOptions } from '../types/image';
9
9
  /**
10
10
  * Main service for image generation operations
11
11
  */
@@ -22,9 +22,10 @@ export declare class ImageService {
22
22
  * Generates images based on the request
23
23
  *
24
24
  * @param request - Image generation request
25
+ * @param options - Per-call options (e.g. an AbortSignal for cancellation)
25
26
  * @returns Promise resolving to response or error
26
27
  */
27
- generateImage(request: ImageGenerationRequest | ImageGenerationRequestWithPreset): Promise<ImageGenerationResponse | ImageFailureResponse>;
28
+ generateImage(request: ImageGenerationRequest | ImageGenerationRequestWithPreset, options?: GenerateImageOptions): Promise<ImageGenerationResponse | ImageFailureResponse>;
28
29
  /**
29
30
  * Gets list of supported image providers
30
31
  *
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ImageService = void 0;
13
13
  const defaultLogger_1 = require("../logging/defaultLogger");
14
+ const types_1 = require("../llm/clients/types");
14
15
  const config_1 = require("./config");
15
16
  const image_presets_json_1 = __importDefault(require("../config/image-presets.json"));
16
17
  const PresetManager_1 = require("../shared/services/PresetManager");
@@ -23,6 +24,9 @@ const ImageSettingsResolver_1 = require("./services/ImageSettingsResolver");
23
24
  const ImageModelResolver_1 = require("./services/ImageModelResolver");
24
25
  // Type assertion for the imported JSON
25
26
  const defaultImagePresets = image_presets_json_1.default;
27
+ // Error codes adapters stamp on the errors they throw; used to decide whether a
28
+ // caught error's classification is safe to surface on the failure envelope
29
+ const ADAPTER_ERROR_CODE_VALUES = new Set(Object.values(types_1.ADAPTER_ERROR_CODES));
26
30
  /**
27
31
  * Main service for image generation operations
28
32
  */
@@ -69,9 +73,10 @@ class ImageService {
69
73
  * Generates images based on the request
70
74
  *
71
75
  * @param request - Image generation request
76
+ * @param options - Per-call options (e.g. an AbortSignal for cancellation)
72
77
  * @returns Promise resolving to response or error
73
78
  */
74
- async generateImage(request) {
79
+ async generateImage(request, options) {
75
80
  this.logger.info('ImageService.generateImage called');
76
81
  try {
77
82
  // Resolve model information
@@ -104,6 +109,19 @@ class ImageService {
104
109
  const adapter = this.adapterRegistry.getAdapter(providerId);
105
110
  // Get API key
106
111
  try {
112
+ // Short-circuit if the caller already aborted — don't touch the adapter
113
+ if (options?.signal?.aborted) {
114
+ return {
115
+ object: 'error',
116
+ providerId: providerId,
117
+ modelId: modelId,
118
+ error: {
119
+ message: 'Image generation request was aborted',
120
+ code: types_1.ADAPTER_ERROR_CODES.REQUEST_ABORTED,
121
+ type: 'abort_error',
122
+ },
123
+ };
124
+ }
107
125
  const apiKey = await this.getApiKey(providerId);
108
126
  // Validate API key if adapter supports it
109
127
  if (apiKey && adapter.validateApiKey && !adapter.validateApiKey(apiKey)) {
@@ -125,12 +143,20 @@ class ImageService {
125
143
  resolvedPrompt,
126
144
  settings: resolvedSettings,
127
145
  apiKey,
146
+ signal: options?.signal,
128
147
  });
129
148
  this.logger.info('ImageService: Image generation completed successfully');
130
149
  return response;
131
150
  }
132
151
  catch (error) {
133
152
  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);
134
160
  return {
135
161
  object: 'error',
136
162
  providerId: providerId,
@@ -139,8 +165,12 @@ class ImageService {
139
165
  message: error instanceof Error
140
166
  ? error.message
141
167
  : 'An unknown error occurred during image generation',
142
- code: 'PROVIDER_ERROR',
143
- type: 'server_error',
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 }),
144
174
  providerError: error,
145
175
  },
146
176
  };
@@ -174,7 +174,9 @@ exports.IMAGE_ADAPTER_CONFIGS = {
174
174
  timeout: 60000, // 60 seconds for image generation
175
175
  },
176
176
  'genai-electron-images': {
177
- baseURL: process.env.GENAI_ELECTRON_IMAGE_BASE_URL || 'http://localhost:8081',
177
+ // 127.0.0.1 (not localhost) avoids a ~2s/request IPv6-fallback stall on
178
+ // Windows — especially costly for the 500ms polling loop
179
+ baseURL: process.env.GENAI_ELECTRON_IMAGE_BASE_URL || 'http://127.0.0.1:8081',
178
180
  timeout: 120000, // 120 seconds for local diffusion
179
181
  checkHealth: false, // Optional health checks
180
182
  },
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export type { OpenRouterClientConfig } from "./llm/clients/OpenRouterClientAdapt
16
16
  export { MistralClientAdapter } from "./llm/clients/MistralClientAdapter";
17
17
  export type { MistralClientConfig } from "./llm/clients/MistralClientAdapter";
18
18
  export { ImageService } from "./image/ImageService";
19
- export type { ImageProviderId, ImageMimeType, ImageResponseFormat, ImageQuality, ImageStyle, DiffusionSampler, ImageProgressStage, ImageProgressCallback, DiffusionSettings, OpenAISpecificSettings, ImageGenerationSettings, ResolvedImageGenerationSettings, ImageUsage, GeneratedImage, ImageGenerationRequestBase, ImageGenerationRequest, ImageGenerationRequestWithPreset, ImageGenerationResponse, ImageFailureResponse, ImageProviderCapabilities, ImageModelInfo, ImageProviderInfo, ImagePreset, ImageProviderAdapterConfig, ImageProviderAdapter, ImageServiceOptions, CreatePromptResult, } from "./types/image";
19
+ export type { ImageProviderId, ImageMimeType, ImageResponseFormat, ImageQuality, ImageStyle, DiffusionSampler, ImageProgressStage, ImageProgressCallback, DiffusionSettings, OpenAISpecificSettings, ImageGenerationSettings, ResolvedImageGenerationSettings, ImageUsage, GeneratedImage, ImageGenerationRequestBase, ImageGenerationRequest, ImageGenerationRequestWithPreset, ImageGenerationResponse, ImageFailureResponse, ImageProviderCapabilities, ImageModelInfo, ImageProviderInfo, ImagePreset, ImageProviderAdapterConfig, ImageProviderAdapter, ImageServiceOptions, GenerateImageOptions, CreatePromptResult, } from "./types/image";
20
20
  export { renderTemplate } from "./prompting/template";
21
21
  export { countTokens, getSmartPreview, extractRandomVariables } from "./prompting/content";
22
22
  export { parseStructuredContent, parseRoleTags, extractInitialTaggedContent, extractMarkerDelimitedContent, parseTemplateWithMetadata } from "./prompting/parser";
@@ -75,6 +75,7 @@ export declare class GeminiClientAdapter implements ILLMClientAdapter {
75
75
  *
76
76
  * @param error - The error from Gemini API
77
77
  * @param request - Original request for context
78
+ * @param context - Adapter-side transport state captured at catch time
78
79
  * @returns Standardized LLM failure response
79
80
  */
80
81
  private createErrorResponse;
@@ -38,6 +38,12 @@ 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.
45
+ let timedOut = false;
46
+ let timeoutHandle;
41
47
  try {
42
48
  // Initialize Gemini client (note: @google/genai performs no automatic retries)
43
49
  const genAI = new genai_1.GoogleGenAI({ apiKey });
@@ -52,6 +58,26 @@ class GeminiClientAdapter {
52
58
  contentsLength: contents.length,
53
59
  safetySettingsCount: safetySettings?.length || 0,
54
60
  });
61
+ let abortSignal = options?.signal;
62
+ if (options?.timeoutMs !== undefined) {
63
+ const controller = new AbortController();
64
+ if (options.signal) {
65
+ if (options.signal.aborted) {
66
+ controller.abort();
67
+ }
68
+ else {
69
+ options.signal.addEventListener("abort", () => controller.abort(), {
70
+ once: true,
71
+ });
72
+ }
73
+ }
74
+ timeoutHandle = setTimeout(() => {
75
+ timedOut = true;
76
+ controller.abort();
77
+ }, options.timeoutMs);
78
+ timeoutHandle.unref?.();
79
+ abortSignal = controller.signal;
80
+ }
55
81
  // Generate content using the modern API
56
82
  const result = await genAI.models.generateContent({
57
83
  model: request.modelId,
@@ -60,9 +86,11 @@ class GeminiClientAdapter {
60
86
  ...generationConfig,
61
87
  safetySettings: safetySettings,
62
88
  ...(systemInstruction && { systemInstruction: systemInstruction }),
63
- ...(options?.signal && { abortSignal: options.signal }),
89
+ ...(abortSignal && { abortSignal }),
90
+ // Keep the SDK's server-side timeout hint (X-Server-Timeout header),
91
+ // padded so the adapter's local timer always fires first
64
92
  ...(options?.timeoutMs !== undefined && {
65
- httpOptions: { timeout: options.timeoutMs },
93
+ httpOptions: { timeout: options.timeoutMs + 1000 },
66
94
  }),
67
95
  },
68
96
  });
@@ -72,7 +100,15 @@ class GeminiClientAdapter {
72
100
  }
73
101
  catch (error) {
74
102
  this.logger.error("Gemini API error:", error);
75
- return this.createErrorResponse(error, request);
103
+ return this.createErrorResponse(error, request, {
104
+ timedOut,
105
+ aborted: options?.signal?.aborted === true,
106
+ });
107
+ }
108
+ finally {
109
+ if (timeoutHandle !== undefined) {
110
+ clearTimeout(timeoutHandle);
111
+ }
76
112
  }
77
113
  }
78
114
  /**
@@ -312,9 +348,38 @@ class GeminiClientAdapter {
312
348
  *
313
349
  * @param error - The error from Gemini API
314
350
  * @param request - Original request for context
351
+ * @param context - Adapter-side transport state captured at catch time
315
352
  * @returns Standardized LLM failure response
316
353
  */
317
- createErrorResponse(error, request) {
354
+ createErrorResponse(error, request, context) {
355
+ // Classify adapter-owned aborts/timeouts first — the SDK surfaces both as
356
+ // plain Errors the common mapping cannot recognize (see sendMessage)
357
+ if (context?.aborted) {
358
+ return {
359
+ provider: request.providerId,
360
+ model: request.modelId,
361
+ error: {
362
+ message: "Request was aborted",
363
+ code: types_1.ADAPTER_ERROR_CODES.REQUEST_ABORTED,
364
+ type: "abort_error",
365
+ providerError: error,
366
+ },
367
+ object: "error",
368
+ };
369
+ }
370
+ if (context?.timedOut) {
371
+ return {
372
+ provider: request.providerId,
373
+ model: request.modelId,
374
+ error: {
375
+ message: "Request timed out",
376
+ code: types_1.ADAPTER_ERROR_CODES.REQUEST_TIMEOUT,
377
+ type: "timeout_error",
378
+ providerError: error,
379
+ },
380
+ object: "error",
381
+ };
382
+ }
318
383
  // Use shared error mapping utility for common error patterns
319
384
  const initialProviderMessage = error?.message;
320
385
  let { errorCode, errorMessage, errorType, status, retryAfterMs } = (0, errorUtils_1.getCommonMappedErrorDetails)(error, initialProviderMessage);
@@ -251,6 +251,8 @@ export interface ImageFailureResponse {
251
251
  code?: string | number;
252
252
  /** Error type (authentication_error, rate_limit_error, etc.) */
253
253
  type?: string;
254
+ /** HTTP status code reported by the provider, when available */
255
+ status?: number;
254
256
  /** Parameter that caused the error (if applicable) */
255
257
  param?: string;
256
258
  /** Original provider error (for debugging) */
@@ -359,6 +361,8 @@ export interface ImageProviderAdapter {
359
361
  resolvedPrompt: string;
360
362
  settings: ResolvedImageGenerationSettings;
361
363
  apiKey: string | null;
364
+ /** Abort signal for request-side cancellation (optional) */
365
+ signal?: AbortSignal;
362
366
  }): Promise<ImageGenerationResponse>;
363
367
  /**
364
368
  * Optional method to get available models from this provider
@@ -395,6 +399,18 @@ export interface ImageServiceOptions {
395
399
  /** Custom logger implementation. If provided, logLevel is ignored. */
396
400
  logger?: Logger;
397
401
  }
402
+ /**
403
+ * Per-call options for ImageService.generateImage
404
+ */
405
+ export interface GenerateImageOptions {
406
+ /**
407
+ * Abort signal to cancel the request (client-side — the provider may still
408
+ * process an already-dispatched request; for local diffusion the adapter
409
+ * additionally issues a best-effort server-side cancellation). Aborted
410
+ * requests surface as REQUEST_ABORTED / abort_error failures.
411
+ */
412
+ signal?: AbortSignal;
413
+ }
398
414
  /**
399
415
  * Result from createPrompt utility
400
416
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genai-lite",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -43,7 +43,7 @@
43
43
  "test:e2e:reasoning": "npm run build && jest --config jest.e2e.config.js reasoning.e2e.test.ts"
44
44
  },
45
45
  "dependencies": {
46
- "@anthropic-ai/sdk": "^0.71.2",
46
+ "@anthropic-ai/sdk": "^0.72.1",
47
47
  "@google/genai": "^1.0.1",
48
48
  "@mistralai/mistralai": "^1.11.0",
49
49
  "js-tiktoken": "^1.0.20",