@umituz/react-native-ai-pruna-provider 1.0.8 → 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.8",
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",
@@ -19,12 +19,6 @@ export enum PrunaErrorType {
19
19
  UNKNOWN = "unknown",
20
20
  }
21
21
 
22
- export interface PrunaErrorCategory {
23
- readonly type: PrunaErrorType;
24
- readonly messageKey: string;
25
- readonly retryable: boolean;
26
- }
27
-
28
22
  export interface PrunaErrorInfo {
29
23
  readonly type: PrunaErrorType;
30
24
  readonly messageKey: string;
@@ -35,18 +29,3 @@ export interface PrunaErrorInfo {
35
29
  readonly statusCode?: number;
36
30
  }
37
31
 
38
- export interface PrunaErrorMessages {
39
- network?: string;
40
- timeout?: string;
41
- api_error?: string;
42
- validation?: string;
43
- content_policy?: string;
44
- rate_limit?: string;
45
- authentication?: string;
46
- quota_exceeded?: string;
47
- model_not_found?: string;
48
- file_upload?: string;
49
- polling_timeout?: string;
50
- invalid_image?: string;
51
- unknown?: string;
52
- }
@@ -23,9 +23,7 @@ export type {
23
23
 
24
24
  export { PrunaErrorType } from "../domain/entities/error.types";
25
25
  export type {
26
- PrunaErrorCategory,
27
26
  PrunaErrorInfo,
28
- PrunaErrorMessages,
29
27
  } from "../domain/entities/error.types";
30
28
 
31
29
  export type {
@@ -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";
@@ -29,6 +29,11 @@ export async function uploadFileToStorage(
29
29
  apiKey: string,
30
30
  sessionId: string,
31
31
  ): Promise<string> {
32
+ // Guard: empty or whitespace-only input
33
+ if (!base64Data || !base64Data.trim()) {
34
+ throw new Error("File data is empty. Provide a base64 string or URL.");
35
+ }
36
+
32
37
  // Already a URL — return as-is
33
38
  if (base64Data.startsWith('http')) {
34
39
  generationLogCollector.log(sessionId, TAG, 'File already a URL, skipping upload');
@@ -60,31 +65,43 @@ export async function uploadFileToStorage(
60
65
 
61
66
  const startTime = Date.now();
62
67
 
63
- const response = await fetch(PRUNA_FILES_URL, {
64
- method: 'POST',
65
- headers: { 'apikey': apiKey },
66
- body: formData,
67
- });
68
+ // Apply timeout to prevent indefinite hangs
69
+ const uploadController = new AbortController();
70
+ const timeoutId = setTimeout(() => uploadController.abort(), UPLOAD_CONFIG.timeoutMs);
68
71
 
69
- if (!response.ok) {
70
- const err = await response.json().catch(() => ({ message: response.statusText }));
71
- const errorMessage = (err as { message?: string }).message || `File upload error: ${response.status}`;
72
- generationLogCollector.error(sessionId, TAG, `File upload failed: ${errorMessage}`);
73
- throw new Error(errorMessage);
74
- }
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
+ }
75
86
 
76
- const data: PrunaFileUploadResponse = await response.json();
77
- 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}`;
78
89
 
79
- const elapsed = Date.now() - startTime;
80
- 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}`);
81
92
 
82
- 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
+ }
83
103
  }
84
104
 
85
- /** @deprecated Use uploadFileToStorage instead */
86
- export const uploadImageToFiles = uploadFileToStorage;
87
-
88
105
  /**
89
106
  * Strip base64 data URI prefix, returning raw base64 string.
90
107
  * If input is already a URL, returns it unchanged.
@@ -104,6 +121,7 @@ export async function submitPrediction(
104
121
  input: Record<string, unknown>,
105
122
  apiKey: string,
106
123
  sessionId: string,
124
+ signal?: AbortSignal,
107
125
  ): Promise<PrunaPredictionResponse> {
108
126
  generationLogCollector.log(sessionId, TAG, `Submitting prediction for model: ${model}`, {
109
127
  inputKeys: Object.keys(input),
@@ -120,11 +138,18 @@ export async function submitPrediction(
120
138
  'Content-Type': 'application/json',
121
139
  },
122
140
  body: JSON.stringify({ input }),
141
+ signal,
123
142
  });
124
143
 
125
144
  if (!response.ok) {
126
- const errorData = await response.json().catch(() => ({ message: response.statusText }));
127
- 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
+ }
128
153
 
129
154
  generationLogCollector.error(sessionId, TAG, `Prediction failed (${response.status}): ${errorMessage}`);
130
155
 
@@ -167,15 +192,19 @@ export async function pollForResult(
167
192
  throw new Error("Request cancelled by user");
168
193
  }
169
194
 
170
- 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));
171
198
 
172
- if (signal?.aborted) {
173
- throw new Error("Request cancelled by user");
199
+ if (signal?.aborted) {
200
+ throw new Error("Request cancelled by user");
201
+ }
174
202
  }
175
203
 
176
204
  try {
177
205
  const statusRes = await fetch(fullPollUrl, {
178
206
  headers: { 'apikey': apiKey },
207
+ signal,
179
208
  });
180
209
 
181
210
  if (!statusRes.ok) {
@@ -226,9 +255,9 @@ export function extractUri(data: PrunaPredictionResponse): string | null {
226
255
  data.generation_url ||
227
256
  (data.output && typeof data.output === 'object' && !Array.isArray(data.output) ? (data.output as { url: string }).url : null) ||
228
257
  (typeof data.output === 'string' ? data.output : null) ||
229
- data.data ||
230
258
  data.video_url ||
231
259
  (Array.isArray(data.output) ? data.output[0] : null) ||
260
+ data.data ||
232
261
  null
233
262
  );
234
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
 
@@ -64,7 +64,7 @@ async function singleSubscribeAttempt<T = unknown>(
64
64
  // Notify progress: IN_PROGRESS
65
65
  options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" });
66
66
 
67
- const response = await submitPrediction(model, modelInput, apiKey, sessionId);
67
+ const response = await submitPrediction(model, modelInput, apiKey, sessionId, signal);
68
68
  let uri = extractUri(response);
69
69
 
70
70
  // If no immediate result, poll for async result
@@ -112,12 +112,19 @@ 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
 
123
+ // Prevent unhandled rejections for promises that lose the race
124
+ // (e.g. timeout fires after abort wins → would cause React Native red screen)
125
+ predictionPromise.catch(() => {});
126
+ timeoutPromise.catch(() => {});
127
+
121
128
  const resultUrl = await Promise.race(promises) as string;
122
129
  const requestId = `pruna_${model}_${Date.now()}`;
123
130
 
@@ -205,7 +212,8 @@ export async function handlePrunaSubscription<T = unknown>(
205
212
  }
206
213
  }
207
214
 
208
- throw lastError;
215
+ // Unreachable: loop always returns or throws. TypeScript safety net.
216
+ throw lastError instanceof Error ? lastError : new Error("Subscription failed after all retry attempts.");
209
217
  }
210
218
 
211
219
  /**
@@ -226,7 +234,7 @@ export async function handlePrunaRun<T = unknown>(
226
234
 
227
235
  try {
228
236
  const modelInput = await buildModelInput(model, input, apiKey, sessionId);
229
- const response = await submitPrediction(model, modelInput, apiKey, sessionId);
237
+ const response = await submitPrediction(model, modelInput, apiKey, sessionId, options?.signal);
230
238
 
231
239
  let uri = extractUri(response);
232
240
 
@@ -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 {
@@ -5,7 +5,8 @@
5
5
 
6
6
  import type { PrunaModelId } from "../../domain/entities/pruna.types";
7
7
  import type { JobSubmission, JobStatus } from "../../domain/types";
8
- import { submitPrediction, extractUri } from "./pruna-api-client";
8
+ import { submitPrediction, extractUri, resolveUri } from "./pruna-api-client";
9
+ import { PRUNA_BASE_URL } from "./pruna-provider.constants";
9
10
  import { buildModelInput } from "./pruna-input-builder";
10
11
  import { generationLogCollector } from "../utils/log-collector";
11
12
 
@@ -62,7 +63,7 @@ export async function getJobStatus(
62
63
  statusUrl: string,
63
64
  apiKey: string,
64
65
  ): Promise<JobStatus> {
65
- const fullUrl = statusUrl.startsWith('http') ? statusUrl : `https://api.pruna.ai${statusUrl}`;
66
+ const fullUrl = statusUrl.startsWith('http') ? statusUrl : `${PRUNA_BASE_URL}${statusUrl}`;
66
67
 
67
68
  const response = await fetch(fullUrl, {
68
69
  headers: { 'apikey': apiKey },
@@ -83,9 +84,11 @@ export async function getJobStatus(
83
84
  }
84
85
 
85
86
  if (typedData.status === 'failed') {
87
+ const errorMessage = typedData.error || "Generation failed during processing.";
86
88
  return {
87
89
  status: "FAILED",
88
90
  requestId: statusUrl,
91
+ logs: [{ message: errorMessage, level: "error" }],
89
92
  };
90
93
  }
91
94
 
@@ -104,7 +107,7 @@ export async function getJobResult<T = unknown>(
104
107
  statusUrl: string,
105
108
  apiKey: string,
106
109
  ): Promise<T> {
107
- const fullUrl = statusUrl.startsWith('http') ? statusUrl : `https://api.pruna.ai${statusUrl}`;
110
+ const fullUrl = statusUrl.startsWith('http') ? statusUrl : `${PRUNA_BASE_URL}${statusUrl}`;
108
111
 
109
112
  const response = await fetch(fullUrl, {
110
113
  headers: { 'apikey': apiKey },
@@ -126,6 +129,5 @@ export async function getJobResult<T = unknown>(
126
129
  throw new Error("Result not ready or extraction failed.");
127
130
  }
128
131
 
129
- const resolvedUri = uri.startsWith('/') ? `https://api.pruna.ai${uri}` : uri;
130
- return { url: resolvedUri } as T;
132
+ return { url: resolveUri(uri) } as T;
131
133
  }
@@ -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
  /**
@@ -25,10 +25,19 @@ interface Session {
25
25
 
26
26
  let sessionCounter = 0;
27
27
 
28
+ /** Max concurrent sessions before auto-evicting oldest */
29
+ const MAX_SESSIONS = 50;
30
+
28
31
  class GenerationLogCollector {
29
32
  private sessions = new Map<string, Session>();
30
33
 
31
34
  startSession(): string {
35
+ // Evict oldest sessions if limit exceeded
36
+ if (this.sessions.size >= MAX_SESSIONS) {
37
+ const oldestKey = this.sessions.keys().next().value;
38
+ if (oldestKey) this.sessions.delete(oldestKey);
39
+ }
40
+
32
41
  const id = `pruna_session_${++sessionCounter}_${Date.now()}`;
33
42
  this.sessions.set(id, { startTime: Date.now(), entries: [] });
34
43
  return id;
@@ -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);
@@ -27,5 +27,5 @@ export function isValidPrompt(value: unknown): value is string {
27
27
  }
28
28
 
29
29
  export function isValidTimeout(value: unknown): value is number {
30
- return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 600000;
30
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 3600000;
31
31
  }
@@ -35,6 +35,13 @@ export interface AiProviderInitModuleConfig {
35
35
  */
36
36
  dependsOn?: string[];
37
37
 
38
+ /**
39
+ * Whether to set Pruna as the active provider after initialization.
40
+ * When false, registers the provider but doesn't make it active.
41
+ * @default true
42
+ */
43
+ setAsActive?: boolean;
44
+
38
45
  /**
39
46
  * Optional callback called after provider is initialized
40
47
  */
@@ -51,6 +58,7 @@ export function createAiProviderInitModule(
51
58
  getApiKey,
52
59
  critical = false,
53
60
  dependsOn = ['firebase'],
61
+ setAsActive = true,
54
62
  onInitialized,
55
63
  } = config;
56
64
 
@@ -58,12 +66,12 @@ export function createAiProviderInitModule(
58
66
  name: 'aiProviders',
59
67
  critical,
60
68
  dependsOn,
61
- init: () => {
69
+ init: async () => {
62
70
  try {
63
71
  const apiKey = getApiKey();
64
72
 
65
73
  if (!apiKey) {
66
- return Promise.resolve(false);
74
+ return false;
67
75
  }
68
76
 
69
77
  prunaProvider.initialize({ apiKey });
@@ -71,13 +79,15 @@ export function createAiProviderInitModule(
71
79
  if (!providerRegistry.hasProvider(prunaProvider.providerId)) {
72
80
  providerRegistry.register(prunaProvider);
73
81
  }
74
- providerRegistry.setActiveProvider(prunaProvider.providerId);
82
+ if (setAsActive) {
83
+ providerRegistry.setActiveProvider(prunaProvider.providerId);
84
+ }
75
85
 
76
86
  if (onInitialized) {
77
87
  onInitialized();
78
88
  }
79
89
 
80
- return Promise.resolve(true);
90
+ return true;
81
91
  } catch (error) {
82
92
  console.error('[AiProviderInitModule] Pruna initialization failed:', error);
83
93
  throw error;
@@ -49,8 +49,10 @@ export function usePrunaGeneration<T = unknown>(
49
49
  const [error, setError] = useState<PrunaErrorInfo | null>(null);
50
50
  const [isLoading, setIsLoading] = useState(false);
51
51
  const [isCancelling, setIsCancelling] = useState(false);
52
+ const [requestId, setRequestId] = useState<string | null>(null);
52
53
 
53
54
  const stateManagerRef = useRef<PrunaGenerationStateManager<T> | null>(null);
55
+ const abortControllerRef = useRef<AbortController | null>(null);
54
56
  const optionsRef = useRef(options);
55
57
 
56
58
  useEffect(() => {
@@ -73,13 +75,13 @@ export function usePrunaGeneration<T = unknown>(
73
75
  stateManagerRef.current = null;
74
76
  }
75
77
 
76
- if (prunaProvider.hasRunningRequest()) {
77
- try {
78
- prunaProvider.cancelCurrentRequest();
79
- } catch (error) {
80
- console.warn('[usePrunaGeneration] Error cancelling request on unmount:', error);
81
- }
78
+ // Cancel this hook's active request on unmount
79
+ if (abortControllerRef.current) {
80
+ abortControllerRef.current.abort();
81
+ abortControllerRef.current = null;
82
82
  }
83
+ // Also cancel the provider's internal request
84
+ prunaProvider.cancelCurrentRequest();
83
85
  };
84
86
  }, []);
85
87
 
@@ -88,10 +90,18 @@ export function usePrunaGeneration<T = unknown>(
88
90
  const stateManager = stateManagerRef.current;
89
91
  if (!stateManager || !stateManager.checkMounted()) return null;
90
92
 
93
+ // Cancel any previous in-flight request from this hook
94
+ if (abortControllerRef.current) {
95
+ abortControllerRef.current.abort();
96
+ }
97
+ const controller = new AbortController();
98
+ abortControllerRef.current = controller;
99
+
91
100
  stateManager.setLastRequest(model, input);
92
101
  setIsLoading(true);
93
102
  setError(null);
94
103
  setData(null);
104
+ setRequestId(null);
95
105
  stateManager.setCurrentRequestId(null);
96
106
  setIsCancelling(false);
97
107
 
@@ -104,6 +114,11 @@ export function usePrunaGeneration<T = unknown>(
104
114
  stateManager.getCurrentRequestId()
105
115
  );
106
116
  stateManager.handleQueueUpdate(prunaStatus);
117
+
118
+ // Update reactive requestId from queue status
119
+ if (status.requestId) {
120
+ setRequestId(status.requestId);
121
+ }
107
122
  },
108
123
  });
109
124
 
@@ -121,6 +136,10 @@ export function usePrunaGeneration<T = unknown>(
121
136
  setIsLoading(false);
122
137
  setIsCancelling(false);
123
138
  }
139
+ // Clean up controller reference
140
+ if (abortControllerRef.current === controller) {
141
+ abortControllerRef.current = null;
142
+ }
124
143
  }
125
144
  },
126
145
  []
@@ -137,10 +156,13 @@ export function usePrunaGeneration<T = unknown>(
137
156
  }, [generate]);
138
157
 
139
158
  const cancel = useCallback(() => {
140
- if (prunaProvider.hasRunningRequest()) {
159
+ if (abortControllerRef.current) {
141
160
  setIsCancelling(true);
142
- prunaProvider.cancelCurrentRequest();
161
+ abortControllerRef.current.abort();
162
+ abortControllerRef.current = null;
143
163
  }
164
+ // Propagate cancel to the provider's internal AbortController
165
+ prunaProvider.cancelCurrentRequest();
144
166
  }, []);
145
167
 
146
168
  const reset = useCallback(() => {
@@ -149,11 +171,10 @@ export function usePrunaGeneration<T = unknown>(
149
171
  setError(null);
150
172
  setIsLoading(false);
151
173
  setIsCancelling(false);
174
+ setRequestId(null);
152
175
  stateManagerRef.current?.clearLastRequest();
153
176
  }, [cancel]);
154
177
 
155
- const requestId = stateManagerRef.current?.getCurrentRequestId() ?? null;
156
-
157
178
  return {
158
179
  data,
159
180
  error,