banatie-service/apps/api-service/src/utils/GeminiErrorDetector.ts

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');
}
}