diff --git a/backend/app/agent.py b/backend/app/agent.py index 93981d4..56e127c 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -18,7 +18,7 @@ from typing import AsyncIterator import httpx -from .ollama_client import ollama +from .ollama_client import OllamaError, ollama from .tools import TOOL_DEFINITIONS, ToolError, run_tool MAX_ITERATIONS = 6 @@ -156,6 +156,11 @@ async def run_agent( if final_chunk.strip(): text_parts.append(final_chunk.strip()) break + except OllamaError as exc: + # Échec signalé par Ollama (HTTP ou ligne d'erreur dans le flux) : + # souvent un débordement mémoire / contexte trop grand. On l'expose. + yield {"type": "error", "message": f"Ollama : {exc}"} + return except (httpx.HTTPError, OSError) as exc: yield {"type": "error", "message": str(exc)} return diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py index 49c5b50..533f1cb 100644 --- a/backend/app/ollama_client.py +++ b/backend/app/ollama_client.py @@ -13,6 +13,38 @@ import httpx from .config import settings +class OllamaError(RuntimeError): + """Erreur renvoyée par Ollama (statut HTTP ≥ 400 ou champ ``error`` dans le flux). + + Ollama signale certains échecs *au milieu* d'un flux streaming (HTTP 200) + via une ligne JSON ``{"error": "..."}`` — typiquement un débordement mémoire + ou un contexte trop grand. On lève alors cette exception pour que l'appelant + la remonte à l'utilisateur au lieu de l'avaler silencieusement. + """ + + +# Connexion rapide à échouer si Ollama est injoignable, mais lecture sans limite : +# une génération longue (ou un chargement de modèle sur CPU) ne doit pas couper. +_STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0) + + +async def _raise_for_stream_status(resp: httpx.Response) -> None: + """Lève une ``OllamaError`` détaillée si la réponse streaming est en erreur. + + Sur une réponse en flux, ``raise_for_status`` n'inclut pas le corps ; on le + lit explicitement pour exposer le message d'Ollama (modèle absent, etc.). + """ + if resp.status_code < 400: + return + body = await resp.aread() + detail = body.decode(errors="replace").strip() + try: + detail = json.loads(detail).get("error", detail) + except (json.JSONDecodeError, AttributeError): + pass + raise OllamaError(f"Ollama a renvoyé {resp.status_code} : {detail[:500]}") + + class OllamaClient: """Enveloppe asynchrone autour de l'API REST d'Ollama.""" @@ -49,14 +81,23 @@ class OllamaClient: async def pull_model(self, name: str) -> AsyncIterator[dict]: """Télécharge un modèle en streamant la progression (/api/pull).""" - async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: + async with httpx.AsyncClient( + timeout=_STREAM_TIMEOUT, follow_redirects=True + ) as client: async with client.stream( "POST", f"{self.host}/api/pull", json={"name": name} ) as resp: - resp.raise_for_status() + await _raise_for_stream_status(resp) async for line in resp.aiter_lines(): - if line.strip(): - yield json.loads(line) + if not line.strip(): + continue + try: + chunk = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(chunk, dict) and chunk.get("error"): + raise OllamaError(str(chunk["error"])) + yield chunk async def chat( self, @@ -74,14 +115,27 @@ class OllamaClient: if options: payload["options"] = options - async with httpx.AsyncClient(timeout=None, follow_redirects=True) as client: + async with httpx.AsyncClient( + timeout=_STREAM_TIMEOUT, follow_redirects=True + ) as client: async with client.stream( "POST", f"{self.host}/api/chat", json=payload ) as resp: - resp.raise_for_status() + await _raise_for_stream_status(resp) async for line in resp.aiter_lines(): - if line.strip(): - yield json.loads(line) + if not line.strip(): + continue + try: + chunk = json.loads(line) + except json.JSONDecodeError: + # Ligne partielle / non-JSON : on l'ignore plutôt que de + # faire planter tout le flux. + continue + # Échec en cours de génération (OOM, contexte trop grand…) : + # Ollama l'émet dans le flux avec HTTP 200. On le remonte. + if isinstance(chunk, dict) and chunk.get("error"): + raise OllamaError(str(chunk["error"])) + yield chunk ollama = OllamaClient() diff --git a/docker-compose.yml b/docker-compose.yml index e67292b..cace754 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,16 @@ services: - "11434:11434" volumes: - ollama-models:/root/.ollama + # Accès GPU NVIDIA : sans ceci, l'Ollama embarqué tourne en CPU (très lent). + # Nécessite le NVIDIA Container Toolkit installé sur l'hôte. Si tu n'as pas + # de GPU NVIDIA, commente tout le bloc `deploy:` ci-dessous. + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: ["gpu"] restart: unless-stopped volumes: