diff --git a/brain/app/core/config.py b/brain/app/core/config.py
index b5ec820..a5039e0 100644
--- a/brain/app/core/config.py
+++ b/brain/app/core/config.py
@@ -25,8 +25,8 @@ class Settings(BaseSettings):
extra="ignore",
)
- # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
- llm_provider: Literal["ollama", "onemin"] = "ollama"
+ # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter.
+ llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama"
ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b"
@@ -47,6 +47,12 @@ class Settings(BaseSettings):
onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini"
+ # OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
+ # Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
+ # Pour un modele precis gratuit : id finissant par `:free`.
+ openrouter_api_key: str = ""
+ openrouter_model: str = "openrouter/free"
+
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
diff --git a/brain/app/core/settings_store.py b/brain/app/core/settings_store.py
index 10d1031..3e5d534 100644
--- a/brain/app/core/settings_store.py
+++ b/brain/app/core/settings_store.py
@@ -29,6 +29,8 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx",
"onemin_api_key",
"onemin_model",
+ "openrouter_api_key",
+ "openrouter_model",
"import_chunk_tokens",
})
diff --git a/brain/app/infrastructure/openrouter_adapter.py b/brain/app/infrastructure/openrouter_adapter.py
new file mode 100644
index 0000000..631ddaa
--- /dev/null
+++ b/brain/app/infrastructure/openrouter_adapter.py
@@ -0,0 +1,130 @@
+"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
+
+OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
+cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
+passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
+passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
+
+Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
+`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
+qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
+"""
+from __future__ import annotations
+
+import json
+from typing import AsyncIterator
+
+import httpx
+
+from app.core.config import Settings
+from app.domain.models import ChatMessage
+from app.domain.ports import LLMProviderError
+
+_API_URL = "https://openrouter.ai/api/v1/chat/completions"
+
+
+class OpenRouterLLMProvider:
+ """Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
+
+ def __init__(self, settings: Settings) -> None:
+ if not settings.openrouter_api_key:
+ raise LLMProviderError(
+ "Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
+ )
+ self._api_key = settings.openrouter_api_key
+ self._model = settings.openrouter_model
+ self._timeout = settings.llm_timeout_seconds
+
+ def _headers(self) -> dict[str, str]:
+ return {
+ "Authorization": f"Bearer {self._api_key}",
+ "Content-Type": "application/json",
+ # Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
+ "HTTP-Referer": "https://loremind.app",
+ "X-Title": "LoreMind",
+ }
+
+ async def generate(
+ self,
+ prompt: str,
+ *,
+ 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)
+
+ async def stream_chat(
+ self,
+ messages: list[ChatMessage],
+ *,
+ system_prompt: str | None = None,
+ temperature: float | None = None,
+ ) -> AsyncIterator[str]:
+ async for token in self._stream(messages, system_prompt, temperature):
+ yield token
+
+ async def _stream(
+ self,
+ messages: list[ChatMessage],
+ system_prompt: str | None,
+ temperature: float | None,
+ ) -> AsyncIterator[str]:
+ payload_messages: list[dict[str, str]] = []
+ if system_prompt:
+ payload_messages.append({"role": "system", "content": system_prompt})
+ for m in messages:
+ payload_messages.append({"role": m.role, "content": m.content})
+
+ body: dict[str, object] = {
+ "model": self._model,
+ "messages": payload_messages,
+ "stream": True,
+ }
+ if temperature is not None:
+ body["temperature"] = temperature
+
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
+ try:
+ async with client.stream(
+ "POST", _API_URL, headers=self._headers(), json=body
+ ) as response:
+ response.raise_for_status()
+ async for token in self._parse_sse(response):
+ yield token
+ except httpx.HTTPError as exc:
+ raise LLMProviderError(self._format_http_error(exc)) from exc
+
+ @staticmethod
+ async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
+ """SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
+ async for line in response.aiter_lines():
+ if not line or not line.startswith("data:"):
+ continue # lignes vides ou commentaires keep-alive (`: ...`)
+ data = line[len("data:"):].strip()
+ if data == "[DONE]":
+ return
+ try:
+ obj = json.loads(data)
+ except json.JSONDecodeError:
+ continue
+ choices = obj.get("choices")
+ if not choices:
+ continue
+ delta = choices[0].get("delta") or {}
+ content = delta.get("content")
+ if content:
+ yield content
+
+ def _format_http_error(self, exc: httpx.HTTPError) -> str:
+ """Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
+ if isinstance(exc, httpx.TimeoutException):
+ return (
+ f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
+ "mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
+ )
+ detail = str(exc) or exc.__class__.__name__
+ return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"
diff --git a/brain/app/main.py b/brain/app/main.py
index e0d3558..82fb6a5 100644
--- a/brain/app/main.py
+++ b/brain/app/main.py
@@ -45,12 +45,13 @@ from app.domain.models import (
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
+from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
- version="0.10.1-beta",
+ version="0.10.2-beta",
)
@@ -354,6 +355,8 @@ def get_llm_provider(
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
+ if settings.llm_provider == "openrouter":
+ return OpenRouterLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
@@ -688,7 +691,9 @@ async def chat_stream(
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
- "max": settings.llm_num_ctx,
+ # Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
+ # on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
+ "max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
}
async def event_stream() -> AsyncIterator[str]:
@@ -885,12 +890,15 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture.
"""
- llm_provider: Literal["ollama", "onemin"]
+ llm_provider: Literal["ollama", "onemin", "openrouter"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
+ openrouter_model: str
+ # True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
+ openrouter_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
@@ -903,12 +911,14 @@ class SettingsDTO(BaseModel):
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
- llm_provider: Literal["ollama", "onemin"] | None = None
+ llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
+ openrouter_model: str | None = None
+ openrouter_api_key: str | None = None
llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None
@@ -921,6 +931,8 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
+ openrouter_model=s.openrouter_model,
+ openrouter_api_key_set=bool(s.openrouter_api_key),
llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds,
@@ -1081,6 +1093,51 @@ async def delete_ollama_model(
return {"status": "deleted", "name": name}
+@app.get("/models/openrouter")
+async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
+ """Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
+
+ Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
+ puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=20) as client:
+ response = await client.get("https://openrouter.ai/api/v1/models")
+ response.raise_for_status()
+ data = response.json()
+ except httpx.HTTPError as exc:
+ raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
+
+ def _is_zero(value: object) -> bool:
+ try:
+ return float(value) == 0.0 # type: ignore[arg-type]
+ except (TypeError, ValueError):
+ return False
+
+ models: list[dict[str, object]] = []
+ for m in data.get("data", []) or []:
+ mid = str(m.get("id") or "")
+ if not mid:
+ continue
+ pricing = m.get("pricing") or {}
+ is_free = mid.endswith(":free") or (
+ _is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
+ )
+ try:
+ ctx = int(m.get("context_length") or 0)
+ except (TypeError, ValueError):
+ ctx = 0
+ models.append({
+ "id": mid,
+ "name": str(m.get("name") or mid),
+ "context_length": ctx,
+ "free": is_free,
+ })
+
+ models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
+ return {"models": models}
+
+
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
diff --git a/core/pom.xml b/core/pom.xml
index 3c56a3e..6bd466f 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -14,7 +14,7 @@
- A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o, - 200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat. -
-