@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.
@@ -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
- const maxAttempts = 1 + retryConfig.maxRetries; // initial + retries
286
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
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
- return await operation();
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
- // Only retry on retryable errors
293
- if (!this.isRetryableError(error)) {
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 if we have retries left
297
- if (attempt < maxAttempts) {
298
- const delay = this.calculateRetryDelay(attempt, retryConfig);
299
- this.log('warn', `Transient error during ${operationName}. Retry ${attempt}/${retryConfig.maxRetries} in ${delay}ms...`, { attempt, maxRetries: retryConfig.maxRetries, delayMs: delay, error: error.message });
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
- // All retries exhausted
305
- this.log('error', `All ${retryConfig.maxRetries} retries exhausted for ${operationName}`, {
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('debug', 'Sending Imagen request', { endpoint, parameters: parameterValue });
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('debug', 'Sending Gemini request', {
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).
@@ -37,4 +37,6 @@ exports.DEFAULT_RETRY_OPTIONS = {
37
37
  backoffMultiplier: 2.0,
38
38
  maxDelayMs: 30000,
39
39
  jitter: true,
40
+ timeoutMs: 45000,
41
+ timeoutRetries: 2,
40
42
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loonylabs/tti-middleware",
3
- "version": "1.2.0",
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": ">=0.14.0"
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": "^0.14.0",
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",