@umituz/react-native-ai-pruna-provider 1.0.64 → 1.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/application/dto/pruna.dto.ts +58 -0
- package/src/application/services/pruna-service.ts +71 -0
- package/src/application/use-cases/generate-image-edit.use-case.ts +165 -0
- package/src/application/use-cases/generate-image.use-case.ts +125 -0
- package/src/application/use-cases/generate-video.use-case.ts +147 -0
- package/src/domain/entities/error.types.ts +1 -0
- package/src/domain/services/error-mapper.domain-service.ts +201 -0
- package/src/domain/services/validation.domain-service.ts +93 -0
- package/src/domain/value-objects/api-key.value.ts +25 -0
- package/src/domain/value-objects/model-id.value.ts +29 -0
- package/src/domain/value-objects/session-id.value.ts +18 -0
- package/src/index.ts +1 -1
- package/src/infrastructure/api/http-client.ts +111 -0
- package/src/infrastructure/logging/pruna-logger.ts +89 -0
- package/src/infrastructure/services/pruna-api-client.ts +34 -16
- package/src/infrastructure/services/pruna-provider.ts +0 -21
- package/src/infrastructure/services/request-store.ts +36 -33
- package/src/infrastructure/storage/file-storage.ts +97 -0
- package/src/infrastructure/utils/constants/mime.constants.ts +2 -3
- package/src/infrastructure/utils/log-collector.ts +10 -12
- package/src/init/createAiProviderInitModule.ts +4 -1
- package/src/init/initializePrunaProvider.ts +4 -1
- package/src/presentation/hooks/use-pruna-generation.ts +1 -5
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Mapper Domain Service
|
|
3
|
+
* Maps raw errors to domain-specific error types
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { PrunaErrorType } from "../entities/error.types";
|
|
7
|
+
import type { PrunaErrorInfo } from "../entities/error.types";
|
|
8
|
+
|
|
9
|
+
export class ErrorMapperService {
|
|
10
|
+
static mapError(error: unknown): PrunaErrorInfo {
|
|
11
|
+
const originalMessage = this.extractMessage(error);
|
|
12
|
+
const errorName = error instanceof Error ? error.name : undefined;
|
|
13
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
14
|
+
const statusCode = (error as Error & { statusCode?: number }).statusCode;
|
|
15
|
+
|
|
16
|
+
// Check for specific error types
|
|
17
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
18
|
+
return this.buildError(
|
|
19
|
+
PrunaErrorType.CANCELLED,
|
|
20
|
+
"Request cancelled by user",
|
|
21
|
+
false,
|
|
22
|
+
originalMessage,
|
|
23
|
+
errorName,
|
|
24
|
+
stack,
|
|
25
|
+
statusCode
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// HTTP status code mapping
|
|
30
|
+
if (statusCode !== undefined) {
|
|
31
|
+
return this.mapStatusCode(statusCode, originalMessage, errorName, stack);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Message pattern matching
|
|
35
|
+
return this.mapByMessage(originalMessage, errorName, stack);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static isRetryable(error: PrunaErrorInfo): boolean {
|
|
39
|
+
return error.retryable;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private static mapStatusCode(
|
|
43
|
+
statusCode: number,
|
|
44
|
+
message: string,
|
|
45
|
+
errorName?: string,
|
|
46
|
+
stack?: string
|
|
47
|
+
): PrunaErrorInfo {
|
|
48
|
+
const statusMap: Record<number, { type: PrunaErrorType; messageKey: string; retryable: boolean }> = {
|
|
49
|
+
400: { type: PrunaErrorType.VALIDATION, messageKey: "error.validation", retryable: false },
|
|
50
|
+
401: { type: PrunaErrorType.AUTHENTICATION, messageKey: "error.auth", retryable: false },
|
|
51
|
+
402: { type: PrunaErrorType.QUOTA_EXCEEDED, messageKey: "error.quota", retryable: false },
|
|
52
|
+
403: { type: PrunaErrorType.AUTHENTICATION, messageKey: "error.auth", retryable: false },
|
|
53
|
+
404: { type: PrunaErrorType.MODEL_NOT_FOUND, messageKey: "error.model_not_found", retryable: false },
|
|
54
|
+
422: { type: PrunaErrorType.VALIDATION, messageKey: "error.validation", retryable: false },
|
|
55
|
+
429: { type: PrunaErrorType.RATE_LIMIT, messageKey: "error.rate_limit", retryable: true },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const mapped = statusMap[statusCode];
|
|
59
|
+
if (mapped) {
|
|
60
|
+
return this.buildError(
|
|
61
|
+
mapped.type,
|
|
62
|
+
mapped.messageKey,
|
|
63
|
+
mapped.retryable,
|
|
64
|
+
message,
|
|
65
|
+
errorName,
|
|
66
|
+
stack,
|
|
67
|
+
statusCode
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Server errors (5xx)
|
|
72
|
+
if (statusCode >= 500 && statusCode <= 504) {
|
|
73
|
+
return this.buildError(
|
|
74
|
+
PrunaErrorType.API_ERROR,
|
|
75
|
+
"error.api",
|
|
76
|
+
true,
|
|
77
|
+
message,
|
|
78
|
+
errorName,
|
|
79
|
+
stack,
|
|
80
|
+
statusCode
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return this.buildError(
|
|
85
|
+
PrunaErrorType.UNKNOWN,
|
|
86
|
+
"error.unknown",
|
|
87
|
+
false,
|
|
88
|
+
message,
|
|
89
|
+
errorName,
|
|
90
|
+
stack,
|
|
91
|
+
statusCode
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static mapByMessage(
|
|
96
|
+
message: string,
|
|
97
|
+
errorName?: string,
|
|
98
|
+
stack?: string
|
|
99
|
+
): PrunaErrorInfo {
|
|
100
|
+
const msg = message.toLowerCase();
|
|
101
|
+
|
|
102
|
+
// Network errors
|
|
103
|
+
if (this.matchesAny(msg, ["network", "fetch", "econnrefused", "enotfound"])) {
|
|
104
|
+
return this.buildError(
|
|
105
|
+
PrunaErrorType.NETWORK,
|
|
106
|
+
"error.network",
|
|
107
|
+
true,
|
|
108
|
+
message,
|
|
109
|
+
errorName,
|
|
110
|
+
stack
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Timeout errors
|
|
115
|
+
if (this.matchesAny(msg, ["timeout", "timed out", "polling attempts reached"])) {
|
|
116
|
+
return this.buildError(
|
|
117
|
+
PrunaErrorType.POLLING_TIMEOUT,
|
|
118
|
+
"error.timeout",
|
|
119
|
+
true,
|
|
120
|
+
message,
|
|
121
|
+
errorName,
|
|
122
|
+
stack
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// File upload errors
|
|
127
|
+
if (msg.includes("file upload")) {
|
|
128
|
+
return this.buildError(
|
|
129
|
+
PrunaErrorType.FILE_UPLOAD,
|
|
130
|
+
"error.file_upload",
|
|
131
|
+
true,
|
|
132
|
+
message,
|
|
133
|
+
errorName,
|
|
134
|
+
stack
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Validation errors
|
|
139
|
+
if (this.matchesAny(msg, ["prompt is required", "image is required", "invalid"])) {
|
|
140
|
+
return this.buildError(
|
|
141
|
+
PrunaErrorType.VALIDATION,
|
|
142
|
+
"error.validation",
|
|
143
|
+
false,
|
|
144
|
+
message,
|
|
145
|
+
errorName,
|
|
146
|
+
stack
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Cancellation
|
|
151
|
+
if (msg.includes("cancelled by user")) {
|
|
152
|
+
return this.buildError(
|
|
153
|
+
PrunaErrorType.CANCELLED,
|
|
154
|
+
"error.cancelled",
|
|
155
|
+
false,
|
|
156
|
+
message,
|
|
157
|
+
errorName,
|
|
158
|
+
stack
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return this.buildError(
|
|
163
|
+
PrunaErrorType.UNKNOWN,
|
|
164
|
+
"error.unknown",
|
|
165
|
+
false,
|
|
166
|
+
message,
|
|
167
|
+
errorName,
|
|
168
|
+
stack
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static buildError(
|
|
173
|
+
type: PrunaErrorType,
|
|
174
|
+
messageKey: string,
|
|
175
|
+
retryable: boolean,
|
|
176
|
+
originalError: string,
|
|
177
|
+
originalErrorName?: string,
|
|
178
|
+
stack?: string,
|
|
179
|
+
statusCode?: number
|
|
180
|
+
): PrunaErrorInfo {
|
|
181
|
+
return {
|
|
182
|
+
type,
|
|
183
|
+
messageKey,
|
|
184
|
+
retryable,
|
|
185
|
+
originalError,
|
|
186
|
+
...(originalErrorName && { originalErrorName }),
|
|
187
|
+
...(stack && { stack }),
|
|
188
|
+
...(statusCode !== undefined && { statusCode }),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private static extractMessage(error: unknown): string {
|
|
193
|
+
if (error instanceof Error) return error.message;
|
|
194
|
+
if (typeof error === 'string') return error;
|
|
195
|
+
return String(error);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private static matchesAny(text: string, patterns: string[]): boolean {
|
|
199
|
+
return patterns.some(pattern => text.includes(pattern));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Domain Service
|
|
3
|
+
* Centralized validation logic for Pruna operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ApiKey } from "../value-objects/api-key.value";
|
|
7
|
+
import { ModelId } from "../value-objects/model-id.value";
|
|
8
|
+
|
|
9
|
+
export interface ValidationResult {
|
|
10
|
+
isValid: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ValidationService {
|
|
15
|
+
static validatePrompt(prompt: unknown): ValidationResult {
|
|
16
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
17
|
+
return { isValid: false, error: "Prompt is required and must be a string" };
|
|
18
|
+
}
|
|
19
|
+
if (prompt.trim().length === 0) {
|
|
20
|
+
return { isValid: false, error: "Prompt cannot be empty" };
|
|
21
|
+
}
|
|
22
|
+
if (prompt.length > 2000) {
|
|
23
|
+
return { isValid: false, error: "Prompt exceeds maximum length of 2000 characters" };
|
|
24
|
+
}
|
|
25
|
+
return { isValid: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static validateApiKey(apiKey: string | ApiKey): ValidationResult {
|
|
29
|
+
try {
|
|
30
|
+
if (apiKey instanceof ApiKey) {
|
|
31
|
+
return { isValid: true };
|
|
32
|
+
}
|
|
33
|
+
ApiKey.create(apiKey);
|
|
34
|
+
return { isValid: true };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
isValid: false,
|
|
38
|
+
error: error instanceof Error ? error.message : "Invalid API key"
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static validateModel(model: string): ValidationResult {
|
|
44
|
+
try {
|
|
45
|
+
ModelId.create(model);
|
|
46
|
+
return { isValid: true };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return {
|
|
49
|
+
isValid: false,
|
|
50
|
+
error: error instanceof Error ? error.message : "Invalid model"
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static validateTimeout(timeoutMs: number, maxTimeoutMs: number): ValidationResult {
|
|
56
|
+
if (!Number.isInteger(timeoutMs)) {
|
|
57
|
+
return { isValid: false, error: "Timeout must be an integer" };
|
|
58
|
+
}
|
|
59
|
+
if (timeoutMs <= 0) {
|
|
60
|
+
return { isValid: false, error: "Timeout must be positive" };
|
|
61
|
+
}
|
|
62
|
+
if (timeoutMs > maxTimeoutMs) {
|
|
63
|
+
return { isValid: false, error: `Timeout cannot exceed ${maxTimeoutMs}ms` };
|
|
64
|
+
}
|
|
65
|
+
return { isValid: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static validateImageData(imageData: unknown): ValidationResult {
|
|
69
|
+
if (!imageData) {
|
|
70
|
+
return { isValid: false, error: "Image data is required" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof imageData === 'string') {
|
|
74
|
+
if (imageData.trim().length === 0) {
|
|
75
|
+
return { isValid: false, error: "Image data cannot be empty" };
|
|
76
|
+
}
|
|
77
|
+
return { isValid: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(imageData)) {
|
|
81
|
+
if (imageData.length === 0) {
|
|
82
|
+
return { isValid: false, error: "Image array cannot be empty" };
|
|
83
|
+
}
|
|
84
|
+
const hasInvalidItem = imageData.some(item => typeof item !== 'string' || item.trim().length === 0);
|
|
85
|
+
if (hasInvalidItem) {
|
|
86
|
+
return { isValid: false, error: "Image array contains invalid items" };
|
|
87
|
+
}
|
|
88
|
+
return { isValid: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { isValid: false, error: "Image data must be a string or array of strings" };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ApiKey Value Object
|
|
3
|
+
* Encapsulates API key validation and storage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ApiKey {
|
|
7
|
+
private static readonly MIN_LENGTH = 10;
|
|
8
|
+
|
|
9
|
+
private readonly value: string;
|
|
10
|
+
|
|
11
|
+
private constructor(value: string) {
|
|
12
|
+
this.value = value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static create(value: string): ApiKey {
|
|
16
|
+
if (!value || value.trim().length < ApiKey.MIN_LENGTH) {
|
|
17
|
+
throw new Error(`API key must be at least ${ApiKey.MIN_LENGTH} characters`);
|
|
18
|
+
}
|
|
19
|
+
return new ApiKey(value.trim());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toString(): string {
|
|
23
|
+
return this.value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelId Value Object
|
|
3
|
+
* Represents a valid Pruna model identifier
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { VALID_PRUNA_MODELS } from "../../infrastructure/services/pruna-provider.constants";
|
|
7
|
+
|
|
8
|
+
export type PrunaModelId = 'p-image' | 'p-image-edit' | 'p-video';
|
|
9
|
+
|
|
10
|
+
export class ModelId {
|
|
11
|
+
private readonly value: PrunaModelId;
|
|
12
|
+
|
|
13
|
+
private constructor(value: PrunaModelId) {
|
|
14
|
+
this.value = value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static create(value: string): ModelId {
|
|
18
|
+
if (!VALID_PRUNA_MODELS.includes(value as PrunaModelId)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Invalid model: "${value}". Valid models: ${VALID_PRUNA_MODELS.join(', ')}`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return new ModelId(value as PrunaModelId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
toString(): string {
|
|
27
|
+
return this.value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionId Value Object
|
|
3
|
+
* Represents a unique session identifier for logging
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class SessionId {
|
|
7
|
+
private static counter = 0;
|
|
8
|
+
|
|
9
|
+
private readonly value: string;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
this.value = `session_${++SessionId.counter}_${Date.now()}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
toString(): string {
|
|
16
|
+
return this.value;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export type { PrunaProvider as PrunaProviderType } from "./infrastructure/servic
|
|
|
52
52
|
export {
|
|
53
53
|
cleanupRequestStore,
|
|
54
54
|
stopAutomaticCleanup,
|
|
55
|
+
cleanupRequestIdMappings,
|
|
55
56
|
} from "./infrastructure/services/request-store";
|
|
56
57
|
export type { ActiveRequest } from "./infrastructure/services/request-store";
|
|
57
58
|
|
|
@@ -102,7 +103,6 @@ export {
|
|
|
102
103
|
MIME_AUDIO_WAV,
|
|
103
104
|
MIME_AUDIO_FLAC,
|
|
104
105
|
MIME_AUDIO_MP4,
|
|
105
|
-
MIME_APPLICATION_OCTET,
|
|
106
106
|
MIME_DEFAULT,
|
|
107
107
|
MIME_TO_EXTENSION,
|
|
108
108
|
getExtensionForMime,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Client Infrastructure
|
|
3
|
+
* Centralized HTTP request handling with error handling and retry logic
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ErrorMapperService } from "../../domain/services/error-mapper.domain-service";
|
|
7
|
+
import { logger } from "../logging/pruna-logger";
|
|
8
|
+
|
|
9
|
+
export interface HttpRequestConfig {
|
|
10
|
+
url: string;
|
|
11
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
body?: string | FormData;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HttpResponse<T = unknown> {
|
|
19
|
+
data: T;
|
|
20
|
+
status: number;
|
|
21
|
+
statusText: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HttpClient {
|
|
25
|
+
async request<T>(config: HttpRequestConfig, sessionId: string, tag: string): Promise<HttpResponse<T>> {
|
|
26
|
+
const log = logger;
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timeoutId = config.timeout ?
|
|
29
|
+
setTimeout(() => controller.abort(), config.timeout) : undefined;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
log.info(sessionId, tag, `HTTP ${config.method} ${config.url}`, {
|
|
33
|
+
hasBody: !!config.body,
|
|
34
|
+
hasSignal: !!config.signal,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const response = await fetch(config.url, {
|
|
38
|
+
method: config.method,
|
|
39
|
+
headers: config.headers,
|
|
40
|
+
body: config.body,
|
|
41
|
+
signal: config.signal || controller.signal,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
await this.handleErrorResponse(response, sessionId, tag);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = await response.json() as T;
|
|
51
|
+
log.info(sessionId, tag, `HTTP ${response.status} ${response.statusText}`, {
|
|
52
|
+
keys: Object.keys(data as Record<string, unknown>),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { data, status: response.status, statusText: response.statusText };
|
|
56
|
+
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
59
|
+
throw this.handleRequestError(error, sessionId, tag);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async handleErrorResponse(response: Response, sessionId: string, tag: string): Promise<never> {
|
|
64
|
+
let rawBody = '';
|
|
65
|
+
try {
|
|
66
|
+
rawBody = await response.text();
|
|
67
|
+
} catch {
|
|
68
|
+
// Ignore body read errors
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const errorMessage = this.extractErrorMessage(rawBody) || `HTTP ${response.status}`;
|
|
72
|
+
const error = new Error(errorMessage);
|
|
73
|
+
(error as Error & { statusCode?: number }).statusCode = response.status;
|
|
74
|
+
|
|
75
|
+
logger.error(sessionId, tag, `HTTP Error ${response.status}`, {
|
|
76
|
+
statusText: response.statusText,
|
|
77
|
+
errorMessage,
|
|
78
|
+
bodyLength: rawBody.length,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private handleRequestError(error: unknown, sessionId: string, tag: string): Error {
|
|
85
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
86
|
+
const abortError = new Error("Request cancelled by user");
|
|
87
|
+
(abortError as Error & { statusCode?: number }).statusCode = 499;
|
|
88
|
+
return abortError;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mappedError = ErrorMapperService.mapError(error);
|
|
92
|
+
logger.error(sessionId, tag, `Request failed: ${mappedError.messageKey}`, {
|
|
93
|
+
originalError: mappedError.originalError,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private extractErrorMessage(body: string): string | null {
|
|
100
|
+
if (!body) return null;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
104
|
+
return String(parsed.message || parsed.detail || parsed.error || body);
|
|
105
|
+
} catch {
|
|
106
|
+
return body || null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const httpClient = new HttpClient();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pruna Logger
|
|
3
|
+
* Centralized logging with automatic session management
|
|
4
|
+
* Eliminates repetitive logging code throughout the codebase
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { SessionId } from "../../domain/value-objects/session-id.value";
|
|
8
|
+
|
|
9
|
+
export enum LogLevel {
|
|
10
|
+
INFO = 'info',
|
|
11
|
+
WARN = 'warn',
|
|
12
|
+
ERROR = 'error',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LogContext {
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly tag: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PrunaLogger {
|
|
21
|
+
private static instance: PrunaLogger;
|
|
22
|
+
private sessions = new Map<string, number>();
|
|
23
|
+
|
|
24
|
+
private constructor() {}
|
|
25
|
+
|
|
26
|
+
static getInstance(): PrunaLogger {
|
|
27
|
+
if (!PrunaLogger.instance) {
|
|
28
|
+
PrunaLogger.instance = new PrunaLogger();
|
|
29
|
+
}
|
|
30
|
+
return PrunaLogger.instance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
createSession(): string {
|
|
34
|
+
const sessionId = new SessionId();
|
|
35
|
+
const id = sessionId.toString();
|
|
36
|
+
this.sessions.set(id, Date.now());
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
endSession(sessionId: string): void {
|
|
41
|
+
this.sessions.delete(sessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private log(
|
|
45
|
+
sessionId: string,
|
|
46
|
+
level: LogLevel,
|
|
47
|
+
tag: string,
|
|
48
|
+
message: string,
|
|
49
|
+
data?: Record<string, unknown>
|
|
50
|
+
): void {
|
|
51
|
+
const elapsed = this.calculateElapsed(sessionId);
|
|
52
|
+
const entry = {
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
elapsed,
|
|
55
|
+
level,
|
|
56
|
+
tag,
|
|
57
|
+
message,
|
|
58
|
+
...(data && { data }),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Console output in DEV mode
|
|
62
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
63
|
+
const consoleFn = level === LogLevel.ERROR ? console.error :
|
|
64
|
+
level === LogLevel.WARN ? console.warn : console.log;
|
|
65
|
+
consoleFn(`[${tag}] ${message}`, data ?? '');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
info(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
|
|
70
|
+
this.log(sessionId, LogLevel.INFO, tag, message, data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
warn(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
|
|
74
|
+
this.log(sessionId, LogLevel.WARN, tag, message, data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
error(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
|
|
78
|
+
this.log(sessionId, LogLevel.ERROR, tag, message, data);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private calculateElapsed(sessionId: string): number {
|
|
82
|
+
const startTime = this.sessions.get(sessionId);
|
|
83
|
+
return startTime ? Date.now() - startTime : 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Singleton instance
|
|
88
|
+
export const logger = PrunaLogger.getInstance();
|
|
89
|
+
|
|
@@ -435,7 +435,10 @@ export async function pollForResult(
|
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
if (!statusRes.ok) {
|
|
438
|
-
|
|
438
|
+
// Only log non-retryable errors to reduce log noise
|
|
439
|
+
if (statusRes.status >= 400 && statusRes.status < 500) {
|
|
440
|
+
generationLogCollector.warn(sessionId, TAG, `Poll HTTP ${statusRes.status}, skipping...`);
|
|
441
|
+
}
|
|
439
442
|
continue;
|
|
440
443
|
}
|
|
441
444
|
|
|
@@ -449,21 +452,20 @@ export async function pollForResult(
|
|
|
449
452
|
}
|
|
450
453
|
} else if (statusData.status === 'failed') {
|
|
451
454
|
const errorMessage = statusData.error || "Generation failed during processing.";
|
|
452
|
-
generationLogCollector.error(sessionId, TAG, `Polling
|
|
455
|
+
generationLogCollector.error(sessionId, TAG, `Polling failed: ${errorMessage}`);
|
|
453
456
|
throw new Error(errorMessage);
|
|
454
457
|
}
|
|
455
458
|
|
|
456
|
-
//
|
|
457
|
-
if ((i + 1) %
|
|
458
|
-
generationLogCollector.log(sessionId, TAG, `
|
|
459
|
+
// Log progress only every 30 attempts to reduce overhead (every ~90 seconds)
|
|
460
|
+
if ((i + 1) % 30 === 0) {
|
|
461
|
+
generationLogCollector.log(sessionId, TAG, `Still processing (attempt ${i + 1}/${maxAttempts})`);
|
|
459
462
|
}
|
|
460
463
|
} catch (error) {
|
|
461
464
|
if (error instanceof Error && error.message.includes("cancelled by user")) {
|
|
462
465
|
throw error;
|
|
463
466
|
}
|
|
464
|
-
// Non-fatal poll error — continue polling
|
|
467
|
+
// Non-fatal poll error — continue polling silently
|
|
465
468
|
if (error instanceof Error && !error.message.includes("failed during processing")) {
|
|
466
|
-
generationLogCollector.warn(sessionId, TAG, `Poll attempt ${i + 1} error: ${error.message}`);
|
|
467
469
|
continue;
|
|
468
470
|
}
|
|
469
471
|
throw error;
|
|
@@ -478,15 +480,31 @@ export async function pollForResult(
|
|
|
478
480
|
* Checks multiple possible locations (priority order).
|
|
479
481
|
*/
|
|
480
482
|
export function extractUri(data: PrunaPredictionResponse): string | null {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
483
|
+
// Priority 1: Direct generation URL
|
|
484
|
+
if (data.generation_url) return data.generation_url;
|
|
485
|
+
|
|
486
|
+
// Priority 2: Output object URL
|
|
487
|
+
if (data.output && typeof data.output === 'object' && !Array.isArray(data.output)) {
|
|
488
|
+
const outputObj = data.output as { url?: string };
|
|
489
|
+
if (outputObj.url) return outputObj.url;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Priority 3: Output as string
|
|
493
|
+
if (typeof data.output === 'string') return data.output;
|
|
494
|
+
|
|
495
|
+
// Priority 4: Video URL
|
|
496
|
+
if (data.video_url) return data.video_url;
|
|
497
|
+
|
|
498
|
+
// Priority 5: First array element
|
|
499
|
+
if (Array.isArray(data.output) && data.output.length > 0) {
|
|
500
|
+
const firstElement = data.output[0];
|
|
501
|
+
if (typeof firstElement === 'string') return firstElement;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Priority 6: Data field
|
|
505
|
+
if (data.data) return data.data;
|
|
506
|
+
|
|
507
|
+
return null;
|
|
490
508
|
}
|
|
491
509
|
|
|
492
510
|
/**
|
|
@@ -300,33 +300,12 @@ export class PrunaProvider implements IAIProvider {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
reset(): void {
|
|
304
|
-
cancelAllRequests();
|
|
305
|
-
this.lastRequestKey = null;
|
|
306
|
-
this.apiKey = null;
|
|
307
|
-
this.initialized = false;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
303
|
cancelCurrentRequest(): void {
|
|
311
304
|
if (this.lastRequestKey) {
|
|
312
305
|
cancelRequest(this.lastRequestKey);
|
|
313
306
|
this.lastRequestKey = null;
|
|
314
307
|
}
|
|
315
308
|
}
|
|
316
|
-
|
|
317
|
-
hasRunningRequest(): boolean {
|
|
318
|
-
return hasActiveRequests();
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
getSessionLogs(sessionId?: string): LogEntry[] {
|
|
322
|
-
if (!sessionId) return [];
|
|
323
|
-
return generationLogCollector.getEntries(sessionId);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
endLogSession(sessionId?: string): LogEntry[] {
|
|
327
|
-
if (!sessionId) return [];
|
|
328
|
-
return generationLogCollector.endSession(sessionId);
|
|
329
|
-
}
|
|
330
309
|
}
|
|
331
310
|
|
|
332
311
|
export const prunaProvider = new PrunaProvider();
|