@loonylabs/tti-middleware 1.2.0 → 1.4.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/dist/middleware/services/tti/providers/base-tti-provider.d.ts +16 -0
- package/dist/middleware/services/tti/providers/base-tti-provider.js +88 -11
- package/dist/middleware/services/tti/providers/google-cloud-provider.js +15 -2
- package/dist/middleware/types/index.d.ts +15 -0
- package/dist/middleware/types/index.js +2 -0
- package/package.json +3 -3
|
@@ -100,10 +100,26 @@ export declare abstract class BaseTTIProvider implements ITTIProvider {
|
|
|
100
100
|
* Sleep for a specified duration
|
|
101
101
|
*/
|
|
102
102
|
protected sleep(ms: number): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Wrap an operation with a timeout. If the operation doesn't resolve
|
|
105
|
+
* within timeoutMs, the returned promise rejects with a timeout error.
|
|
106
|
+
* The original operation continues running (promises can't be cancelled),
|
|
107
|
+
* but its result is ignored.
|
|
108
|
+
*/
|
|
109
|
+
private withTimeout;
|
|
110
|
+
/**
|
|
111
|
+
* Check if an error is a timeout error (from our withTimeout wrapper).
|
|
112
|
+
*/
|
|
113
|
+
private isTimeoutError;
|
|
103
114
|
/**
|
|
104
115
|
* Execute a generation function with retry logic for transient errors.
|
|
105
116
|
* Retries on: 429, 408, 5xx, network timeouts, TCP disconnects.
|
|
106
117
|
* Does NOT retry on: 400, 401, 403, and other client errors.
|
|
118
|
+
*
|
|
119
|
+
* Each attempt is wrapped with a per-attempt timeout (configurable via
|
|
120
|
+
* retry.timeoutMs, default 45s). Timeout errors have their own retry
|
|
121
|
+
* counter (timeoutRetries, default 2) independent from the general
|
|
122
|
+
* maxRetries used for quota/server errors.
|
|
107
123
|
*/
|
|
108
124
|
protected executeWithRetry<T>(request: TTIRequest, operation: () => Promise<T>, operationName: string): Promise<T>;
|
|
109
125
|
/**
|
|
@@ -246,6 +246,8 @@ class BaseTTIProvider {
|
|
|
246
246
|
backoffMultiplier,
|
|
247
247
|
maxDelayMs: retryOption.maxDelayMs ?? types_1.DEFAULT_RETRY_OPTIONS.maxDelayMs,
|
|
248
248
|
jitter: retryOption.jitter ?? types_1.DEFAULT_RETRY_OPTIONS.jitter,
|
|
249
|
+
timeoutMs: retryOption.timeoutMs ?? types_1.DEFAULT_RETRY_OPTIONS.timeoutMs,
|
|
250
|
+
timeoutRetries: retryOption.timeoutRetries ?? types_1.DEFAULT_RETRY_OPTIONS.timeoutRetries,
|
|
249
251
|
};
|
|
250
252
|
}
|
|
251
253
|
/**
|
|
@@ -270,10 +272,43 @@ class BaseTTIProvider {
|
|
|
270
272
|
sleep(ms) {
|
|
271
273
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
272
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Wrap an operation with a timeout. If the operation doesn't resolve
|
|
277
|
+
* within timeoutMs, the returned promise rejects with a timeout error.
|
|
278
|
+
* The original operation continues running (promises can't be cancelled),
|
|
279
|
+
* but its result is ignored.
|
|
280
|
+
*/
|
|
281
|
+
withTimeout(operation, timeoutMs, operationName) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
reject(new Error(`timeout: ${operationName} did not complete within ${timeoutMs}ms`));
|
|
285
|
+
}, timeoutMs);
|
|
286
|
+
operation()
|
|
287
|
+
.then((result) => {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
resolve(result);
|
|
290
|
+
})
|
|
291
|
+
.catch((error) => {
|
|
292
|
+
clearTimeout(timer);
|
|
293
|
+
reject(error);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Check if an error is a timeout error (from our withTimeout wrapper).
|
|
299
|
+
*/
|
|
300
|
+
isTimeoutError(error) {
|
|
301
|
+
return error.message.toLowerCase().startsWith('timeout:');
|
|
302
|
+
}
|
|
273
303
|
/**
|
|
274
304
|
* Execute a generation function with retry logic for transient errors.
|
|
275
305
|
* Retries on: 429, 408, 5xx, network timeouts, TCP disconnects.
|
|
276
306
|
* Does NOT retry on: 400, 401, 403, and other client errors.
|
|
307
|
+
*
|
|
308
|
+
* Each attempt is wrapped with a per-attempt timeout (configurable via
|
|
309
|
+
* retry.timeoutMs, default 45s). Timeout errors have their own retry
|
|
310
|
+
* counter (timeoutRetries, default 2) independent from the general
|
|
311
|
+
* maxRetries used for quota/server errors.
|
|
277
312
|
*/
|
|
278
313
|
async executeWithRetry(request, operation, operationName) {
|
|
279
314
|
const retryConfig = this.resolveRetryConfig(request);
|
|
@@ -281,29 +316,71 @@ class BaseTTIProvider {
|
|
|
281
316
|
if (!retryConfig) {
|
|
282
317
|
return operation();
|
|
283
318
|
}
|
|
319
|
+
const timeoutMs = retryConfig.timeoutMs || 0;
|
|
320
|
+
const maxTimeoutRetries = retryConfig.timeoutRetries ?? 2;
|
|
284
321
|
let lastError = null;
|
|
285
|
-
|
|
286
|
-
|
|
322
|
+
let generalRetryCount = 0;
|
|
323
|
+
let timeoutRetryCount = 0;
|
|
324
|
+
const maxGeneralRetries = retryConfig.maxRetries;
|
|
325
|
+
// Total attempt cap to prevent infinite loops
|
|
326
|
+
const absoluteMaxAttempts = 1 + maxGeneralRetries + maxTimeoutRetries;
|
|
327
|
+
for (let attempt = 1; attempt <= absoluteMaxAttempts; attempt++) {
|
|
328
|
+
const attemptStart = Date.now();
|
|
287
329
|
try {
|
|
288
|
-
|
|
330
|
+
this.log('info', `${operationName} attempt ${attempt}${timeoutMs ? ` (timeout: ${timeoutMs}ms)` : ''} [retries: general=${generalRetryCount}/${maxGeneralRetries}, timeout=${timeoutRetryCount}/${maxTimeoutRetries}]`, {
|
|
331
|
+
attempt,
|
|
332
|
+
timeoutMs: timeoutMs || 'none',
|
|
333
|
+
generalRetries: `${generalRetryCount}/${maxGeneralRetries}`,
|
|
334
|
+
timeoutRetries: `${timeoutRetryCount}/${maxTimeoutRetries}`,
|
|
335
|
+
});
|
|
336
|
+
// Wrap with timeout if configured
|
|
337
|
+
const result = timeoutMs > 0
|
|
338
|
+
? await this.withTimeout(operation, timeoutMs, operationName)
|
|
339
|
+
: await operation();
|
|
340
|
+
const duration = Date.now() - attemptStart;
|
|
341
|
+
this.log('info', `${operationName} completed in ${duration}ms`, {
|
|
342
|
+
attempt,
|
|
343
|
+
durationMs: duration,
|
|
344
|
+
});
|
|
345
|
+
return result;
|
|
289
346
|
}
|
|
290
347
|
catch (error) {
|
|
348
|
+
const duration = Date.now() - attemptStart;
|
|
291
349
|
lastError = error;
|
|
292
|
-
|
|
293
|
-
|
|
350
|
+
const isTimeout = this.isTimeoutError(error);
|
|
351
|
+
// Non-retryable errors: fail immediately
|
|
352
|
+
if (!isTimeout && !this.isRetryableError(error)) {
|
|
353
|
+
this.log('error', `${operationName} failed with non-retryable error after ${duration}ms: ${error.message}`, { attempt, durationMs: duration });
|
|
294
354
|
throw error;
|
|
295
355
|
}
|
|
296
|
-
// Check
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
356
|
+
// Check retry budget for this error type
|
|
357
|
+
if (isTimeout) {
|
|
358
|
+
timeoutRetryCount++;
|
|
359
|
+
if (timeoutRetryCount > maxTimeoutRetries) {
|
|
360
|
+
this.log('error', `${operationName} timeout retry budget exhausted (${maxTimeoutRetries} retries, ${duration}ms on last attempt)`, { attempt, timeoutRetryCount, durationMs: duration });
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
// Short fixed delay before timeout retry (no exponential backoff)
|
|
364
|
+
this.log('warn', `${operationName} timed out after ${duration}ms. Timeout retry ${timeoutRetryCount}/${maxTimeoutRetries} in 2s...`, { attempt, timeoutRetryCount, maxTimeoutRetries, durationMs: duration });
|
|
365
|
+
await this.sleep(2000);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
generalRetryCount++;
|
|
369
|
+
if (generalRetryCount > maxGeneralRetries) {
|
|
370
|
+
this.log('error', `${operationName} general retry budget exhausted (${maxGeneralRetries} retries): ${error.message}`, { attempt, generalRetryCount, durationMs: duration });
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
const delay = this.calculateRetryDelay(generalRetryCount, retryConfig);
|
|
374
|
+
this.log('warn', `Transient error during ${operationName} after ${duration}ms. Retry ${generalRetryCount}/${maxGeneralRetries} in ${delay}ms: ${error.message}`, { attempt, generalRetryCount, maxGeneralRetries, delayMs: delay, durationMs: duration });
|
|
300
375
|
await this.sleep(delay);
|
|
301
376
|
}
|
|
302
377
|
}
|
|
303
378
|
}
|
|
304
|
-
//
|
|
305
|
-
this.log('error', `All
|
|
379
|
+
// Safety: should not reach here
|
|
380
|
+
this.log('error', `All retries exhausted for ${operationName}`, {
|
|
306
381
|
lastError: lastError?.message,
|
|
382
|
+
generalRetryCount,
|
|
383
|
+
timeoutRetryCount,
|
|
307
384
|
});
|
|
308
385
|
throw lastError;
|
|
309
386
|
}
|
|
@@ -273,13 +273,14 @@ class GoogleCloudTTIProvider extends base_tti_provider_1.BaseTTIProvider {
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
const parameters = helpers.toValue(parameterValue);
|
|
276
|
-
this.log('
|
|
276
|
+
this.log('info', 'Sending Imagen request to Vertex AI', { endpoint, parameters: parameterValue });
|
|
277
277
|
const [response] = await client.predict({
|
|
278
278
|
endpoint,
|
|
279
279
|
instances: [instance],
|
|
280
280
|
parameters,
|
|
281
281
|
});
|
|
282
282
|
const duration = Date.now() - startTime;
|
|
283
|
+
this.log('info', `Imagen response received in ${duration}ms`, { duration, hasPredictions: !!response.predictions?.length });
|
|
283
284
|
if (!response.predictions || response.predictions.length === 0) {
|
|
284
285
|
throw new base_tti_provider_1.GenerationFailedError(this.providerName, 'No images returned from Imagen API');
|
|
285
286
|
}
|
|
@@ -392,14 +393,22 @@ class GoogleCloudTTIProvider extends base_tti_provider_1.BaseTTIProvider {
|
|
|
392
393
|
const config = {
|
|
393
394
|
responseModalities: ['TEXT', 'IMAGE'],
|
|
394
395
|
};
|
|
396
|
+
// Add imageConfig with aspectRatio if provided
|
|
397
|
+
if (request.aspectRatio) {
|
|
398
|
+
config.imageConfig = {
|
|
399
|
+
aspectRatio: request.aspectRatio,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
395
402
|
// Add temperature if provided
|
|
396
403
|
if (request.providerOptions?.temperature !== undefined) {
|
|
397
404
|
config.temperature = request.providerOptions.temperature;
|
|
398
405
|
}
|
|
399
|
-
this.log('
|
|
406
|
+
this.log('info', 'Sending Gemini generateContent request to Vertex AI', {
|
|
400
407
|
model: internalModelId,
|
|
401
408
|
region,
|
|
402
409
|
hasReferenceImages: (0, base_tti_provider_1.hasReferenceImages)(request),
|
|
410
|
+
referenceImageCount: request.referenceImages?.length || 0,
|
|
411
|
+
aspectRatio: request.aspectRatio,
|
|
403
412
|
});
|
|
404
413
|
const response = await client.generateContent({
|
|
405
414
|
model: internalModelId,
|
|
@@ -407,6 +416,10 @@ class GoogleCloudTTIProvider extends base_tti_provider_1.BaseTTIProvider {
|
|
|
407
416
|
config,
|
|
408
417
|
});
|
|
409
418
|
const duration = Date.now() - startTime;
|
|
419
|
+
this.log('info', `Gemini response received in ${duration}ms`, {
|
|
420
|
+
duration,
|
|
421
|
+
hasCandidates: !!(response?.candidates || response?.response),
|
|
422
|
+
});
|
|
410
423
|
return this.processGeminiResponse(response, duration);
|
|
411
424
|
}
|
|
412
425
|
catch (error) {
|
|
@@ -198,6 +198,21 @@ export interface RetryOptions {
|
|
|
198
198
|
* When enabled, actual delay is randomized between 0 and the computed delay.
|
|
199
199
|
*/
|
|
200
200
|
jitter?: boolean;
|
|
201
|
+
/**
|
|
202
|
+
* Timeout per attempt in milliseconds (default: 45000 = 45s).
|
|
203
|
+
* If the provider SDK call doesn't resolve within this time,
|
|
204
|
+
* the attempt is aborted and counted as a retryable timeout error.
|
|
205
|
+
* Set to 0 to disable timeout.
|
|
206
|
+
*/
|
|
207
|
+
timeoutMs?: number;
|
|
208
|
+
/**
|
|
209
|
+
* Maximum retries specifically for timeout errors (default: 2).
|
|
210
|
+
* Timeout retries are tracked independently from other transient errors
|
|
211
|
+
* (429, 5xx, etc.) which use the general `maxRetries` counter.
|
|
212
|
+
* This prevents a hung service from burning through all retries with
|
|
213
|
+
* long waits, while still allowing many retries for quota errors.
|
|
214
|
+
*/
|
|
215
|
+
timeoutRetries?: number;
|
|
201
216
|
/**
|
|
202
217
|
* @deprecated Use `backoffMultiplier` instead. Will be removed in v2.0.
|
|
203
218
|
* When true, equivalent to backoffMultiplier of 1.0 with linear scaling (delayMs * attempt).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loonylabs/tti-middleware",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Provider-agnostic Text-to-Image middleware with GDPR compliance. Supports Google Cloud (Imagen, Gemini), Eden AI, and IONOS.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"homepage": "https://github.com/loonylabs-dev/tti-middleware#readme",
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@google-cloud/aiplatform": ">=3.0.0",
|
|
58
|
-
"@google/genai": ">=
|
|
58
|
+
"@google/genai": ">=1.40.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependenciesMeta": {
|
|
61
61
|
"@google-cloud/aiplatform": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@google-cloud/aiplatform": "^3.29.0",
|
|
70
|
-
"@google/genai": "^
|
|
70
|
+
"@google/genai": "^1.40.0",
|
|
71
71
|
"@types/jest": "^29.5.8",
|
|
72
72
|
"@types/node": "^20.10.0",
|
|
73
73
|
"@typescript-eslint/eslint-plugin": "^6.13.0",
|