genai-lite 0.9.2 → 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 +1 -1
- package/dist/adapters/image/GenaiElectronImageAdapter.d.ts +26 -2
- package/dist/adapters/image/GenaiElectronImageAdapter.js +151 -24
- package/dist/adapters/image/MockImageAdapter.d.ts +2 -0
- package/dist/adapters/image/MockImageAdapter.js +8 -1
- package/dist/adapters/image/OpenAIImageAdapter.d.ts +2 -0
- package/dist/adapters/image/OpenAIImageAdapter.js +22 -7
- package/dist/image/ImageService.d.ts +14 -2
- package/dist/image/ImageService.js +100 -22
- package/dist/image/config.js +8 -1
- package/dist/index.d.ts +1 -1
- package/dist/llm/clients/GeminiClientAdapter.js +8 -5
- package/dist/llm/clients/MistralClientAdapter.js +19 -5
- package/dist/shared/adapters/errorUtils.d.ts +2 -1
- package/dist/shared/adapters/errorUtils.js +56 -18
- package/dist/types/image.d.ts +49 -0
- package/package.json +7 -4
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 — 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
|
|
@@ -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://
|
|
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,8 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
|
|
|
34
34
|
resolvedPrompt: string;
|
|
35
35
|
settings: ResolvedImageGenerationSettings;
|
|
36
36
|
apiKey: string | null;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
timeoutMs?: number;
|
|
37
39
|
}): Promise<ImageGenerationResponse>;
|
|
38
40
|
/**
|
|
39
41
|
* Builds the request payload for genai-electron
|
|
@@ -41,12 +43,24 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
|
|
|
41
43
|
private buildRequestPayload;
|
|
42
44
|
/**
|
|
43
45
|
* Starts generation and returns the generation ID
|
|
46
|
+
*
|
|
47
|
+
* The adapter's timeout and the caller's abort signal share one controller,
|
|
48
|
+
* so both surface as AbortError — classification comes from adapter-side
|
|
49
|
+
* state (timer-fired flag vs signal.aborted), with the caller's abort winning.
|
|
44
50
|
*/
|
|
45
51
|
private startGeneration;
|
|
46
52
|
/**
|
|
47
53
|
* Polls for generation completion with progress updates
|
|
48
54
|
*/
|
|
49
55
|
private pollForCompletion;
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort cancellation of a server-side generation
|
|
58
|
+
* (DELETE /v1/images/generations/:id, available since genai-electron 0.6.0).
|
|
59
|
+
*
|
|
60
|
+
* All failures are swallowed: 404/409 just mean there is nothing left to
|
|
61
|
+
* cancel, and cleanup must never mask the caller's primary error.
|
|
62
|
+
*/
|
|
63
|
+
private cancelGeneration;
|
|
50
64
|
/**
|
|
51
65
|
* Converts genai-electron result to ImageGenerationResponse
|
|
52
66
|
*/
|
|
@@ -55,6 +69,16 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
|
|
|
55
69
|
* Creates an HTTP error with context
|
|
56
70
|
*/
|
|
57
71
|
private createHttpError;
|
|
72
|
+
/**
|
|
73
|
+
* Creates a timeout-typed error: name 'TimeoutError' is recognized by the
|
|
74
|
+
* shared error mapping and classified as REQUEST_TIMEOUT / timeout_error
|
|
75
|
+
*/
|
|
76
|
+
private createTimeoutError;
|
|
77
|
+
/**
|
|
78
|
+
* Creates an abort-typed error: name 'AbortError' is recognized by the
|
|
79
|
+
* shared error mapping and classified as REQUEST_ABORTED / abort_error
|
|
80
|
+
*/
|
|
81
|
+
private createAbortError;
|
|
58
82
|
/**
|
|
59
83
|
* Creates a generation error from genai-electron error codes
|
|
60
84
|
*/
|
|
@@ -64,7 +88,7 @@ export declare class GenaiElectronImageAdapter implements ImageProviderAdapter {
|
|
|
64
88
|
*/
|
|
65
89
|
private handleError;
|
|
66
90
|
/**
|
|
67
|
-
* Sleep helper for polling
|
|
91
|
+
* Sleep helper for polling; rejects immediately when the signal aborts
|
|
68
92
|
*/
|
|
69
93
|
private sleep;
|
|
70
94
|
}
|
|
@@ -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://
|
|
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:
|
|
@@ -34,7 +34,7 @@ class GenaiElectronImageAdapter {
|
|
|
34
34
|
supportsNegativePrompt: true, // full diffusion support
|
|
35
35
|
defaultModelId: 'sdxl',
|
|
36
36
|
};
|
|
37
|
-
this.baseURL = config?.baseURL || 'http://
|
|
37
|
+
this.baseURL = config?.baseURL || 'http://127.0.0.1:8081';
|
|
38
38
|
this.timeout = config?.timeout || 120000; // 120 seconds for diffusion
|
|
39
39
|
this.pollInterval = 500; // Poll every 500ms
|
|
40
40
|
this.logger = config?.logger ?? (0, defaultLogger_1.createDefaultLogger)();
|
|
@@ -43,8 +43,15 @@ 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 } = 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;
|
|
50
|
+
let generationId;
|
|
47
51
|
try {
|
|
52
|
+
if (signal?.aborted) {
|
|
53
|
+
throw this.createAbortError('Image generation request was aborted before it started');
|
|
54
|
+
}
|
|
48
55
|
// Build request payload
|
|
49
56
|
const payload = this.buildRequestPayload(resolvedPrompt, request, settings);
|
|
50
57
|
this.logger.debug(`GenaiElectron Image API: Starting generation`, {
|
|
@@ -54,16 +61,22 @@ class GenaiElectronImageAdapter {
|
|
|
54
61
|
steps: payload.steps,
|
|
55
62
|
});
|
|
56
63
|
// Start generation (returns immediately with ID)
|
|
57
|
-
|
|
64
|
+
generationId = await this.startGeneration(payload, signal, effectiveTimeout);
|
|
58
65
|
this.logger.info(`GenaiElectron Image API: Generation started with ID: ${generationId}`);
|
|
59
66
|
// Poll for completion
|
|
60
|
-
const result = await this.pollForCompletion(generationId, settings.diffusion?.onProgress);
|
|
67
|
+
const result = await this.pollForCompletion(generationId, settings.diffusion?.onProgress, signal, effectiveTimeout);
|
|
61
68
|
this.logger.info(`GenaiElectron Image API: Generation complete (${result.timeTaken}ms)`);
|
|
62
69
|
// Convert to ImageGenerationResponse
|
|
63
70
|
return this.convertToResponse(result, request);
|
|
64
71
|
}
|
|
65
72
|
catch (error) {
|
|
66
73
|
this.logger.error('GenaiElectron Image API error:', error);
|
|
74
|
+
// Best-effort server-side cancellation when a started generation is being
|
|
75
|
+
// abandoned (caller abort, or client-side poll timeout — cancel frees the
|
|
76
|
+
// GPU; the DELETE is cleanup, not a reclassification)
|
|
77
|
+
if (generationId && (signal?.aborted || error?.name === 'TimeoutError')) {
|
|
78
|
+
await this.cancelGeneration(generationId);
|
|
79
|
+
}
|
|
67
80
|
throw this.handleError(error, request);
|
|
68
81
|
}
|
|
69
82
|
}
|
|
@@ -89,12 +102,28 @@ class GenaiElectronImageAdapter {
|
|
|
89
102
|
}
|
|
90
103
|
/**
|
|
91
104
|
* Starts generation and returns the generation ID
|
|
105
|
+
*
|
|
106
|
+
* The adapter's timeout and the caller's abort signal share one controller,
|
|
107
|
+
* so both surface as AbortError — classification comes from adapter-side
|
|
108
|
+
* state (timer-fired flag vs signal.aborted), with the caller's abort winning.
|
|
92
109
|
*/
|
|
93
|
-
async startGeneration(payload) {
|
|
110
|
+
async startGeneration(payload, signal, effectiveTimeout) {
|
|
94
111
|
const url = `${this.baseURL}/v1/images/generations`;
|
|
95
|
-
// Create abort controller for timeout
|
|
96
112
|
const controller = new AbortController();
|
|
97
|
-
|
|
113
|
+
let timedOut = false;
|
|
114
|
+
const abortFromCaller = () => controller.abort();
|
|
115
|
+
if (signal) {
|
|
116
|
+
if (signal.aborted) {
|
|
117
|
+
controller.abort();
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
signal.addEventListener('abort', abortFromCaller, { once: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const timeoutId = setTimeout(() => {
|
|
124
|
+
timedOut = true;
|
|
125
|
+
controller.abort();
|
|
126
|
+
}, effectiveTimeout);
|
|
98
127
|
try {
|
|
99
128
|
const response = await fetch(url, {
|
|
100
129
|
method: 'POST',
|
|
@@ -102,7 +131,6 @@ class GenaiElectronImageAdapter {
|
|
|
102
131
|
body: JSON.stringify(payload),
|
|
103
132
|
signal: controller.signal,
|
|
104
133
|
});
|
|
105
|
-
clearTimeout(timeoutId);
|
|
106
134
|
if (!response.ok) {
|
|
107
135
|
const errorText = await response.text();
|
|
108
136
|
throw this.createHttpError(response.status, errorText, url);
|
|
@@ -111,27 +139,47 @@ class GenaiElectronImageAdapter {
|
|
|
111
139
|
return data.id;
|
|
112
140
|
}
|
|
113
141
|
catch (error) {
|
|
114
|
-
clearTimeout(timeoutId);
|
|
115
|
-
// Handle AbortError
|
|
116
142
|
if (error.name === 'AbortError') {
|
|
117
|
-
|
|
143
|
+
if (signal?.aborted) {
|
|
144
|
+
throw this.createAbortError('Image generation request was aborted before it started');
|
|
145
|
+
}
|
|
146
|
+
if (timedOut) {
|
|
147
|
+
throw this.createTimeoutError(`Request timeout after ${effectiveTimeout}ms (connecting to ${this.baseURL})`);
|
|
148
|
+
}
|
|
118
149
|
}
|
|
119
150
|
throw error;
|
|
120
151
|
}
|
|
152
|
+
finally {
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
signal?.removeEventListener('abort', abortFromCaller);
|
|
155
|
+
}
|
|
121
156
|
}
|
|
122
157
|
/**
|
|
123
158
|
* Polls for generation completion with progress updates
|
|
124
159
|
*/
|
|
125
|
-
async pollForCompletion(generationId, onProgress) {
|
|
160
|
+
async pollForCompletion(generationId, onProgress, signal, effectiveTimeout) {
|
|
126
161
|
const url = `${this.baseURL}/v1/images/generations/${generationId}`;
|
|
127
162
|
const startTime = Date.now();
|
|
128
163
|
while (true) {
|
|
164
|
+
// Check caller abort (before timeout — user intent wins)
|
|
165
|
+
if (signal?.aborted) {
|
|
166
|
+
throw this.createAbortError(`Image generation request was aborted (ID: ${generationId})`);
|
|
167
|
+
}
|
|
129
168
|
// Check overall timeout
|
|
130
|
-
if (Date.now() - startTime >
|
|
131
|
-
throw
|
|
169
|
+
if (Date.now() - startTime > effectiveTimeout) {
|
|
170
|
+
throw this.createTimeoutError(`Generation timeout after ${effectiveTimeout}ms (ID: ${generationId})`);
|
|
132
171
|
}
|
|
133
172
|
// Fetch status
|
|
134
|
-
|
|
173
|
+
let response;
|
|
174
|
+
try {
|
|
175
|
+
response = await fetch(url, signal ? { signal } : undefined);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.name === 'AbortError' && signal?.aborted) {
|
|
179
|
+
throw this.createAbortError(`Image generation request was aborted (ID: ${generationId})`);
|
|
180
|
+
}
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
135
183
|
if (!response.ok) {
|
|
136
184
|
const errorText = await response.text();
|
|
137
185
|
throw this.createHttpError(response.status, errorText, url);
|
|
@@ -165,7 +213,29 @@ class GenaiElectronImageAdapter {
|
|
|
165
213
|
throw cancelError;
|
|
166
214
|
}
|
|
167
215
|
// Wait before next poll
|
|
168
|
-
await this.sleep(this.pollInterval);
|
|
216
|
+
await this.sleep(this.pollInterval, signal);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Best-effort cancellation of a server-side generation
|
|
221
|
+
* (DELETE /v1/images/generations/:id, available since genai-electron 0.6.0).
|
|
222
|
+
*
|
|
223
|
+
* All failures are swallowed: 404/409 just mean there is nothing left to
|
|
224
|
+
* cancel, and cleanup must never mask the caller's primary error.
|
|
225
|
+
*/
|
|
226
|
+
async cancelGeneration(generationId) {
|
|
227
|
+
const url = `${this.baseURL}/v1/images/generations/${generationId}`;
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
230
|
+
try {
|
|
231
|
+
await fetch(url, { method: 'DELETE', signal: controller.signal });
|
|
232
|
+
this.logger.debug(`GenaiElectron Image API: Requested cancellation of generation ${generationId}`);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
this.logger.debug(`GenaiElectron Image API: Best-effort cancellation failed for ${generationId}:`, error);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
clearTimeout(timeoutId);
|
|
169
239
|
}
|
|
170
240
|
}
|
|
171
241
|
/**
|
|
@@ -205,11 +275,15 @@ class GenaiElectronImageAdapter {
|
|
|
205
275
|
*/
|
|
206
276
|
createHttpError(status, errorText, url) {
|
|
207
277
|
let errorMessage = `HTTP ${status} error`;
|
|
278
|
+
let serverCode;
|
|
208
279
|
try {
|
|
209
280
|
const errorData = JSON.parse(errorText);
|
|
210
281
|
if (errorData.error?.message) {
|
|
211
282
|
errorMessage = errorData.error.message;
|
|
212
283
|
}
|
|
284
|
+
if (typeof errorData.error?.code === 'string') {
|
|
285
|
+
serverCode = errorData.error.code;
|
|
286
|
+
}
|
|
213
287
|
}
|
|
214
288
|
catch {
|
|
215
289
|
// Not JSON, use raw text
|
|
@@ -220,6 +294,27 @@ class GenaiElectronImageAdapter {
|
|
|
220
294
|
const error = new Error(`${errorMessage} (${url})`);
|
|
221
295
|
error.status = status;
|
|
222
296
|
error.url = url;
|
|
297
|
+
if (serverCode) {
|
|
298
|
+
error.code = serverCode;
|
|
299
|
+
}
|
|
300
|
+
return error;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Creates a timeout-typed error: name 'TimeoutError' is recognized by the
|
|
304
|
+
* shared error mapping and classified as REQUEST_TIMEOUT / timeout_error
|
|
305
|
+
*/
|
|
306
|
+
createTimeoutError(message) {
|
|
307
|
+
const error = new Error(message);
|
|
308
|
+
error.name = 'TimeoutError';
|
|
309
|
+
return error;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Creates an abort-typed error: name 'AbortError' is recognized by the
|
|
313
|
+
* shared error mapping and classified as REQUEST_ABORTED / abort_error
|
|
314
|
+
*/
|
|
315
|
+
createAbortError(message) {
|
|
316
|
+
const error = new Error(message);
|
|
317
|
+
error.name = 'AbortError';
|
|
223
318
|
return error;
|
|
224
319
|
}
|
|
225
320
|
/**
|
|
@@ -248,19 +343,34 @@ class GenaiElectronImageAdapter {
|
|
|
248
343
|
}
|
|
249
344
|
else if (error.code === 'SERVER_BUSY') {
|
|
250
345
|
errorMessage = 'Image generation server is busy. Wait for current generation to complete.';
|
|
251
|
-
|
|
346
|
+
errorCode = types_1.ADAPTER_ERROR_CODES.RATE_LIMIT_EXCEEDED;
|
|
347
|
+
errorType = 'rate_limit_error';
|
|
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';
|
|
252
359
|
}
|
|
253
360
|
else if (error.code === 'SERVER_NOT_RUNNING') {
|
|
254
361
|
errorMessage = `Image generation server is not running (connecting to ${this.baseURL})`;
|
|
255
|
-
|
|
362
|
+
errorCode = types_1.ADAPTER_ERROR_CODES.NETWORK_ERROR;
|
|
363
|
+
errorType = 'connection_error';
|
|
256
364
|
}
|
|
257
365
|
else if (error.code === 'BACKEND_ERROR') {
|
|
258
366
|
errorMessage = `Diffusion backend error: ${error.message}`;
|
|
259
|
-
|
|
367
|
+
errorCode = types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR;
|
|
368
|
+
errorType = 'server_error';
|
|
260
369
|
}
|
|
261
370
|
else if (error.code === 'IO_ERROR') {
|
|
262
371
|
errorMessage = `Image I/O error: ${error.message}`;
|
|
263
|
-
|
|
372
|
+
errorCode = types_1.ADAPTER_ERROR_CODES.PROVIDER_ERROR;
|
|
373
|
+
errorType = 'server_error';
|
|
264
374
|
}
|
|
265
375
|
// Add baseURL context for network errors
|
|
266
376
|
if (mapped.errorCode === 'NETWORK_ERROR') {
|
|
@@ -275,15 +385,32 @@ class GenaiElectronImageAdapter {
|
|
|
275
385
|
enhancedError.code = errorCode;
|
|
276
386
|
enhancedError.type = errorType;
|
|
277
387
|
enhancedError.status = mapped.status;
|
|
388
|
+
if (mapped.retryAfterMs !== undefined) {
|
|
389
|
+
enhancedError.retryAfterMs = mapped.retryAfterMs;
|
|
390
|
+
}
|
|
278
391
|
enhancedError.providerId = this.id;
|
|
279
392
|
enhancedError.modelId = request.modelId;
|
|
280
393
|
return enhancedError;
|
|
281
394
|
}
|
|
282
395
|
/**
|
|
283
|
-
* Sleep helper for polling
|
|
396
|
+
* Sleep helper for polling; rejects immediately when the signal aborts
|
|
284
397
|
*/
|
|
285
|
-
sleep(ms) {
|
|
286
|
-
return new Promise((resolve) =>
|
|
398
|
+
sleep(ms, signal) {
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
if (signal?.aborted) {
|
|
401
|
+
reject(this.createAbortError('Image generation request was aborted'));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const onAbort = () => {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
reject(this.createAbortError('Image generation request was aborted'));
|
|
407
|
+
};
|
|
408
|
+
const timer = setTimeout(() => {
|
|
409
|
+
signal?.removeEventListener('abort', onAbort);
|
|
410
|
+
resolve();
|
|
411
|
+
}, ms);
|
|
412
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
413
|
+
});
|
|
287
414
|
}
|
|
288
415
|
}
|
|
289
416
|
exports.GenaiElectronImageAdapter = GenaiElectronImageAdapter;
|
|
@@ -19,5 +19,7 @@ export declare class MockImageAdapter implements ImageProviderAdapter {
|
|
|
19
19
|
resolvedPrompt: string;
|
|
20
20
|
settings: ResolvedImageGenerationSettings;
|
|
21
21
|
apiKey: string | null;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
timeoutMs?: number;
|
|
22
24
|
}): Promise<ImageGenerationResponse>;
|
|
23
25
|
}
|
|
@@ -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,8 @@ export declare class OpenAIImageAdapter implements ImageProviderAdapter {
|
|
|
35
35
|
resolvedPrompt: string;
|
|
36
36
|
settings: ResolvedImageGenerationSettings;
|
|
37
37
|
apiKey: string | null;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
timeoutMs?: number;
|
|
38
40
|
}): Promise<ImageGenerationResponse>;
|
|
39
41
|
/**
|
|
40
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 } = 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
|
|
106
|
-
const
|
|
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);
|
|
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) {
|
|
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
|
|
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;
|
|
@@ -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
|
*/
|
|
@@ -17,14 +17,26 @@ 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
|
|
23
24
|
*
|
|
24
25
|
* @param request - Image generation request
|
|
26
|
+
* @param options - Per-call options (e.g. an AbortSignal for cancellation)
|
|
25
27
|
* @returns Promise resolving to response or error
|
|
26
28
|
*/
|
|
27
|
-
generateImage(request: ImageGenerationRequest | ImageGenerationRequestWithPreset): Promise<ImageGenerationResponse | ImageFailureResponse>;
|
|
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;
|
|
28
40
|
/**
|
|
29
41
|
* Gets list of supported image providers
|
|
30
42
|
*
|
|
@@ -11,6 +11,8 @@ 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");
|
|
15
|
+
const withRetry_1 = require("../shared/services/withRetry");
|
|
14
16
|
const config_1 = require("./config");
|
|
15
17
|
const image_presets_json_1 = __importDefault(require("../config/image-presets.json"));
|
|
16
18
|
const PresetManager_1 = require("../shared/services/PresetManager");
|
|
@@ -23,12 +25,16 @@ const ImageSettingsResolver_1 = require("./services/ImageSettingsResolver");
|
|
|
23
25
|
const ImageModelResolver_1 = require("./services/ImageModelResolver");
|
|
24
26
|
// Type assertion for the imported JSON
|
|
25
27
|
const defaultImagePresets = image_presets_json_1.default;
|
|
28
|
+
// Error codes adapters stamp on the errors they throw; used to decide whether a
|
|
29
|
+
// caught error's classification is safe to surface on the failure envelope
|
|
30
|
+
const ADAPTER_ERROR_CODE_VALUES = new Set(Object.values(types_1.ADAPTER_ERROR_CODES));
|
|
26
31
|
/**
|
|
27
32
|
* Main service for image generation operations
|
|
28
33
|
*/
|
|
29
34
|
class ImageService {
|
|
30
35
|
constructor(getApiKey, options = {}) {
|
|
31
36
|
this.getApiKey = getApiKey;
|
|
37
|
+
this.retryOptions = options.retry;
|
|
32
38
|
// Initialize logger - custom logger takes precedence over logLevel
|
|
33
39
|
this.logger = options.logger ?? (0, defaultLogger_1.createDefaultLogger)(options.logLevel);
|
|
34
40
|
// Initialize helper services
|
|
@@ -69,9 +75,10 @@ class ImageService {
|
|
|
69
75
|
* Generates images based on the request
|
|
70
76
|
*
|
|
71
77
|
* @param request - Image generation request
|
|
78
|
+
* @param options - Per-call options (e.g. an AbortSignal for cancellation)
|
|
72
79
|
* @returns Promise resolving to response or error
|
|
73
80
|
*/
|
|
74
|
-
async generateImage(request) {
|
|
81
|
+
async generateImage(request, options) {
|
|
75
82
|
this.logger.info('ImageService.generateImage called');
|
|
76
83
|
try {
|
|
77
84
|
// Resolve model information
|
|
@@ -104,6 +111,19 @@ class ImageService {
|
|
|
104
111
|
const adapter = this.adapterRegistry.getAdapter(providerId);
|
|
105
112
|
// Get API key
|
|
106
113
|
try {
|
|
114
|
+
// Short-circuit if the caller already aborted — don't touch the adapter
|
|
115
|
+
if (options?.signal?.aborted) {
|
|
116
|
+
return {
|
|
117
|
+
object: 'error',
|
|
118
|
+
providerId: providerId,
|
|
119
|
+
modelId: modelId,
|
|
120
|
+
error: {
|
|
121
|
+
message: 'Image generation request was aborted',
|
|
122
|
+
code: types_1.ADAPTER_ERROR_CODES.REQUEST_ABORTED,
|
|
123
|
+
type: 'abort_error',
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
107
127
|
const apiKey = await this.getApiKey(providerId);
|
|
108
128
|
// Validate API key if adapter supports it
|
|
109
129
|
if (apiKey && adapter.validateApiKey && !adapter.validateApiKey(apiKey)) {
|
|
@@ -118,32 +138,58 @@ class ImageService {
|
|
|
118
138
|
},
|
|
119
139
|
};
|
|
120
140
|
}
|
|
121
|
-
// 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).
|
|
122
145
|
this.logger.info(`ImageService: Calling adapter for provider: ${providerId}`);
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 }),
|
|
184
|
+
signal: options?.signal,
|
|
185
|
+
logger: this.logger,
|
|
186
|
+
label: `${providerId}/${modelId}`,
|
|
128
187
|
});
|
|
129
|
-
this.logger.info('ImageService: Image generation completed successfully');
|
|
130
|
-
return response;
|
|
131
188
|
}
|
|
132
189
|
catch (error) {
|
|
190
|
+
// Errors thrown outside the adapter call (e.g. a failing ApiKeyProvider)
|
|
133
191
|
this.logger.error('ImageService: Error during image generation:', error);
|
|
134
|
-
return
|
|
135
|
-
object: 'error',
|
|
136
|
-
providerId: providerId,
|
|
137
|
-
modelId: modelId,
|
|
138
|
-
error: {
|
|
139
|
-
message: error instanceof Error
|
|
140
|
-
? error.message
|
|
141
|
-
: 'An unknown error occurred during image generation',
|
|
142
|
-
code: 'PROVIDER_ERROR',
|
|
143
|
-
type: 'server_error',
|
|
144
|
-
providerError: error,
|
|
145
|
-
},
|
|
146
|
-
};
|
|
192
|
+
return this.buildFailureEnvelope(error, providerId, modelId);
|
|
147
193
|
}
|
|
148
194
|
}
|
|
149
195
|
catch (error) {
|
|
@@ -162,6 +208,38 @@ class ImageService {
|
|
|
162
208
|
};
|
|
163
209
|
}
|
|
164
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
|
+
}
|
|
165
243
|
/**
|
|
166
244
|
* Gets list of supported image providers
|
|
167
245
|
*
|
package/dist/image/config.js
CHANGED
|
@@ -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,
|
|
@@ -174,7 +179,9 @@ exports.IMAGE_ADAPTER_CONFIGS = {
|
|
|
174
179
|
timeout: 60000, // 60 seconds for image generation
|
|
175
180
|
},
|
|
176
181
|
'genai-electron-images': {
|
|
177
|
-
|
|
182
|
+
// 127.0.0.1 (not localhost) avoids a ~2s/request IPv6-fallback stall on
|
|
183
|
+
// Windows — especially costly for the 500ms polling loop
|
|
184
|
+
baseURL: process.env.GENAI_ELECTRON_IMAGE_BASE_URL || 'http://127.0.0.1:8081',
|
|
178
185
|
timeout: 120000, // 120 seconds for local diffusion
|
|
179
186
|
checkHealth: false, // Optional health checks
|
|
180
187
|
},
|
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";
|
|
@@ -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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// the timeout instead and classifies from its own state in the
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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) {
|
package/dist/types/image.d.ts
CHANGED
|
@@ -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
|
*/
|
|
@@ -251,6 +252,10 @@ export interface ImageFailureResponse {
|
|
|
251
252
|
code?: string | number;
|
|
252
253
|
/** Error type (authentication_error, rate_limit_error, etc.) */
|
|
253
254
|
type?: string;
|
|
255
|
+
/** HTTP status code reported by the provider, when available */
|
|
256
|
+
status?: number;
|
|
257
|
+
/** Provider-suggested wait before retrying, in ms (from a Retry-After header) */
|
|
258
|
+
retryAfterMs?: number;
|
|
254
259
|
/** Parameter that caused the error (if applicable) */
|
|
255
260
|
param?: string;
|
|
256
261
|
/** Original provider error (for debugging) */
|
|
@@ -307,6 +312,13 @@ export interface ImageProviderInfo {
|
|
|
307
312
|
capabilities: ImageProviderCapabilities;
|
|
308
313
|
/** Available models from this provider */
|
|
309
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;
|
|
310
322
|
}
|
|
311
323
|
/**
|
|
312
324
|
* Preset configuration for image generation
|
|
@@ -359,6 +371,10 @@ export interface ImageProviderAdapter {
|
|
|
359
371
|
resolvedPrompt: string;
|
|
360
372
|
settings: ResolvedImageGenerationSettings;
|
|
361
373
|
apiKey: string | null;
|
|
374
|
+
/** Abort signal for request-side cancellation (optional) */
|
|
375
|
+
signal?: AbortSignal;
|
|
376
|
+
/** Per-request timeout override in ms; adapters fall back to their construction-time default when undefined */
|
|
377
|
+
timeoutMs?: number;
|
|
362
378
|
}): Promise<ImageGenerationResponse>;
|
|
363
379
|
/**
|
|
364
380
|
* Optional method to get available models from this provider
|
|
@@ -394,6 +410,39 @@ export interface ImageServiceOptions {
|
|
|
394
410
|
logLevel?: LogLevel;
|
|
395
411
|
/** Custom logger implementation. If provided, logLevel is ignored. */
|
|
396
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
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Per-call options for ImageService.generateImage
|
|
426
|
+
*/
|
|
427
|
+
export interface GenerateImageOptions {
|
|
428
|
+
/**
|
|
429
|
+
* Abort signal to cancel the request (client-side — the provider may still
|
|
430
|
+
* process an already-dispatched request; for local diffusion the adapter
|
|
431
|
+
* additionally issues a best-effort server-side cancellation). Aborted
|
|
432
|
+
* requests surface as REQUEST_ABORTED / abort_error failures.
|
|
433
|
+
*/
|
|
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;
|
|
397
446
|
}
|
|
398
447
|
/**
|
|
399
448
|
* Result from createPrompt utility
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genai-lite",
|
|
3
|
-
"version": "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.
|
|
47
|
-
"@google/genai": "^
|
|
48
|
-
"@mistralai/mistralai": "^1.
|
|
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
|
},
|