import express, { Router } from 'express'; import { geminiHealthService } from './gemini.health.service.mjs'; const router = Router(); router.use(express.json()); // --- Helpers --------------------------------------------------------------- async function listModels(baseUrl, ver, apiKey) { const url = `${baseUrl}/${ver}/models?key=${encodeURIComponent(apiKey)}`; const r = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey } }); if (!r.ok) { // Return empty list on error; caller will try fallbacks return []; } const data = await r.json().catch(() => ({})); const items = Array.isArray(data?.models) ? data.models : []; return items.map(m => ({ name: typeof m?.name === 'string' ? m.name.replace(/^models\//, '') : '', raw: m })).filter(m => !!m.name); } function scoreModelName(name) { // Prefer 1.5 flash latest > 1.5 flash > 1.5 pro latest > 1.5 pro > gemini-pro > others const n = name.toLowerCase(); if (n.includes('1.5') && n.includes('flash') && n.includes('latest')) return 100; if (n.includes('1.5') && n.includes('flash')) return 95; if (n.includes('1.5') && n.includes('pro') && n.includes('latest')) return 90; if (n.includes('1.5') && n.includes('pro')) return 85; if (n === 'gemini-pro' || n.startsWith('gemini-pro')) return 80; return 10; } function normalizeRequestedModel(reqModel) { const base = String(reqModel || '').trim(); return base.replace(/^models\//, '') || 'gemini-1.5-flash'; } async function resolveModel(baseUrl, apiKey, preferredVer, requestedModel) { const versions = preferredVer === 'v1' ? ['v1', 'v1beta'] : ['v1beta', 'v1']; const want = normalizeRequestedModel(requestedModel); for (const ver of versions) { const models = await listModels(baseUrl, ver, apiKey); if (!models.length) continue; // Try exact (with/without -latest) and common variants const candidates = new Map(); for (const m of models) { candidates.set(m.name, m); } const exact = candidates.get(want) || candidates.get(`${want}-latest`) || candidates.get(want.replace(/-latest$/i, '')); if (exact) { return { ver, model: exact.name }; } // Otherwise pick best scored const sorted = models .map(m => ({ ...m, score: scoreModelName(m.name) })) .sort((a, b) => b.score - a.score); if (sorted.length) { return { ver, model: sorted[0].name }; } } return null; } /** * GET /api/integrations/gemini/status * Retourne le statut actuel de la clé API Gemini */ router.get('/status', (req, res) => { try { const status = geminiHealthService.getStatus(); res.set({ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); res.json(status); } catch (error) { console.error('[Gemini Routes] Error getting status:', error); res.status(500).json({ status: 'INVALID', lastCheckedAt: null, details: { reason: 'unexpected_response', httpCode: 500 } }); } }); /** * POST /api/integrations/gemini/test * Exécute un test live de la clé API Gemini */ router.post('/test', async (req, res) => { try { // Récupérer l'IP du client pour l'anti-spam const clientIp = req.ip || req.connection.remoteAddress || 'unknown'; // Vérifier le rate limiting avant d'exécuter le test const canTest = geminiHealthService.canRunTest(clientIp); if (!canTest.allowed) { return res.status(429).json({ status: geminiHealthService.cache.status, lastCheckedAt: geminiHealthService.cache.lastCheckedAt, details: { reason: 'rate_limited', httpCode: 429, message: `Veuillez patienter ${canTest.waitSeconds} secondes avant de retester`, waitSeconds: canTest.waitSeconds } }); } const result = await geminiHealthService.runLiveTest(clientIp); res.set({ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); res.json(result); } catch (error) { console.error('[Gemini Routes] Error running test:', error); res.status(500).json({ status: 'INVALID', lastCheckedAt: new Date().toISOString(), details: { reason: 'unexpected_response', httpCode: 500 } }); } }); /** * DELETE /api/integrations/gemini/cache * Réinitialise le cache (utile pour les tests/debug) */ router.delete('/cache', (req, res) => { try { geminiHealthService.reset(); res.set({ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); res.json({ success: true, message: 'Cache réinitialisé' }); } catch (error) { console.error('[Gemini Routes] Error resetting cache:', error); res.status(500).json({ success: false, error: 'Failed to reset cache' }); } }); export default router; // --- Below: AI utility endpoints --- /** * POST /api/integrations/gemini/summarize * Body: { text: string, title?: string, maxChars?: number, model?: string, language?: string } * Returns: { success: boolean, summary?: string, model: string, duration: number, error?: string } */ router.post('/summarize', async (req, res) => { try { const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) return res.status(400).json({ success: false, error: 'Gemini API key not configured' }); const baseUrl = process.env.GEMINI_API_BASE || 'https://generativelanguage.googleapis.com'; const preferredVer = (process.env.GEMINI_API_VERSION || 'v1').trim(); const requestedModel = (req.body?.model || process.env.GEMINI_DEFAULT_MODEL || 'gemini-1.5-flash').toString(); const text = (req.body?.text || '').toString(); const title = (req.body?.title || '').toString(); const maxChars = Number(req.body?.maxChars || 240); const language = (req.body?.language || 'fr').toString(); if (!text) return res.status(400).json({ success: false, error: 'Missing text' }); const start = Date.now(); const prompt = [ `Tu es un assistant qui résume des notes Obsidian de façon concise.`, `Objectif: produire 1 à 2 phrases (${maxChars} caractères max) en ${language}.`, title ? `Titre: ${title}` : null, `Contenu: ${text.substring(0, 8000)}` ].filter(Boolean).join('\n\n'); const body = { contents: [ { role: 'user', parts: [{ text: prompt }] } ] }; // Resolve a compatible model/version first const resolved = await resolveModel(baseUrl, apiKey, preferredVer, requestedModel); if (resolved) { const { ver, model } = resolved; const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`; const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify(body) }); const duration = Date.now() - start; if (!r.ok) { let msg = `HTTP ${r.status}`; let payload = null; try { payload = await r.json(); msg = payload?.error?.message || msg; } catch { try { msg = await r.text(); } catch {} } return res.status(502).json({ success: false, error: msg, httpStatus: r.status, model, version: ver, duration, details: payload }); } const data = await r.json().catch(() => ({})); const summary = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || ''; if (!summary) return res.status(502).json({ success: false, error: 'Empty response', model, version: ver, duration }); return res.json({ success: true, summary, model, version: ver, duration }); } // Fallback: previous retry logic const versionsToTry = preferredVer === 'v1' ? ['v1', 'v1beta'] : ['v1beta', 'v1']; const baseModel = requestedModel.replace(/-latest$/i, ''); const modelsToTry = Array.from(new Set([ requestedModel, baseModel, `${baseModel}-latest`, 'gemini-1.5-flash', 'gemini-1.5-pro' ])); let lastErr = null; for (const ver of versionsToTry) { for (const model of modelsToTry) { const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`; const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify(body) }); const duration = Date.now() - start; if (!r.ok) { try { const payload = await r.json(); const msg = payload?.error?.message || `HTTP ${r.status}`; lastErr = { msg, payload, status: r.status, ver, model, duration }; continue; } catch { const txt = await r.text().catch(() => ''); lastErr = { msg: txt || `HTTP ${r.status}`, payload: null, status: r.status, ver, model, duration }; continue; } } const data = await r.json().catch(() => ({})); const summary = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || ''; if (!summary) { lastErr = { msg: 'Empty response', payload: data, status: r.status, ver, model, duration }; continue; } return res.json({ success: true, summary, model, version: ver, duration }); } } console.warn('[Gemini summarize] All attempts failed:', lastErr); return res.status(502).json({ success: false, error: lastErr?.msg || 'Upstream error', httpStatus: lastErr?.status, tried: { versions: versionsToTry, models: modelsToTry }, details: lastErr?.payload }); } catch (error) { console.error('[Gemini Routes] summarize error:', error); return res.status(500).json({ success: false, error: 'Internal error' }); } }); /** * POST /api/integrations/gemini/description * Alias de /summarize avec un prompt orienté "description de frontmatter". */ router.post('/description', async (req, res) => { try { const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) return res.status(400).json({ success: false, error: 'Gemini API key not configured' }); const baseUrl = process.env.GEMINI_API_BASE || 'https://generativelanguage.googleapis.com'; const preferredVer = (process.env.GEMINI_API_VERSION || 'v1beta').trim(); const requestedModel = (req.body?.model || 'gemini-1.5-flash-latest').toString(); const text = (req.body?.text || '').toString(); const title = (req.body?.title || '').toString(); const maxChars = Number(req.body?.maxChars || 140); const language = (req.body?.language || 'fr').toString(); if (!text) return res.status(400).json({ success: false, error: 'Missing text' }); const start = Date.now(); const prompt = [ `Rédige une description frontmatter concise (une seule phrase) en ${language}.`, `Maximum ${maxChars} caractères, sans balises, sans mise en forme.`, title ? `Titre: ${title}` : null, `Contenu: ${text.substring(0, 8000)}` ].filter(Boolean).join('\n\n'); const body = { contents: [ { role: 'user', parts: [{ text: prompt }] } ] }; // Resolve a compatible model/version first const resolved = await resolveModel(baseUrl, apiKey, preferredVer, requestedModel); if (resolved) { const { ver, model } = resolved; const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`; const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify(body) }); const duration = Date.now() - start; if (!r.ok) { let msg = `HTTP ${r.status}`; let payload = null; try { payload = await r.json(); msg = payload?.error?.message || msg; } catch { try { msg = await r.text(); } catch {} } return res.status(502).json({ success: false, error: msg, httpStatus: r.status, model, version: ver, duration, details: payload }); } const data = await r.json().catch(() => ({})); const description = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || ''; if (!description) return res.status(502).json({ success: false, error: 'Empty response', model, version: ver, duration }); return res.json({ success: true, description, model, version: ver, duration }); } // Fallback: basic retry logic const versionsToTry = preferredVer === 'v1' ? ['v1', 'v1beta'] : ['v1beta', 'v1']; const baseModel = requestedModel.replace(/-latest$/i, ''); const modelsToTry = Array.from(new Set([ requestedModel, baseModel, `${baseModel}-latest`, 'gemini-1.5-flash', 'gemini-1.5-pro' ])); let lastErr = null; for (const ver of versionsToTry) { for (const model of modelsToTry) { const url = `${baseUrl}/${ver}/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`; const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify(body) }); const duration = Date.now() - start; if (!r.ok) { try { const payload = await r.json(); const msg = payload?.error?.message || `HTTP ${r.status}`; lastErr = { msg, payload, status: r.status, ver, model, duration }; continue; } catch { const txt = await r.text().catch(() => ''); lastErr = { msg: txt || `HTTP ${r.status}`, payload: null, status: r.status, ver, model, duration }; continue; } } const data = await r.json().catch(() => ({})); const description = data?.candidates?.[0]?.content?.parts?.map(p => p?.text || '').join(' ').trim() || ''; if (!description) { lastErr = { msg: 'Empty response', payload: data, status: r.status, ver, model, duration }; continue; } return res.json({ success: true, description, model, version: ver, duration }); } } console.warn('[Gemini description] All attempts failed:', lastErr); return res.status(502).json({ success: false, error: lastErr?.msg || 'Upstream error', httpStatus: lastErr?.status, tried: { versions: versionsToTry, models: modelsToTry }, details: lastErr?.payload }); } catch (error) { console.error('[Gemini Routes] description error:', error); return res.status(500).json({ success: false, error: 'Internal error' }); } });