@loonylabs/tti-middleware 1.3.0 → 1.4.1

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.
@@ -22,7 +22,8 @@ export declare class ProviderUnavailableError extends TTIError {
22
22
  constructor(provider: string, message?: string, cause?: Error);
23
23
  }
24
24
  export declare class GenerationFailedError extends TTIError {
25
- constructor(provider: string, message: string, cause?: Error);
25
+ readonly modelResponse?: string;
26
+ constructor(provider: string, message: string, cause?: Error, modelResponse?: string);
26
27
  }
27
28
  export declare class NetworkError extends TTIError {
28
29
  constructor(provider: string, message: string, cause?: Error);
@@ -100,10 +101,26 @@ export declare abstract class BaseTTIProvider implements ITTIProvider {
100
101
  * Sleep for a specified duration
101
102
  */
102
103
  protected sleep(ms: number): Promise<void>;
104
+ /**
105
+ * Wrap an operation with a timeout. If the operation doesn't resolve
106
+ * within timeoutMs, the returned promise rejects with a timeout error.
107
+ * The original operation continues running (promises can't be cancelled),
108
+ * but its result is ignored.
109
+ */
110
+ private withTimeout;
111
+ /**
112
+ * Check if an error is a timeout error (from our withTimeout wrapper).
113
+ */
114
+ private isTimeoutError;
103
115
  /**
104
116
  * Execute a generation function with retry logic for transient errors.
105
117
  * Retries on: 429, 408, 5xx, network timeouts, TCP disconnects.
106
118
  * Does NOT retry on: 400, 401, 403, and other client errors.
119
+ *
120
+ * Each attempt is wrapped with a per-attempt timeout (configurable via
121
+ * retry.timeoutMs, default 45s). Timeout errors have their own retry
122
+ * counter (timeoutRetries, default 2) independent from the general
123
+ * maxRetries used for quota/server errors.
107
124
  */
108
125
  protected executeWithRetry<T>(request: TTIRequest, operation: () => Promise<T>, operationName: string): Promise<T>;
109
126
  /**
@@ -55,9 +55,10 @@ class ProviderUnavailableError extends TTIError {
55
55
  }
56
56
  exports.ProviderUnavailableError = ProviderUnavailableError;
57
57
  class GenerationFailedError extends TTIError {
58
- constructor(provider, message, cause) {
58
+ constructor(provider, message, cause, modelResponse) {
59
59
  super(provider, 'GENERATION_FAILED', message, cause);
60
60
  this.name = 'GenerationFailedError';
61
+ this.modelResponse = modelResponse;
61
62
  }
62
63
  }
63
64
  exports.GenerationFailedError = GenerationFailedError;
@@ -246,6 +247,8 @@ class BaseTTIProvider {
246
247
  backoffMultiplier,
247
248
  maxDelayMs: retryOption.maxDelayMs ?? types_1.DEFAULT_RETRY_OPTIONS.maxDelayMs,
248
249
  jitter: retryOption.jitter ?? types_1.DEFAULT_RETRY_OPTIONS.jitter,
250
+ timeoutMs: retryOption.timeoutMs ?? types_1.DEFAULT_RETRY_OPTIONS.timeoutMs,
251
+ timeoutRetries: retryOption.timeoutRetries ?? types_1.DEFAULT_RETRY_OPTIONS.timeoutRetries,
249
252
  };
250
253
  }
251
254
  /**
@@ -270,10 +273,43 @@ class BaseTTIProvider {
270
273
  sleep(ms) {
271
274
  return new Promise((resolve) => setTimeout(resolve, ms));
272
275
  }
276
+ /**
277
+ * Wrap an operation with a timeout. If the operation doesn't resolve
278
+ * within timeoutMs, the returned promise rejects with a timeout error.
279
+ * The original operation continues running (promises can't be cancelled),
280
+ * but its result is ignored.
281
+ */
282
+ withTimeout(operation, timeoutMs, operationName) {
283
+ return new Promise((resolve, reject) => {
284
+ const timer = setTimeout(() => {
285
+ reject(new Error(`timeout: ${operationName} did not complete within ${timeoutMs}ms`));
286
+ }, timeoutMs);
287
+ operation()
288
+ .then((result) => {
289
+ clearTimeout(timer);
290
+ resolve(result);
291
+ })
292
+ .catch((error) => {
293
+ clearTimeout(timer);
294
+ reject(error);
295
+ });
296
+ });
297
+ }
298
+ /**
299
+ * Check if an error is a timeout error (from our withTimeout wrapper).
300
+ */
301
+ isTimeoutError(error) {
302
+ return error.message.toLowerCase().startsWith('timeout:');
303
+ }
273
304
  /**
274
305
  * Execute a generation function with retry logic for transient errors.
275
306
  * Retries on: 429, 408, 5xx, network timeouts, TCP disconnects.
276
307
  * Does NOT retry on: 400, 401, 403, and other client errors.
308
+ *
309
+ * Each attempt is wrapped with a per-attempt timeout (configurable via
310
+ * retry.timeoutMs, default 45s). Timeout errors have their own retry
311
+ * counter (timeoutRetries, default 2) independent from the general
312
+ * maxRetries used for quota/server errors.
277
313
  */
278
314
  async executeWithRetry(request, operation, operationName) {
279
315
  const retryConfig = this.resolveRetryConfig(request);
@@ -281,29 +317,71 @@ class BaseTTIProvider {
281
317
  if (!retryConfig) {
282
318
  return operation();
283
319
  }
320
+ const timeoutMs = retryConfig.timeoutMs || 0;
321
+ const maxTimeoutRetries = retryConfig.timeoutRetries ?? 2;
284
322
  let lastError = null;
285
- const maxAttempts = 1 + retryConfig.maxRetries; // initial + retries
286
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
323
+ let generalRetryCount = 0;
324
+ let timeoutRetryCount = 0;
325
+ const maxGeneralRetries = retryConfig.maxRetries;
326
+ // Total attempt cap to prevent infinite loops
327
+ const absoluteMaxAttempts = 1 + maxGeneralRetries + maxTimeoutRetries;
328
+ for (let attempt = 1; attempt <= absoluteMaxAttempts; attempt++) {
329
+ const attemptStart = Date.now();
287
330
  try {
288
- return await operation();
331
+ this.log('info', `${operationName} attempt ${attempt}${timeoutMs ? ` (timeout: ${timeoutMs}ms)` : ''} [retries: general=${generalRetryCount}/${maxGeneralRetries}, timeout=${timeoutRetryCount}/${maxTimeoutRetries}]`, {
332
+ attempt,
333
+ timeoutMs: timeoutMs || 'none',
334
+ generalRetries: `${generalRetryCount}/${maxGeneralRetries}`,
335
+ timeoutRetries: `${timeoutRetryCount}/${maxTimeoutRetries}`,
336
+ });
337
+ // Wrap with timeout if configured
338
+ const result = timeoutMs > 0
339
+ ? await this.withTimeout(operation, timeoutMs, operationName)
340
+ : await operation();
341
+ const duration = Date.now() - attemptStart;
342
+ this.log('info', `${operationName} completed in ${duration}ms`, {
343
+ attempt,
344
+ durationMs: duration,
345
+ });
346
+ return result;
289
347
  }
290
348
  catch (error) {
349
+ const duration = Date.now() - attemptStart;
291
350
  lastError = error;
292
- // Only retry on retryable errors
293
- if (!this.isRetryableError(error)) {
351
+ const isTimeout = this.isTimeoutError(error);
352
+ // Non-retryable errors: fail immediately
353
+ if (!isTimeout && !this.isRetryableError(error)) {
354
+ this.log('error', `${operationName} failed with non-retryable error after ${duration}ms: ${error.message}`, { attempt, durationMs: duration });
294
355
  throw error;
295
356
  }
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 });
357
+ // Check retry budget for this error type
358
+ if (isTimeout) {
359
+ timeoutRetryCount++;
360
+ if (timeoutRetryCount > maxTimeoutRetries) {
361
+ this.log('error', `${operationName} timeout retry budget exhausted (${maxTimeoutRetries} retries, ${duration}ms on last attempt)`, { attempt, timeoutRetryCount, durationMs: duration });
362
+ throw error;
363
+ }
364
+ // Short fixed delay before timeout retry (no exponential backoff)
365
+ this.log('warn', `${operationName} timed out after ${duration}ms. Timeout retry ${timeoutRetryCount}/${maxTimeoutRetries} in 2s...`, { attempt, timeoutRetryCount, maxTimeoutRetries, durationMs: duration });
366
+ await this.sleep(2000);
367
+ }
368
+ else {
369
+ generalRetryCount++;
370
+ if (generalRetryCount > maxGeneralRetries) {
371
+ this.log('error', `${operationName} general retry budget exhausted (${maxGeneralRetries} retries): ${error.message}`, { attempt, generalRetryCount, durationMs: duration });
372
+ throw error;
373
+ }
374
+ const delay = this.calculateRetryDelay(generalRetryCount, retryConfig);
375
+ 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
376
  await this.sleep(delay);
301
377
  }
302
378
  }
303
379
  }
304
- // All retries exhausted
305
- this.log('error', `All ${retryConfig.maxRetries} retries exhausted for ${operationName}`, {
380
+ // Safety: should not reach here
381
+ this.log('error', `All retries exhausted for ${operationName}`, {
306
382
  lastError: lastError?.message,
383
+ generalRetryCount,
384
+ timeoutRetryCount,
307
385
  });
308
386
  throw lastError;
309
387
  }
@@ -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
  }
@@ -402,11 +403,12 @@ class GoogleCloudTTIProvider extends base_tti_provider_1.BaseTTIProvider {
402
403
  if (request.providerOptions?.temperature !== undefined) {
403
404
  config.temperature = request.providerOptions.temperature;
404
405
  }
405
- this.log('debug', 'Sending Gemini request', {
406
+ this.log('info', 'Sending Gemini generateContent request to Vertex AI', {
406
407
  model: internalModelId,
407
408
  region,
408
409
  hasReferenceImages: (0, base_tti_provider_1.hasReferenceImages)(request),
409
- config: JSON.stringify(config, null, 2),
410
+ referenceImageCount: request.referenceImages?.length || 0,
411
+ aspectRatio: request.aspectRatio,
410
412
  });
411
413
  const response = await client.generateContent({
412
414
  model: internalModelId,
@@ -414,6 +416,10 @@ class GoogleCloudTTIProvider extends base_tti_provider_1.BaseTTIProvider {
414
416
  config,
415
417
  });
416
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
+ });
417
423
  return this.processGeminiResponse(response, duration);
418
424
  }
419
425
  catch (error) {
@@ -480,9 +486,12 @@ IMPORTANT: Maintain exact visual consistency with the subject in the reference -
480
486
  }
481
487
  if (images.length === 0) {
482
488
  const firstParts = candidates[0]?.content?.parts || [];
489
+ const fullTexts = [];
483
490
  const partTypes = firstParts.map((p) => {
484
- if (p.text)
485
- return `text(${p.text.substring(0, 50)}...)`;
491
+ if (p.text) {
492
+ fullTexts.push(p.text);
493
+ return `text(${p.text.substring(0, 200)}${p.text.length > 200 ? '...' : ''})`;
494
+ }
486
495
  if (p.inlineData)
487
496
  return `inlineData(${p.inlineData.mimeType})`;
488
497
  return 'unknown';
@@ -490,9 +499,11 @@ IMPORTANT: Maintain exact visual consistency with the subject in the reference -
490
499
  this.log('error', 'No images in Gemini response', {
491
500
  candidateCount: candidates.length,
492
501
  partTypes,
502
+ ...(fullTexts.length > 0 && { modelResponse: fullTexts.join('\n') }),
493
503
  });
504
+ const fullModelResponse = fullTexts.length > 0 ? fullTexts.join('\n') : undefined;
494
505
  throw new base_tti_provider_1.GenerationFailedError(this.providerName, `No images in response. Model returned: ${partTypes.join(', ')}. ` +
495
- 'Make sure responseModalities includes IMAGE.');
506
+ 'Make sure responseModalities includes IMAGE.', undefined, fullModelResponse);
496
507
  }
497
508
  const usage = {
498
509
  imagesGenerated: images.length,
@@ -45,6 +45,7 @@ export interface TTIDebugInfo {
45
45
  message: string;
46
46
  code?: string;
47
47
  details?: unknown;
48
+ modelResponse?: string;
48
49
  };
49
50
  }
50
51
  /**
@@ -145,6 +146,7 @@ export declare class TTIDebugger {
145
146
  static updateWithError(debugInfo: TTIDebugInfo, error: Error & {
146
147
  code?: string;
147
148
  cause?: unknown;
149
+ modelResponse?: string;
148
150
  }): TTIDebugInfo;
149
151
  }
150
152
  export default TTIDebugger;
@@ -297,6 +297,9 @@ class TTIDebugger {
297
297
  if (debugInfo.error.code) {
298
298
  sections.push(`- **Code**: ${debugInfo.error.code}`);
299
299
  }
300
+ if (debugInfo.error.modelResponse) {
301
+ sections.push(`- **Model Response**: ${debugInfo.error.modelResponse}`);
302
+ }
300
303
  if (debugInfo.error.details) {
301
304
  sections.push('- **Details**:');
302
305
  sections.push('```json');
@@ -401,6 +404,7 @@ class TTIDebugger {
401
404
  message: error.message,
402
405
  code: error.code,
403
406
  details: error.cause,
407
+ modelResponse: error.modelResponse,
404
408
  },
405
409
  };
406
410
  }
@@ -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.3.0",
3
+ "version": "1.4.1",
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",