diff --git a/backend/main.py b/backend/main.py index 7e79623..d196054 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,30 +3,43 @@ from fastapi.responses import RedirectResponse, FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from typing import List -import os -import json +import os, json, asyncio, shutil from datetime import datetime +import pytz -# Importation de nos modules locaux -from .database import engine, Base, get_db +from .database import engine, Base, get_db, SessionLocal from .auth import get_password_hash, generate_totp_secret, get_totp_uri, verify_password, verify_totp, create_access_token, verify_token from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse, PasswordChange -from .models import User, Project, Message, SystemSettings, AIModel -from .orchestrator import run_orchestrator, sync_providers_models +from .models import User, Project, Message, SystemSettings, AIModel, FinancialLog +from .orchestrator import run_orchestrator, sync_providers_models, sync_finances -# Création des tables Base.metadata.create_all(bind=engine) +app = FastAPI(title="AETHAS38") -app = FastAPI(title="AETHAS38 - Orchestrateur Multi-IA") - -# Fichiers statiques assets_path = os.path.join(os.getcwd(), "frontend", "assets") +avatars_path = os.path.join(assets_path, "avatars") os.makedirs(assets_path, exist_ok=True) +os.makedirs(avatars_path, exist_ok=True) app.mount("/assets", StaticFiles(directory=assets_path), name="assets") -def is_setup_required(db: Session) -> bool: - admin = db.query(User).filter(User.is_admin == True).first() - return admin is None +async def scheduler_task(): + tz = pytz.timezone('Europe/Paris') + while True: + now = datetime.now(tz) + if (now.hour == 0 or now.hour == 12) and now.minute == 0: + db = SessionLocal() + settings = db.query(SystemSettings).first() + if settings: + try: await sync_providers_models(db, settings, "Automatique") + except: pass + db.close() + await asyncio.sleep(60) + await asyncio.sleep(30) + +@app.on_event("startup") +async def startup_event(): asyncio.create_task(scheduler_task()) + +def is_setup_required(db: Session) -> bool: return db.query(User).filter(User.is_superadmin == True).first() is None def get_current_user(request: Request, db: Session = Depends(get_db)): token = request.cookies.get("session_token") @@ -38,63 +51,41 @@ def get_current_user(request: Request, db: Session = Depends(get_db)): return user @app.get("/") -def read_root(db: Session = Depends(get_db)): - if is_setup_required(db): - return RedirectResponse(url="/setup") - return RedirectResponse(url="/login") - +def read_root(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else RedirectResponse(url="/login") @app.get("/setup") -def setup_page(db: Session = Depends(get_db)): - if not is_setup_required(db): - return RedirectResponse(url="/login") - frontend_path = os.path.join(os.getcwd(), "frontend", "index.html") - if not os.path.exists(frontend_path): raise HTTPException(status_code=404, detail="Interface introuvable.") - return FileResponse(frontend_path) - +def setup_page(db: Session = Depends(get_db)): return RedirectResponse(url="/login") if not is_setup_required(db) else FileResponse(os.path.join(os.getcwd(), "frontend", "index.html")) @app.get("/login") -def login_page(db: Session = Depends(get_db)): - if is_setup_required(db): - return RedirectResponse(url="/setup") - frontend_path = os.path.join(os.getcwd(), "frontend", "login.html") - if not os.path.exists(frontend_path): raise HTTPException(status_code=404, detail="Interface de connexion introuvable.") - return FileResponse(frontend_path) +def login_page(db: Session = Depends(get_db)): return RedirectResponse(url="/setup") if is_setup_required(db) else FileResponse(os.path.join(os.getcwd(), "frontend", "login.html")) @app.post("/api/setup") def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)): - if not is_setup_required(db): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Déjà installé.") - hashed_pw = get_password_hash(admin_data.password) - totp_secret = generate_totp_secret() - new_admin = User(email=admin_data.email, username=admin_data.username, hashed_password=hashed_pw, totp_secret=totp_secret, is_admin=True) + if not is_setup_required(db): raise HTTPException(status_code=403, detail="Déjà installé.") + # Le créateur initial devient automatiquement Super-Admin ET Admin + new_admin = User(email=admin_data.email, username=admin_data.username, hashed_password=get_password_hash(admin_data.password), totp_secret=generate_totp_secret(), is_admin=True, is_superadmin=True) db.add(new_admin) - - new_settings = SystemSettings( - smtp_host=admin_data.smtp_host, smtp_port=admin_data.smtp_port, smtp_user=admin_data.smtp_user, smtp_password=admin_data.smtp_password, - openrouter_api_key=admin_data.openrouter_api_key, openrouter_management_key=admin_data.openrouter_management_key, - groq_api_key=admin_data.groq_api_key, gemini_api_key=admin_data.gemini_api_key, deepseek_api_key=admin_data.deepseek_api_key, - mistral_api_key=admin_data.mistral_api_key, cloudflare_account_id=admin_data.cloudflare_account_id, cloudflare_api_token=admin_data.cloudflare_api_token, - huggingface_api_key=admin_data.huggingface_api_key - ) + new_settings = SystemSettings(smtp_host=admin_data.smtp_host, smtp_port=admin_data.smtp_port, smtp_user=admin_data.smtp_user, smtp_password=admin_data.smtp_password, openrouter_api_key=admin_data.openrouter_api_key, openrouter_management_key=admin_data.openrouter_management_key, groq_api_key=admin_data.groq_api_key, gemini_api_key=admin_data.gemini_api_key, deepseek_api_key=admin_data.deepseek_api_key, mistral_api_key=admin_data.mistral_api_key, cloudflare_account_id=admin_data.cloudflare_account_id, cloudflare_api_token=admin_data.cloudflare_api_token, huggingface_api_key=admin_data.huggingface_api_key) db.add(new_settings) db.commit() db.refresh(new_admin) - return {"message": "Configuration terminée avec succès.", "totp_secret": totp_secret, "totp_uri": get_totp_uri(totp_secret, new_admin.username)} + return {"message": "Succès", "totp_secret": new_admin.totp_secret, "totp_uri": get_totp_uri(new_admin.totp_secret, new_admin.username)} @app.post("/api/login") def login(login_data: LoginRequest, response: Response, db: Session = Depends(get_db)): user = db.query(User).filter(User.username == login_data.username).first() - if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Identifiants incorrects.") - if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Code 2FA invalide.") - - access_token = create_access_token(data={"sub": user.username}) - response.set_cookie(key="session_token", value=access_token, httponly=True, max_age=3600, samesite="lax") + if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Identifiants incorrects.") + if not verify_totp(user.totp_secret, login_data.totp_code): raise HTTPException(status_code=401, detail="2FA invalide.") + response.set_cookie(key="session_token", value=create_access_token(data={"sub": user.username}), httponly=True, max_age=3600, samesite="lax") return {"message": "Connexion réussie"} @app.get("/dashboard") def dashboard(request: Request): token = request.cookies.get("session_token") if not token or not verify_token(token): return RedirectResponse(url="/login") - frontend_path = os.path.join(os.getcwd(), "frontend", "dashboard.html") - return FileResponse(frontend_path) + return FileResponse(os.path.join(os.getcwd(), "frontend", "dashboard.html")) + +@app.get("/api/users/me") +def get_me(current_user: User = Depends(get_current_user)): + return {"username": current_user.username, "is_admin": current_user.is_admin, "is_superadmin": current_user.is_superadmin, "avatar_path": current_user.avatar_path} @app.put("/api/users/me/password") def change_password(passwords: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): @@ -102,126 +93,88 @@ def change_password(passwords: PasswordChange, db: Session = Depends(get_db), cu if len(passwords.new_password) < 8: raise HTTPException(status_code=400, detail="8 caractères minimum.") current_user.hashed_password = get_password_hash(passwords.new_password) db.commit() - return {"message": "Mot de passe mis à jour avec succès."} + return {"message": "Mot de passe mis à jour."} -# --- GESTION DES PROJETS --- +@app.post("/api/users/me/avatar") +async def upload_avatar(file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + """Enregistre l'avatar. Le redimensionnement parfait est géré visuellement par CSS (object-cover) dans le frontend.""" + file_location = os.path.join(avatars_path, f"user_{current_user.id}.jpg") + with open(file_location, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + current_user.avatar_path = f"/assets/avatars/user_{current_user.id}.jpg?v={int(datetime.now().timestamp())}" + db.commit() + return {"message": "Avatar mis à jour", "avatar_path": current_user.avatar_path} + +# --- GESTION DES PROJETS ET MESSAGES --- @app.get("/api/projects", response_model=List[ProjectResponse]) -def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all() +def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all() @app.post("/api/projects", response_model=ProjectResponse) def create_project(project: ProjectCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - new_project = Project(title=project.title, user_id=current_user.id) - db.add(new_project) - db.commit() - db.refresh(new_project) - return new_project + p = Project(title=project.title, user_id=current_user.id); db.add(p); db.commit(); db.refresh(p); return p @app.put("/api/projects/{project_id}/rename", response_model=ProjectResponse) def rename_project(project_id: int, project_data: ProjectRename, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() - if not project: raise HTTPException(status_code=404, detail="Projet introuvable") - project.title = project_data.title - db.commit() - db.refresh(project) - return project + p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() + if not p: raise HTTPException(status_code=404) + p.title = project_data.title; db.commit(); db.refresh(p); return p @app.put("/api/projects/{project_id}/pin", response_model=ProjectResponse) def toggle_pin_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() - if not project: raise HTTPException(status_code=404, detail="Projet introuvable") - project.is_pinned = not project.is_pinned - db.commit() - db.refresh(project) - return project + p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() + if not p: raise HTTPException(status_code=404) + p.is_pinned = not p.is_pinned; db.commit(); db.refresh(p); return p @app.delete("/api/projects/{project_id}") def delete_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() - if not project: raise HTTPException(status_code=404, detail="Projet introuvable") - db.delete(project) - db.commit() - return {"message": "Projet supprimé"} + p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() + if not p: raise HTTPException(status_code=404) + db.delete(p); db.commit(); return {"message": "Supprimé"} -# --- NOUVEAU : EXPORT DE CONVERSATION --- @app.get("/api/projects/{project_id}/export") def export_project(project_id: int, format: str = "txt", db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - """Exporte l'intégralité d'une conversation au format TXT ou JSON.""" - project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() - if not project: raise HTTPException(status_code=404, detail="Projet introuvable") - + p = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() messages = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() - if format == "json": data = [{"role": m.role, "content": m.content, "date": m.created_at.isoformat()} for m in messages] - return Response(content=json.dumps(data, indent=2), media_type="application/json", headers={"Content-Disposition": f"attachment; filename=aethas38_export_{project_id}.json"}) - else: - text = f"--- HISTORIQUE DU PROJET : {project.title} ---\n\n" - for m in messages: - role_name = "VOUS" if m.role == "user" else "AETHAS38" - date_str = m.created_at.strftime("%Y-%m-%d %H:%M:%S") - text += f"[{date_str}] {role_name}:\n{m.content}\n\n{'-'*50}\n\n" - return Response(content=text, media_type="text/plain;charset=utf-8", headers={"Content-Disposition": f"attachment; filename=aethas38_export_{project_id}.txt"}) + return Response(content=json.dumps(data, indent=2), media_type="application/json", headers={"Content-Disposition": f"attachment; filename=export_{project_id}.json"}) + text = f"--- HISTORIQUE : {p.title} ---\n\n" + for m in messages: text += f"[{m.created_at.strftime('%Y-%m-%d %H:%M:%S')}] {'VOUS' if m.role == 'user' else 'AETHAS38'}:\n{m.content}\n\n{'-'*50}\n\n" + return Response(content=text, media_type="text/plain;charset=utf-8", headers={"Content-Disposition": f"attachment; filename=export_{project_id}.txt"}) -# --- GESTION DES MESSAGES --- @app.get("/api/projects/{project_id}/messages", response_model=List[MessageResponse]) -def get_messages(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() - if not project: raise HTTPException(status_code=404, detail="Projet introuvable") - return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() +def get_messages(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() @app.post("/api/projects/{project_id}/messages", response_model=List[MessageResponse]) async def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first() - if not project: raise HTTPException(status_code=404, detail="Projet introuvable") - - user_message = Message(role=message.role, content=message.content, project_id=project_id) - db.add(user_message) - db.commit() - + db.add(Message(role=message.role, content=message.content, project_id=project_id)); db.commit() history = db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() settings = db.query(SystemSettings).first() - - orchestrator_config = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]} - ai_response_text = await run_orchestrator(history, settings, orchestrator_config) - - ai_message = Message(role="assistant", content=ai_response_text, project_id=project_id) - db.add(ai_message) - db.commit() - + conf = message.config.dict() if message.config else {"workers": ["gemini-3.5-flash-lite"]} + ai_resp = await run_orchestrator(history, settings, conf) + db.add(Message(role="assistant", content=ai_resp, project_id=project_id)); db.commit() return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all() -# --- GESTION DES MODELES --- +# --- ROUTES MODÈLES & FINANCES --- +@app.get("/api/models/info") +def get_models_info(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + settings = db.query(SystemSettings).first() + return { + "last_sync_date": settings.last_sync_date.isoformat() if settings and settings.last_sync_date else None, + "last_sync_type": settings.last_sync_type if settings else None, + "models": db.query(AIModel).order_by(AIModel.name.asc()).all(), + "finances": db.query(FinancialLog).all() + } + @app.post("/api/models/sync") async def trigger_model_sync(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès réservé aux administrateurs.") + if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.") settings = db.query(SystemSettings).first() - return await sync_providers_models(db, settings) - -@app.get("/api/models") -def get_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - return db.query(AIModel).order_by(AIModel.name.asc()).all() + return await sync_providers_models(db, settings, "Manuelle") @app.get("/api/models/export") def export_models(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.") models = db.query(AIModel).all() - models_data = [{"provider": m.provider, "model_id": m.model_id, "name": m.name, "context_length": m.context_length, "pricing_prompt": m.pricing_prompt, "pricing_completion": m.pricing_completion} for m in models] - return Response(content=json.dumps(models_data), media_type="application/json", headers={"Content-Disposition": "attachment; filename=aethas38_models.json"}) - -@app.post("/api/models/import") -async def import_models(file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): - if not current_user.is_admin: raise HTTPException(status_code=403, detail="Accès admin requis.") - content = await file.read() - try: - data = json.loads(content) - imported_count = 0 - for item in data: - existing = db.query(AIModel).filter(AIModel.model_id == item["model_id"]).first() - if not existing: - db.add(AIModel(**item)) - imported_count += 1 - db.commit() - return {"message": f"Import réussi. {imported_count} nouveaux modèles ajoutés."} - except Exception as e: - raise HTTPException(status_code=400, detail="Fichier JSON invalide ou mal formaté.") \ No newline at end of file + data = [{"provider": m.provider, "model_id": m.model_id, "name": m.name, "description_fr": m.description_fr, "domain": m.domain, "is_free": m.is_free, "context_length": m.context_length, "pricing_prompt": m.pricing_prompt, "pricing_completion": m.pricing_completion} for m in models] + return Response(content=json.dumps(data), media_type="application/json", headers={"Content-Disposition": "attachment; filename=aethas38_models.json"}) \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 91dcc2a..b44e902 100644 --- a/backend/models.py +++ b/backend/models.py @@ -11,6 +11,8 @@ class User(Base): hashed_password = Column(String) totp_secret = Column(String) is_admin = Column(Boolean, default=False) + is_superadmin = Column(Boolean, default=False) + avatar_path = Column(String, nullable=True) projects = relationship("Project", back_populates="owner") class Project(Base): @@ -48,24 +50,27 @@ class SystemSettings(Base): cloudflare_account_id = Column(String, nullable=True) cloudflare_api_token = Column(String, nullable=True) huggingface_api_key = Column(String, nullable=True) + last_sync_date = Column(DateTime, nullable=True) + last_sync_type = Column(String, nullable=True) class AIModel(Base): - """Stocke la liste des modèles extraits depuis les fournisseurs.""" __tablename__ = "ai_models" id = Column(Integer, primary_key=True, index=True) provider = Column(String, index=True) model_id = Column(String, unique=True, index=True) name = Column(String) + description_fr = Column(String, nullable=True) + domain = Column(String, default="Texte") + is_free = Column(Boolean, default=False) context_length = Column(Integer) pricing_prompt = Column(Float) pricing_completion = Column(Float) last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc)) class FinancialLog(Base): - """Suivi financier par fournisseur.""" __tablename__ = "financial_logs" id = Column(Integer, primary_key=True, index=True) - provider = Column(String, index=True) - balance = Column(Float) - total_usage = Column(Float) + provider = Column(String, unique=True, index=True) + balance = Column(Float, default=0.0) + total_usage = Column(Float, default=0.0) checked_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) \ No newline at end of file diff --git a/backend/orchestrator.py b/backend/orchestrator.py index 01c1cee..76bfe81 100644 --- a/backend/orchestrator.py +++ b/backend/orchestrator.py @@ -3,125 +3,113 @@ import httpx from openai import AsyncOpenAI from fastapi import HTTPException from sqlalchemy.orm import Session -from .models import SystemSettings, AIModel -from .logger import system_logger +from .models import SystemSettings, AIModel, FinancialLog from datetime import datetime, timezone -# --- PARTIE 1 : EXTRACTION DES MODÈLES --- -async def sync_providers_models(db: Session, settings: SystemSettings): - """Extrait et met à jour les modèles depuis les fournisseurs configurés.""" - added_or_updated = 0 - - # httpx.AsyncClient permet des requêtes non-bloquantes (ultra rapide) +def determine_domain(model_id: str) -> str: + mid = model_id.lower() + if "vision" in mid or "vl" in mid: return "Vision & Texte" + if "coder" in mid or "code" in mid or "math" in mid: return "Code & Logique" + if "audio" in mid or "whisper" in mid: return "Audio" + return "Texte Polyvalent" + +async def sync_finances(db: Session, settings: SystemSettings): + async with httpx.AsyncClient() as client: + if settings.openrouter_management_key or settings.openrouter_api_key: + try: + key = settings.openrouter_management_key or settings.openrouter_api_key + resp = await client.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {key}"}) + if resp.status_code == 200: + data = resp.json().get("data", {}) + limit = data.get("limit") + usage = data.get("usage", 0) + balance = (limit - usage) if limit is not None else 0.0 + update_finance_db(db, "OpenRouter", balance, usage) + except Exception: pass + + if settings.groq_api_key: update_finance_db(db, "Groq", 999.0, 0.0) + + if settings.deepseek_api_key: + try: + resp = await client.get("https://api.deepseek.com/user/balance", headers={"Authorization": f"Bearer {settings.deepseek_api_key}"}) + if resp.status_code == 200: + infos = resp.json().get("balance_infos", [{}])[0] + update_finance_db(db, "DeepSeek", float(infos.get("total_balance", 0)), 0.0) + except Exception: pass + db.commit() + +def update_finance_db(db, provider, balance, usage): + log = db.query(FinancialLog).filter(FinancialLog.provider == provider).first() + if log: + log.balance = balance; log.total_usage = usage; log.checked_at = datetime.now(timezone.utc) + else: + db.add(FinancialLog(provider=provider, balance=balance, total_usage=usage)) + +async def sync_providers_models(db: Session, settings: SystemSettings, sync_type: str = "Automatique"): + added = 0 async with httpx.AsyncClient() as client: - # 1. OpenRouter (Exemple principal pour l'extraction massive) if settings.openrouter_api_key: try: - response = await client.get("https://openrouter.ai/api/v1/models") - if response.status_code == 200: - for item in response.json().get("data", []): - model_id = item["id"] - existing = db.query(AIModel).filter(AIModel.model_id == model_id).first() - + resp = await client.get("https://openrouter.ai/api/v1/models") + if resp.status_code == 200: + for item in resp.json().get("data", []): pricing = item.get("pricing", {}) - # Conversion en coût pour 1 Million de tokens - p_prompt = float(pricing.get("prompt", 0)) * 1000000 if pricing.get("prompt") else 0.0 - p_comp = float(pricing.get("completion", 0)) * 1000000 if pricing.get("completion") else 0.0 + pp = float(pricing.get("prompt", 0)) * 1000000 if pricing.get("prompt") else 0.0 + pc = float(pricing.get("completion", 0)) * 1000000 if pricing.get("completion") else 0.0 + is_free = (pp == 0.0 and pc == 0.0) + desc = item.get("description", "Modèle IA générique.")[:200] + "..." + process_model(db, "openrouter", item["id"], item["name"], desc, determine_domain(item["id"]), is_free, item.get("context_length", 0), pp, pc) + added += 1 + except Exception: pass + + if settings.groq_api_key: + try: + resp = await client.get("https://api.groq.com/openai/v1/models", headers={"Authorization": f"Bearer {settings.groq_api_key}"}) + if resp.status_code == 200: + for item in resp.json().get("data", []): + process_model(db, "groq", item["id"], item["id"].capitalize(), "Modèle ultra-rapide exécuté sur LPU Groq.", determine_domain(item["id"]), True, 8192, 0.0, 0.0) + added += 1 + except Exception: pass - if existing: - existing.pricing_prompt = p_prompt - existing.pricing_completion = p_comp - existing.last_updated = datetime.now(timezone.utc) - else: - new_model = AIModel( - provider="openrouter", - model_id=model_id, - name=item["name"], - context_length=item.get("context_length", 0), - pricing_prompt=p_prompt, - pricing_completion=p_comp - ) - db.add(new_model) - added_or_updated += 1 - except Exception as e: - system_logger.error(f"Erreur Sync OpenRouter: {e}") - + settings.last_sync_date = datetime.now(timezone.utc) + settings.last_sync_type = sync_type db.commit() - return {"status": "success", "models_processed": added_or_updated} + await sync_finances(db, settings) + return {"status": "success", "models_processed": added} + +def process_model(db, provider, mod_id, name, desc, domain, is_free, ctx, pp, pc): + existing = db.query(AIModel).filter(AIModel.model_id == mod_id).first() + if existing: + existing.pricing_prompt = pp; existing.pricing_completion = pc; existing.is_free = is_free; existing.last_updated = datetime.now(timezone.utc) + else: + db.add(AIModel(provider=provider, model_id=mod_id, name=name, description_fr=desc, domain=domain, is_free=is_free, context_length=ctx, pricing_prompt=pp, pricing_completion=pc)) -# --- PARTIE 2 : MOTEUR MULTI-AGENTS --- def get_client_for_model(model_id: str, settings: SystemSettings): - """Retourne le client AsyncOpenAI approprié selon le modèle sélectionné.""" - if "gemini" in model_id.lower() and settings.gemini_api_key: - return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key) - elif settings.openrouter_api_key: - return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key) - raise ValueError(f"Aucun fournisseur configuré pour {model_id}") + if "gemini" in model_id.lower() and settings.gemini_api_key: return AsyncOpenAI(base_url="https://generativelanguage.googleapis.com/v1beta/openai/", api_key=settings.gemini_api_key) + elif "groq" in model_id.lower() or "llama" in model_id.lower(): return AsyncOpenAI(base_url="https://api.groq.com/openai/v1", api_key=settings.groq_api_key) + return AsyncOpenAI(base_url="https://openrouter.ai/api/v1", api_key=settings.openrouter_api_key) async def ask_agent(client, model_id, messages, is_openrouter=False): - """Appel asynchrone à un modèle IA.""" kwargs = {"model": model_id, "messages": messages} - if is_openrouter: - kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"} - - response = await client.chat.completions.create(**kwargs) - return response.choices[0].message.content + if is_openrouter: kwargs["extra_headers"] = {"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "AETHAS38 Orchestrator"} + resp = await client.chat.completions.create(**kwargs) + return resp.choices[0].message.content async def run_orchestrator(history: list, settings: SystemSettings, config: dict) -> str: - """ - Gère la logique : 1 Prompteur -> N Travailleurs -> 1 Concaténeur. - Le dictionnaire 'config' proviendra de l'interface graphique. - """ - workers = config.get("workers", []) - if not workers: - workers = ["gemini-3.5-flash-lite"] # Fallback de sécurité - + workers = config.get("workers", ["gemini-3.5-flash-lite"]) user_prompt = history[-1].content formatted_history = [{"role": msg.role, "content": msg.content} for msg in history[:-1]] - # SCÉNARIO 1 : Un seul travailleur (Pas besoin de prompteur/concaténeur) if len(workers) == 1: - worker_model = workers[0] - client = get_client_for_model(worker_model, settings) - messages = formatted_history + [{"role": "user", "content": user_prompt}] - return await ask_agent(client, worker_model, messages, "openrouter" in worker_model.lower()) + w_mod = workers[0] + return await ask_agent(get_client_for_model(w_mod, settings), w_mod, formatted_history + [{"role": "user", "content": user_prompt}], "openrouter" in w_mod.lower()) - # SCÉNARIO 2 : Multi-Travailleurs (Le pipeline complet) - try: - # Étape 1 : Le Prompteur améliore la requête - prompter_model = config.get("prompter", "gemini-3.5-flash-lite") - p_client = get_client_for_model(prompter_model, settings) - p_messages = [{"role": "system", "content": "Tu es un expert en Prompt Engineering. Optimise la requête de l'utilisateur pour qu'elle soit claire, directive et parfaite pour des IAs de génération. Retourne UNIQUEMENT le prompt optimisé."}] - p_messages.append({"role": "user", "content": user_prompt}) - - system_logger.info("Démarrage du Prompteur...") - optimized_prompt = await ask_agent(p_client, prompter_model, p_messages) + p_mod = config.get("prompter", "gemini-3.5-flash-lite") + optimized = await ask_agent(get_client_for_model(p_mod, settings), p_mod, [{"role": "system", "content": "Optimise cette requête."}, {"role": "user", "content": user_prompt}]) - # Étape 2 : Les Travailleurs en parallèle (Magie de l'Asynchrone) - system_logger.info(f"Lancement de {len(workers)} travailleurs en parallèle...") - w_tasks = [] - for w_model in workers: - w_client = get_client_for_model(w_model, settings) - w_messages = formatted_history + [{"role": "user", "content": optimized_prompt}] - # On stocke les tâches sans les attendre immédiatement - w_tasks.append(ask_agent(w_client, w_model, w_messages, "openrouter" in w_model.lower())) - - # 'gather' exécute toutes les requêtes en même temps ! - workers_responses = await asyncio.gather(*w_tasks, return_exceptions=True) + w_tasks = [ask_agent(get_client_for_model(w, settings), w, formatted_history + [{"role": "user", "content": optimized}], "openrouter" in w.lower()) for w in workers] + responses = await asyncio.gather(*w_tasks, return_exceptions=True) - # Étape 3 : Le Concaténeur synthétise - concat_model = config.get("concatenator", "gemini-3.5-flash-lite") - c_client = get_client_for_model(concat_model, settings) - - synthesis_prompt = f"Voici la requête initiale : {user_prompt}\n\nVoici les réponses de {len(workers)} experts IA différents :\n" - for i, resp in enumerate(workers_responses): - synthesis_prompt += f"--- EXPERT {i+1} ---\n{resp if not isinstance(resp, Exception) else 'Erreur de génération'}\n\n" - synthesis_prompt += "Fais une synthèse finale parfaite, complète et structurée de ces réponses, en gardant le meilleur de chacune." - - system_logger.info("Démarrage du Concaténeur...") - c_messages = [{"role": "user", "content": synthesis_prompt}] - return await ask_agent(c_client, concat_model, c_messages) - - except Exception as e: - system_logger.error(f"Erreur Pipeline Multi-Agents: {e}") - raise HTTPException(status_code=502, detail=f"Échec de l'orchestration : {str(e)}") \ No newline at end of file + c_mod = config.get("concatenator", "gemini-3.5-flash-lite") + synth = f"Requête: {user_prompt}\n\n" + "\n".join([f"--- EXPERT {i+1} ---\n{r}" for i, r in enumerate(responses)]) + "\n\nFais une synthèse finale." + return await ask_agent(get_client_for_model(c_mod, settings), c_mod, [{"role": "user", "content": synth}]) \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py index 3f812fc..8c17b12 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -6,12 +6,10 @@ class AdminCreate(BaseModel): email: EmailStr username: str password: str - # Serveur Mail (Obligatoire) smtp_host: str smtp_port: int smtp_user: str smtp_password: str - # Clés API (Optionnelles) openrouter_api_key: Optional[str] = None openrouter_management_key: Optional[str] = None groq_api_key: Optional[str] = None @@ -27,25 +25,20 @@ class LoginRequest(BaseModel): password: str totp_code: str -# --- GESTION DES PROJETS --- class ProjectBase(BaseModel): title: str -class ProjectCreate(ProjectBase): - pass +class ProjectCreate(ProjectBase): pass class ProjectResponse(ProjectBase): id: int created_at: datetime is_pinned: bool - - class Config: - from_attributes = True + class Config: from_attributes = True class ProjectRename(BaseModel): title: str -# --- GESTION DES MESSAGES ET ORCHESTRATION --- class OrchestratorConfig(BaseModel): workers: List[str] prompter: Optional[str] = "gemini-3.5-flash-lite" @@ -62,11 +55,8 @@ class MessageResponse(MessageBase): id: int created_at: datetime project_id: int + class Config: from_attributes = True - class Config: - from_attributes = True - -# --- UTILISATEUR --- class PasswordChange(BaseModel): old_password: str new_password: str \ No newline at end of file diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 94ff84f..1a0b2fa 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -21,69 +21,12 @@
Aucun modèle en base. Cliquez sur Synchroniser.
-| ID Modèle | Fournisseur | Contexte |
|---|---|---|
| {{ model.model_id }} | -{{ model.provider }} | -{{ model.context_length }} | -