/** * /api/admin/ai — Configuration de l'Assistant IA embarqué (back-office). * Protégé par requireAuth + requireAdmin (appliqués dans server.js). * * GET /providers — liste des fournisseurs (sans la clé API en clair) * POST /providers — crée un fournisseur (clé API obligatoire) * PUT /providers/:id — modifie un fournisseur (clé API optionnelle — * voir le pattern « write-only » de smtp.js : * omise = on conserve la clé déjà enregistrée) * DELETE /providers/:id — supprime un fournisseur * POST /providers/:id/test — vérifie la connexion (clé/modèle/base_url) * avec un appel minimal, sans passer par le * chat ni par le pont MCP — demandé par * Olivier pour valider une config sans * avoir à ouvrir le tchat utilisateur * GET /config — configuration globale (actif, quota) * PATCH /config — modifie la configuration globale * * Distinct de routes/ai.js (chat utilisateur, protégé par requireAiAccess) : * ce routeur ne fait que gérer la configuration ; seul /providers/:id/test * appelle réellement le fournisseur IA, et seulement pour un ping minimal. */ import { Router } from 'express'; import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { encryptSecret, decryptSecret } from '../ai/crypto.js'; import { audit } from '../utils/audit.js'; import { testConnection as testAnthropic } from '../ai/providers/anthropic.js'; import { testConnection as testOpenAiCompatible } from '../ai/providers/openaiCompatible.js'; const router = Router(); // ── Helpers ────────────────────────────────────────────────────────────── function serializeProvider(row) { return { id: row.id, nom: row.nom, type: row.type, baseUrl: row.base_url || '', modele: row.modele, hasApiKey: !!row.api_key_chiffree, actif: !!row.actif, isDefault: !!row.is_default, createdAt: row.created_at, updatedAt: row.updated_at, }; } function ensureConfigRow() { db.prepare('INSERT OR IGNORE INTO ai_config (id, actif) VALUES (1, 0)').run(); } // ── GET /providers ─────────────────────────────────────────────────────── router.get('/providers', (_req, res) => { const rows = db.prepare('SELECT * FROM ai_providers ORDER BY is_default DESC, id ASC').all(); res.json(rows.map(serializeProvider)); }); // ── POST /providers ────────────────────────────────────────────────────── const CreateProviderSchema = z.object({ nom: z.string().min(1).max(100), type: z.enum(['anthropic', 'openai_compatible']), baseUrl: z.string().max(500).optional(), modele: z.string().min(1).max(200), apiKey: z.string().min(1), actif: z.boolean().optional(), isDefault: z.boolean().optional(), }); router.post('/providers', (req, res, next) => { try { const body = CreateProviderSchema.parse(req.body); if (body.type === 'openai_compatible' && !body.baseUrl?.trim()) { throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1)."); } const apiKeyChiffree = encryptSecret(body.apiKey); const tx = db.transaction(() => { if (body.isDefault) { db.prepare('UPDATE ai_providers SET is_default = 0').run(); } const r = db.prepare(` INSERT INTO ai_providers (nom, type, base_url, modele, api_key_chiffree, actif, is_default, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) `).run( body.nom.trim(), body.type, body.baseUrl?.trim() || null, body.modele.trim(), apiKeyChiffree, body.actif === false ? 0 : 1, body.isDefault ? 1 : 0, ); return r.lastInsertRowid; }); const id = tx(); audit(req, { action: 'ai_provider_created', category: 'ai', actorId: req.user.id, details: { id, nom: body.nom, type: body.type } }); res.status(201).json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id))); } catch (e) { next(e); } }); // ── PUT /providers/:id ─────────────────────────────────────────────────── const UpdateProviderSchema = z.object({ nom: z.string().min(1).max(100).optional(), type: z.enum(['anthropic', 'openai_compatible']).optional(), baseUrl: z.string().max(500).optional(), modele: z.string().min(1).max(200).optional(), apiKey: z.string().min(1).optional(), // omis = clé conservée telle quelle actif: z.boolean().optional(), isDefault: z.boolean().optional(), }); router.put('/providers/:id', (req, res, next) => { try { const id = Number(req.params.id); const row = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id); if (!row) throw new HttpError(404, 'Fournisseur introuvable'); const body = UpdateProviderSchema.parse(req.body); const type = body.type !== undefined ? body.type : row.type; const baseUrl = body.baseUrl !== undefined ? body.baseUrl.trim() : row.base_url; if (type === 'openai_compatible' && !baseUrl) { throw new HttpError(400, "L'URL de base est requise pour un fournisseur openai_compatible (ex : https://openrouter.ai/api/v1)."); } const apiKeyChiffree = body.apiKey !== undefined ? encryptSecret(body.apiKey) : row.api_key_chiffree; db.transaction(() => { if (body.isDefault) { db.prepare('UPDATE ai_providers SET is_default = 0 WHERE id != ?').run(id); } db.prepare(` UPDATE ai_providers SET nom = ?, type = ?, base_url = ?, modele = ?, api_key_chiffree = ?, actif = ?, is_default = ?, updated_at = datetime('now') WHERE id = ? `).run( body.nom !== undefined ? body.nom.trim() : row.nom, type, baseUrl || null, body.modele !== undefined ? body.modele.trim() : row.modele, apiKeyChiffree, body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif, body.isDefault !== undefined ? (body.isDefault ? 1 : 0) : row.is_default, id, ); })(); audit(req, { action: 'ai_provider_updated', category: 'ai', actorId: req.user.id, details: { id, apiKeyChanged: body.apiKey !== undefined } }); res.json(serializeProvider(db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id))); } catch (e) { next(e); } }); // ── DELETE /providers/:id ──────────────────────────────────────────────── router.delete('/providers/:id', (req, res, next) => { try { const id = Number(req.params.id); const row = db.prepare('SELECT id, nom FROM ai_providers WHERE id = ?').get(id); if (!row) throw new HttpError(404, 'Fournisseur introuvable'); db.transaction(() => { // provider_defaut_id référence ai_providers sans PRAGMA foreign_keys=ON // garanti actif dans ce process : on nettoie explicitement plutôt que // de compter sur ON DELETE SET NULL. db.prepare('UPDATE ai_config SET provider_defaut_id = NULL WHERE provider_defaut_id = ?').run(id); db.prepare('UPDATE ai_usage_log SET provider_id = NULL WHERE provider_id = ?').run(id); db.prepare('DELETE FROM ai_providers WHERE id = ?').run(id); })(); audit(req, { action: 'ai_provider_deleted', category: 'ai', actorId: req.user.id, details: { id, nom: row.nom } }); res.json({ deleted: true }); } catch (e) { next(e); } }); // ── POST /providers/:id/test ───────────────────────────────────────────── // Réponse toujours 200 : { ok:true, reponse, dureeMs } en cas de succès, // { ok:false, error } si le fournisseur a répondu une erreur ou n'a pas pu // être joint — seule une ligne introuvable ou une clé indéchiffrable est // une vraie erreur HTTP (le test lui-même a bien pu s'exécuter). router.post('/providers/:id/test', async (req, res, next) => { try { const id = Number(req.params.id); const provider = db.prepare('SELECT * FROM ai_providers WHERE id = ?').get(id); if (!provider) throw new HttpError(404, 'Fournisseur introuvable'); if (!provider.api_key_chiffree) { return res.json({ ok: false, error: "Aucune clé API enregistrée pour ce fournisseur." }); } let apiKey; try { apiKey = decryptSecret(provider.api_key_chiffree); } catch (e) { throw new HttpError(500, `Impossible de déchiffrer la clé API : ${e.message}`); } if (provider.type === 'openai_compatible' && !provider.base_url) { return res.json({ ok: false, error: "URL de base manquante pour ce fournisseur." }); } try { const result = provider.type === 'anthropic' ? await testAnthropic({ apiKey, model: provider.modele }) : await testOpenAiCompatible({ apiKey, baseUrl: provider.base_url, model: provider.modele }); audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: true, dureeMs: result.durationMs } }); res.json({ ok: true, reponse: result.reply, dureeMs: result.durationMs }); } catch (e) { audit(req, { action: 'ai_provider_tested', category: 'ai', actorId: req.user.id, details: { id, ok: false, error: e.message } }); res.json({ ok: false, error: e.message }); } } catch (e) { next(e); } }); // ── GET /config ─────────────────────────────────────────────────────────── router.get('/config', (_req, res) => { ensureConfigRow(); const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get(); res.json({ actif: !!row.actif, quotaQuotidien: row.quota_quotidien ?? null, updatedAt: row.updated_at, }); }); // ── PATCH /config ───────────────────────────────────────────────────────── const PatchConfigSchema = z.object({ actif: z.boolean().optional(), quotaQuotidien: z.number().int().positive().nullable().optional(), }); router.patch('/config', (req, res, next) => { try { ensureConfigRow(); const body = PatchConfigSchema.parse(req.body); const row = db.prepare('SELECT * FROM ai_config WHERE id = 1').get(); if (body.actif === true) { const activeProvider = db.prepare("SELECT id FROM ai_providers WHERE actif = 1 LIMIT 1").get(); if (!activeProvider) { throw new HttpError(400, "Impossible d'activer l'Assistant IA : aucun fournisseur actif n'est configuré."); } } db.prepare(` UPDATE ai_config SET actif = ?, quota_quotidien = ?, updated_at = datetime('now') WHERE id = 1 `).run( body.actif !== undefined ? (body.actif ? 1 : 0) : row.actif, body.quotaQuotidien !== undefined ? body.quotaQuotidien : row.quota_quotidien, ); // Desactivation globale (transition actif -> inactif) : on retire aussi // l'acces individuel de chaque utilisateur plutot que de le laisser // "vrai" en base mais inoperant, ce qui afficherait un interrupteur // actif mais sans effet dans le panneau Utilisateurs. let usersReset = 0; if (body.actif === false && row.actif) { usersReset = db.prepare('UPDATE users SET ai_enabled = 0 WHERE ai_enabled = 1').run().changes; } audit(req, { action: 'ai_config_updated', category: 'ai', actorId: req.user.id, details: { ...body, usersReset } }); res.json({ ok: true, usersReset }); } catch (e) { next(e); } }); // ── GET /usage/summary ─────────────────────────────────────────────────── // Vue d'ensemble de la consommation : total requetes/tokens sur la periode, // repartition par utilisateur, et serie quotidienne (pour un mini-graphe). // `days` : fenetre glissante en jours (defaut 30, borne a [1, 365]). router.get('/usage/summary', (req, res) => { const days = Math.min(Math.max(Number(req.query.days) || 30, 1), 365); const since = `-${days} days`; const totals = db.prepare(` SELECT COUNT(*) AS requests, COALESCE(SUM(tokens_entree), 0) AS tokensIn, COALESCE(SUM(tokens_sortie), 0) AS tokensOut, SUM(CASE WHEN erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors FROM ai_usage_log WHERE created_at >= datetime('now', ?) `).get(since); const byUser = db.prepare(` SELECT u.id AS userId, u.email, u.display_name AS displayName, COUNT(*) AS requests, COALESCE(SUM(l.tokens_entree), 0) AS tokensIn, COALESCE(SUM(l.tokens_sortie), 0) AS tokensOut, SUM(CASE WHEN l.erreur IS NOT NULL THEN 1 ELSE 0 END) AS errors, MAX(l.created_at) AS lastUsed FROM ai_usage_log l JOIN users u ON u.id = l.user_id WHERE l.created_at >= datetime('now', ?) GROUP BY u.id ORDER BY requests DESC `).all(since); const daily = db.prepare(` SELECT date(created_at) AS day, COUNT(*) AS requests, COALESCE(SUM(tokens_entree), 0) AS tokensIn, COALESCE(SUM(tokens_sortie), 0) AS tokensOut FROM ai_usage_log WHERE created_at >= datetime('now', ?) GROUP BY date(created_at) ORDER BY day ASC `).all(since); res.json({ days, totals: { requests: totals.requests, tokensIn: totals.tokensIn, tokensOut: totals.tokensOut, tokensTotal: totals.tokensIn + totals.tokensOut, errors: totals.errors, }, byUser: byUser.map(r => ({ userId: r.userId, email: r.email, displayName: r.displayName, requests: r.requests, tokensIn: r.tokensIn, tokensOut: r.tokensOut, tokensTotal: r.tokensIn + r.tokensOut, errors: r.errors, lastUsed: r.lastUsed, })), daily, }); }); // ── GET /usage/log ─────────────────────────────────────────────────────── // Journal detaille, pagine, requete par requete — pour l'audit fin. // Filtrable par utilisateur (userId) et par fenetre (days). router.get('/usage/log', (req, res) => { const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100); const offset = Math.max(Number(req.query.offset) || 0, 0); const userId = req.query.userId ? Number(req.query.userId) : null; const days = req.query.days ? Math.min(Math.max(Number(req.query.days), 1), 365) : null; const conditions = []; const params = []; if (userId) { conditions.push('l.user_id = ?'); params.push(userId); } if (days) { conditions.push("l.created_at >= datetime('now', ?)"); params.push(`-${days} days`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const total = db.prepare(`SELECT COUNT(*) AS n FROM ai_usage_log l ${where}`).get(...params).n; const rows = db.prepare(` SELECT l.id, l.created_at AS createdAt, l.modele, l.tokens_entree AS tokensIn, l.tokens_sortie AS tokensOut, l.outils_appeles AS outilsAppeles, l.erreur, u.id AS userId, u.email, u.display_name AS displayName, i.nom AS investisseurNom, p.nom AS providerNom FROM ai_usage_log l JOIN users u ON u.id = l.user_id LEFT JOIN investisseurs i ON i.id = l.investisseur_id LEFT JOIN ai_providers p ON p.id = l.provider_id ${where} ORDER BY l.id DESC LIMIT ? OFFSET ? `).all(...params, limit, offset); res.json({ total, rows: rows.map(r => ({ id: r.id, createdAt: r.createdAt, modele: r.modele, tokensIn: r.tokensIn, tokensOut: r.tokensOut, tokensTotal: (r.tokensIn || 0) + (r.tokensOut || 0), outilsAppeles: r.outilsAppeles ? JSON.parse(r.outilsAppeles) : [], erreur: r.erreur, userId: r.userId, email: r.email, displayName: r.displayName, investisseurNom: r.investisseurNom, providerNom: r.providerNom, })), }); }); export default router;