"""Routes liées à Ollama : statut de connexion, modèles, téléchargement.""" from __future__ import annotations import json import httpx from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from ..config import settings from ..ollama_client import ollama router = APIRouter(prefix="/api", tags=["ollama"]) @router.get("/status") async def status() -> dict: """État de la connexion Ollama (point vert/rouge de la barre supérieure).""" try: version = await ollama.ping() return { "connected": True, "host": ollama.host, "version": version.get("version"), "default_model": settings.default_model, } except (httpx.HTTPError, OSError) as exc: return { "connected": False, "host": ollama.host, "error": str(exc), "default_model": settings.default_model, } @router.get("/models") async def list_models() -> dict: """Modèles installés localement, formatés pour le sélecteur de l'UI.""" try: raw = await ollama.list_models() except (httpx.HTTPError, OSError): return {"models": []} models = [] for m in raw: details = m.get("details", {}) size_go = round(m.get("size", 0) / 1_000_000_000, 1) models.append( { "name": m.get("name"), "size_go": size_go, "parameter_size": details.get("parameter_size"), "quantization": details.get("quantization_level"), "family": details.get("family"), } ) return {"models": models, "default": settings.default_model} class WarmRequest(BaseModel): name: str keep_alive: str = "30m" @router.post("/models/warm") async def warm_model(req: WarmRequest) -> dict: """Précharge un modèle en VRAM (préchargement).""" if not req.name.strip(): raise HTTPException(400, "nom de modèle vide") try: await ollama.warm(req.name.strip(), req.keep_alive) except (httpx.HTTPError, OSError) as exc: raise HTTPException(502, f"préchargement impossible : {exc}") from exc return {"warmed": req.name.strip()} @router.get("/models/loaded") async def loaded_models() -> dict: """Modèles actuellement chargés en mémoire + placement GPU/CPU (/api/ps).""" try: loaded = await ollama.ps() except (httpx.HTTPError, OSError): return {"loaded": []} result = [] for m in loaded: size = m.get("size", 0) or 0 vram = m.get("size_vram", 0) or 0 result.append({ "name": m.get("name") or m.get("model"), "on_gpu": bool(size and vram >= size * 0.99), "gpu_percent": int(vram / size * 100) if size else 0, }) return {"loaded": result} class PullRequest(BaseModel): name: str class DeleteModelRequest(BaseModel): name: str @router.delete("/models") async def delete_model(req: DeleteModelRequest) -> dict: """Supprime explicitement un modèle de l'instance Ollama.""" if not req.name.strip(): raise HTTPException(400, "nom de modèle vide") try: await ollama.delete_model(req.name.strip()) except httpx.HTTPStatusError as exc: detail = exc.response.text[:500] or str(exc) raise HTTPException(502, f"Ollama : {detail}") from exc except (httpx.HTTPError, OSError) as exc: raise HTTPException(502, f"Ollama injoignable : {exc}") from exc return {"deleted": req.name.strip()} @router.post("/models/pull") async def pull_model(req: PullRequest) -> StreamingResponse: """Télécharge un modèle en streamant la progression (SSE).""" async def event_stream(): try: async for chunk in ollama.pull_model(req.name): yield f"data: {json.dumps(chunk)}\n\n" except (httpx.HTTPError, OSError) as exc: yield f"data: {json.dumps({'error': str(exc)})}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream")