Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
This commit is contained in:
2026-06-07 09:52:15 +02:00
parent 5eb15dc449
commit edc4434298
113 changed files with 6347 additions and 662 deletions

View File

@@ -11,17 +11,26 @@ qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
from app.domain.ports import LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class OpenRouterLLMProvider:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
@@ -51,11 +60,63 @@ class OpenRouterLLMProvider:
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties."""
chunks: list[str] = []
async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature):
chunks.append(token)
return "".join(chunks)
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un
modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER
PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout
(des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après
`self._timeout` secondes, quoi qu'il arrive.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter"
)
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
provider: str,
) -> str:
"""Collecte le stream avec DEUX garde-fous au temps écoulé :
- 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué
en file d'attente (que des keep-alive, aucun contenu) → échec rapide ;
- ceiling global (`self._timeout`) : génération qui ne se termine jamais.
Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des
octets' et empêchent son read-timeout de se déclencher.
"""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
# Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite
# on laisse générer (le ceiling global couvre le reste).
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur {provider} : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
"probablement en file d'attente / saturé. Réessayez plus tard ou "
"choisissez un autre modèle (1min.ai, ou payant)."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMProviderError(
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
async def stream_chat(
self,
@@ -72,6 +133,7 @@ class OpenRouterLLMProvider:
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
@@ -86,6 +148,10 @@ class OpenRouterLLMProvider:
}
if temperature is not None:
body["temperature"] = temperature
# NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/
# providers GRATUITS ne le supportent pas et renvoient une réponse VIDE.
# On laisse le modèle répondre librement ; l'extraction JSON en aval
# (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
async with httpx.AsyncClient(timeout=self._timeout) as client:
try: