@umituz/react-native-ai-pruna-provider 1.0.56 → 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 +1 -1
- package/src/infrastructure/services/pruna-api-client.ts +17 -16
- package/src/infrastructure/services/pruna-input-builder.ts +21 -17
- package/src/infrastructure/services/pruna-provider-subscription.ts +24 -7
- package/src/infrastructure/services/pruna-provider.constants.ts +1 -0
- package/src/infrastructure/services/pruna-provider.ts +6 -2
- package/src/infrastructure/services/request-store.ts +56 -5
- package/src/infrastructure/utils/calculation.utils.ts +10 -2
- package/src/infrastructure/utils/{helpers/index.ts → helpers.ts} +1 -1
- package/src/infrastructure/utils/log-collector.ts +12 -13
- package/src/infrastructure/utils/pruna-draft-mode.util.ts +2 -1
- package/src/infrastructure/utils/{type-guards/index.ts → type-guards.ts} +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-pruna-provider",
|
|
3
|
-
"version": "1.0.
|
|
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,
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
226
|
-
generationLogCollector.log(sessionId, TAG, `File upload completed in ${
|
|
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
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: [${
|
|
153
|
-
imageSizeKB:
|
|
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 =
|
|
163
|
-
generationLogCollector.log(sessionId, TAG, `p-image-edit: [${
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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 >
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 });
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 <=
|
|
97
|
+
timeoutMs <= MAX_TIMEOUT_MS
|
|
90
98
|
);
|
|
91
99
|
}
|
|
92
100
|
|
|
@@ -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
|
|
36
|
-
if (this.
|
|
37
|
-
|
|
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 =
|
|
216
|
+
const discountPercent = calculatePercentage(savings, normalPrice, 0);
|
|
216
217
|
|
|
217
218
|
return {
|
|
218
219
|
normalPrice,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Type Guards
|
|
2
|
+
* Type Guards
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { PrunaModelId } from "
|
|
6
|
-
import { PrunaErrorType } from "
|
|
7
|
-
import { VALID_PRUNA_MODELS } from "
|
|
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 <=
|
|
32
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= DEFAULT_PRUNA_CONFIG.maxTimeoutMs;
|
|
33
33
|
}
|