@umituz/react-native-ai-pruna-provider 1.0.0

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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Pruna Provider Subscription Handlers
3
+ * Handles subscribe and run methods with retry, timeout, and cancellation
4
+ *
5
+ * Retry strategy for subscribe:
6
+ * - Retries on: network errors, timeouts, server errors (5xx)
7
+ * - NO retry on: auth, validation, quota, user cancellation
8
+ * - Max 1 retry (2 total attempts) with 3s delay
9
+ */
10
+
11
+ import type { PrunaModelId } from "../../domain/entities/pruna.types";
12
+ import type { SubscribeOptions, RunOptions } from "../../domain/types";
13
+ import { DEFAULT_PRUNA_CONFIG } from "./pruna-provider.constants";
14
+ import { submitPrediction, extractUri, resolveUri, pollForResult } from "./pruna-api-client";
15
+ import { buildModelInput } from "./pruna-input-builder";
16
+ import { generationLogCollector } from "../utils/log-collector";
17
+
18
+ const TAG = 'pruna-subscription';
19
+
20
+ // ─── Helpers ────────────────────────────────────────────────────────────────
21
+
22
+ function isRetryableError(error: unknown): boolean {
23
+ if (error instanceof Error && error.message.includes("cancelled by user")) return false;
24
+
25
+ // Check HTTP status code if attached
26
+ const statusCode = (error as Error & { statusCode?: number }).statusCode;
27
+ if (statusCode !== undefined) {
28
+ if (statusCode >= 500 && statusCode <= 504) return true;
29
+ return false; // 4xx are not retryable
30
+ }
31
+
32
+ // Generic Error — check message patterns
33
+ if (error instanceof Error) {
34
+ const msg = error.message.toLowerCase();
35
+ if (msg.includes("network")) return true;
36
+ if (msg.includes("timeout") || msg.includes("timed out")) return true;
37
+ if (msg.includes("fetch")) return true;
38
+ if (msg.includes("econnrefused") || msg.includes("enotfound")) return true;
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ // ─── Single Subscribe Attempt ───────────────────────────────────────────────
45
+
46
+ async function singleSubscribeAttempt<T = unknown>(
47
+ model: PrunaModelId,
48
+ input: Record<string, unknown>,
49
+ apiKey: string,
50
+ sessionId: string,
51
+ options: SubscribeOptions<T> | undefined,
52
+ signal: AbortSignal | undefined,
53
+ timeoutMs: number,
54
+ ): Promise<{ result: T; requestId: string }> {
55
+ if (signal?.aborted) {
56
+ throw new Error("Request cancelled by user");
57
+ }
58
+
59
+ // Build model-specific input
60
+ const modelInput = await buildModelInput(model, input, apiKey, sessionId);
61
+
62
+ // Race between prediction and timeout
63
+ const predictionPromise = (async (): Promise<string> => {
64
+ // Notify progress: IN_PROGRESS
65
+ options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" });
66
+
67
+ const response = await submitPrediction(model, modelInput, apiKey, sessionId);
68
+ let uri = extractUri(response);
69
+
70
+ // If no immediate result, poll for async result
71
+ if (!uri && (response.get_url || response.status_url)) {
72
+ const pollUrl = (response.get_url || response.status_url)!;
73
+
74
+ generationLogCollector.log(sessionId, TAG, 'No immediate result — starting async polling...');
75
+ options?.onQueueUpdate?.({
76
+ status: "IN_QUEUE",
77
+ requestId: pollUrl,
78
+ });
79
+
80
+ uri = await pollForResult(
81
+ pollUrl,
82
+ apiKey,
83
+ sessionId,
84
+ DEFAULT_PRUNA_CONFIG.maxPollAttempts,
85
+ DEFAULT_PRUNA_CONFIG.pollIntervalMs,
86
+ signal,
87
+ );
88
+ }
89
+
90
+ if (!uri) {
91
+ throw new Error("Empty result from Pruna AI. Please try again.");
92
+ }
93
+
94
+ return resolveUri(uri);
95
+ })();
96
+
97
+ const timeoutPromise = new Promise<never>((_, reject) => {
98
+ const timeoutId = setTimeout(() => {
99
+ reject(new Error(`Pruna subscription timeout after ${timeoutMs}ms for model ${model}`));
100
+ }, timeoutMs);
101
+
102
+ // Cleanup timeout if prediction finishes first
103
+ predictionPromise.finally(() => clearTimeout(timeoutId));
104
+ });
105
+
106
+ const promises: Promise<unknown>[] = [predictionPromise, timeoutPromise];
107
+
108
+ if (signal) {
109
+ const abortPromise = new Promise<never>((_, reject) => {
110
+ const handler = () => reject(new Error("Request cancelled by user"));
111
+ signal.addEventListener("abort", handler, { once: true });
112
+ predictionPromise.finally(() => signal.removeEventListener("abort", handler));
113
+ });
114
+ promises.push(abortPromise);
115
+
116
+ if (signal.aborted) {
117
+ throw new Error("Request cancelled by user");
118
+ }
119
+ }
120
+
121
+ const resultUrl = await Promise.race(promises) as string;
122
+ const requestId = `pruna_${model}_${Date.now()}`;
123
+
124
+ // Notify progress: COMPLETED
125
+ options?.onProgress?.({ progress: 100, status: "COMPLETED" });
126
+ options?.onQueueUpdate?.({
127
+ status: "COMPLETED",
128
+ requestId,
129
+ });
130
+
131
+ // Wrap result in expected format
132
+ // Pruna returns a URL string — wrap in a structure similar to FAL's response
133
+ const result = { url: resultUrl } as T;
134
+
135
+ return { result, requestId };
136
+ }
137
+
138
+ // ─── Public API ─────────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Handle Pruna subscription with timeout, cancellation, and retry.
142
+ */
143
+ export async function handlePrunaSubscription<T = unknown>(
144
+ model: PrunaModelId,
145
+ input: Record<string, unknown>,
146
+ apiKey: string,
147
+ sessionId: string,
148
+ options?: SubscribeOptions<T>,
149
+ signal?: AbortSignal,
150
+ ): Promise<{ result: T; requestId: string }> {
151
+ const overallStart = Date.now();
152
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_PRUNA_CONFIG.defaultTimeoutMs;
153
+ const maxRetries = DEFAULT_PRUNA_CONFIG.subscribeMaxRetries;
154
+ const retryDelay = DEFAULT_PRUNA_CONFIG.subscribeRetryDelayMs;
155
+
156
+ generationLogCollector.log(sessionId, TAG, `Starting subscription for model: ${model}`, {
157
+ timeoutMs,
158
+ maxRetries,
159
+ inputKeys: Object.keys(input),
160
+ });
161
+
162
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3600000) {
163
+ throw new Error(
164
+ `Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and 3600000ms (1 hour)`
165
+ );
166
+ }
167
+
168
+ let lastError: unknown;
169
+
170
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
171
+ if (attempt > 0) {
172
+ generationLogCollector.warn(sessionId, TAG, `Retry ${attempt}/${maxRetries} after ${retryDelay}ms delay...`);
173
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
174
+
175
+ if (signal?.aborted) {
176
+ throw new Error("Request cancelled by user");
177
+ }
178
+ }
179
+
180
+ try {
181
+ generationLogCollector.log(sessionId, TAG, `Attempt ${attempt + 1}/${maxRetries + 1} starting...`);
182
+
183
+ const result = await singleSubscribeAttempt<T>(
184
+ model, input, apiKey, sessionId, options, signal, timeoutMs,
185
+ );
186
+
187
+ const totalElapsed = Date.now() - overallStart;
188
+ const suffix = attempt > 0 ? ` (succeeded on retry ${attempt})` : '';
189
+ generationLogCollector.log(sessionId, TAG, `Subscription completed in ${totalElapsed}ms${suffix}. Request ID: ${result.requestId}`);
190
+
191
+ return result;
192
+ } catch (error) {
193
+ lastError = error;
194
+ const message = error instanceof Error ? error.message : String(error);
195
+
196
+ if (attempt < maxRetries && isRetryableError(error)) {
197
+ generationLogCollector.warn(sessionId, TAG, `Attempt ${attempt + 1} failed (retryable): ${message}`);
198
+ continue;
199
+ }
200
+
201
+ const totalElapsed = Date.now() - overallStart;
202
+ const retryInfo = attempt > 0 ? ` after ${attempt + 1} attempts` : '';
203
+ generationLogCollector.error(sessionId, TAG, `Subscription FAILED in ${totalElapsed}ms${retryInfo}: ${message}`);
204
+ throw error instanceof Error ? error : new Error(message);
205
+ }
206
+ }
207
+
208
+ throw lastError;
209
+ }
210
+
211
+ /**
212
+ * Handle Pruna run (no retry — direct execution)
213
+ */
214
+ export async function handlePrunaRun<T = unknown>(
215
+ model: PrunaModelId,
216
+ input: Record<string, unknown>,
217
+ apiKey: string,
218
+ sessionId: string,
219
+ options?: RunOptions,
220
+ ): Promise<T> {
221
+ const runTag = 'pruna-run';
222
+ const startTime = Date.now();
223
+ generationLogCollector.log(sessionId, runTag, `Starting run for model: ${model}`);
224
+
225
+ options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" as const });
226
+
227
+ try {
228
+ const modelInput = await buildModelInput(model, input, apiKey, sessionId);
229
+ const response = await submitPrediction(model, modelInput, apiKey, sessionId);
230
+
231
+ let uri = extractUri(response);
232
+
233
+ // Poll if needed
234
+ if (!uri && (response.get_url || response.status_url)) {
235
+ const pollUrl = (response.get_url || response.status_url)!;
236
+ uri = await pollForResult(
237
+ pollUrl,
238
+ apiKey,
239
+ sessionId,
240
+ DEFAULT_PRUNA_CONFIG.maxPollAttempts,
241
+ DEFAULT_PRUNA_CONFIG.pollIntervalMs,
242
+ options?.signal,
243
+ );
244
+ }
245
+
246
+ if (!uri) {
247
+ throw new Error("Empty result from Pruna AI. Please try again.");
248
+ }
249
+
250
+ const resultUrl = resolveUri(uri);
251
+ const elapsed = Date.now() - startTime;
252
+ generationLogCollector.log(sessionId, runTag, `Run completed in ${elapsed}ms`);
253
+
254
+ options?.onProgress?.({ progress: 100, status: "COMPLETED" as const });
255
+ return { url: resultUrl } as T;
256
+ } catch (error) {
257
+ const elapsed = Date.now() - startTime;
258
+ const message = error instanceof Error ? error.message : String(error);
259
+ generationLogCollector.error(sessionId, runTag, `Run FAILED after ${elapsed}ms for model ${model}: ${message}`);
260
+ throw error instanceof Error ? error : new Error(message);
261
+ }
262
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pruna Provider Constants
3
+ * Configuration and capability definitions for Pruna AI provider
4
+ *
5
+ * Retry Strategy:
6
+ * ┌──────────────────────────────────────────────────┐
7
+ * │ FILE UPLOAD (Pruna /v1/files) │
8
+ * │ Timeout: 30s / attempt │
9
+ * │ Retries: 2 (3 total attempts) │
10
+ * │ Backoff: 1s → 2s (exponential) │
11
+ * │ Retries on: network, timeout │
12
+ * ├──────────────────────────────────────────────────┤
13
+ * │ PREDICTION (Pruna /v1/predictions) │
14
+ * │ Timeout: caller-defined (120s image / 300s video)│
15
+ * │ Retries: 1 (2 total attempts) │
16
+ * │ Backoff: 3s (fixed — server needs recovery time) │
17
+ * │ Retries on: network, timeout, server (5xx) │
18
+ * │ NO retry: auth, validation, quota, cancel │
19
+ * ├──────────────────────────────────────────────────┤
20
+ * │ POLLING (async result polling) │
21
+ * │ Interval: 3s │
22
+ * │ Max attempts: 120 (~6 min) │
23
+ * │ Retries on: non-ok responses (skip & continue) │
24
+ * └──────────────────────────────────────────────────┘
25
+ */
26
+
27
+ import type { ProviderCapabilities } from "../../domain/types";
28
+
29
+ export const PRUNA_BASE_URL = 'https://api.pruna.ai';
30
+ export const PRUNA_PREDICTIONS_URL = `${PRUNA_BASE_URL}/v1/predictions`;
31
+ export const PRUNA_FILES_URL = `${PRUNA_BASE_URL}/v1/files`;
32
+
33
+ export const DEFAULT_PRUNA_CONFIG = {
34
+ /** Prediction retry — retries the entire prediction call on transient failures */
35
+ maxRetries: 1,
36
+ baseDelay: 1000,
37
+ maxDelay: 10000,
38
+
39
+ /** Subscribe defaults */
40
+ defaultTimeoutMs: 360_000,
41
+
42
+ /** Polling configuration */
43
+ pollIntervalMs: 3_000,
44
+ maxPollAttempts: 120,
45
+
46
+ /** Subscribe retry */
47
+ subscribeMaxRetries: 1,
48
+ subscribeRetryDelayMs: 3_000,
49
+ } as const;
50
+
51
+ export const UPLOAD_CONFIG = {
52
+ /** Timeout per individual upload attempt */
53
+ timeoutMs: 30_000,
54
+ /** Max retries (2 = 3 total attempts) */
55
+ maxRetries: 2,
56
+ /** Initial backoff delay (doubles each retry) */
57
+ baseDelayMs: 1_000,
58
+ } as const;
59
+
60
+ export const PRUNA_CAPABILITIES: ProviderCapabilities = {
61
+ imageFeatures: [] as const,
62
+ videoFeatures: [] as const,
63
+ textToImage: true,
64
+ textToVideo: false,
65
+ imageToVideo: true,
66
+ textToVoice: false,
67
+ textToText: false,
68
+ };
69
+
70
+ /** Valid Pruna model IDs */
71
+ export const VALID_PRUNA_MODELS = ['p-video', 'p-image', 'p-image-edit'] as const;
72
+
73
+ /** Default values for p-video model */
74
+ export const P_VIDEO_DEFAULTS = {
75
+ duration: 5,
76
+ resolution: '720p' as const,
77
+ fps: 24,
78
+ draft: false,
79
+ promptUpsampling: true,
80
+ } as const;
81
+
82
+ /** Default aspect ratio for all models */
83
+ export const DEFAULT_ASPECT_RATIO = '16:9' as const;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Pruna Provider - Implements IAIProvider interface
3
+ * Each subscribe/run call creates an isolated log session via sessionId.
4
+ *
5
+ * Pruna AI models:
6
+ * p-image: text-to-image
7
+ * p-image-edit: image-to-image
8
+ * p-video: image-to-video
9
+ */
10
+
11
+ import type {
12
+ IAIProvider, AIProviderConfig, JobSubmission, JobStatus, SubscribeOptions,
13
+ RunOptions, ProviderCapabilities, ImageFeatureType, VideoFeatureType,
14
+ ImageFeatureInputData, VideoFeatureInputData,
15
+ } from "../../domain/types";
16
+ import type { PrunaModelId } from "../../domain/entities/pruna.types";
17
+ import { DEFAULT_PRUNA_CONFIG, PRUNA_CAPABILITIES, VALID_PRUNA_MODELS } from "./pruna-provider.constants";
18
+ import { handlePrunaSubscription, handlePrunaRun } from "./pruna-provider-subscription";
19
+ import * as queueOps from "./pruna-queue-operations";
20
+ import { generationLogCollector } from "../utils/log-collector";
21
+ import type { LogEntry } from "../utils/log-collector";
22
+ import {
23
+ createRequestKey, getExistingRequest, storeRequest,
24
+ removeRequest, cancelRequest, cancelAllRequests, hasActiveRequests,
25
+ } from "./request-store";
26
+
27
+ export class PrunaProvider implements IAIProvider {
28
+ readonly providerId = "pruna";
29
+ readonly providerName = "Pruna AI";
30
+
31
+ private apiKey: string | null = null;
32
+ private initialized = false;
33
+ private lastRequestKey: string | null = null;
34
+
35
+ initialize(config: AIProviderConfig): void {
36
+ this.apiKey = config.apiKey;
37
+ this.initialized = true;
38
+ }
39
+
40
+ isInitialized(): boolean {
41
+ return this.initialized;
42
+ }
43
+
44
+ getCapabilities(): ProviderCapabilities {
45
+ return PRUNA_CAPABILITIES;
46
+ }
47
+
48
+ isFeatureSupported(_feature: ImageFeatureType | VideoFeatureType): boolean {
49
+ return false;
50
+ }
51
+
52
+ getImageFeatureModel(_feature: ImageFeatureType): string {
53
+ throw new Error("Feature-specific models not supported. Use main app's feature implementations.");
54
+ }
55
+
56
+ buildImageFeatureInput(_feature: ImageFeatureType, _data: ImageFeatureInputData): Record<string, unknown> {
57
+ throw new Error("Feature-specific input building not supported. Use main app's feature implementations.");
58
+ }
59
+
60
+ getVideoFeatureModel(_feature: VideoFeatureType): string {
61
+ throw new Error("Feature-specific models not supported. Use main app's feature implementations.");
62
+ }
63
+
64
+ buildVideoFeatureInput(_feature: VideoFeatureType, _data: VideoFeatureInputData): Record<string, unknown> {
65
+ throw new Error("Feature-specific input building not supported. Use main app's feature implementations.");
66
+ }
67
+
68
+ private validateInit(): string {
69
+ if (!this.apiKey || !this.initialized) {
70
+ throw new Error("Pruna provider not initialized. Call initialize() first.");
71
+ }
72
+ return this.apiKey;
73
+ }
74
+
75
+ private validateModel(model: string): PrunaModelId {
76
+ if (!VALID_PRUNA_MODELS.includes(model as PrunaModelId)) {
77
+ throw new Error(
78
+ `Invalid Pruna model: "${model}". Valid models: ${VALID_PRUNA_MODELS.join(', ')}`
79
+ );
80
+ }
81
+ return model as PrunaModelId;
82
+ }
83
+
84
+ async submitJob(model: string, input: Record<string, unknown>): Promise<JobSubmission> {
85
+ const apiKey = this.validateInit();
86
+ const prunaModel = this.validateModel(model);
87
+ const sessionId = generationLogCollector.startSession();
88
+ generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() for model: ${model}`);
89
+ return queueOps.submitJob(prunaModel, input, apiKey, sessionId);
90
+ }
91
+
92
+ async getJobStatus(model: string, requestId: string): Promise<JobStatus> {
93
+ const apiKey = this.validateInit();
94
+ const prunaModel = this.validateModel(model);
95
+ return queueOps.getJobStatus(prunaModel, requestId, apiKey);
96
+ }
97
+
98
+ async getJobResult<T = unknown>(model: string, requestId: string): Promise<T> {
99
+ const apiKey = this.validateInit();
100
+ const prunaModel = this.validateModel(model);
101
+ return queueOps.getJobResult<T>(prunaModel, requestId, apiKey);
102
+ }
103
+
104
+ async subscribe<T = unknown>(
105
+ model: string,
106
+ input: Record<string, unknown>,
107
+ options?: SubscribeOptions<T>,
108
+ ): Promise<T> {
109
+ const TAG = 'pruna-provider';
110
+ const totalStart = Date.now();
111
+ const apiKey = this.validateInit();
112
+ const prunaModel = this.validateModel(model);
113
+
114
+ const sessionId = generationLogCollector.startSession();
115
+ generationLogCollector.log(sessionId, TAG, `subscribe() called for model: ${model}`);
116
+
117
+ const key = createRequestKey(model, input);
118
+
119
+ const existing = getExistingRequest<T>(key);
120
+ if (existing) {
121
+ generationLogCollector.log(sessionId, TAG, `Dedup hit — returning existing request`);
122
+ return existing.promise;
123
+ }
124
+
125
+ const abortController = new AbortController();
126
+
127
+ let resolvePromise!: (value: T) => void;
128
+ let rejectPromise!: (error: unknown) => void;
129
+ const promise = new Promise<T>((resolve, reject) => {
130
+ resolvePromise = resolve;
131
+ rejectPromise = reject;
132
+ });
133
+
134
+ this.lastRequestKey = key;
135
+ storeRequest(key, { promise, abortController, createdAt: Date.now() });
136
+
137
+ handlePrunaSubscription<T>(prunaModel, input, apiKey, sessionId, options, abortController.signal)
138
+ .then((res) => {
139
+ const totalElapsed = Date.now() - totalStart;
140
+ generationLogCollector.log(sessionId, TAG, `Generation SUCCESS in ${totalElapsed}ms`);
141
+ const result = res.result;
142
+ if (result && typeof result === 'object') {
143
+ Object.defineProperty(result, '__providerSessionId', { value: sessionId, enumerable: false });
144
+ }
145
+ resolvePromise(result);
146
+ })
147
+ .catch((error) => {
148
+ const totalElapsed = Date.now() - totalStart;
149
+ generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
150
+ rejectPromise(error);
151
+ })
152
+ .finally(() => {
153
+ try {
154
+ removeRequest(key);
155
+ } catch (cleanupError) {
156
+ generationLogCollector.warn(sessionId, TAG, `Error removing request: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
157
+ }
158
+ });
159
+
160
+ return promise;
161
+ }
162
+
163
+ async run<T = unknown>(model: string, input: Record<string, unknown>, options?: RunOptions): Promise<T> {
164
+ const apiKey = this.validateInit();
165
+ const prunaModel = this.validateModel(model);
166
+
167
+ const sessionId = generationLogCollector.startSession();
168
+ generationLogCollector.log(sessionId, 'pruna-provider', `run() for model: ${model}`);
169
+
170
+ const signal = options?.signal;
171
+ if (signal?.aborted) {
172
+ throw new Error("Request cancelled by user");
173
+ }
174
+
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 });
178
+ }
179
+ return result;
180
+ }
181
+
182
+ reset(): void {
183
+ cancelAllRequests();
184
+ this.lastRequestKey = null;
185
+ this.apiKey = null;
186
+ this.initialized = false;
187
+ }
188
+
189
+ cancelCurrentRequest(): void {
190
+ if (this.lastRequestKey) {
191
+ cancelRequest(this.lastRequestKey);
192
+ this.lastRequestKey = null;
193
+ }
194
+ }
195
+
196
+ hasRunningRequest(): boolean {
197
+ return hasActiveRequests();
198
+ }
199
+
200
+ getSessionLogs(sessionId?: string): LogEntry[] {
201
+ if (!sessionId) return [];
202
+ return generationLogCollector.getEntries(sessionId);
203
+ }
204
+
205
+ endLogSession(sessionId?: string): LogEntry[] {
206
+ if (!sessionId) return [];
207
+ return generationLogCollector.endSession(sessionId);
208
+ }
209
+ }
210
+
211
+ export const prunaProvider = new PrunaProvider();
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Pruna Queue Operations - Direct Pruna API queue interactions
3
+ * No silent fallbacks - throws descriptive errors on unexpected responses
4
+ */
5
+
6
+ import type { PrunaModelId } from "../../domain/entities/pruna.types";
7
+ import type { JobSubmission, JobStatus } from "../../domain/types";
8
+ import { submitPrediction, extractUri } from "./pruna-api-client";
9
+ import { buildModelInput } from "./pruna-input-builder";
10
+ import { generationLogCollector } from "../utils/log-collector";
11
+
12
+ const TAG = 'pruna-queue';
13
+
14
+ /**
15
+ * Submit job to Pruna queue
16
+ * @throws {Error} if prediction fails or response is missing required data
17
+ */
18
+ export async function submitJob(
19
+ model: PrunaModelId,
20
+ input: Record<string, unknown>,
21
+ apiKey: string,
22
+ sessionId: string,
23
+ ): Promise<JobSubmission> {
24
+ generationLogCollector.log(sessionId, TAG, `Submitting job for model: ${model}`);
25
+
26
+ const modelInput = await buildModelInput(model, input, apiKey, sessionId);
27
+ const response = await submitPrediction(model, modelInput, apiKey, sessionId);
28
+
29
+ // If we got an immediate result, return it with a generated request ID
30
+ const uri = extractUri(response);
31
+ if (uri) {
32
+ const requestId = `pruna_immediate_${Date.now()}`;
33
+ return {
34
+ requestId,
35
+ statusUrl: undefined,
36
+ responseUrl: uri,
37
+ };
38
+ }
39
+
40
+ // Async result — return polling URL
41
+ const pollUrl = response.get_url || response.status_url;
42
+ if (!pollUrl) {
43
+ throw new Error(
44
+ `Pruna prediction response missing both result and polling URL for model ${model}.`
45
+ );
46
+ }
47
+
48
+ const requestId = `pruna_async_${Date.now()}`;
49
+ return {
50
+ requestId,
51
+ statusUrl: pollUrl,
52
+ responseUrl: undefined,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Get job status from Pruna polling URL
58
+ * @throws {Error} if polling URL is invalid or response is unexpected
59
+ */
60
+ export async function getJobStatus(
61
+ _model: PrunaModelId,
62
+ statusUrl: string,
63
+ apiKey: string,
64
+ ): Promise<JobStatus> {
65
+ const fullUrl = statusUrl.startsWith('http') ? statusUrl : `https://api.pruna.ai${statusUrl}`;
66
+
67
+ const response = await fetch(fullUrl, {
68
+ headers: { 'apikey': apiKey },
69
+ });
70
+
71
+ if (!response.ok) {
72
+ throw new Error(`Pruna status check failed: HTTP ${response.status}`);
73
+ }
74
+
75
+ const data = await response.json();
76
+ const typedData = data as { status?: string; error?: string };
77
+
78
+ if (typedData.status === 'succeeded' || typedData.status === 'completed') {
79
+ return {
80
+ status: "COMPLETED",
81
+ requestId: statusUrl,
82
+ };
83
+ }
84
+
85
+ if (typedData.status === 'failed') {
86
+ return {
87
+ status: "FAILED",
88
+ requestId: statusUrl,
89
+ };
90
+ }
91
+
92
+ return {
93
+ status: "IN_PROGRESS",
94
+ requestId: statusUrl,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Get job result from Pruna polling URL
100
+ * @throws {Error} if result is not ready or extraction fails
101
+ */
102
+ export async function getJobResult<T = unknown>(
103
+ _model: PrunaModelId,
104
+ statusUrl: string,
105
+ apiKey: string,
106
+ ): Promise<T> {
107
+ const fullUrl = statusUrl.startsWith('http') ? statusUrl : `https://api.pruna.ai${statusUrl}`;
108
+
109
+ const response = await fetch(fullUrl, {
110
+ headers: { 'apikey': apiKey },
111
+ });
112
+
113
+ if (!response.ok) {
114
+ throw new Error(`Pruna result fetch failed: HTTP ${response.status}`);
115
+ }
116
+
117
+ const data = await response.json();
118
+ const typedData = data as { status?: string; error?: string };
119
+
120
+ if (typedData.status === 'failed') {
121
+ throw new Error(typedData.error || "Generation failed.");
122
+ }
123
+
124
+ const uri = extractUri(data as Record<string, unknown>);
125
+ if (!uri) {
126
+ throw new Error("Result not ready or extraction failed.");
127
+ }
128
+
129
+ const resolvedUri = uri.startsWith('/') ? `https://api.pruna.ai${uri}` : uri;
130
+ return { url: resolvedUri } as T;
131
+ }