299 lines
8.9 KiB
TypeScript
299 lines
8.9 KiB
TypeScript
import { ERROR_CODES, ERROR_MESSAGES } from './constants/errors';
|
|
|
|
/**
|
|
* Result of Gemini error analysis
|
|
*/
|
|
export interface GeminiErrorResult {
|
|
code: string;
|
|
message: string;
|
|
finishReason?: string | undefined;
|
|
blockReason?: string | undefined;
|
|
safetyCategories?: string[] | undefined;
|
|
retryAfter?: number | undefined;
|
|
httpStatus?: number | undefined;
|
|
technicalDetails?: string | undefined;
|
|
}
|
|
|
|
/**
|
|
* Safety rating from Gemini response
|
|
*/
|
|
interface SafetyRating {
|
|
category?: string;
|
|
probability?: string;
|
|
}
|
|
|
|
/**
|
|
* Gemini response structure (partial)
|
|
*/
|
|
interface GeminiResponse {
|
|
candidates?: Array<{
|
|
finishReason?: string;
|
|
finishMessage?: string;
|
|
content?: {
|
|
parts?: Array<{
|
|
text?: string;
|
|
inlineData?: { data?: string; mimeType?: string };
|
|
}>;
|
|
};
|
|
safetyRatings?: SafetyRating[];
|
|
}>;
|
|
promptFeedback?: {
|
|
blockReason?: string;
|
|
blockReasonMessage?: string;
|
|
safetyRatings?: SafetyRating[];
|
|
};
|
|
usageMetadata?: {
|
|
promptTokenCount?: number;
|
|
candidatesTokenCount?: number;
|
|
totalTokenCount?: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detector for Gemini AI specific errors
|
|
* Provides detailed error classification for rate limits, safety blocks, timeouts, etc.
|
|
*/
|
|
export class GeminiErrorDetector {
|
|
/**
|
|
* Classify an API-level error (HTTP errors from Gemini)
|
|
*/
|
|
static classifyApiError(error: unknown): GeminiErrorResult {
|
|
const err = error as { status?: number; message?: string; details?: unknown };
|
|
|
|
// Check for rate limit (HTTP 429)
|
|
if (err.status === 429) {
|
|
const retryAfter = this.extractRetryAfter(error);
|
|
return {
|
|
code: ERROR_CODES.GEMINI_RATE_LIMIT,
|
|
message: retryAfter
|
|
? `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Retry after ${retryAfter} seconds.`
|
|
: `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Please wait before retrying.`,
|
|
httpStatus: 429,
|
|
retryAfter,
|
|
technicalDetails: err.message,
|
|
};
|
|
}
|
|
|
|
// Check for authentication errors
|
|
if (err.status === 401 || err.status === 403) {
|
|
return {
|
|
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
message: 'Gemini API authentication failed. Check API key.',
|
|
httpStatus: err.status,
|
|
technicalDetails: err.message,
|
|
};
|
|
}
|
|
|
|
// Check for server errors
|
|
if (err.status === 500 || err.status === 503) {
|
|
return {
|
|
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
message: 'Gemini API service temporarily unavailable.',
|
|
httpStatus: err.status,
|
|
technicalDetails: err.message,
|
|
};
|
|
}
|
|
|
|
// Check for bad request
|
|
if (err.status === 400) {
|
|
return {
|
|
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
message: `Gemini API invalid request: ${err.message || 'Unknown error'}`,
|
|
httpStatus: 400,
|
|
technicalDetails: err.message,
|
|
};
|
|
}
|
|
|
|
// Generic API error
|
|
return {
|
|
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
message: err.message || ERROR_MESSAGES.GEMINI_API_ERROR,
|
|
httpStatus: err.status,
|
|
technicalDetails: err.message,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze a Gemini response for errors (finishReason, blockReason)
|
|
* Returns null if no error detected
|
|
*/
|
|
static analyzeResponse(response: GeminiResponse): GeminiErrorResult | null {
|
|
// Check promptFeedback for blocked prompts
|
|
if (response.promptFeedback?.blockReason) {
|
|
const safetyCategories = this.extractSafetyCategories(
|
|
response.promptFeedback.safetyRatings
|
|
);
|
|
return {
|
|
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
message:
|
|
response.promptFeedback.blockReasonMessage ||
|
|
`Prompt blocked: ${response.promptFeedback.blockReason}`,
|
|
blockReason: response.promptFeedback.blockReason,
|
|
safetyCategories,
|
|
technicalDetails: `blockReason: ${response.promptFeedback.blockReason}`,
|
|
};
|
|
}
|
|
|
|
// Check candidate finishReason
|
|
const candidate = response.candidates?.[0];
|
|
if (!candidate) {
|
|
return {
|
|
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
|
message: 'No response candidates from Gemini AI.',
|
|
technicalDetails: 'response.candidates is empty or undefined',
|
|
};
|
|
}
|
|
|
|
const finishReason = candidate.finishReason;
|
|
|
|
// STOP is normal completion
|
|
if (!finishReason || finishReason === 'STOP') {
|
|
return null;
|
|
}
|
|
|
|
// Handle different finishReasons
|
|
switch (finishReason) {
|
|
case 'SAFETY':
|
|
case 'IMAGE_SAFETY': {
|
|
const safetyCategories = this.extractSafetyCategories(candidate.safetyRatings);
|
|
return {
|
|
code: ERROR_CODES.GEMINI_SAFETY_BLOCK,
|
|
message: `Content blocked due to safety: ${safetyCategories.join(', ') || 'unspecified'}`,
|
|
finishReason,
|
|
safetyCategories,
|
|
technicalDetails: `finishReason: ${finishReason}, safetyRatings: ${JSON.stringify(candidate.safetyRatings)}`,
|
|
};
|
|
}
|
|
|
|
case 'NO_IMAGE':
|
|
return {
|
|
code: ERROR_CODES.GEMINI_NO_IMAGE,
|
|
message: 'Gemini AI could not generate an image for this prompt. Try rephrasing.',
|
|
finishReason,
|
|
technicalDetails: `finishReason: ${finishReason}`,
|
|
};
|
|
|
|
case 'IMAGE_PROHIBITED_CONTENT':
|
|
return {
|
|
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
message: 'Image generation blocked due to prohibited content in prompt.',
|
|
finishReason,
|
|
technicalDetails: `finishReason: ${finishReason}`,
|
|
};
|
|
|
|
case 'MAX_TOKENS':
|
|
return {
|
|
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
message: 'Response exceeded maximum token limit. Try a shorter prompt.',
|
|
finishReason,
|
|
technicalDetails: `finishReason: ${finishReason}`,
|
|
};
|
|
|
|
case 'RECITATION':
|
|
case 'IMAGE_RECITATION':
|
|
return {
|
|
code: ERROR_CODES.GEMINI_CONTENT_BLOCKED,
|
|
message: 'Response blocked due to potential copyright concerns.',
|
|
finishReason,
|
|
technicalDetails: `finishReason: ${finishReason}`,
|
|
};
|
|
|
|
default:
|
|
return {
|
|
code: ERROR_CODES.GEMINI_API_ERROR,
|
|
message: `Generation stopped unexpectedly: ${finishReason}`,
|
|
finishReason,
|
|
technicalDetails: `finishReason: ${finishReason}, finishMessage: ${candidate.finishMessage}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if response has image data
|
|
*/
|
|
static hasImageData(response: GeminiResponse): boolean {
|
|
const parts = response.candidates?.[0]?.content?.parts;
|
|
if (!parts) return false;
|
|
return parts.some((part) => part.inlineData?.data);
|
|
}
|
|
|
|
/**
|
|
* Format error result for logging
|
|
*/
|
|
static formatForLogging(result: GeminiErrorResult): string {
|
|
const parts = [`[${result.code}] ${result.message}`];
|
|
|
|
if (result.finishReason) {
|
|
parts.push(`finishReason=${result.finishReason}`);
|
|
}
|
|
if (result.blockReason) {
|
|
parts.push(`blockReason=${result.blockReason}`);
|
|
}
|
|
if (result.httpStatus) {
|
|
parts.push(`httpStatus=${result.httpStatus}`);
|
|
}
|
|
if (result.retryAfter) {
|
|
parts.push(`retryAfter=${result.retryAfter}s`);
|
|
}
|
|
if (result.safetyCategories?.length) {
|
|
parts.push(`safety=[${result.safetyCategories.join(', ')}]`);
|
|
}
|
|
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
/**
|
|
* Log Gemini response structure for debugging
|
|
*/
|
|
static logResponseStructure(response: GeminiResponse, prefix: string = ''): void {
|
|
const parts = response.candidates?.[0]?.content?.parts || [];
|
|
const partTypes = parts.map((p) => {
|
|
if (p.inlineData) return 'image';
|
|
if (p.text) return 'text';
|
|
return 'other';
|
|
});
|
|
|
|
console.log(`[ImageGenService]${prefix ? ` [${prefix}]` : ''} Gemini response:`, {
|
|
hasCandidates: !!response.candidates?.length,
|
|
candidateCount: response.candidates?.length || 0,
|
|
finishReason: response.candidates?.[0]?.finishReason || null,
|
|
blockReason: response.promptFeedback?.blockReason || null,
|
|
partsCount: parts.length,
|
|
partTypes,
|
|
usageMetadata: response.usageMetadata || null,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract retry-after value from error
|
|
*/
|
|
private static extractRetryAfter(error: unknown): number | undefined {
|
|
const err = error as { headers?: { get?: (key: string) => string | null } };
|
|
|
|
// Try to get from headers
|
|
if (err.headers?.get) {
|
|
const retryAfter = err.headers.get('retry-after');
|
|
if (retryAfter) {
|
|
const seconds = parseInt(retryAfter, 10);
|
|
if (!isNaN(seconds)) return seconds;
|
|
}
|
|
}
|
|
|
|
// Default retry after for rate limits
|
|
return 60;
|
|
}
|
|
|
|
/**
|
|
* Extract safety category names from ratings
|
|
*/
|
|
private static extractSafetyCategories(ratings?: SafetyRating[]): string[] {
|
|
if (!ratings || ratings.length === 0) return [];
|
|
|
|
// Filter for high/medium probability ratings and extract category names
|
|
return ratings
|
|
.filter((r) => r.probability === 'HIGH' || r.probability === 'MEDIUM')
|
|
.map((r) => r.category?.replace('HARM_CATEGORY_', '') || 'UNKNOWN')
|
|
.filter((c) => c !== 'UNKNOWN');
|
|
}
|
|
}
|