@umituz/react-native-ai-generation-content 1.0.4 → 1.1.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.
- package/package.json +1 -1
- package/src/index.ts +26 -2
- package/src/infrastructure/services/generation-orchestrator.service.ts +25 -2
- package/src/infrastructure/services/index.ts +2 -0
- package/src/infrastructure/services/job-poller.service.ts +208 -0
- package/src/infrastructure/utils/error-classifier.util.ts +36 -14
- package/src/infrastructure/utils/index.ts +2 -0
- package/src/infrastructure/utils/result-validator.util.ts +232 -0
- package/src/infrastructure/utils/status-checker.util.ts +101 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -53,26 +53,50 @@ export { DEFAULT_POLLING_CONFIG, DEFAULT_PROGRESS_STAGES } from "./domain/entiti
|
|
|
53
53
|
export {
|
|
54
54
|
providerRegistry,
|
|
55
55
|
generationOrchestrator,
|
|
56
|
+
pollJob,
|
|
57
|
+
createJobPoller,
|
|
56
58
|
} from "./infrastructure/services";
|
|
57
59
|
|
|
58
|
-
export type {
|
|
60
|
+
export type {
|
|
61
|
+
OrchestratorConfig,
|
|
62
|
+
PollJobOptions,
|
|
63
|
+
PollJobResult,
|
|
64
|
+
} from "./infrastructure/services";
|
|
59
65
|
|
|
60
66
|
// =============================================================================
|
|
61
67
|
// INFRASTRUCTURE LAYER - Utils
|
|
62
68
|
// =============================================================================
|
|
63
69
|
|
|
64
70
|
export {
|
|
71
|
+
// Error classification
|
|
65
72
|
classifyError,
|
|
66
73
|
isTransientError,
|
|
67
74
|
isPermanentError,
|
|
75
|
+
// Polling
|
|
68
76
|
calculatePollingInterval,
|
|
69
77
|
createPollingDelay,
|
|
78
|
+
// Progress
|
|
70
79
|
getProgressForStatus,
|
|
71
80
|
interpolateProgress,
|
|
72
81
|
createProgressTracker,
|
|
82
|
+
// Status checking
|
|
83
|
+
checkStatusForErrors,
|
|
84
|
+
isJobComplete,
|
|
85
|
+
isJobProcessing,
|
|
86
|
+
isJobFailed,
|
|
87
|
+
// Result validation
|
|
88
|
+
validateResult,
|
|
89
|
+
extractOutputUrl,
|
|
90
|
+
extractOutputUrls,
|
|
73
91
|
} from "./infrastructure/utils";
|
|
74
92
|
|
|
75
|
-
export type {
|
|
93
|
+
export type {
|
|
94
|
+
IntervalOptions,
|
|
95
|
+
ProgressOptions,
|
|
96
|
+
StatusCheckResult,
|
|
97
|
+
ResultValidation,
|
|
98
|
+
ValidateResultOptions,
|
|
99
|
+
} from "./infrastructure/utils";
|
|
76
100
|
|
|
77
101
|
// =============================================================================
|
|
78
102
|
// PRESENTATION LAYER - Hooks
|
|
@@ -40,6 +40,15 @@ class GenerationOrchestratorService {
|
|
|
40
40
|
const progressTracker = createProgressTracker();
|
|
41
41
|
const startTime = Date.now();
|
|
42
42
|
|
|
43
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
44
|
+
// eslint-disable-next-line no-console
|
|
45
|
+
console.log("[Orchestrator] Generate started:", {
|
|
46
|
+
model: request.model,
|
|
47
|
+
capability: request.capability,
|
|
48
|
+
provider: provider.providerId,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
const updateProgress = (
|
|
44
53
|
stage: GenerationProgress["stage"],
|
|
45
54
|
subProgress = 0,
|
|
@@ -60,7 +69,10 @@ class GenerationOrchestratorService {
|
|
|
60
69
|
|
|
61
70
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
62
71
|
// eslint-disable-next-line no-console
|
|
63
|
-
console.log("[Orchestrator] Job submitted:",
|
|
72
|
+
console.log("[Orchestrator] Job submitted:", {
|
|
73
|
+
requestId: submission.requestId,
|
|
74
|
+
provider: provider.providerId,
|
|
75
|
+
});
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
updateProgress("generating");
|
|
@@ -74,6 +86,17 @@ class GenerationOrchestratorService {
|
|
|
74
86
|
|
|
75
87
|
updateProgress("completed");
|
|
76
88
|
|
|
89
|
+
const duration = Date.now() - startTime;
|
|
90
|
+
|
|
91
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.log("[Orchestrator] Generate completed:", {
|
|
94
|
+
requestId: submission.requestId,
|
|
95
|
+
duration: `${duration}ms`,
|
|
96
|
+
success: true,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
77
100
|
return {
|
|
78
101
|
success: true,
|
|
79
102
|
data: result,
|
|
@@ -84,7 +107,7 @@ class GenerationOrchestratorService {
|
|
|
84
107
|
capability: request.capability,
|
|
85
108
|
startTime,
|
|
86
109
|
endTime: Date.now(),
|
|
87
|
-
duration
|
|
110
|
+
duration,
|
|
88
111
|
},
|
|
89
112
|
};
|
|
90
113
|
} catch (error) {
|
|
@@ -5,3 +5,5 @@
|
|
|
5
5
|
export { providerRegistry } from "./provider-registry.service";
|
|
6
6
|
export { generationOrchestrator } from "./generation-orchestrator.service";
|
|
7
7
|
export type { OrchestratorConfig } from "./generation-orchestrator.service";
|
|
8
|
+
export { pollJob, createJobPoller } from "./job-poller.service";
|
|
9
|
+
export type { PollJobOptions, PollJobResult } from "./job-poller.service";
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Poller Service
|
|
3
|
+
* Provider-agnostic job polling with exponential backoff
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IAIProvider, JobStatus } from "../../domain/interfaces";
|
|
7
|
+
import type { PollingConfig } from "../../domain/entities";
|
|
8
|
+
import { DEFAULT_POLLING_CONFIG } from "../../domain/entities";
|
|
9
|
+
import { calculatePollingInterval } from "../utils/polling-interval.util";
|
|
10
|
+
import { checkStatusForErrors, isJobComplete } from "../utils/status-checker.util";
|
|
11
|
+
import { validateResult } from "../utils/result-validator.util";
|
|
12
|
+
import { isTransientError } from "../utils/error-classifier.util";
|
|
13
|
+
|
|
14
|
+
declare const __DEV__: boolean;
|
|
15
|
+
|
|
16
|
+
export interface PollJobOptions {
|
|
17
|
+
provider: IAIProvider;
|
|
18
|
+
model: string;
|
|
19
|
+
requestId: string;
|
|
20
|
+
config?: Partial<PollingConfig>;
|
|
21
|
+
onProgress?: (progress: number) => void;
|
|
22
|
+
onStatusChange?: (status: JobStatus) => void;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PollJobResult<T = unknown> {
|
|
27
|
+
success: boolean;
|
|
28
|
+
data?: T;
|
|
29
|
+
error?: Error;
|
|
30
|
+
attempts: number;
|
|
31
|
+
elapsedMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_CONSECUTIVE_TRANSIENT_ERRORS = 5;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Poll job until completion with exponential backoff
|
|
38
|
+
*/
|
|
39
|
+
export async function pollJob<T = unknown>(
|
|
40
|
+
options: PollJobOptions,
|
|
41
|
+
): Promise<PollJobResult<T>> {
|
|
42
|
+
const {
|
|
43
|
+
provider,
|
|
44
|
+
model,
|
|
45
|
+
requestId,
|
|
46
|
+
config,
|
|
47
|
+
onProgress,
|
|
48
|
+
onStatusChange,
|
|
49
|
+
signal,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
const pollingConfig = { ...DEFAULT_POLLING_CONFIG, ...config };
|
|
53
|
+
const { maxAttempts } = pollingConfig;
|
|
54
|
+
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
let consecutiveTransientErrors = 0;
|
|
57
|
+
let lastProgress = 0;
|
|
58
|
+
|
|
59
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
60
|
+
// Check for abort
|
|
61
|
+
if (signal?.aborted) {
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error: new Error("Polling aborted"),
|
|
65
|
+
attempts: attempt + 1,
|
|
66
|
+
elapsedMs: Date.now() - startTime,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Wait for polling interval (skip first attempt)
|
|
71
|
+
if (attempt > 0) {
|
|
72
|
+
const interval = calculatePollingInterval({ attempt, config: pollingConfig });
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Get job status
|
|
78
|
+
const status = await provider.getJobStatus(model, requestId);
|
|
79
|
+
onStatusChange?.(status);
|
|
80
|
+
|
|
81
|
+
// Check for errors in status
|
|
82
|
+
const statusCheck = checkStatusForErrors(status);
|
|
83
|
+
|
|
84
|
+
if (statusCheck.shouldStop && statusCheck.hasError) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: new Error(statusCheck.errorMessage || "Job failed"),
|
|
88
|
+
attempts: attempt + 1,
|
|
89
|
+
elapsedMs: Date.now() - startTime,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reset transient error counter on success
|
|
94
|
+
consecutiveTransientErrors = 0;
|
|
95
|
+
|
|
96
|
+
// Update progress
|
|
97
|
+
const progress = calculateProgressFromStatus(status, attempt, maxAttempts);
|
|
98
|
+
if (progress > lastProgress) {
|
|
99
|
+
lastProgress = progress;
|
|
100
|
+
onProgress?.(progress);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check if complete
|
|
104
|
+
if (isJobComplete(status)) {
|
|
105
|
+
onProgress?.(90);
|
|
106
|
+
|
|
107
|
+
// Fetch result
|
|
108
|
+
const result = await provider.getJobResult<T>(model, requestId);
|
|
109
|
+
|
|
110
|
+
// Validate result
|
|
111
|
+
const validation = validateResult(result);
|
|
112
|
+
if (!validation.isValid) {
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
error: new Error(validation.errorMessage || "Invalid result"),
|
|
116
|
+
attempts: attempt + 1,
|
|
117
|
+
elapsedMs: Date.now() - startTime,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onProgress?.(100);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
success: true,
|
|
125
|
+
data: result,
|
|
126
|
+
attempts: attempt + 1,
|
|
127
|
+
elapsedMs: Date.now() - startTime,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (isTransientError(error)) {
|
|
132
|
+
consecutiveTransientErrors++;
|
|
133
|
+
|
|
134
|
+
// Too many consecutive transient errors
|
|
135
|
+
if (consecutiveTransientErrors >= MAX_CONSECUTIVE_TRANSIENT_ERRORS) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
139
|
+
attempts: attempt + 1,
|
|
140
|
+
elapsedMs: Date.now() - startTime,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Continue retrying
|
|
145
|
+
if (attempt < maxAttempts - 1) {
|
|
146
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.log(
|
|
149
|
+
`[JobPoller] Transient error, retrying (${attempt + 1}/${maxAttempts})`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Permanent error or max retries reached
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
160
|
+
attempts: attempt + 1,
|
|
161
|
+
elapsedMs: Date.now() - startTime,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Max attempts reached
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
error: new Error(`Polling timeout after ${maxAttempts} attempts`),
|
|
170
|
+
attempts: maxAttempts,
|
|
171
|
+
elapsedMs: Date.now() - startTime,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Calculate progress percentage from job status
|
|
177
|
+
*/
|
|
178
|
+
function calculateProgressFromStatus(
|
|
179
|
+
status: JobStatus,
|
|
180
|
+
attempt: number,
|
|
181
|
+
maxAttempts: number,
|
|
182
|
+
): number {
|
|
183
|
+
const statusString = String(status.status).toUpperCase();
|
|
184
|
+
|
|
185
|
+
switch (statusString) {
|
|
186
|
+
case "IN_QUEUE":
|
|
187
|
+
return 30 + Math.min(attempt * 2, 10);
|
|
188
|
+
case "IN_PROGRESS":
|
|
189
|
+
return 50 + Math.min(attempt * 3, 30);
|
|
190
|
+
case "COMPLETED":
|
|
191
|
+
return 90;
|
|
192
|
+
case "FAILED":
|
|
193
|
+
return 0;
|
|
194
|
+
default:
|
|
195
|
+
return 20 + Math.min((attempt / maxAttempts) * 30, 30);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create a job poller with preset configuration
|
|
201
|
+
*/
|
|
202
|
+
export function createJobPoller(defaultConfig?: Partial<PollingConfig>) {
|
|
203
|
+
return {
|
|
204
|
+
poll<T = unknown>(options: Omit<PollJobOptions, "config">) {
|
|
205
|
+
return pollJob<T>({ ...options, config: defaultConfig });
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { AIErrorType, type AIErrorInfo } from "../../domain/entities";
|
|
7
7
|
|
|
8
|
+
declare const __DEV__: boolean;
|
|
9
|
+
|
|
8
10
|
const NETWORK_ERROR_PATTERNS = [
|
|
9
11
|
"network",
|
|
10
12
|
"timeout",
|
|
@@ -60,18 +62,38 @@ function getStatusCode(error: unknown): number | undefined {
|
|
|
60
62
|
return undefined;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
function logClassification(info: AIErrorInfo): AIErrorInfo {
|
|
66
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.log("[ErrorClassifier] Classified as:", {
|
|
69
|
+
type: info.type,
|
|
70
|
+
messageKey: info.messageKey,
|
|
71
|
+
retryable: info.retryable,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return info;
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
export function classifyError(error: unknown): AIErrorInfo {
|
|
64
78
|
const message = error instanceof Error ? error.message : String(error);
|
|
65
79
|
const statusCode = getStatusCode(error);
|
|
66
80
|
|
|
81
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
82
|
+
// eslint-disable-next-line no-console
|
|
83
|
+
console.log("[ErrorClassifier] Classifying error:", {
|
|
84
|
+
message: message.slice(0, 100),
|
|
85
|
+
statusCode,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
if (statusCode === 429 || matchesPatterns(message, RATE_LIMIT_PATTERNS)) {
|
|
68
|
-
return {
|
|
90
|
+
return logClassification({
|
|
69
91
|
type: AIErrorType.RATE_LIMIT,
|
|
70
92
|
messageKey: "error.rateLimit",
|
|
71
93
|
retryable: true,
|
|
72
94
|
originalError: error,
|
|
73
95
|
statusCode,
|
|
74
|
-
};
|
|
96
|
+
});
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
if (
|
|
@@ -79,65 +101,65 @@ export function classifyError(error: unknown): AIErrorInfo {
|
|
|
79
101
|
statusCode === 403 ||
|
|
80
102
|
matchesPatterns(message, AUTH_ERROR_PATTERNS)
|
|
81
103
|
) {
|
|
82
|
-
return {
|
|
104
|
+
return logClassification({
|
|
83
105
|
type: AIErrorType.AUTHENTICATION,
|
|
84
106
|
messageKey: "error.authentication",
|
|
85
107
|
retryable: false,
|
|
86
108
|
originalError: error,
|
|
87
109
|
statusCode,
|
|
88
|
-
};
|
|
110
|
+
});
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
if (matchesPatterns(message, CONTENT_POLICY_PATTERNS)) {
|
|
92
|
-
return {
|
|
114
|
+
return logClassification({
|
|
93
115
|
type: AIErrorType.CONTENT_POLICY,
|
|
94
116
|
messageKey: "error.contentPolicy",
|
|
95
117
|
retryable: false,
|
|
96
118
|
originalError: error,
|
|
97
119
|
statusCode,
|
|
98
|
-
};
|
|
120
|
+
});
|
|
99
121
|
}
|
|
100
122
|
|
|
101
123
|
if (matchesPatterns(message, NETWORK_ERROR_PATTERNS)) {
|
|
102
|
-
return {
|
|
124
|
+
return logClassification({
|
|
103
125
|
type: AIErrorType.NETWORK,
|
|
104
126
|
messageKey: "error.network",
|
|
105
127
|
retryable: true,
|
|
106
128
|
originalError: error,
|
|
107
129
|
statusCode,
|
|
108
|
-
};
|
|
130
|
+
});
|
|
109
131
|
}
|
|
110
132
|
|
|
111
133
|
if (
|
|
112
134
|
(statusCode && statusCode >= 500) ||
|
|
113
135
|
matchesPatterns(message, SERVER_ERROR_PATTERNS)
|
|
114
136
|
) {
|
|
115
|
-
return {
|
|
137
|
+
return logClassification({
|
|
116
138
|
type: AIErrorType.SERVER,
|
|
117
139
|
messageKey: "error.server",
|
|
118
140
|
retryable: true,
|
|
119
141
|
originalError: error,
|
|
120
142
|
statusCode,
|
|
121
|
-
};
|
|
143
|
+
});
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
if (message.toLowerCase().includes("timeout")) {
|
|
125
|
-
return {
|
|
147
|
+
return logClassification({
|
|
126
148
|
type: AIErrorType.TIMEOUT,
|
|
127
149
|
messageKey: "error.timeout",
|
|
128
150
|
retryable: true,
|
|
129
151
|
originalError: error,
|
|
130
152
|
statusCode,
|
|
131
|
-
};
|
|
153
|
+
});
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
return {
|
|
156
|
+
return logClassification({
|
|
135
157
|
type: AIErrorType.UNKNOWN,
|
|
136
158
|
messageKey: "error.unknown",
|
|
137
159
|
retryable: false,
|
|
138
160
|
originalError: error,
|
|
139
161
|
statusCode,
|
|
140
|
-
};
|
|
162
|
+
});
|
|
141
163
|
}
|
|
142
164
|
|
|
143
165
|
export function isTransientError(error: unknown): boolean {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result Validator Utility
|
|
3
|
+
* Validates AI generation job results
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
declare const __DEV__: boolean;
|
|
7
|
+
|
|
8
|
+
export interface ResultValidation {
|
|
9
|
+
isValid: boolean;
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
errorMessage?: string;
|
|
12
|
+
hasOutput: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ValidateResultOptions {
|
|
16
|
+
/** Custom output field names to check */
|
|
17
|
+
outputFields?: string[];
|
|
18
|
+
/** Whether empty results are allowed */
|
|
19
|
+
allowEmpty?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_OUTPUT_FIELDS = [
|
|
23
|
+
"data",
|
|
24
|
+
"output",
|
|
25
|
+
"image",
|
|
26
|
+
"image_url",
|
|
27
|
+
"images",
|
|
28
|
+
"video",
|
|
29
|
+
"video_url",
|
|
30
|
+
"url",
|
|
31
|
+
"result",
|
|
32
|
+
"text",
|
|
33
|
+
"content",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate job result and detect errors
|
|
38
|
+
* Checks for error fields even if job status was COMPLETED
|
|
39
|
+
*/
|
|
40
|
+
export function validateResult(
|
|
41
|
+
result: unknown,
|
|
42
|
+
options?: ValidateResultOptions,
|
|
43
|
+
): ResultValidation {
|
|
44
|
+
const { outputFields = DEFAULT_OUTPUT_FIELDS, allowEmpty = false } =
|
|
45
|
+
options ?? {};
|
|
46
|
+
|
|
47
|
+
// Handle null/undefined
|
|
48
|
+
if (result === null || result === undefined) {
|
|
49
|
+
return {
|
|
50
|
+
isValid: allowEmpty,
|
|
51
|
+
hasError: !allowEmpty,
|
|
52
|
+
errorMessage: allowEmpty ? undefined : "Result is empty",
|
|
53
|
+
hasOutput: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Handle non-object results
|
|
58
|
+
if (typeof result !== "object") {
|
|
59
|
+
return {
|
|
60
|
+
isValid: true,
|
|
61
|
+
hasError: false,
|
|
62
|
+
hasOutput: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resultObj = result as Record<string, unknown>;
|
|
67
|
+
|
|
68
|
+
// Check for error fields
|
|
69
|
+
const errorValue = resultObj.error || resultObj.detail;
|
|
70
|
+
const errorString = errorValue ? String(errorValue).toLowerCase() : "";
|
|
71
|
+
|
|
72
|
+
const hasInternalServerError =
|
|
73
|
+
errorString.includes("internal server error") ||
|
|
74
|
+
errorString.includes("500") ||
|
|
75
|
+
errorString === "internal server error";
|
|
76
|
+
|
|
77
|
+
// Check for empty object
|
|
78
|
+
const isEmpty = Object.keys(resultObj).length === 0;
|
|
79
|
+
|
|
80
|
+
// Check for output in expected fields
|
|
81
|
+
const hasOutput = outputFields.some((field) => {
|
|
82
|
+
const value = resultObj[field];
|
|
83
|
+
if (!value) return false;
|
|
84
|
+
|
|
85
|
+
// Handle nested output structures
|
|
86
|
+
if (typeof value === "object" && value !== null) {
|
|
87
|
+
const nested = value as Record<string, unknown>;
|
|
88
|
+
return !!(
|
|
89
|
+
nested.url ||
|
|
90
|
+
nested.image_url ||
|
|
91
|
+
nested.video_url ||
|
|
92
|
+
Object.keys(nested).length > 0
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Determine if result has error
|
|
100
|
+
const hasError =
|
|
101
|
+
hasInternalServerError || (isEmpty && !hasOutput && !allowEmpty);
|
|
102
|
+
|
|
103
|
+
const validation: ResultValidation = {
|
|
104
|
+
isValid: !hasError && (hasOutput || allowEmpty),
|
|
105
|
+
hasError,
|
|
106
|
+
errorMessage: hasError && errorValue ? String(errorValue) : undefined,
|
|
107
|
+
hasOutput,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.log("[ResultValidator] Validation result:", {
|
|
113
|
+
isValid: validation.isValid,
|
|
114
|
+
hasOutput: validation.hasOutput,
|
|
115
|
+
hasError: validation.hasError,
|
|
116
|
+
checkedFields: outputFields.join(", "),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return validation;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract output URL from result
|
|
125
|
+
* Supports various AI provider response formats
|
|
126
|
+
*/
|
|
127
|
+
export function extractOutputUrl(
|
|
128
|
+
result: unknown,
|
|
129
|
+
urlFields?: string[],
|
|
130
|
+
): string | undefined {
|
|
131
|
+
if (!result || typeof result !== "object") {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fields = urlFields ?? [
|
|
136
|
+
"url",
|
|
137
|
+
"image_url",
|
|
138
|
+
"video_url",
|
|
139
|
+
"output_url",
|
|
140
|
+
"result_url",
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const resultObj = result as Record<string, unknown>;
|
|
144
|
+
|
|
145
|
+
// Check top-level fields
|
|
146
|
+
for (const field of fields) {
|
|
147
|
+
const value = resultObj[field];
|
|
148
|
+
if (typeof value === "string" && value.length > 0) {
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check nested data/output objects
|
|
154
|
+
const nested =
|
|
155
|
+
(resultObj.data as Record<string, unknown>) ||
|
|
156
|
+
(resultObj.output as Record<string, unknown>) ||
|
|
157
|
+
(resultObj.result as Record<string, unknown>);
|
|
158
|
+
|
|
159
|
+
if (nested && typeof nested === "object") {
|
|
160
|
+
for (const field of fields) {
|
|
161
|
+
const value = nested[field];
|
|
162
|
+
if (typeof value === "string" && value.length > 0) {
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check for nested image/video objects
|
|
168
|
+
const media =
|
|
169
|
+
(nested.image as Record<string, unknown>) ||
|
|
170
|
+
(nested.video as Record<string, unknown>);
|
|
171
|
+
if (media && typeof media === "object" && typeof media.url === "string") {
|
|
172
|
+
return media.url;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract multiple output URLs from result
|
|
181
|
+
*/
|
|
182
|
+
export function extractOutputUrls(
|
|
183
|
+
result: unknown,
|
|
184
|
+
urlFields?: string[],
|
|
185
|
+
): string[] {
|
|
186
|
+
if (!result || typeof result !== "object") {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const urls: string[] = [];
|
|
191
|
+
const resultObj = result as Record<string, unknown>;
|
|
192
|
+
|
|
193
|
+
// Check for arrays
|
|
194
|
+
const arrayFields = ["images", "videos", "outputs", "results", "urls"];
|
|
195
|
+
for (const field of arrayFields) {
|
|
196
|
+
const arr = resultObj[field];
|
|
197
|
+
if (Array.isArray(arr)) {
|
|
198
|
+
for (const item of arr) {
|
|
199
|
+
const url = extractOutputUrl(item, urlFields);
|
|
200
|
+
if (url) {
|
|
201
|
+
urls.push(url);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check nested data/output
|
|
208
|
+
const nested = resultObj.data || resultObj.output;
|
|
209
|
+
if (nested && typeof nested === "object") {
|
|
210
|
+
for (const field of arrayFields) {
|
|
211
|
+
const arr = (nested as Record<string, unknown>)[field];
|
|
212
|
+
if (Array.isArray(arr)) {
|
|
213
|
+
for (const item of arr) {
|
|
214
|
+
const url = extractOutputUrl(item, urlFields);
|
|
215
|
+
if (url) {
|
|
216
|
+
urls.push(url);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// If no array found, try single URL
|
|
224
|
+
if (urls.length === 0) {
|
|
225
|
+
const singleUrl = extractOutputUrl(result, urlFields);
|
|
226
|
+
if (singleUrl) {
|
|
227
|
+
urls.push(singleUrl);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return urls;
|
|
232
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status Checker Utility
|
|
3
|
+
* Checks job status responses for errors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { JobStatus, AILogEntry } from "../../domain/interfaces";
|
|
7
|
+
|
|
8
|
+
export interface StatusCheckResult {
|
|
9
|
+
status: string;
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
errorMessage?: string;
|
|
12
|
+
shouldStop: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check job status response for errors
|
|
17
|
+
* Detects errors even if status is not explicitly FAILED
|
|
18
|
+
*/
|
|
19
|
+
export function checkStatusForErrors(
|
|
20
|
+
status: JobStatus | Record<string, unknown>,
|
|
21
|
+
): StatusCheckResult {
|
|
22
|
+
const statusString = String(
|
|
23
|
+
(status as Record<string, unknown>)?.status || "",
|
|
24
|
+
).toUpperCase();
|
|
25
|
+
|
|
26
|
+
// Check for error in status response fields
|
|
27
|
+
const statusError =
|
|
28
|
+
(status as Record<string, unknown>)?.error ||
|
|
29
|
+
(status as Record<string, unknown>)?.detail ||
|
|
30
|
+
(status as Record<string, unknown>)?.message;
|
|
31
|
+
const hasStatusError = !!statusError;
|
|
32
|
+
|
|
33
|
+
// Check logs array for ERROR/FATAL level logs
|
|
34
|
+
const logs = Array.isArray((status as JobStatus)?.logs)
|
|
35
|
+
? (status as JobStatus).logs
|
|
36
|
+
: [];
|
|
37
|
+
const errorLogs = (logs as AILogEntry[]).filter((log) => {
|
|
38
|
+
const level = String(log?.level || "").toUpperCase();
|
|
39
|
+
return level === "ERROR" || level === "FATAL";
|
|
40
|
+
});
|
|
41
|
+
const hasErrorLog = errorLogs.length > 0;
|
|
42
|
+
|
|
43
|
+
// Extract error message from logs
|
|
44
|
+
const errorLogMessage =
|
|
45
|
+
errorLogs.length > 0
|
|
46
|
+
? (errorLogs[0] as AILogEntry & { text?: string; content?: string })
|
|
47
|
+
?.message ||
|
|
48
|
+
(errorLogs[0] as AILogEntry & { text?: string })?.text ||
|
|
49
|
+
(errorLogs[0] as AILogEntry & { content?: string })?.content
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
// Combine error messages
|
|
53
|
+
const errorMessage = statusError || errorLogMessage;
|
|
54
|
+
|
|
55
|
+
// Determine if we should stop immediately
|
|
56
|
+
const shouldStop =
|
|
57
|
+
statusString === "FAILED" || hasStatusError || hasErrorLog;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
status: statusString,
|
|
61
|
+
hasError: hasStatusError || hasErrorLog,
|
|
62
|
+
errorMessage: errorMessage ? String(errorMessage) : undefined,
|
|
63
|
+
shouldStop,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if status indicates job is complete
|
|
69
|
+
*/
|
|
70
|
+
export function isJobComplete(status: JobStatus | string): boolean {
|
|
71
|
+
const statusString =
|
|
72
|
+
typeof status === "string"
|
|
73
|
+
? status.toUpperCase()
|
|
74
|
+
: String(status?.status || "").toUpperCase();
|
|
75
|
+
|
|
76
|
+
return statusString === "COMPLETED";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if status indicates job is still processing
|
|
81
|
+
*/
|
|
82
|
+
export function isJobProcessing(status: JobStatus | string): boolean {
|
|
83
|
+
const statusString =
|
|
84
|
+
typeof status === "string"
|
|
85
|
+
? status.toUpperCase()
|
|
86
|
+
: String(status?.status || "").toUpperCase();
|
|
87
|
+
|
|
88
|
+
return statusString === "IN_QUEUE" || statusString === "IN_PROGRESS";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if status indicates job has failed
|
|
93
|
+
*/
|
|
94
|
+
export function isJobFailed(status: JobStatus | string): boolean {
|
|
95
|
+
const statusString =
|
|
96
|
+
typeof status === "string"
|
|
97
|
+
? status.toUpperCase()
|
|
98
|
+
: String(status?.status || "").toUpperCase();
|
|
99
|
+
|
|
100
|
+
return statusString === "FAILED";
|
|
101
|
+
}
|