ObsiViewer/server/integrations/gemini/gemini.health.service.mjs
Bruno Charest 59d8a9f83a feat: add multi-select notes and Gemini AI integration
- Implemented multi-selection for notes with Ctrl+click, long-press, and keyboard shortcuts (Ctrl+A, Escape)
- Added Gemini API integration with environment configuration and routes
- Enhanced code block UI with improved copy feedback animation and visual polish
- Added sort order toggle (asc/desc) for note lists with persistent state
2025-11-04 09:54:03 -05:00

282 lines
8.2 KiB
JavaScript

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<string, number>} - 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<GeminiStatusResponse>}
*/
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();