From d47f7476edd9e0c9841a18f4aea173a68873c322 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 30 Jun 2026 09:52:11 +0200 Subject: [PATCH] Fix: remonter les erreurs Ollama au lieu de les avaler (agent muet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama signale certains échecs en cours de génération (OOM, contexte trop grand, débordement sur CPU) via une ligne JSON {"error": ...} avec un statut HTTP 200. La boucle de streaming ne lisait que message.content / tool_calls / done : ces lignes étaient ignorées, le flux se terminait avec un contenu vide, et l'UI n'affichait rien — l'agent semblait "ne pas répondre". - ollama_client: OllamaError + détection des chunks {"error": ...} (chat & pull), corps de réponse remonté sur erreur HTTP, timeout de connexion court (échec rapide si Ollama injoignable) avec lecture sans limite, et parsing JSON tolérant aux lignes partielles. - agent: capture d'OllamaError -> événement "error" affiché à l'utilisateur. - docker-compose: config GPU NVIDIA pour le service Ollama embarqué (sinon CPU). Co-Authored-By: Claude Opus 4.8 --- backend/app/agent.py | 7 +++- backend/app/ollama_client.py | 70 +++++++++++++++++++++++++++++++----- docker-compose.yml | 10 ++++++ 3 files changed, 78 insertions(+), 9 deletions(-) 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: