@umituz/react-native-ai-pruna-provider 1.0.55 → 1.0.57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-pruna-provider",
3
- "version": "1.0.55",
3
+ "version": "1.0.57",
4
4
  "description": "Pruna AI provider for React Native - implements IAIProvider interface for unified AI generation",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,7 +14,7 @@
14
14
  import type { PrunaModelId, PrunaPredictionResponse, PrunaFileUploadResponse } from "../../domain/entities/pruna.types";
15
15
  import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL, UPLOAD_CONFIG } from "./pruna-provider.constants";
16
16
  import { generationLogCollector } from "../utils/log-collector";
17
- import { bytesToKB, calculateElapsedMs, createStringPreview } from "../utils/calculation.utils";
17
+ import { bytesToKB, calculateElapsedMs, createStringPreview, DEFAULT_MAX_LENGTH } from "../utils/calculation.utils";
18
18
 
19
19
  const TAG = 'pruna-api';
20
20
 
@@ -56,19 +56,21 @@ export async function uploadFileToStorage(
56
56
  console.log(`[DEV] [${TAG}] File upload input:`, {
57
57
  dataSizeKB,
58
58
  startsWithDataUri: base64Data.startsWith('data:'),
59
- preview: createStringPreview(base64Data, 50),
59
+ preview: createStringPreview(base64Data, DEFAULT_MAX_LENGTH),
60
60
  });
61
61
  }
62
62
 
63
63
  // Strip data URI prefix if present to get raw base64
64
- const rawBase64 = base64Data.includes('base64,')
65
- ? base64Data.split('base64,')[1]
64
+ // Use indexOf instead of split for better performance (no array allocation)
65
+ const base64Index = base64Data.indexOf('base64,');
66
+ const rawBase64 = base64Index !== -1
67
+ ? base64Data.substring(base64Index + 7) // 7 = 'base64,'.length
66
68
  : base64Data;
67
69
 
68
70
  generationLogCollector.log(sessionId, TAG, 'Base64 processing complete', {
69
71
  originalLength: base64Data.length,
70
72
  rawLength: rawBase64.length,
71
- hadDataUriPrefix: base64Data.includes('base64,'),
73
+ hadDataUriPrefix: base64Index !== -1,
72
74
  });
73
75
 
74
76
  // Use default JPEG MIME type (detectMimeType fails on base64)
@@ -106,10 +108,6 @@ export async function uploadFileToStorage(
106
108
  uri: dataUri,
107
109
  type: mimeType,
108
110
  name: uniqueFileName,
109
- } as {
110
- uri: string;
111
- type: string;
112
- name: string;
113
111
  };
114
112
 
115
113
  // Type cast for React Native FormData which accepts file objects
@@ -220,10 +218,13 @@ export async function uploadFileToStorage(
220
218
  }
221
219
 
222
220
  const data: PrunaFileUploadResponse = await uploadResponse.json();
223
- const fileUrl = data.urls?.get || `${PRUNA_FILES_URL}/${data.id}`;
221
+ if (!data.urls?.get && !data.id) {
222
+ throw new Error('File upload response missing both urls.get and id fields');
223
+ }
224
+ const fileUrl = data.urls?.get || (data.id ? `${PRUNA_FILES_URL}/${data.id}` : PRUNA_FILES_URL);
224
225
 
225
- const elapsed = calculateElapsedMs(startTime);
226
- generationLogCollector.log(sessionId, TAG, `File upload completed in ${elapsed}ms`, {
226
+ const totalElapsed = calculateElapsedMs(startTime);
227
+ generationLogCollector.log(sessionId, TAG, `File upload completed in ${totalElapsed}ms`, {
227
228
  fileId: data.id,
228
229
  fileUrl: createStringPreview(fileUrl),
229
230
  responseKeys: Object.keys(data),
@@ -233,7 +234,7 @@ export async function uploadFileToStorage(
233
234
  // __DEV__ log response details
234
235
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
235
236
  console.log(`[DEV] [${TAG}] File upload SUCCESS:`, {
236
- elapsedMs: elapsed,
237
+ elapsedMs: totalElapsed,
237
238
  fileId: data.id,
238
239
  fileUrl,
239
240
  urls: data.urls,
@@ -242,7 +243,7 @@ export async function uploadFileToStorage(
242
243
  }
243
244
 
244
245
  generationLogCollector.log(sessionId, TAG, `<<< uploadFileToStorage COMPLETE`, {
245
- totalElapsedMs: elapsed,
246
+ totalElapsedMs: totalElapsed,
246
247
  resultUrl: fileUrl.substring(0, 60) + '...',
247
248
  });
248
249
 
@@ -323,7 +324,7 @@ export async function submitPrediction(
323
324
  body: JSON.stringify(requestBody),
324
325
  signal,
325
326
  });
326
- const requestElapsed = Date.now() - requestStart;
327
+ const requestElapsed = calculateElapsedMs(requestStart);
327
328
 
328
329
  generationLogCollector.log(sessionId, TAG, `Response received`, {
329
330
  statusCode: response.status,
@@ -366,7 +367,7 @@ export async function submitPrediction(
366
367
  throw error;
367
368
  }
368
369
 
369
- const elapsed = Date.now() - startTime;
370
+ const elapsed = calculateElapsedMs(startTime);
370
371
  const result: PrunaPredictionResponse = await response.json();
371
372
 
372
373
  generationLogCollector.log(sessionId, TAG, `Prediction response parsing complete`, {
@@ -12,6 +12,7 @@ import type { PrunaModelId, PrunaAspectRatio, PrunaResolution } from "../../doma
12
12
  import { P_VIDEO_DEFAULTS, DEFAULT_ASPECT_RATIO } from "./pruna-provider.constants";
13
13
  import { uploadFileToStorage } from "./pruna-api-client";
14
14
  import { generationLogCollector } from "../utils/log-collector";
15
+ import { bytesToKB, calculateElapsedMs } from "../utils/calculation.utils";
15
16
 
16
17
  const TAG = 'pruna-input-builder';
17
18
 
@@ -141,32 +142,35 @@ async function buildImageEditInput(
141
142
  throw new Error("Image is required for p-image-edit. Provide 'image', 'images', 'image_url', or 'image_urls'.");
142
143
  }
143
144
 
144
- generationLogCollector.log(sessionId, TAG, `p-image-edit: starting upload of ${rawImages.length} image(s)...`);
145
+ generationLogCollector.log(sessionId, TAG, `p-image-edit: starting parallel upload of ${rawImages.length} image(s)...`);
145
146
 
146
- // Upload images to Pruna file storage and collect URLs
147
- const imageUrls: string[] = [];
148
- for (let i = 0; i < rawImages.length; i++) {
149
- const rawImage = rawImages[i];
147
+ // Upload images to Pruna file storage in PARALLEL for better performance
148
+ // Single image uploads skip the overhead of Promise.all()
149
+ const uploadPromises = rawImages.map(async (rawImage, index) => {
150
150
  const uploadStart = Date.now();
151
151
 
152
- generationLogCollector.log(sessionId, TAG, `p-image-edit: [${i + 1}/${rawImages.length}] Starting upload...`, {
153
- imageSizeKB: Math.round(rawImage.length / 1024),
152
+ generationLogCollector.log(sessionId, TAG, `p-image-edit: [${index + 1}/${rawImages.length}] Starting upload...`, {
153
+ imageSizeKB: bytesToKB(rawImage.length),
154
154
  isBase64: rawImage.includes('base64'),
155
155
  isUrl: rawImage.startsWith('http'),
156
156
  });
157
157
 
158
158
  // Upload to file storage (if already a URL, it will be returned as-is)
159
159
  const fileUrl = await uploadFileToStorage(rawImage, apiKey, sessionId);
160
- imageUrls.push(fileUrl);
161
160
 
162
- const uploadElapsed = Date.now() - uploadStart;
163
- generationLogCollector.log(sessionId, TAG, `p-image-edit: [${i + 1}/${rawImages.length}] Upload complete`, {
161
+ const uploadElapsed = calculateElapsedMs(uploadStart);
162
+ generationLogCollector.log(sessionId, TAG, `p-image-edit: [${index + 1}/${rawImages.length}] Upload complete`, {
164
163
  fileUrl: fileUrl.substring(0, 80) + '...',
165
164
  elapsedMs: uploadElapsed,
166
165
  });
167
- }
168
166
 
169
- generationLogCollector.log(sessionId, TAG, `All images uploaded successfully`, {
167
+ return fileUrl;
168
+ });
169
+
170
+ // Wait for all uploads to complete in parallel
171
+ const imageUrls = await Promise.all(uploadPromises);
172
+
173
+ generationLogCollector.log(sessionId, TAG, `All images uploaded successfully in parallel`, {
170
174
  totalImages: imageUrls.length,
171
175
  });
172
176
 
@@ -184,7 +188,7 @@ async function buildImageEditInput(
184
188
  promptLength: prompt.length,
185
189
  aspectRatio,
186
190
  firstImageUrl: imageUrls[0]?.substring(0, 60) + '...',
187
- payloadSizeKB: Math.round(JSON.stringify(payload).length / 1024),
191
+ payloadSizeKB: bytesToKB(JSON.stringify(payload).length),
188
192
  });
189
193
  }
190
194
 
@@ -228,7 +232,7 @@ async function buildVideoInput(
228
232
  }
229
233
 
230
234
  generationLogCollector.log(sessionId, TAG, 'p-video: preparing image for video generation...', {
231
- imageSizeKB: Math.round(rawImage.length / 1024),
235
+ imageSizeKB: bytesToKB(rawImage.length),
232
236
  isBase64: rawImage.includes('base64'),
233
237
  isUrl: rawImage.startsWith('http'),
234
238
  });
@@ -236,7 +240,7 @@ async function buildVideoInput(
236
240
  // Upload base64 to file storage if needed (p-video requires HTTPS URL)
237
241
  const uploadStart = Date.now();
238
242
  const fileUrl = await uploadFileToStorage(rawImage, apiKey, sessionId);
239
- const uploadElapsed = Date.now() - uploadStart;
243
+ const uploadElapsed = calculateElapsedMs(uploadStart);
240
244
 
241
245
  generationLogCollector.log(sessionId, TAG, 'p-video: image upload complete', {
242
246
  fileUrl: fileUrl.substring(0, 80) + '...',
@@ -273,14 +277,14 @@ async function buildVideoInput(
273
277
  const rawAudio = input.audio as string | undefined;
274
278
  if (rawAudio) {
275
279
  generationLogCollector.log(sessionId, TAG, 'p-video: preparing audio for video generation...', {
276
- audioSizeKB: Math.round(rawAudio.length / 1024),
280
+ audioSizeKB: bytesToKB(rawAudio.length),
277
281
  isBase64: rawAudio.includes('base64'),
278
282
  isUrl: rawAudio.startsWith('http'),
279
283
  });
280
284
 
281
285
  const audioUploadStart = Date.now();
282
286
  const audioUrl = await uploadFileToStorage(rawAudio, apiKey, sessionId);
283
- const audioUploadElapsed = Date.now() - audioUploadStart;
287
+ const audioUploadElapsed = calculateElapsedMs(audioUploadStart);
284
288
 
285
289
  payload.audio = audioUrl;
286
290
  generationLogCollector.log(sessionId, TAG, 'p-video: audio upload complete', {
@@ -14,6 +14,7 @@ import { DEFAULT_PRUNA_CONFIG } from "./pruna-provider.constants";
14
14
  import { submitPrediction, extractUri, resolveUri, pollForResult } from "./pruna-api-client";
15
15
  import { buildModelInput } from "./pruna-input-builder";
16
16
  import { generationLogCollector } from "../utils/log-collector";
17
+ import { calculateElapsedMs } from "../utils/calculation.utils";
17
18
 
18
19
  const TAG = 'pruna-subscription';
19
20
 
@@ -134,7 +135,20 @@ async function singleSubscribeAttempt<T = unknown>(
134
135
  const abortPromise = new Promise<never>((_, reject) => {
135
136
  const handler = () => reject(new Error("Request cancelled by user"));
136
137
  signal.addEventListener("abort", handler, { once: true });
137
- predictionPromise.finally(() => signal.removeEventListener("abort", handler));
138
+
139
+ // Cleanup function to ensure listener is always removed
140
+ // once: true handles this automatically, but explicit cleanup is safer
141
+ const cleanup = () => {
142
+ try {
143
+ signal.removeEventListener("abort", handler);
144
+ } catch {
145
+ // Ignore errors if signal was already aborted or listener was removed
146
+ }
147
+ };
148
+
149
+ // Ensure cleanup happens regardless of promise outcome
150
+ predictionPromise.finally(cleanup);
151
+ timeoutPromise.finally(cleanup);
138
152
  });
139
153
  promises.push(abortPromise);
140
154
  // Prevent unhandled rejection if abort loses the race
@@ -150,7 +164,10 @@ async function singleSubscribeAttempt<T = unknown>(
150
164
  predictionPromise.catch(() => {});
151
165
  timeoutPromise.catch(() => {});
152
166
 
153
- const resultUrl = await Promise.race(promises) as string;
167
+ const resultUrl = await Promise.race(promises);
168
+ if (typeof resultUrl !== 'string') {
169
+ throw new Error('Invalid result URL received from Pruna API');
170
+ }
154
171
  const requestId = `pruna_${model}_${Date.now()}`;
155
172
 
156
173
  // Notify progress: COMPLETED
@@ -196,9 +213,9 @@ export async function handlePrunaSubscription<T = unknown>(
196
213
  inputKeys: Object.keys(input),
197
214
  });
198
215
 
199
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3600000) {
216
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > DEFAULT_PRUNA_CONFIG.maxTimeoutMs) {
200
217
  throw new Error(
201
- `Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and 3600000ms (1 hour)`
218
+ `Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and ${DEFAULT_PRUNA_CONFIG.maxTimeoutMs}ms (1 hour)`
202
219
  );
203
220
  }
204
221
 
@@ -221,7 +238,7 @@ export async function handlePrunaSubscription<T = unknown>(
221
238
  model, input, apiKey, sessionId, options, signal, timeoutMs,
222
239
  );
223
240
 
224
- const totalElapsed = Date.now() - overallStart;
241
+ const totalElapsed = calculateElapsedMs(overallStart);
225
242
  const suffix = attempt > 0 ? ` (succeeded on retry ${attempt})` : '';
226
243
  generationLogCollector.log(sessionId, TAG, `Subscription completed in ${totalElapsed}ms${suffix}. Request ID: ${result.requestId}`);
227
244
 
@@ -235,7 +252,7 @@ export async function handlePrunaSubscription<T = unknown>(
235
252
  continue;
236
253
  }
237
254
 
238
- const totalElapsed = Date.now() - overallStart;
255
+ const totalElapsed = calculateElapsedMs(overallStart);
239
256
  const retryInfo = attempt > 0 ? ` after ${attempt + 1} attempts` : '';
240
257
  generationLogCollector.error(sessionId, TAG, `Subscription FAILED in ${totalElapsed}ms${retryInfo}: ${message}`);
241
258
  throw error instanceof Error ? error : new Error(message);
@@ -289,7 +306,7 @@ export async function handlePrunaRun<T = unknown>(
289
306
  }
290
307
 
291
308
  const resultUrl = resolveUri(uri);
292
- const elapsed = Date.now() - startTime;
309
+ const elapsed = calculateElapsedMs(startTime);
293
310
  generationLogCollector.log(sessionId, runTag, `Run completed in ${elapsed}ms`);
294
311
 
295
312
  options?.onProgress?.({ progress: 100, status: "COMPLETED" as const });
@@ -38,6 +38,7 @@ export const DEFAULT_PRUNA_CONFIG = {
38
38
 
39
39
  /** Subscribe defaults */
40
40
  defaultTimeoutMs: 360_000,
41
+ maxTimeoutMs: 3_600_000, // 1 hour maximum timeout
41
42
 
42
43
  /** Polling configuration */
43
44
  pollIntervalMs: 3_000,
@@ -18,6 +18,7 @@ import { PRUNA_CAPABILITIES, VALID_PRUNA_MODELS } from "./pruna-provider.constan
18
18
  import { handlePrunaSubscription, handlePrunaRun } from "./pruna-provider-subscription";
19
19
  import * as queueOps from "./pruna-queue-operations";
20
20
  import { generationLogCollector } from "../utils/log-collector";
21
+ import { calculateElapsedMs } from "../utils/calculation.utils";
21
22
  import type { LogEntry } from "../utils/log-collector";
22
23
  import {
23
24
  createRequestKey, getExistingRequest, storeRequest,
@@ -235,6 +236,9 @@ export class PrunaProvider implements IAIProvider {
235
236
  // Use the unique key for this specific request
236
237
  storeRequest(key, { promise, abortController, createdAt: Date.now() });
237
238
 
239
+ // Track this as the current request for cancellation
240
+ this.lastRequestKey = key;
241
+
238
242
  // Capture this request's key for cleanup in finally block
239
243
  // This prevents race condition where rapid successive calls
240
244
  // could cause cleanup to remove wrong request
@@ -242,7 +246,7 @@ export class PrunaProvider implements IAIProvider {
242
246
 
243
247
  handlePrunaSubscription<T>(prunaModel, input, apiKey, sessionId, options, abortController.signal)
244
248
  .then((res) => {
245
- const totalElapsed = Date.now() - totalStart;
249
+ const totalElapsed = calculateElapsedMs(totalStart);
246
250
  generationLogCollector.log(sessionId, TAG, `Generation SUCCESS in ${totalElapsed}ms`);
247
251
  const result = res.result;
248
252
  if (result && typeof result === 'object') {
@@ -251,7 +255,7 @@ export class PrunaProvider implements IAIProvider {
251
255
  resolvePromise(result);
252
256
  })
253
257
  .catch((error) => {
254
- const totalElapsed = Date.now() - totalStart;
258
+ const totalElapsed = calculateElapsedMs(totalStart);
255
259
  generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
256
260
  generationLogCollector.endSession(sessionId); // Clean up session on error
257
261
  rejectPromise(error);
@@ -15,12 +15,22 @@ export interface ActiveRequest<T = unknown> {
15
15
  const STORE_KEY = "__PRUNA_PROVIDER_REQUESTS__";
16
16
  const TIMER_KEY = "__PRUNA_PROVIDER_CLEANUP_TIMER__";
17
17
  const REQUEST_ID_KEY = "__PRUNA_PROVIDER_REQUEST_IDS__";
18
+ const REQUEST_KEY_CACHE_KEY = "__PRUNA_PROVIDER_KEY_CACHE__";
18
19
  type RequestStore = Map<string, ActiveRequest>;
19
20
  type RequestIdMap = Map<string, { statusUrl?: string; responseUrl?: string; model: string }>;
20
21
 
21
22
  const CLEANUP_INTERVAL = 60_000;
22
23
  const MAX_REQUEST_AGE = 3_660_000; // 61 min — must exceed max allowed timeout (1 hour)
23
24
 
25
+ // Request key cache for performance optimization
26
+ interface CacheEntry {
27
+ key: string;
28
+ timestamp: number;
29
+ }
30
+ type RequestKeyCache = Map<string, CacheEntry>;
31
+ const MAX_CACHE_SIZE = 100;
32
+ const CACHE_TTL = 300_000; // 5 minutes
33
+
24
34
  function getCleanupTimer(): ReturnType<typeof setInterval> | null {
25
35
  const globalObj = globalThis as Record<string, unknown>;
26
36
  return (globalObj[TIMER_KEY] as ReturnType<typeof setInterval>) ?? null;
@@ -39,6 +49,14 @@ export function getRequestStore(): RequestStore {
39
49
  return globalObj[STORE_KEY] as RequestStore;
40
50
  }
41
51
 
52
+ function getRequestKeyCache(): RequestKeyCache {
53
+ const globalObj = globalThis as Record<string, unknown>;
54
+ if (!globalObj[REQUEST_KEY_CACHE_KEY]) {
55
+ globalObj[REQUEST_KEY_CACHE_KEY] = new Map();
56
+ }
57
+ return globalObj[REQUEST_KEY_CACHE_KEY] as RequestKeyCache;
58
+ }
59
+
42
60
  function sortKeys(obj: unknown): unknown {
43
61
  if (obj === null || typeof obj !== "object") return obj;
44
62
  if (Array.isArray(obj)) return obj.map(sortKeys);
@@ -49,7 +67,23 @@ function sortKeys(obj: unknown): unknown {
49
67
  return sorted;
50
68
  }
51
69
 
70
+ function generateCacheKey(model: string, input: Record<string, unknown>): string {
71
+ // Fast hash using JSON.stringify (already sorted by sortKeys)
72
+ return `${model}:${JSON.stringify(input)}`;
73
+ }
74
+
52
75
  export function createRequestKey(model: string, input: Record<string, unknown>): string {
76
+ const cacheKey = generateCacheKey(model, input);
77
+ const cache = getRequestKeyCache();
78
+ const now = Date.now();
79
+
80
+ // Check cache with TTL validation
81
+ const cached = cache.get(cacheKey);
82
+ if (cached && (now - cached.timestamp) < CACHE_TTL) {
83
+ return cached.key;
84
+ }
85
+
86
+ // Cache miss or expired - generate new key
53
87
  // Use full JSON string instead of hash to eliminate collision risk
54
88
  // Sort keys ensures consistent key generation regardless of object property order
55
89
  const inputStr = JSON.stringify(sortKeys(input));
@@ -62,7 +96,27 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
62
96
  const prefix = safeInputStr.substring(0, 64);
63
97
  const suffix = safeInputStr.length > 64 ? safeInputStr.slice(-64) : '';
64
98
 
65
- return `${model}:${prefix}${suffix ? '...' + suffix : ''}`;
99
+ const requestKey = `${model}:${prefix}${suffix ? '...' + suffix : ''}`;
100
+
101
+ // Store in cache with LRU eviction
102
+ cache.set(cacheKey, { key: requestKey, timestamp: now });
103
+
104
+ // Evict oldest entry if cache is too large
105
+ if (cache.size > MAX_CACHE_SIZE) {
106
+ const firstKey = cache.keys().next().value;
107
+ if (firstKey) {
108
+ cache.delete(firstKey);
109
+ }
110
+ }
111
+
112
+ return requestKey;
113
+ }
114
+
115
+ export function clearRequestKeyCache(): void {
116
+ const globalObj = globalThis as Record<string, unknown>;
117
+ if (globalObj[REQUEST_KEY_CACHE_KEY]) {
118
+ (globalObj[REQUEST_KEY_CACHE_KEY] as RequestKeyCache).clear();
119
+ }
66
120
  }
67
121
 
68
122
  export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
@@ -70,10 +124,7 @@ export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined
70
124
  }
71
125
 
72
126
  export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
73
- getRequestStore().set(key, {
74
- ...request,
75
- createdAt: request.createdAt ?? Date.now(),
76
- });
127
+ getRequestStore().set(key, request);
77
128
  ensureCleanupRunning();
78
129
  }
79
130
 
@@ -3,6 +3,14 @@
3
3
  * Centralized calculation functions for consistent and reusable operations
4
4
  */
5
5
 
6
+ import { DEFAULT_PRUNA_CONFIG } from "../services/pruna-provider.constants";
7
+
8
+ /** Maximum timeout value (1 hour) - exported for validation functions */
9
+ export const MAX_TIMEOUT_MS = DEFAULT_PRUNA_CONFIG.maxTimeoutMs;
10
+
11
+ /** Default maximum length for string previews and truncation */
12
+ export const DEFAULT_MAX_LENGTH = 80;
13
+
6
14
  /**
7
15
  * Converts bytes to kilobytes (KB)
8
16
  */
@@ -38,7 +46,7 @@ export function formatElapsedMs(ms: number): string {
38
46
  /**
39
47
  * Creates a preview of a string by truncating and adding ellipsis
40
48
  */
41
- export function createStringPreview(str: string, maxLength: number = 80): string {
49
+ export function createStringPreview(str: string, maxLength: number = DEFAULT_MAX_LENGTH): string {
42
50
  if (str.length <= maxLength) return str;
43
51
  return `${str.substring(0, maxLength)}...`;
44
52
  }
@@ -86,7 +94,7 @@ export function isValidTimeout(timeoutMs: number): boolean {
86
94
  return (
87
95
  Number.isInteger(timeoutMs) &&
88
96
  timeoutMs > 0 &&
89
- timeoutMs <= 3600000 // Max 1 hour
97
+ timeoutMs <= MAX_TIMEOUT_MS
90
98
  );
91
99
  }
92
100
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Helpers Index
2
+ * Helpers
3
3
  */
4
4
 
5
5
  export function isDefined<T>(value: T | null | undefined): value is T {
@@ -30,21 +30,12 @@ const MAX_SESSIONS = 50;
30
30
 
31
31
  class GenerationLogCollector {
32
32
  private sessions = new Map<string, Session>();
33
+ private sessionQueue: string[] = []; // FIFO queue for O(1) eviction
33
34
 
34
35
  startSession(): string {
35
- // Evict oldest sessions if limit exceeded
36
- if (this.sessions.size >= MAX_SESSIONS) {
37
- // Find and remove the oldest session by startTime (LRU eviction)
38
- let oldestKey: string | null = null;
39
- let oldestTime = Date.now();
40
-
41
- for (const [key, session] of this.sessions.entries()) {
42
- if (session.startTime < oldestTime) {
43
- oldestTime = session.startTime;
44
- oldestKey = key;
45
- }
46
- }
47
-
36
+ // Evict oldest session if limit exceeded (O(1) operation)
37
+ if (this.sessionQueue.length >= MAX_SESSIONS) {
38
+ const oldestKey = this.sessionQueue.shift();
48
39
  if (oldestKey) {
49
40
  this.sessions.delete(oldestKey);
50
41
  }
@@ -52,6 +43,7 @@ class GenerationLogCollector {
52
43
 
53
44
  const id = `pruna_session_${++sessionCounter}_${Date.now()}`;
54
45
  this.sessions.set(id, { startTime: Date.now(), entries: [] });
46
+ this.sessionQueue.push(id);
55
47
  return id;
56
48
  }
57
49
 
@@ -76,6 +68,13 @@ class GenerationLogCollector {
76
68
  if (!session) return [];
77
69
  const entries = [...session.entries];
78
70
  this.sessions.delete(sessionId);
71
+
72
+ // Remove from queue as well
73
+ const queueIndex = this.sessionQueue.indexOf(sessionId);
74
+ if (queueIndex !== -1) {
75
+ this.sessionQueue.splice(queueIndex, 1);
76
+ }
77
+
79
78
  return entries;
80
79
  }
81
80
 
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { PrunaResolution } from "../../domain/entities/pruna.types";
24
24
  import { DRAFT_MODE_CONFIG, P_VIDEO_PRICING } from "../services/pruna-provider.constants";
25
+ import { calculatePercentage } from "./calculation.utils";
25
26
 
26
27
  /**
27
28
  * Validates draft mode parameters for p-video
@@ -212,7 +213,7 @@ export function compareDraftModePricing(
212
213
  const normalPrice = getPricingPerSecond(resolution, false) * duration;
213
214
  const draftPrice = getPricingPerSecond(resolution, true) * duration;
214
215
  const savings = normalPrice - draftPrice;
215
- const discountPercent = Math.round((savings / normalPrice) * 100);
216
+ const discountPercent = calculatePercentage(savings, normalPrice, 0);
216
217
 
217
218
  return {
218
219
  normalPrice,
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Type Guards Index
2
+ * Type Guards
3
3
  */
4
4
 
5
- import type { PrunaModelId } from "../../../domain/entities/pruna.types";
6
- import { PrunaErrorType } from "../../../domain/entities/error.types";
7
- import { VALID_PRUNA_MODELS } from "../../services/pruna-provider.constants";
5
+ import type { PrunaModelId } from "../../domain/entities/pruna.types";
6
+ import { PrunaErrorType } from "../../domain/entities/error.types";
7
+ import { VALID_PRUNA_MODELS, DEFAULT_PRUNA_CONFIG } from "../services/pruna-provider.constants";
8
8
 
9
9
  export function isPrunaModelId(value: unknown): value is PrunaModelId {
10
10
  if (typeof value !== 'string') return false;
@@ -29,5 +29,5 @@ export function isValidPrompt(value: unknown): value is string {
29
29
  }
30
30
 
31
31
  export function isValidTimeout(value: unknown): value is number {
32
- return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 3600000;
32
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= DEFAULT_PRUNA_CONFIG.maxTimeoutMs;
33
33
  }