From 9ea43a1889f5768331bb65145d31fc6df7f0ed1d Mon Sep 17 00:00:00 2001
From: "IETM_FIXE\\ietm6"
Date: Fri, 5 Jun 2026 00:23:19 +0200
Subject: [PATCH] =?UTF-8?q?Ajout=20d'open=20router=20en=20fournisseur=20IA?=
=?UTF-8?q?=20;=20ajout=20de=20la=20possibilit=C3=A9=20de=20mettre=20des?=
=?UTF-8?q?=20conditions=20de=20d=C3=A9verouillage=20pour=20les=20chapitre?=
=?UTF-8?q?s=20optionnels?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
brain/app/core/config.py | 10 +-
brain/app/core/settings_store.py | 2 +
.../app/infrastructure/openrouter_adapter.py | 130 ++++++++++++++++++
brain/app/main.py | 65 ++++++++-
core/pom.xml | 2 +-
.../SessionStructuralContextBuilder.java | 11 +-
.../web/controller/SettingsController.java | 5 +
web/package-lock.json | 4 +-
web/package.json | 2 +-
web/src/app/campaigns/campaign-tree.helper.ts | 2 +
.../chapter-edit/chapter-edit.component.html | 16 ++-
.../chapter-view/chapter-view.component.html | 19 ++-
.../chapter-view/chapter-view.component.scss | 16 +++
.../chapter-view/chapter-view.component.ts | 3 +-
web/src/app/services/settings.service.ts | 25 +++-
web/src/app/settings/settings.component.html | 59 +++++---
web/src/app/settings/settings.component.ts | 48 ++++++-
.../ai-chat-drawer.component.html | 9 +-
.../ai-chat-drawer.component.ts | 4 +
19 files changed, 382 insertions(+), 50 deletions(-)
create mode 100644 brain/app/infrastructure/openrouter_adapter.py
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 @@
com.loremind
loremind-core
- 0.10.1-beta
+ 0.10.2-beta
LoreMind Core
Backend Core - Architecture Hexagonale
diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
index 1c600c6..2b97308 100644
--- a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
+++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java
@@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder {
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
- boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
- if (!anyHub) {
- return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
- }
+ // On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
+ // ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
+ // condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map progressionByChapter = new HashMap<>();
@@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder {
List lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
- if (arc.getType() != ArcType.HUB) continue;
+ boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
+ boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
+ if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java
index dbd2cce..f70155d 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java
@@ -140,6 +140,11 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null);
}
+ @GetMapping("/models/openrouter")
+ public ResponseEntity
-
-
diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts
index d525fb7..63b5cdb 100644
--- a/web/src/app/settings/settings.component.ts
+++ b/web/src/app/settings/settings.component.ts
@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
-import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
+import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LayoutService } from '../services/layout.service';
@@ -103,6 +103,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
settings: AppSettings | null = null;
ollamaModels: string[] = [];
oneminGroups: OneMinModelGroup[] = [];
+
+ /** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
+ openrouterModels: OpenRouterModel[] = [];
+ openrouterFreeOnly = true;
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = '';
@@ -128,6 +132,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** True si l'utilisateur a coche "effacer la cle". */
clearApiKey = false;
+ /** Cle OpenRouter saisie — vide = on ne touche pas a la cle persistee. */
+ openrouterApiKeyInput = '';
+ /** True si l'utilisateur a coche "effacer la cle OpenRouter". */
+ clearOpenrouterKey = false;
+
constructor(
private settingsService: SettingsService,
private router: Router,
@@ -457,6 +466,35 @@ export class SettingsComponent implements OnInit, OnDestroy {
},
error: () => this.oneminGroups = []
});
+
+ this.settingsService.listOpenRouterModels().subscribe({
+ next: (r) => this.openrouterModels = r.models,
+ error: () => this.openrouterModels = []
+ });
+ }
+
+ /** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
+ get filteredOpenrouterModels(): OpenRouterModel[] {
+ return this.openrouterFreeOnly
+ ? this.openrouterModels.filter(m => m.free)
+ : this.openrouterModels;
+ }
+
+ /** Options du select OpenRouter : routeur auto + liste filtree + valeur courante. */
+ get openrouterSelectOptions(): { id: string; label: string }[] {
+ const options: { id: string; label: string }[] = [
+ { id: 'openrouter/free', label: 'openrouter/free — routeur auto (gratuit)' }
+ ];
+ for (const m of this.filteredOpenrouterModels) {
+ const ctx = m.context_length ? ` — ${m.context_length.toLocaleString()} ctx` : '';
+ options.push({ id: m.id, label: `${m.id}${ctx}${m.free ? ' · gratuit' : ''}` });
+ }
+ // Garantit que le modele actuellement configure reste selectionnable.
+ const cur = this.settings?.openrouter_model;
+ if (cur && !options.some(o => o.id === cur)) {
+ options.splice(1, 0, { id: cur, label: `${cur} (actuel)` });
+ }
+ return options;
}
/** Deduit le fournisseur a partir du modele actuellement configure. */
@@ -518,6 +556,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model,
+ openrouter_model: this.settings.openrouter_model,
llm_num_ctx: this.settings.llm_num_ctx,
import_chunk_tokens: this.settings.import_chunk_tokens,
llm_timeout_seconds: this.settings.llm_timeout_seconds
@@ -527,12 +566,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
} else if (this.oneminApiKeyInput.trim()) {
patch.onemin_api_key = this.oneminApiKeyInput.trim();
}
+ if (this.clearOpenrouterKey) {
+ patch.openrouter_api_key = '';
+ } else if (this.openrouterApiKeyInput.trim()) {
+ patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
+ }
this.settingsService.updateSettings(patch).subscribe({
next: (s) => {
this.settings = { ...s };
this.oneminApiKeyInput = '';
this.clearApiKey = false;
+ this.openrouterApiKeyInput = '';
+ this.clearOpenrouterKey = false;
this.successMessage = 'Parametres sauvegardes.';
this.saving = false;
},
diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html
index 606a3c5..9d39af8 100644
--- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html
+++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html
@@ -97,13 +97,14 @@
-
+ [attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
+
+
- Contexte : {{ usageTotal }} / {{ usage.max }} tokens
- {{ usagePercent }}%
+ Contexte : {{ usageTotal }} / {{ usage.max }} tokens
+ {{ usagePercent }}%
diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
index 42a8d4a..1b615c8 100644
--- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
+++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts
@@ -129,6 +129,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current;
}
+ /** Vrai si on connaît la fenêtre max (Ollama) → affiche dénominateur + barre. */
+ get usageHasMax(): boolean {
+ return !!this.usage && this.usage.max > 0;
+ }
get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max);