import './gemini.types.mjs'; /** * Service de vérification de santé de l'API Gemini * Gère l'état de la clé API et les tests de connectivité */ export class GeminiHealthService { constructor() { /** @type {GeminiStatusResponse} */ this.cache = { status: 'CONFIGURED_UNVERIFIED', lastCheckedAt: null, details: { reason: 'not_tested', httpCode: null } }; /** @type {boolean} */ this.tested = false; /** @type {number} - TTL du cache en millisecondes (5 minutes) */ this.CACHE_TTL_MS = 5 * 60 * 1000; /** @type {number} - Timeout pour les requêtes API (8 secondes) */ this.REQUEST_TIMEOUT_MS = 8000; /** @type {Map} - Anti-spam: dernier appel par IP */ this.lastTestByIp = new Map(); /** @type {number} - Délai minimum entre deux tests (30 secondes) */ this.MIN_TEST_INTERVAL_MS = 30 * 1000; } /** * Obtient le statut actuel de la clé API Gemini * @returns {GeminiStatusResponse} */ getStatus() { const apiKey = process.env.GEMINI_API_KEY; // Cas 1: Clé non configurée if (!apiKey || apiKey.trim() === '') { return { status: 'NOT_CONFIGURED', lastCheckedAt: this.cache.lastCheckedAt, details: { reason: 'missing_key', httpCode: null } }; } // Cas 2: Clé configurée mais jamais testée if (!this.tested) { return { status: 'CONFIGURED_UNVERIFIED', lastCheckedAt: this.cache.lastCheckedAt, details: { reason: 'not_tested', httpCode: null } }; } // Cas 3 & 4: Retourner le cache (WORKING ou INVALID) return this.cache; } /** * Vérifie si un test peut être exécuté (anti-spam) * @param {string} clientIp - IP du client * @returns {{ allowed: boolean, reason?: string, waitSeconds?: number }} */ canRunTest(clientIp) { const now = Date.now(); const lastTest = this.lastTestByIp.get(clientIp); if (lastTest) { const elapsed = now - lastTest; if (elapsed < this.MIN_TEST_INTERVAL_MS) { const waitSeconds = Math.ceil((this.MIN_TEST_INTERVAL_MS - elapsed) / 1000); return { allowed: false, reason: 'rate_limited', waitSeconds }; } } return { allowed: true }; } /** * Exécute un test live de la clé API Gemini * @param {string} clientIp - IP du client (pour anti-spam) * @returns {Promise} */ async runLiveTest(clientIp = 'unknown') { const apiKey = process.env.GEMINI_API_KEY; const now = new Date().toISOString(); // Vérifier si la clé est configurée if (!apiKey || apiKey.trim() === '') { this.cache = { status: 'NOT_CONFIGURED', lastCheckedAt: now, details: { reason: 'missing_key', httpCode: null } }; this.tested = false; return this.cache; } // Anti-spam const testAllowed = this.canRunTest(clientIp); if (!testAllowed.allowed) { // Retourner le cache existant avec un message return { ...this.cache, details: { ...this.cache.details, reason: 'rate_limited', waitSeconds: testAllowed.waitSeconds } }; } // Marquer le timestamp du test this.lastTestByIp.set(clientIp, Date.now()); try { // Appel minimal à l'API Gemini: liste des modèles const baseUrl = process.env.GEMINI_API_BASE || 'https://generativelanguage.googleapis.com'; const preferredApiVersion = (process.env.GEMINI_API_VERSION || 'v1').trim(); // 'v1' ou 'v1beta' const buildUrl = (ver) => `${baseUrl}/${ver}/models`; console.log('[GeminiHealth] Testing API key connectivity...'); // Créer un controller pour le timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.REQUEST_TIMEOUT_MS); // 1er essai: version préférée let response = await fetch(buildUrl(preferredApiVersion), { method: 'GET', signal: controller.signal, headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey } }); // Fallback automatique: si 400, retenter avec l'autre version (v1 <-> v1beta) if (response.status === 400) { const altVersion = preferredApiVersion === 'v1' ? 'v1beta' : 'v1'; console.warn(`[GeminiHealth] 400 on ${preferredApiVersion}, retrying with ${altVersion}...`); response = await fetch(buildUrl(altVersion), { method: 'GET', signal: controller.signal, headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey } }); } clearTimeout(timeoutId); // Analyser la réponse if (response.ok) { // Vérifier que la réponse contient des modèles const data = await response.json(); const hasModels = data.models && Array.isArray(data.models) && data.models.length > 0; if (hasModels) { console.log(`[GeminiHealth] ✅ API key is WORKING (${data.models.length} models available)`); this.cache = { status: 'WORKING', lastCheckedAt: now, details: { reason: 'ok', httpCode: response.status } }; } else { console.warn('[GeminiHealth] ⚠️ API key valid but unexpected response structure'); this.cache = { status: 'INVALID', lastCheckedAt: now, details: { reason: 'unexpected_response', httpCode: response.status } }; } } else if (response.status === 401 || response.status === 403) { let errMsg = 'authentication failed'; try { const err = await response.json(); errMsg = err?.error?.message || errMsg; } catch {} console.error('[GeminiHealth] ❌ API key is INVALID:', errMsg); this.cache = { status: 'INVALID', lastCheckedAt: now, details: { reason: 'auth_error', httpCode: response.status, message: errMsg } }; } else if (response.status === 429) { let errMsg = 'rate limited'; try { const err = await response.json(); errMsg = err?.error?.message || errMsg; } catch {} console.error('[GeminiHealth] ❌ API rate limited:', errMsg); this.cache = { status: 'INVALID', lastCheckedAt: now, details: { reason: 'rate_limited', httpCode: response.status, message: errMsg } }; } else { let errMsg = 'unexpected_response'; try { const err = await response.json(); errMsg = err?.error?.message || errMsg; } catch {} console.error(`[GeminiHealth] ❌ Unexpected HTTP status: ${response.status}`, errMsg); this.cache = { status: 'INVALID', lastCheckedAt: now, details: { reason: 'unexpected_response', httpCode: response.status, message: errMsg } }; } this.tested = true; return this.cache; } catch (error) { console.error('[GeminiHealth] ❌ Network error during API test:', error.message); // Timeout ou erreur réseau const isTimeout = error.name === 'AbortError' || error.message.includes('timeout'); this.cache = { status: 'INVALID', lastCheckedAt: now, details: { reason: isTimeout ? 'network_error' : 'network_error', httpCode: null } }; this.tested = true; return this.cache; } } /** * Réinitialise le cache (utile pour les tests) */ reset() { this.cache = { status: 'CONFIGURED_UNVERIFIED', lastCheckedAt: null, details: { reason: 'not_tested', httpCode: null } }; this.tested = false; this.lastTestByIp.clear(); } } // Singleton global export const geminiHealthService = new GeminiHealthService();