Fix: remonter les erreurs Ollama au lieu de les avaler (agent muet)

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-30 09:52:11 +02:00
1 parent 52f67e9344
commit d47f7476ed
3 files changed
+78 -9

No files matched your search

+6 -1
View File
@@ -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
+62 -8
View File
@@ -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()
+10
View File
@@ -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: