@umituz/react-native-ai-pruna-provider 1.0.64 → 1.0.66

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.
@@ -57,19 +57,16 @@ function getRequestKeyCache(): RequestKeyCache {
57
57
  return globalObj[REQUEST_KEY_CACHE_KEY] as RequestKeyCache;
58
58
  }
59
59
 
60
- function sortKeys(obj: unknown): unknown {
61
- if (obj === null || typeof obj !== "object") return obj;
62
- if (Array.isArray(obj)) return obj.map(sortKeys);
63
- const sorted: Record<string, unknown> = {};
64
- for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
65
- sorted[key] = sortKeys((obj as Record<string, unknown>)[key]);
66
- }
67
- return sorted;
68
- }
69
-
70
60
  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)}`;
61
+ // Fast deterministic hash using sorted property names
62
+ // Much faster than deep object sorting + JSON.stringify
63
+ const keys = Object.keys(input).sort();
64
+ let hash = model;
65
+ for (const key of keys) {
66
+ const value = input[key];
67
+ hash += `|${key}:${typeof value === 'object' ? JSON.stringify(value) : String(value)}`;
68
+ }
69
+ return hash;
73
70
  }
74
71
 
75
72
  export function createRequestKey(model: string, input: Record<string, unknown>): string {
@@ -83,20 +80,11 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
83
80
  return cached.key;
84
81
  }
85
82
 
86
- // Cache miss or expired - generate new key
87
- // Use full JSON string instead of hash to eliminate collision risk
88
- // Sort keys ensures consistent key generation regardless of object property order
89
- const inputStr = JSON.stringify(sortKeys(input));
90
-
91
- // Use base64 encoding for safer string representation
92
- // This eliminates collision risk entirely while maintaining readability
93
- const safeInputStr = inputStr.replace(/[^a-zA-Z0-9]/g, '_');
94
-
95
- // Use first 64 chars to keep key length manageable while maintaining uniqueness
96
- const prefix = safeInputStr.substring(0, 64);
97
- const suffix = safeInputStr.length > 64 ? safeInputStr.slice(-64) : '';
98
-
99
- const requestKey = `${model}:${prefix}${suffix ? '...' + suffix : ''}`;
83
+ // Cache miss or expired - generate new key using simple hash
84
+ // Use timestamp + random suffix for uniqueness (crypto-friendly)
85
+ const timestamp = Date.now();
86
+ const randomSuffix = Math.random().toString(36).substring(2, 10);
87
+ const requestKey = `${model}_${cacheKey.substring(0, 16)}_${timestamp}_${randomSuffix}`;
100
88
 
101
89
  // Store in cache with LRU eviction
102
90
  cache.set(cacheKey, { key: requestKey, timestamp: now });
@@ -112,13 +100,6 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
112
100
  return requestKey;
113
101
  }
114
102
 
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
- }
120
- }
121
-
122
103
  export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
123
104
  return getRequestStore().get(key) as ActiveRequest<T> | undefined;
124
105
  }
@@ -172,6 +153,9 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
172
153
  stopCleanupTimer();
173
154
  }
174
155
 
156
+ // Also cleanup orphaned requestId mappings
157
+ cleanupRequestIdMappings(maxAge);
158
+
175
159
  return cleanedCount;
176
160
  }
177
161
 
@@ -229,6 +213,25 @@ export function removeRequestIdMapping(requestId: string): void {
229
213
  getRequestIdMap().delete(requestId);
230
214
  }
231
215
 
216
+ /** Cleanup old requestId mappings to prevent unbounded memory growth */
217
+ export function cleanupRequestIdMappings(maxAge: number = MAX_REQUEST_AGE): number {
218
+ const requestStore = getRequestStore();
219
+ const requestIdMap = getRequestIdMap();
220
+ const now = Date.now();
221
+ let cleanedCount = 0;
222
+
223
+ // Remove mappings for requests that no longer exist or are too old
224
+ for (const [requestId] of requestIdMap.entries()) {
225
+ const request = requestStore.get(requestId);
226
+ if (!request || (now - request.createdAt > maxAge)) {
227
+ requestIdMap.delete(requestId);
228
+ cleanedCount++;
229
+ }
230
+ }
231
+
232
+ return cleanedCount;
233
+ }
234
+
232
235
  // Clear any leftover timer on module load (hot reload safety)
233
236
  if (typeof globalThis !== "undefined") {
234
237
  const existingTimer = getCleanupTimer();
@@ -0,0 +1,97 @@
1
+ /**
2
+ * File Storage Infrastructure
3
+ * Handles file uploads to Pruna's storage service
4
+ */
5
+
6
+ import { httpClient } from "../api/http-client";
7
+ import { logger } from "../logging/pruna-logger";
8
+ import { PRUNA_FILES_URL, UPLOAD_CONFIG } from "../services/pruna-provider.constants";
9
+
10
+ export class FileStorageService {
11
+ async uploadFile(
12
+ base64Data: string,
13
+ apiKey: string,
14
+ sessionId: string
15
+ ): Promise<string> {
16
+ const log = logger;
17
+
18
+ // Validation
19
+ if (!base64Data?.trim()) {
20
+ log.error(sessionId, 'file-storage', 'Empty file data');
21
+ throw new Error("File data is empty. Provide a base64 string or URL.");
22
+ }
23
+
24
+ // Already a URL
25
+ if (base64Data.startsWith('http')) {
26
+ log.info(sessionId, 'file-storage', 'File already a URL', {
27
+ url: base64Data.substring(0, 80) + '...',
28
+ });
29
+ return base64Data;
30
+ }
31
+
32
+ // Process base64
33
+ const rawBase64 = this.extractBase64(base64Data);
34
+ const dataUri = this.createDataUri(rawBase64);
35
+ const formData = this.createFormData(dataUri);
36
+
37
+ log.info(sessionId, 'file-storage', 'Uploading file', {
38
+ size: Math.round(rawBase64.length / 1024) + 'KB',
39
+ });
40
+
41
+ try {
42
+ const response = await httpClient.request<{ urls?: { get?: string }; id?: string }>(
43
+ {
44
+ url: PRUNA_FILES_URL,
45
+ method: 'POST',
46
+ headers: { apikey: apiKey },
47
+ body: formData,
48
+ timeout: UPLOAD_CONFIG.timeoutMs,
49
+ },
50
+ sessionId,
51
+ 'file-storage'
52
+ );
53
+
54
+ const fileUrl = response.data.urls?.get ||
55
+ (response.data.id ? `${PRUNA_FILES_URL}/${response.data.id}` : PRUNA_FILES_URL);
56
+
57
+ log.info(sessionId, 'file-storage', 'Upload complete', {
58
+ url: fileUrl.substring(0, 80) + '...',
59
+ });
60
+
61
+ return fileUrl;
62
+
63
+ } catch (error) {
64
+ log.error(sessionId, 'file-storage', 'Upload failed', {
65
+ error: error instanceof Error ? error.message : String(error),
66
+ });
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ private extractBase64(data: string): string {
72
+ const base64Index = data.indexOf('base64,');
73
+ return base64Index !== -1 ? data.substring(base64Index + 7) : data;
74
+ }
75
+
76
+ private createDataUri(base64: string): string {
77
+ return `data:image/jpeg;base64,${base64}`;
78
+ }
79
+
80
+ private createFormData(dataUri: string): FormData {
81
+ const formData = new FormData();
82
+ const timestamp = Date.now();
83
+ const randomId = Math.random().toString(36).substring(2, 8);
84
+ const fileName = `vivoim_${timestamp}_${randomId}.jpg`;
85
+
86
+ (formData as unknown as { append: (name: string, value: { uri: string; type: string; name: string }) => void })
87
+ .append('content', {
88
+ uri: dataUri,
89
+ type: 'image/jpeg',
90
+ name: fileName,
91
+ });
92
+
93
+ return formData;
94
+ }
95
+ }
96
+
97
+ export const fileStorageService = new FileStorageService();
@@ -15,8 +15,7 @@ export const MIME_AUDIO_FLAC = 'audio/flac' as const;
15
15
  export const MIME_AUDIO_MP4 = 'audio/mp4' as const;
16
16
 
17
17
  // ── Fallback ────────────────────────────────────────────────
18
- export const MIME_APPLICATION_OCTET = 'application/octet-stream' as const;
19
- export const MIME_DEFAULT = MIME_APPLICATION_OCTET;
18
+ export const MIME_DEFAULT = 'application/octet-stream' as const;
20
19
 
21
20
  /** Maps MIME type → file extension for upload naming */
22
21
  export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
@@ -27,7 +26,7 @@ export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
27
26
  [MIME_AUDIO_WAV]: 'wav',
28
27
  [MIME_AUDIO_FLAC]: 'flac',
29
28
  [MIME_AUDIO_MP4]: 'm4a',
30
- [MIME_APPLICATION_OCTET]: 'bin',
29
+ [MIME_DEFAULT]: 'bin',
31
30
  };
32
31
 
33
32
  /**
@@ -30,20 +30,21 @@ 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
+ private sessionQueue = new Set<string>(); // O(1) lookup and deletion
34
34
 
35
35
  startSession(): string {
36
- // Evict oldest session if limit exceeded (O(1) operation)
37
- if (this.sessionQueue.length >= MAX_SESSIONS) {
38
- const oldestKey = this.sessionQueue.shift();
36
+ // Evict oldest session if limit exceeded
37
+ if (this.sessionQueue.size >= MAX_SESSIONS) {
38
+ const oldestKey = this.sessionQueue.keys().next().value;
39
39
  if (oldestKey) {
40
40
  this.sessions.delete(oldestKey);
41
+ this.sessionQueue.delete(oldestKey);
41
42
  }
42
43
  }
43
44
 
44
45
  const id = `pruna_session_${++sessionCounter}_${Date.now()}`;
45
46
  this.sessions.set(id, { startTime: Date.now(), entries: [] });
46
- this.sessionQueue.push(id);
47
+ this.sessionQueue.add(id);
47
48
  return id;
48
49
  }
49
50
 
@@ -60,20 +61,17 @@ class GenerationLogCollector {
60
61
  }
61
62
 
62
63
  getEntries(sessionId: string): LogEntry[] {
63
- return [...(this.sessions.get(sessionId)?.entries ?? [])];
64
+ return this.sessions.get(sessionId)?.entries ?? [];
64
65
  }
65
66
 
66
67
  endSession(sessionId: string): LogEntry[] {
67
68
  const session = this.sessions.get(sessionId);
68
69
  if (!session) return [];
69
- const entries = [...session.entries];
70
+ const entries = session.entries;
70
71
  this.sessions.delete(sessionId);
71
72
 
72
- // Remove from queue as well
73
- const queueIndex = this.sessionQueue.indexOf(sessionId);
74
- if (queueIndex !== -1) {
75
- this.sessionQueue.splice(queueIndex, 1);
76
- }
73
+ // Remove from queue as well (O(1) with Set)
74
+ this.sessionQueue.delete(sessionId);
77
75
 
78
76
  return entries;
79
77
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { providerRegistry } from '@umituz/react-native-ai-generation-content';
7
7
  import { prunaProvider } from '../infrastructure/services/pruna-provider';
8
+ import { logger } from '../infrastructure/logging/pruna-logger';
8
9
 
9
10
  /**
10
11
  * InitModule interface (from @umituz/react-native-design-system)
@@ -89,7 +90,9 @@ export function createAiProviderInitModule(
89
90
 
90
91
  return true;
91
92
  } catch (error) {
92
- console.error('[AiProviderInitModule] Pruna initialization failed:', error);
93
+ logger.error('app-init', 'ai-provider', 'Pruna initialization failed', {
94
+ error: error instanceof Error ? error.message : String(error),
95
+ });
93
96
  throw error;
94
97
  }
95
98
  },
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { providerRegistry } from '@umituz/react-native-ai-generation-content';
7
7
  import { prunaProvider } from '../infrastructure/services/pruna-provider';
8
+ import { logger } from '../infrastructure/logging/pruna-logger';
8
9
 
9
10
  /**
10
11
  * Initializes Pruna provider and registers it with providerRegistry in one call.
@@ -33,7 +34,9 @@ export function initializePrunaProvider(config: {
33
34
 
34
35
  return true;
35
36
  } catch (error) {
36
- console.error('[initializePrunaProvider] Initialization failed:', error);
37
+ logger.error('app-init', 'pruna-provider', 'Initialization failed', {
38
+ error: error instanceof Error ? error.message : String(error),
39
+ });
37
40
  throw error;
38
41
  }
39
42
  }
@@ -34,11 +34,7 @@ function convertJobStatusToPrunaQueueStatus(status: JobStatus, currentRequestId:
34
34
  return {
35
35
  status: status.status as PrunaQueueStatus["status"],
36
36
  requestId: status.requestId ?? currentRequestId ?? "",
37
- logs: status.logs?.map((log: AILogEntry) => ({
38
- message: log.message,
39
- level: log.level,
40
- timestamp: log.timestamp,
41
- })),
37
+ logs: status.logs as PrunaQueueStatus["logs"], // Type-safe cast, no array copy
42
38
  };
43
39
  }
44
40