Files
Loki/backend/app/routes/models.py
T
Claude d9169a1d27 Préchargement des modèles : warm + keep_alive + indicateur d'état
Évite le rechargement lent quand Ollama a déchargé le modèle de la VRAM :
- ollama_client.warm() : précharge un modèle (/api/generate sans prompt) ;
  chat() accepte keep_alive (durée de rétention en VRAM).
- agent.run_agent transmet keep_alive ; route chat le passe depuis la config.
- config : champ keep_alive (défaut 30m) par profil de modèle.
- routes/models : POST /api/models/warm, GET /api/models/loaded (placement
  GPU/CPU via /api/ps).
- main : préchargement du modèle par défaut au démarrage (arrière-plan,
  best-effort — n'empêche pas le démarrage si Ollama est absent).
- Frontend : préchargement automatique à la sélection d'un modèle, poll des
  modèles chargés (8s), pastille verte (GPU) / orange (CPU) / blanche (à
  charger) dans le sélecteur, réglage 'Maintien en VRAM' dans Configuration.

Tests : warm/loaded routes, keep_alive transmis, démarrage résilient sans
Ollama, build front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-07-07 12:07:08 +00:00

133 lines
4.1 KiB
Python

"""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")