@umituz/react-native-ai-pruna-provider 1.0.9 → 1.0.10

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.9",
3
+ "version": "1.0.10",
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",
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { PrunaModelId, PrunaPredictionResponse, PrunaFileUploadResponse } from "../../domain/entities/pruna.types";
15
- import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL } from "./pruna-provider.constants";
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
17
  import { detectMimeType } from "../utils/mime-detection.util";
18
18
  import { getExtensionForMime } from "../utils/constants/mime.constants";
@@ -65,26 +65,41 @@ export async function uploadFileToStorage(
65
65
 
66
66
  const startTime = Date.now();
67
67
 
68
- const response = await fetch(PRUNA_FILES_URL, {
69
- method: 'POST',
70
- headers: { 'apikey': apiKey },
71
- body: formData,
72
- });
68
+ // Apply timeout to prevent indefinite hangs
69
+ const uploadController = new AbortController();
70
+ const timeoutId = setTimeout(() => uploadController.abort(), UPLOAD_CONFIG.timeoutMs);
73
71
 
74
- if (!response.ok) {
75
- const err = await response.json().catch(() => ({ message: response.statusText }));
76
- const errorMessage = (err as { message?: string }).message || `File upload error: ${response.status}`;
77
- generationLogCollector.error(sessionId, TAG, `File upload failed: ${errorMessage}`);
78
- throw new Error(errorMessage);
79
- }
72
+ try {
73
+ const response = await fetch(PRUNA_FILES_URL, {
74
+ method: 'POST',
75
+ headers: { 'apikey': apiKey },
76
+ body: formData,
77
+ signal: uploadController.signal,
78
+ });
79
+
80
+ if (!response.ok) {
81
+ const err = await response.json().catch(() => ({ message: response.statusText }));
82
+ const errorMessage = (err as { message?: string }).message || `File upload error: ${response.status}`;
83
+ generationLogCollector.error(sessionId, TAG, `File upload failed: ${errorMessage}`);
84
+ throw new Error(errorMessage);
85
+ }
80
86
 
81
- const data: PrunaFileUploadResponse = await response.json();
82
- const fileUrl = data.urls?.get || `${PRUNA_FILES_URL}/${data.id}`;
87
+ const data: PrunaFileUploadResponse = await response.json();
88
+ const fileUrl = data.urls?.get || `${PRUNA_FILES_URL}/${data.id}`;
83
89
 
84
- const elapsed = Date.now() - startTime;
85
- generationLogCollector.log(sessionId, TAG, `File upload completed in ${elapsed}ms → ${fileUrl}`);
90
+ const elapsed = Date.now() - startTime;
91
+ generationLogCollector.log(sessionId, TAG, `File upload completed in ${elapsed}ms → ${fileUrl}`);
86
92
 
87
- return fileUrl;
93
+ return fileUrl;
94
+ } catch (error) {
95
+ if (error instanceof Error && error.name === 'AbortError') {
96
+ generationLogCollector.error(sessionId, TAG, `File upload timed out after ${UPLOAD_CONFIG.timeoutMs}ms`);
97
+ throw new Error(`File upload timed out after ${UPLOAD_CONFIG.timeoutMs}ms`);
98
+ }
99
+ throw error;
100
+ } finally {
101
+ clearTimeout(timeoutId);
102
+ }
88
103
  }
89
104
 
90
105
  /**
@@ -127,8 +142,14 @@ export async function submitPrediction(
127
142
  });
128
143
 
129
144
  if (!response.ok) {
130
- const errorData = await response.json().catch(() => ({ message: response.statusText }));
131
- const errorMessage = (errorData as { message?: string }).message || `API error: ${response.status}`;
145
+ const rawBody = await response.text().catch(() => '');
146
+ let errorMessage = `API error: ${response.status}`;
147
+ try {
148
+ const errObj = JSON.parse(rawBody) as Record<string, unknown>;
149
+ errorMessage = String(errObj.message || errObj.detail || errObj.error || rawBody) || errorMessage;
150
+ } catch {
151
+ if (rawBody) errorMessage = rawBody;
152
+ }
132
153
 
133
154
  generationLogCollector.error(sessionId, TAG, `Prediction failed (${response.status}): ${errorMessage}`);
134
155
 
@@ -171,10 +192,13 @@ export async function pollForResult(
171
192
  throw new Error("Request cancelled by user");
172
193
  }
173
194
 
174
- await new Promise(resolve => setTimeout(resolve, intervalMs));
195
+ // Wait between polls skip delay on first attempt for faster response
196
+ if (i > 0) {
197
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
175
198
 
176
- if (signal?.aborted) {
177
- throw new Error("Request cancelled by user");
199
+ if (signal?.aborted) {
200
+ throw new Error("Request cancelled by user");
201
+ }
178
202
  }
179
203
 
180
204
  try {
@@ -231,9 +255,9 @@ export function extractUri(data: PrunaPredictionResponse): string | null {
231
255
  data.generation_url ||
232
256
  (data.output && typeof data.output === 'object' && !Array.isArray(data.output) ? (data.output as { url: string }).url : null) ||
233
257
  (typeof data.output === 'string' ? data.output : null) ||
234
- data.data ||
235
258
  data.video_url ||
236
259
  (Array.isArray(data.output) ? data.output[0] : null) ||
260
+ data.data ||
237
261
  null
238
262
  );
239
263
  }
@@ -68,11 +68,15 @@ function buildImageEditInput(
68
68
  input: Record<string, unknown>,
69
69
  sessionId: string,
70
70
  ): Record<string, unknown> {
71
- // p-image-edit expects images array
71
+ // p-image-edit expects images array (base64 or HTTPS URLs — file URIs resolved by ai-generation-content)
72
72
  let images: string[];
73
73
 
74
74
  if (Array.isArray(input.images)) {
75
- images = (input.images as string[]).map(stripBase64Prefix);
75
+ const validImages = (input.images as unknown[]).filter((img): img is string => typeof img === 'string');
76
+ if (validImages.length === 0) {
77
+ throw new Error("Image array is empty or contains no valid strings for p-image-edit.");
78
+ }
79
+ images = validImages.map(stripBase64Prefix);
76
80
  } else if (typeof input.image === 'string') {
77
81
  images = [stripBase64Prefix(input.image as string)];
78
82
  } else if (typeof input.image_url === 'string') {
@@ -92,11 +96,6 @@ function buildImageEditInput(
92
96
  if (input.width !== undefined) payload.width = input.width;
93
97
  if (input.height !== undefined) payload.height = input.height;
94
98
 
95
- // reference_image: designates the primary/main image in multi-image edits
96
- if (typeof input.reference_image === 'string') {
97
- payload.reference_image = stripBase64Prefix(input.reference_image);
98
- }
99
-
100
99
  return payload;
101
100
  }
102
101
 
@@ -112,15 +112,18 @@ async function singleSubscribeAttempt<T = unknown>(
112
112
  predictionPromise.finally(() => signal.removeEventListener("abort", handler));
113
113
  });
114
114
  promises.push(abortPromise);
115
+ // Prevent unhandled rejection if abort loses the race
116
+ abortPromise.catch(() => {});
115
117
 
116
118
  if (signal.aborted) {
117
119
  throw new Error("Request cancelled by user");
118
120
  }
119
121
  }
120
122
 
121
- // Prevent unhandled rejection if predictionPromise loses the race
122
- // (timeout or abort wins → prediction may reject later with no handler)
123
+ // Prevent unhandled rejections for promises that lose the race
124
+ // (e.g. timeout fires after abort wins → would cause React Native red screen)
123
125
  predictionPromise.catch(() => {});
126
+ timeoutPromise.catch(() => {});
124
127
 
125
128
  const resultUrl = await Promise.race(promises) as string;
126
129
  const requestId = `pruna_${model}_${Date.now()}`;
@@ -86,7 +86,11 @@ export class PrunaProvider implements IAIProvider {
86
86
  const prunaModel = this.validateModel(model);
87
87
  const sessionId = generationLogCollector.startSession();
88
88
  generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() for model: ${model}`);
89
- return queueOps.submitJob(prunaModel, input, apiKey, sessionId);
89
+ try {
90
+ return await queueOps.submitJob(prunaModel, input, apiKey, sessionId);
91
+ } finally {
92
+ generationLogCollector.endSession(sessionId);
93
+ }
90
94
  }
91
95
 
92
96
  async getJobStatus(model: string, requestId: string): Promise<JobStatus> {
@@ -119,6 +123,7 @@ export class PrunaProvider implements IAIProvider {
119
123
  const existing = getExistingRequest<T>(key);
120
124
  if (existing) {
121
125
  generationLogCollector.log(sessionId, TAG, `Dedup hit — returning existing request`);
126
+ generationLogCollector.endSession(sessionId); // Clean up unused session
122
127
  return existing.promise;
123
128
  }
124
129
 
@@ -147,6 +152,7 @@ export class PrunaProvider implements IAIProvider {
147
152
  .catch((error) => {
148
153
  const totalElapsed = Date.now() - totalStart;
149
154
  generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
155
+ generationLogCollector.endSession(sessionId); // Clean up session on error
150
156
  rejectPromise(error);
151
157
  })
152
158
  .finally(() => {
@@ -169,14 +175,19 @@ export class PrunaProvider implements IAIProvider {
169
175
 
170
176
  const signal = options?.signal;
171
177
  if (signal?.aborted) {
178
+ generationLogCollector.endSession(sessionId);
172
179
  throw new Error("Request cancelled by user");
173
180
  }
174
181
 
175
- const result = await handlePrunaRun<T>(prunaModel, input, apiKey, sessionId, options);
176
- if (result && typeof result === 'object') {
177
- Object.defineProperty(result, '__providerSessionId', { value: sessionId, enumerable: false });
182
+ try {
183
+ const result = await handlePrunaRun<T>(prunaModel, input, apiKey, sessionId, options);
184
+ if (result && typeof result === 'object') {
185
+ Object.defineProperty(result, '__providerSessionId', { value: sessionId, enumerable: false });
186
+ }
187
+ return result;
188
+ } finally {
189
+ generationLogCollector.endSession(sessionId);
178
190
  }
179
- return result;
180
191
  }
181
192
 
182
193
  reset(): void {
@@ -16,8 +16,8 @@ const STORE_KEY = "__PRUNA_PROVIDER_REQUESTS__";
16
16
  const TIMER_KEY = "__PRUNA_PROVIDER_CLEANUP_TIMER__";
17
17
  type RequestStore = Map<string, ActiveRequest>;
18
18
 
19
- const CLEANUP_INTERVAL = 60000;
20
- const MAX_REQUEST_AGE = 300000;
19
+ const CLEANUP_INTERVAL = 60_000;
20
+ const MAX_REQUEST_AGE = 3_660_000; // 61 min — must exceed max allowed timeout (1 hour)
21
21
 
22
22
  function getCleanupTimer(): ReturnType<typeof setInterval> | null {
23
23
  const globalObj = globalThis as Record<string, unknown>;
@@ -6,6 +6,7 @@ export {
6
6
  MIME_AUDIO_WAV,
7
7
  MIME_AUDIO_FLAC,
8
8
  MIME_AUDIO_MP4,
9
+ MIME_APPLICATION_OCTET,
9
10
  MIME_DEFAULT,
10
11
  MIME_TO_EXTENSION,
11
12
  getExtensionForMime,
@@ -15,7 +15,8 @@ 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_DEFAULT = MIME_IMAGE_PNG;
18
+ export const MIME_APPLICATION_OCTET = 'application/octet-stream' as const;
19
+ export const MIME_DEFAULT = MIME_APPLICATION_OCTET;
19
20
 
20
21
  /** Maps MIME type → file extension for upload naming */
21
22
  export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
@@ -26,6 +27,7 @@ export const MIME_TO_EXTENSION: Readonly<Record<string, string>> = {
26
27
  [MIME_AUDIO_WAV]: 'wav',
27
28
  [MIME_AUDIO_FLAC]: 'flac',
28
29
  [MIME_AUDIO_MP4]: 'm4a',
30
+ [MIME_APPLICATION_OCTET]: 'bin',
29
31
  };
30
32
 
31
33
  /**
@@ -48,7 +48,8 @@ export function detectMimeType(bytes: Uint8Array): string {
48
48
  // WEBP at offset 8
49
49
  if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return MIME_IMAGE_WEBP;
50
50
  }
51
- return MIME_IMAGE_WEBP;
51
+ // Unknown RIFF subtype (AVI, AIFF, etc.) or insufficient bytes — don't assume WebP
52
+ return MIME_DEFAULT;
52
53
  }
53
54
 
54
55
  // ── Audio formats ───────────────────────────────────────
@@ -15,6 +15,11 @@ export function mapPrunaError(error: unknown): PrunaErrorInfo {
15
15
  const stack = error instanceof Error ? error.stack : undefined;
16
16
  const statusCode = (error as Error & { statusCode?: number }).statusCode;
17
17
 
18
+ // AbortError from signal.abort() during fetch — treat as user cancellation
19
+ if (error instanceof Error && error.name === 'AbortError') {
20
+ return buildErrorInfo(PrunaErrorType.UNKNOWN, "error.pruna.cancelled", false, originalError, originalErrorName, stack);
21
+ }
22
+
18
23
  // HTTP status code mapping
19
24
  if (statusCode !== undefined) {
20
25
  return mapStatusCode(statusCode, originalError, originalErrorName, stack);
@@ -66,12 +66,12 @@ export function createAiProviderInitModule(
66
66
  name: 'aiProviders',
67
67
  critical,
68
68
  dependsOn,
69
- init: () => {
69
+ init: async () => {
70
70
  try {
71
71
  const apiKey = getApiKey();
72
72
 
73
73
  if (!apiKey) {
74
- return Promise.resolve(false);
74
+ return false;
75
75
  }
76
76
 
77
77
  prunaProvider.initialize({ apiKey });
@@ -87,7 +87,7 @@ export function createAiProviderInitModule(
87
87
  onInitialized();
88
88
  }
89
89
 
90
- return Promise.resolve(true);
90
+ return true;
91
91
  } catch (error) {
92
92
  console.error('[AiProviderInitModule] Pruna initialization failed:', error);
93
93
  throw error;
@@ -75,11 +75,13 @@ export function usePrunaGeneration<T = unknown>(
75
75
  stateManagerRef.current = null;
76
76
  }
77
77
 
78
- // Cancel only this hook's active request on unmount
78
+ // Cancel this hook's active request on unmount
79
79
  if (abortControllerRef.current) {
80
80
  abortControllerRef.current.abort();
81
81
  abortControllerRef.current = null;
82
82
  }
83
+ // Also cancel the provider's internal request
84
+ prunaProvider.cancelCurrentRequest();
83
85
  };
84
86
  }, []);
85
87
 
@@ -159,6 +161,8 @@ export function usePrunaGeneration<T = unknown>(
159
161
  abortControllerRef.current.abort();
160
162
  abortControllerRef.current = null;
161
163
  }
164
+ // Propagate cancel to the provider's internal AbortController
165
+ prunaProvider.cancelCurrentRequest();
162
166
  }, []);
163
167
 
164
168
  const reset = useCallback(() => {