From c9b6c8bb3e9da599168634e9ff98afffd57fc48a Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 7 Jul 2026 15:34:08 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89vite=20le=20timeout=20du=20pr=C3=A9charge?= =?UTF-8?q?ment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 12 ++++---- backend/app/ollama_client.py | 5 +++- backend/app/routes/models.py | 58 +++++++++++++++++++++++++++++------- frontend/src/api/client.ts | 29 +++++++++++++++++- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index d02eec2..e58893f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,16 +22,16 @@ async def _warm_default_model() -> None: import asyncio import logging - from .ollama_client import OllamaError, ollama - await asyncio.sleep(2) # laisse le service démarrer try: cfg = agent_config.get_config(settings.default_model) - await ollama.warm(settings.default_model, cfg.get("keep_alive", "30m")) - logging.getLogger(__name__).info( - "Modèle %s préchargé en VRAM", settings.default_model + models.start_model_warm( + settings.default_model, cfg.get("keep_alive", "30m") ) - except (OllamaError, OSError, Exception) as exc: # best-effort + logging.getLogger(__name__).info( + "Préchargement du modèle %s lancé", settings.default_model + ) + except Exception as exc: # best-effort logging.getLogger(__name__).info( "Préchargement au démarrage ignoré : %s", exc ) diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py index 34808d0..f15cef6 100644 --- a/backend/app/ollama_client.py +++ b/backend/app/ollama_client.py @@ -122,7 +122,10 @@ class OllamaClient: Le paramètre keep_alive fixe la durée de rétention en mémoire. """ - async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: + # Le chargement se fait désormais en tâche de fond côté API Loki. On lui + # laisse jusqu'à dix minutes pour les gros modèles ou un stockage lent. + timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: resp = await client.post( f"{self.host}/api/generate", json={"model": model, "keep_alive": keep_alive, "stream": False}, diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py index f31ecbe..874356f 100644 --- a/backend/app/routes/models.py +++ b/backend/app/routes/models.py @@ -1,10 +1,11 @@ """Routes liées à Ollama : statut de connexion, modèles, téléchargement.""" from __future__ import annotations +import asyncio import json import httpx -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel @@ -13,6 +14,12 @@ from ..ollama_client import ollama router = APIRouter(prefix="/api", tags=["ollama"]) +# État en mémoire des préchargements. Loki est lancé avec un worker unique ; ce +# suivi permet au navigateur d'interroger une route courte pendant que le long +# chargement Ollama continue sans maintenir la requête HTTP initiale ouverte. +_warm_states: dict[str, dict[str, str]] = {} +_warm_tasks: dict[str, asyncio.Task[None]] = {} + @router.get("/status") async def status() -> dict: @@ -63,16 +70,47 @@ class WarmRequest(BaseModel): 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") +async def _warm_in_background(name: str, keep_alive: str) -> None: 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()} + await ollama.warm(name, keep_alive) + _warm_states[name] = {"state": "loaded"} + except Exception as exc: + _warm_states[name] = { + "state": "error", + "error": f"préchargement impossible : {str(exc)[:500]}", + } + + +def start_model_warm(name: str, keep_alive: str) -> None: + """Démarre au plus une tâche de préchargement par modèle.""" + current = _warm_tasks.get(name) + if current and not current.done(): + return + _warm_states[name] = {"state": "loading"} + task = asyncio.create_task(_warm_in_background(name, keep_alive)) + _warm_tasks[name] = task + + def forget(done: asyncio.Task[None]) -> None: + if _warm_tasks.get(name) is done: + _warm_tasks.pop(name, None) + + task.add_done_callback(forget) + + +@router.post("/models/warm", status_code=202) +async def warm_model(req: WarmRequest) -> dict: + """Démarre le préchargement sans exposer sa durée au reverse proxy.""" + name = req.name.strip() + if not name: + raise HTTPException(400, "nom de modèle vide") + start_model_warm(name, req.keep_alive) + return {"warming": name, "state": _warm_states[name]["state"]} + + +@router.get("/models/warm/status") +async def warm_status(name: str = Query(min_length=1)) -> dict: + """État court du préchargement : idle, loading, loaded ou error.""" + return _warm_states.get(name.strip(), {"state": "idle"}) @router.get("/models/loaded") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 37fcdd7..540710d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -56,7 +56,17 @@ async function apiError(res: Response, fallback: string): Promise { } } -/** Précharge un modèle en mémoire et remonte toute erreur à l'interface. */ +interface WarmStatus { + state: "idle" | "loading" | "loaded" | "error"; + error?: string; +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Lance le préchargement en arrière-plan puis suit son état. Chaque requête + * reste courte afin qu'un reverse proxy ne puisse plus interrompre le warm-up. + */ export async function warmModel(name: string, keepAlive = "30m"): Promise { const res = await fetch("/api/models/warm", { method: "POST", @@ -66,6 +76,23 @@ export async function warmModel(name: string, keepAlive = "30m"): Promise if (!res.ok) { throw await apiError(res, `préchargement refusé (${res.status})`); } + + for (let attempt = 0; attempt < 300; attempt++) { + const statusRes = await fetch( + `/api/models/warm/status?name=${encodeURIComponent(name)}`, + { cache: "no-store" } + ); + if (!statusRes.ok) { + throw await apiError(statusRes, `suivi du préchargement refusé (${statusRes.status})`); + } + const status = (await statusRes.json()) as WarmStatus; + if (status.state === "loaded") return; + if (status.state === "error") { + throw new Error(status.error ?? "préchargement impossible"); + } + await wait(2000); + } + throw new Error("préchargement toujours en cours après 10 minutes"); } export async function getLoadedModels(): Promise {