Compare commits

13 Commits
Author SHA1 Message Date
Folzo 9439307e6a jean-router: contexte reel depuis llama-server + overrides
- Interroge llama-server:8080 pour le n_ctx du modele actif
- JEAN_CTX_OVERRIDES prioritaire si defini
- Fallback: Ollama matching → heuristique
- Plus de devinette, le contexte est precis
2026-08-02 10:25:51 +02:00
Folzo fcaad763da dashboard: plus de spam [Presets] Erreur depuis le polling
- dashboard_status utilise le cache presets au lieu d'appeler Jean
- jean_service_ok/ollama_service_ok silencieux (pas de warning log)
- slot check uniquement si jean est UP
- [Presets] Erreur ne vient plus du dashboard mais d'OpenFox/OpenWebUI
2026-08-02 09:53:29 +02:00
Folzo e93ab95350 jean-router: detection a 3 niveaux - PC / Jean / Ollama
- PC: TCP connect (ON/OFF) → WoL uniquement si OFF
- Jean: HTTP /api/presets (UP/DOWN)
- Ollama: HTTP /api/version (UP/DOWN)
- Dashboard: 3 lignes de statut distinctes
- WoL ne se declenche que si PC OFF, pas si service DOWN
2026-08-02 01:19:35 +02:00
Folzo dc900172f2 jean-router: detection PC via TCP connect au lieu de HTTP
- TCP connect vers 8090 ou 11434 (pas de requete HTTP)
- ConnectionRefused = PC allume (service down)
- Timeout = PC eteint → WoL
- Plus de spam HTTP vers un serveur bloque
2026-08-02 01:15:46 +02:00
Folzo 697e4e36c9 jean-router: WoL ne se declenche plus si Ollama repond (PC allume)
- jean_alive verifie Jean(8090) OU Ollama(11434)
- WoL seulement si les DEUX sont inaccessibles
- Logs WoL espaces a 5s au lieu de 2s
2026-08-02 01:13:21 +02:00
Folzo bb15bd8d24 dashboard: supprimer le spam de logs DEBUG et reduire le polling
- urllib3/requests en WARNING (plus de 'Starting new HTTP connection')
- QueueHandler en INFO (plus de DEBUG)
- Polling status reduit de 3s a 10s
2026-08-02 01:11:49 +02:00
Folzo 62bb38ecff jean-router: dashboard HTML de diagnostic temps reel
- /dashboard : page HTML avec statut, slot, stats, actions
- /api/dashboard/status : JSON etat complet (Jean, Ollama, slot, VRAM)
- /api/dashboard/logs/stream : SSE logs en temps reel
- /api/dashboard/logs : derniers logs JSON
- /api/dashboard/actions/refresh : force rafraichissement cache
- /api/dashboard/actions/restart-jean : restart Jean via WoL
- Tracking des requetes (total, jean, ollama, erreurs)
- Log buffer en memoire avec QueueHandler
2026-08-02 01:08:55 +02:00
Folzo 06285b8d4f jean-router: streaming de messages d'attente pendant le WoL
- Quand le PC est eteint/en veille, stream immediat de messages
- 'PC de jeu se reveille...' → 'Magic packet envoye...' → 'Demarrage X%...'
- Une fois pret, forward la vraie reponse en stream
- Plus de blocage silencieux, l'utilisateur voit la progression
2026-08-01 19:27:54 +02:00
Folzo f2bcfb98e3 jean-router: WoL plus robuste + retry sur echec upstream
- Retry avec WoL quand la requete upstream echoue (connexion refusee)
- Logs plus verbeux dans wait_for_jean
- Cache jean_alive reduit a 2s
2026-08-01 08:51:30 +02:00
Folzo 4f8a5288ff jean-router: ajout Wake-on-LAN
- Detection si Jean/Ollama inaccessible
- Envoi magic packet WoL vers TARGET_MAC (fallback PC_GAMING_MAC)
- Attente WAKE_TIMEOUT secondes que la machine demarre
- Integration dans get_presets, get_ollama_models, chat_completions
- Verrous pour eviter les WoL simultanes
- Active via ENABLE_WOL=true
2026-08-01 08:20:35 +02:00
Folzo 68a76aa254 jean-router: utiliser le nom d'affichage (name) comme id modele
- /v1/models expose name au lieu de l'id technique des presets Jean
- Routing: resolution name -> id interne pour le switch
- Correspondance bidirectionnelle dans chat_completions
2026-07-26 21:30:32 +02:00
Folzo cbb43ad0f3 docs: ajout procedures mise a jour OpenFox et jean-router 2026-07-22 22:00:31 +02:00
Folzo 4f95757489 jean-router: resolution dynamique des context_length + max_tokens + capabilities
- Suppression du preset_ctx_map code en dur
- Resolution dynamique: Jean /v1/models → matching Ollama → heuristique
- Ajout max_tokens / max_input_tokens dans /v1/models
- Enrichissement Ollama via /api/show pour donnees manquantes
- Detection capabilities (vision, thinking) pour presets Jean
- Auto-doublement max_tokens pour presets reasoning=on
- Timeout switch augmente a 15s avec retry
- Tracking du dernier preset actif (bypass bug Jean active=false)
- Cache TTL configurable (15s par defaut), ?refresh=true
- Plan de test des capacites LLM
2026-07-22 21:59:07 +02:00
21 changed files with 2786 additions and 775 deletions
+8 -3
View File
@@ -16,17 +16,22 @@ WEBUI_SECRET_KEY=changez_moi_par_une_cle_longue_et_aleatoire
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 2. OLLAMA / PC GAMING # 2. OLLAMA / PC GAMING
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# IP du PC Gaming où tournent Ollama, llama.cpp et STT # IP du PC Gaming où tourne Ollama (+ Jean + GPU AMD)
# (utilisée par le service llm-proxy + Wake-on-LAN)
OLLAMA_HOST=192.168.1.11 OLLAMA_HOST=192.168.1.11
# Adresse MAC du PC Gaming (pour Wake-on-LAN) # Adresse MAC du PC Gaming (pour Wake-on-LAN)
# Format : XX:XX:XX:XX:XX:XX # Format : XX:XX:XX:XX:XX:XX
PC_GAMING_MAC=00:00:00:00:00:00:00 PC_GAMING_MAC=00:00:00:00:00:00
# IP de broadcast du réseau local (généralement 192.168.1.255) # IP de broadcast du réseau local (généralement 192.168.1.255)
BROADCAST_IP=192.168.1.255 BROADCAST_IP=192.168.1.255
# ═══════════════════════════════════════════════════════════
# 2b. LITELLM (multi-backends LLM proxy — voir docker-compose.litellm.yml)
# ═══════════════════════════════════════════════════════════
# Cle maitre pour l'API LiteLLM (open-webui devra envoyer cette cle en Bearer)
LITELLM_MASTER_KEY=sk-effi-litellm-master
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
# 3. COUCHDB (Obsidian LiveSync) # 3. COUCHDB (Obsidian LiveSync)
# ═══════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════
+1 -5
View File
@@ -29,6 +29,7 @@ pipelines-data/
tts-voices/ tts-voices/
tts-config/ tts-config/
mcpo-config/ mcpo-config/
wol-proxy/
# Fichiers de base de données # Fichiers de base de données
*.db *.db
@@ -85,8 +86,3 @@ comfyui/input/*.png
comfyui/input/*.jpg comfyui/input/*.jpg
comfyui/input/*.jpeg comfyui/input/*.jpeg
comfyui/input/*.webp comfyui/input/*.webp
# llm-proxy
llm-proxy/state/*
!llm-proxy/state/.gitkeep
llm-proxy/__pycache__/
+131 -88
View File
@@ -10,8 +10,8 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
| Machine | IP | Rôle | Services | | Machine | IP | Rôle | Services |
|---------|-----|------|----------| |---------|-----|------|----------|
| **effi-server** | `192.168.1.27` | Serveur Docker principal | OpenWebUI, Qdrant, n8n, llm-proxy, TTS | | **effi-server** | `192.168.1.27` | Serveur Docker principal | OpenWebUI, Qdrant, n8n, jean-router |
| **folzo-Corp** | `192.168.1.11` | Station GPU (LLM + STT) | llama.cpp, Ollama, ComfyUI, effi-stt, kreuzberg | | **folzo-Corp** | `192.168.1.11` | LLM + STT + TTS + Gen | Jean, Ollama, ComfyUI, effi-stt, openedai-speech, kreuzberg |
| **NAS Synology** | `192.168.1.25` | Monitoring | Beszel Hub | | **NAS Synology** | `192.168.1.25` | Monitoring | Beszel Hub |
### Schéma Global ### Schéma Global
@@ -24,27 +24,33 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
│ │ │ │ │ │
│ Monitoring │ Monitoring │ Monitoring │ Monitoring │ Monitoring │ Monitoring
▼ ▼ ▼ ▼ ▼ ▼
┌───────────────────────────────────────┐ ┌─────────────────────────────────────────┐ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ EFFI SERVER (192.168.1.27) │ │ FOLZO-CORP (192.168.1.11) │ │ EFFI SERVER (192.168.1.27) │ │ FOLZO-CORP (192.168.1.11) │
│ │ │ │ │ │ │ │
│ ┌──────────────┐ ┌────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────┐ │ │ ┌──────────────┐ ┌──────────────┐ │
│ │ Open WebUI │ │ llm-proxy │ │ │ │ llama.cpp A │ │ Ollama │ │ │ │ Open WebUI │ │ Qdrant │ │ │ │ Jean │ │ Ollama │ │
│ │ Port 3000 │ │ :3011 Ollama │ │ │ │ :8080 │ │ :11434 │ │ │ │ Port 3000 │ │ Port 6333│ │ │ │ Port 8080 │ │ Port 11434 │ │
│ └──────────────┘ │ :3012 llama.cpp│ │ │ │ (Q4_K_M) │ │ (vision, │ │ │ └──────────────┘ └──────────┘ │ │ │ llama.cpp │ │ Vision │ │
│ ┌──────────────┐ │ :3015 STT │ │ │ └──────────────┘ │ embeddings) │ │ │ │ │ │ Ornith 35B │ │ mistral, │ │
│ │ Qdrant │ │ :3016 TTS │ │ │ ┌──────────────┐ └──────────────┘ │ │ ┌──────────────┐ ┌──────────┐ │ │ │ Qwen3.6... │ │ qwen3.5:9b │ │
│ │ Port 6333 │ │ :3017 UI switch│ │ │ │ llama.cpp B │ │ │ │ n8n │ │jean-router│ │ │ └──────────────┘ └──────────────┘ │
│ └──────────────┘ │ + Wake-on-LAN │ │ │ │ :8081 │ │ │ │ Port 5678 │ │ Port 3014│ │ │ │
│ └────────────────┘ │ │ │ (IQ4_XS) │ │ │ └──────────────┘ └──────────┘ │ │ ┌──────────────┐ ┌──────────────┐ │
│ │ │ └──────────────┘ │ │ │ │ │ ComfyUI │ │ effi-stt │ │
│ │ │ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────┐ │ │ │ Port 8188 │ │ Port 8200 │ │
│ │ │ │ ComfyUI │ │ effi-stt │ │ │ │ Beszel Agent │ │ Watchtower│ │ │ │ Image/Audio │ │ Whisper STT │ │
│ │ │ │ :8188 │ │ :8200 │ │ │ └──────────────┘ └──────────┘ │ │ └──────────────┘ └──────────────┘ │
│ │ │ └──────────────┘ └──────────────┘ │ └─────────────────────────────────┘ │ │
└───────────────────────────────────────┘ └─────────────────────────────────────────┘ │ ┌──────────────┐ ┌──────────────┐ │
│ │ openedai │ │ kreuzberg │ │
│ │ Port 8100 │ │ Port 8300 │ │
│ │ TTS │ │ Doc Extract │ │
│ └──────────────┘ └──────────────┘ │
│ RX 7800 XT (VRAM 16GB) │
└─────────────────────────────────────────┘
``` ```
**Note :** `llm-proxy` est le point d'entrée unique vers la station GPU. Il fait du Wake-on-LAN : si le PC est éteint, il envoie un paquet magic avant d'attendre que le service cible soit prêt. Voir [WOL et démarrage auto](./docs/WOL-ET-DEMARRAGE-AUTO.md). **Note :** jean-router gère le routing dynamique entre Jean et Ollama avec gestion automatique de la VRAM partagée. Voir [ARCHITECTURE-JEAN-ROUTER.md](docs/ARCHITECTURE-JEAN-ROUTER.md) pour les détails.
--- ---
@@ -54,14 +60,16 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
| Service | Port | Image | Rôle | RAM/VRAM | Auto-unload | | Service | Port | Image | Rôle | RAM/VRAM | Auto-unload |
|---------|------|-------|------|----------|-------------| |---------|------|-------|------|----------|-------------|
| **llama.cpp A** | 8080 | Native | LLM principal — qwen3.8-27b **Q4_K_M** (17,5GB) | ~28GB | Démarré au boot | | **Jean** | 8080 | Native | LLM inference (llama.cpp, presets dynamiques) | ~28GB | Arrêt via API |
| **llama.cpp B** | 8081 | Native | LLM alternatif — qwen3.8-27b **IQ4_XS** (14,2GB) | ~24GB | Démarré au boot | | **Ollama** | 11434 | Native | LLM vision (mistral, qwen3.5:9b) | ~4-6GB | 5min après dernier usage |
| **Ollama** | 11434 | Native | LLM vision + embeddings | ~4-6GB | 5min après dernier usage |
| **ComfyUI** | 8188 | `yanwk/comfyui-boot:rocm` | Image + Audio generation | 18GB shm | ✅ après 10min idle | | **ComfyUI** | 8188 | `yanwk/comfyui-boot:rocm` | Image + Audio generation | 18GB shm | ✅ après 10min idle |
| **effi-stt** | 8200 | Build local | Whisper STT | ~20MB | ✅ après 5min idle | | **effi-stt** | 8200 | Build local | Whisper STT | ~20MB | ✅ après 5min idle |
| **openedai-speech** | 8100 | `ghcr.io/matatonic/openedai-speech` | TTS | ~15MB | Non (GPU) |
| **kreuzberg** | 8300 | `ghcr.io/kreuzberg-dev/kreuzberg:latest` | Document extraction | ~5MB | ✅ après 10min idle | | **kreuzberg** | 8300 | `ghcr.io/kreuzberg-dev/kreuzberg:latest` | Document extraction | ~5MB | ✅ après 10min idle |
| **beszel-agent** | - | `henrygd/beszel-agent:latest` | Monitoring | ~5MB | Auto-start | | **beszel-agent** | - | `henrygd/beszel-agent:latest` | Monitoring | ~5MB | Auto-start |
**Note :** Jean et Ollama partagent la VRAM (RX 7800 XT, 16GB). jean-router gère automatiquement le switch pour éviter la contention mémoire.
### EffiServer (192.168.1.27) ### EffiServer (192.168.1.27)
| Service | Port | Image | Rôle | | Service | Port | Image | Rôle |
@@ -69,8 +77,9 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
| **Open WebUI** | 3000 | `ghcr.io/open-webui/open-webui:main` | Interface IA | | **Open WebUI** | 3000 | `ghcr.io/open-webui/open-webui:main` | Interface IA |
| **Qdrant** | 6333 | `qdrant/qdrant:latest` | Vector DB (RAG) | | **Qdrant** | 6333 | `qdrant/qdrant:latest` | Vector DB (RAG) |
| **n8n** | 5678 | `docker.n8n.io/n8nio/n8n:latest` | Automatisation | | **n8n** | 5678 | `docker.n8n.io/n8nio/n8n:latest` | Automatisation |
| **llm-proxy** | 3011/3012/3015/3016/3017 | Build local | Proxy unique + Wake-on-LAN + UI switch | | **jean-router** | 3014 | Build local | Routing dynamique Jean/Ollama + gestion VRAM |
| **openedai-speech** | 8100 | `ghcr.io/matatonic/openedai-speech` | TTS | | **jean-proxy** | 3012 | Build local | Wake-on-LAN + Jean proxy |
| **wol-proxy** | 3011 | Build local | Wake-on-LAN + Ollama proxy |
| **beszel-agent** | - | `henrygd/beszel-agent:latest` | Monitoring | | **beszel-agent** | - | `henrygd/beszel-agent:latest` | Monitoring |
### NAS Synology (192.168.1.25) ### NAS Synology (192.168.1.25)
@@ -85,42 +94,35 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
### 💬 Chat IA ### 💬 Chat IA
- **Interface**: Open WebUI - **Interface**: Open WebUI
- **LLM principal**: llama.cpp (`qwen3.8-27b`) via `llm-proxy:3012` - **LLM principal**: Jean (llama.cpp) avec presets dynamiques
- **Switch d'instance** : 2 instances llama.cpp (8080 Q4_K_M, 8081 IQ4_XS), bascule via l'UI du proxy sans toucher aux configs - **ornith-35b-q6** (35B, Q6_K, reasoning) — modèle principal
- **LLM vision / embeddings**: Ollama via `llm-proxy:3011` - **qwen3.6-27b-mtp** (27B, IQ4_XS, reasoning, MTP)
- **Wake-on-LAN automatique**: le proxy réveille la station GPU si elle est éteinte - **qwen3.6-35b-moe-compact** (35B MoE, compact)
- **qwen3.6-35b-moe-compact-reason** (35B MoE, reasoning)
- **qwen3.6-35b-moe-nano** (35B MoE, nano)
- **LLM vision**: Ollama (mistral, qwen3.5:9b) pour les tâches avec images
- **Routing automatique**: jean-router détecte le modèle demandé et route vers Jean ou Ollama
- **Gestion VRAM**: Switch automatique entre Jean et Ollama pour éviter la contention mémoire
### 🔌 llm-proxy — Proxy unique + Wake-on-LAN + UI switch **Note :** Nouveaux presets Jean apparaissent automatiquement dans OpenWebUI sans configuration. Voir [ARCHITECTURE-JEAN-ROUTER.md](docs/ARCHITECTURE-JEAN-ROUTER.md) pour les détails.
- **5 routes** : Ollama (`:3011`), llama.cpp (`:3012`), STT (`:3015`), TTS (`:3016`), UI de gestion (`:3017`)
- **Route virtuelle `:3012`** : pointe vers l'instance llama.cpp active (8080 ou 8081), choix persisté dans `llm-proxy/state/active.json`
- **Alias modèle** : les harnais envoient toujours `qwen3.8-27b` ; le proxy réécrit l'id réel dans les 2 sens (requêtes, `/v1/models`, streaming SSE) → le switch est invisible pour OpenWebUI/OpenFox/Hermes
- **Wake-on-LAN** : si le PC est éteint, envoie 3 paquets magic (MAC `04:D9:F5:D1:EC:DF`) puis attend le service
- **Anti-spam WOL** : un seul réveil en cours à la fois, pas de re-envoi pendant l'attente
- **Fail-fast** : PC up mais instance cible éteinte → 503 après 20s (pas de WOL)
- **API OpenAI-compatible** : `/v1/models` et `/v1/chat/completions` sur les routes LLM
- **Streaming** : passthrough des réponses SSE (alias réécrit par ligne)
- **UI `:3017`** : statut des 2 instances, bouton *Activer* (persisté), bouton *Tester* (latence + réponse)
#### Switcher entre les 2 versions de qwen3.8 ### 🚀 jean-router — Routing dynamique LLM
- **Découverte automatique**: Interroge Jean et Ollama pour lister les modèles disponibles
1. Sur le PC : démarrer l'instance voulue (et arrêter l'autre — une seule tient dans la VRAM) - **Routing intelligent**: Route vers Jean (presets) ou Ollama (vision) selon le modèle demandé
2. Ouvrir http://192.168.1.27:3017 → *Tester* pour vérifier que l'instance répond → *Activer* - **Switch automatique**: Change de preset Jean si nécessaire (via API web)
3. Terminé : `:3012` sert désormais la nouvelle instance, zéro changement de config côté harnais - **Gestion VRAM**:
- Arrête Jean quand on passe sur Ollama (libère VRAM)
```bash - Garde Ollama en mémoire 5 min avant déchargement automatique
# Alternative sans UI - Évite la contention mémoire entre les deux backends
curl -X POST http://192.168.1.27:3017/api/activate -d '{"real":"qwen3.8-27b"}' # instance A (8080) - **API OpenAI-compatible**: `/v1/models` et `/v1/chat/completions`
curl -X POST http://192.168.1.27:3017/api/activate -d '{"real":"qwen3.8-27b-iq4xs"}' # instance B (8081)
curl http://192.168.1.27:3017/api/status
```
### 🎤 Pipeline Vocal ### 🎤 Pipeline Vocal
- **STT**: Whisper large-v3 via effi-stt (CPU, déchargement auto) - **STT**: Whisper large-v3 via effi-stt (CPU, déchargement auto)
- **TTS**: openedai-speech (serveur, voix Piper + XTTS) - **TTS**: XTTS v2 via openedai-speech (GPU ROCm)
### 🖼️ Génération Images/Audio ### 🖼️ Génération Images/Audio
- **ComfyUI**: Workflows pour image et audio - **ComfyUI**: Workflows pour image et audio
- **Modèle audio**: ACE-Step (génération musicale) - **Modèle audio**: ACE-Step ( génération musicale)
- **Modèles image**: Multiples checkpoints disponibles - **Modèles image**: Multiples checkpoints disponibles
### 📄 Extraction Documents ### 📄 Extraction Documents
@@ -146,6 +148,12 @@ Outil custom pour interroger Beszel depuis OpenWebUI via Effi.
- `get_system_stats` → retourne CPU, RAM, GPU (%) - `get_system_stats` → retourne CPU, RAM, GPU (%)
- Données temps réel depuis `info` (cpu, mp=gpu, g=gpu, dt=timestamp) - Données temps réel depuis `info` (cpu, mp=gpu, g=gpu, dt=timestamp)
**Améliorations futures** (pas encore implémentées):
- Support historique: données des N dernières minutes
- Filtres par période
- Graphiques/Métriques avancés
- Alertes automatisées via le tool
**Documentation**: [Tool Beszel OpenWebUI](./docs/TOOL-BESZEL-OPENWEBUI.md) **Documentation**: [Tool Beszel OpenWebUI](./docs/TOOL-BESZEL-OPENWEBUI.md)
### Scripts Memory ### Scripts Memory
@@ -158,14 +166,22 @@ Outil custom pour interroger Beszel depuis OpenWebUI via Effi.
### Commandes Utiles ### Commandes Utiles
| Script | Condition | Action |
|--------|-----------|--------|
| comfyui-memory-manager.py | 10min idle + VRAM<1% + RAM>5GB | Restart container |
| kreuzberg-memory-manager.py | 10min idle + RAM>2GB | Restart container |
| effi-stt | 5min idle | Déchargement modèle Whisper |
### Commandes Utiles
```bash ```bash
# Statut services # Statut services
sudo systemctl status comfyui-memory-manager kreuzberg-memory-manager
docker ps docker ps
docker logs -f llm-proxy
# Tester le proxy (PC allumé) # Logs
curl http://192.168.1.27:3012/health journalctl -u comfyui-memory-manager -f
curl http://192.168.1.27:3011/api/version docker logs -f comfyui
# Métriques Beszel # Métriques Beszel
python3 scripts/beszel_client.py --system folzocorp --check stats python3 scripts/beszel_client.py --system folzocorp --check stats
@@ -175,7 +191,8 @@ python3 scripts/beszel_client.py --system folzocorp --check stats
## 📚 Documentation ## 📚 Documentation
- [WOL et démarrage auto](./docs/WOL-ET-DEMARRAGE-AUTO.md) - [Architecture jean-router](./docs/ARCHITECTURE-JEAN-ROUTER.md) — Routing dynamique LLM + gestion VRAM
- [Session 2026-07-20](./docs/05-journal/SESSION-2026-07-20-JEAN-ROUTER.md) — Création de jean-router
- [Installation Beszel](./docs/BESZEL-INSTALLATION.md) - [Installation Beszel](./docs/BESZEL-INSTALLATION.md)
- [Gestion Mémoire](./docs/MEMOIRE-GESTION.md) - [Gestion Mémoire](./docs/MEMOIRE-GESTION.md)
- [Session 2026-04-12](./docs/SESSION-2026-04-12-RAM-BESZEL.md) - [Session 2026-04-12](./docs/SESSION-2026-04-12-RAM-BESZEL.md)
@@ -188,11 +205,11 @@ python3 scripts/beszel_client.py --system folzocorp --check stats
| Domaine | Technologies | | Domaine | Technologies |
|---------|-------------| |---------|-------------|
| **LLM** | llama.cpp (principal), Ollama (vision + embeddings) | | **LLM** | Jean (llama.cpp), Ollama (vision) |
| **Proxy / WOL** | llm-proxy (Flask, proxy multi-cibles + Wake-on-LAN) | | **Routing** | jean-router (Flask, routing dynamique + gestion VRAM) |
| **Interface** | Open WebUI | | **Interface** | Open WebUI |
| **STT** | Faster Whisper (CPU) | | **STT** | Faster Whisper (CPU) |
| **TTS** | openedai-speech (Piper + XTTS) | | **TTS** | XTTS v2 (ROCm GPU) |
| **Image/Audio** | ComfyUI + ACE-Step | | **Image/Audio** | ComfyUI + ACE-Step |
| **Vector DB** | Qdrant | | **Vector DB** | Qdrant |
| **Document** | Kreuzberg (docling) | | **Document** | Kreuzberg (docling) |
@@ -206,11 +223,6 @@ python3 scripts/beszel_client.py --system folzocorp --check stats
``` ```
effi/ effi/
├── llm-proxy/ # Proxy unique + Wake-on-LAN + UI switch
│ ├── app.py
│ ├── Dockerfile
│ └── state/ # Persistance instance active (active.json)
│
├── beszel/ # Configuration Beszel ├── beszel/ # Configuration Beszel
│ ├── docker-compose-hub.yml # Hub (Synology) │ ├── docker-compose-hub.yml # Hub (Synology)
│ ├── docker-compose-agent-folzocorp.yml # Agent FolzoCorp │ ├── docker-compose-agent-folzocorp.yml # Agent FolzoCorp
@@ -240,44 +252,75 @@ effi/
├── systemd/ # Services systemd ├── systemd/ # Services systemd
│ ├── comfyui-memory-manager.service │ ├── comfyui-memory-manager.service
│ ├── kreuzberg-memory-manager.service │ ├── kreuzberg-memory-manager.service
│ └── beszel-agent-wake.service │ ├── beszel-agent-wake.service
│ └── ollama.service # Ollama LLM (folzo-Corp)
│ │
├── docs/ # Documentation ├── docs/ # Documentation
│ ├── WOL-ET-DEMARRAGE-AUTO.md │ ├── SESSION-2026-04-12-RAM-BESZEL.md
│ ├── BESZEL-INSTALLATION.md │ ├── BESZEL-INSTALLATION.md
│ ├── MEMOIRE-GESTION.md │ ├── MEMOIRE-GESTION.md
│ └── TOOL-BESZEL-OPENWEBUI.md │ └── TOOL-BESZEL-OPENWEBUI.md
│ │
└── docker-compose.yml # Stack principale (effi-server) └── docker-compose*.yml # Divers compose files (legacy/backup)
``` ```
--- ---
## 📝 Historique ## 📝 Historique
**Septembre 2026** - Switch d'instance llama.cpp **Juillet 2026** - jean-router : routing dynamique LLM + gestion VRAM
- 2 instances llama.cpp sur folzo-Corp (8080 Q4_K_M, 8081 IQ4_XS)
- Route virtuelle `:3012` + alias modèle `qwen3.8-27b` : switch transparent, zéro re-config harnais
- UI de gestion `:3017` (statut, Activer, Tester)
- Fail-fast 503 (20s) quand l'instance active est éteinte, sans spam WOL
**Août 2026** - Simplification : proxy unique + Wake-on-LAN ### jean-router ✅
- Création de jean-router (Flask) pour routing dynamique entre Jean et Ollama
- Découverte automatique des presets Jean via `/api/presets`
- Découverte automatique des modèles Ollama via `/api/tags`
- Nouveaux presets Jean automatiquement visibles dans OpenWebUI
- Gestion VRAM : arrêt Jean quand on passe sur Ollama, timer 5min pour Ollama
- Suppression de LiteLLM (une couche de moins, config statique obsolète)
- OpenWebUI pointe directement vers jean-router:3014
### llm-proxy ✅ ### Jean (llama.cpp) ✅
- Un seul service remplace jean-router, jean-proxy, jean-proxy-moe, wol-proxy - Migration vers Jean comme backend LLM principal
- 4 routes : Ollama (`:3011`), llama.cpp (`:3012`), STT (`:3015`), TTS (`:3016`) - Presets dynamiques : ornith-35b-q6, qwen3.6-27b-mtp, qwen3.6-35b-moe-compact...
- Wake-on-LAN intégré avec anti-spam (un seul réveil en cours) - Service systemd avec démarrage au boot (sans login)
- Suppression de tout le code Jean (plus besoin de routing dynamique) - API web pour gestion (start/stop/switch presets)
- OpenWebUI pointe vers `llm-proxy:3011` (Ollama) et `llm-proxy:3012` (llama.cpp)
**Juillet 2026** - Routing dynamique (abandonné)
- jean-router : routing dynamique Jean/Ollama + gestion VRAM (supprimé)
**Avril 2026** - Optimisation RAM + Monitoring + Tool OpenWebUI **Avril 2026** - Optimisation RAM + Monitoring + Tool OpenWebUI
### RAM Management ✅
- Scripts déchargement mémoire avec restart container (ComfyUI, Kreuzberg) - Scripts déchargement mémoire avec restart container (ComfyUI, Kreuzberg)
- Timeout inactivité 300s pour effi-stt avec déchargement Whisper - Timeout inactivité 300s pour effi-stt avec déchargement Whisper
- Beszel Monitoring (Hub NAS + agents FolzoCorp/EffiServer) - Services systemd pour gestion automatique
- Tool OpenWebUI Beszel
### Beszel Monitoring ✅
- Hub sur NAS Synology (http://192.168.1.25:8090)
- Agents sur FolzoCorp + EffiServer + NAS Synology
- Service restart après veille (beszel-agent-wake.service)
### Tool OpenWebUI Beszel ✅
- Outil custom pour interrogation Beszel depuis OpenWebUI
- Endpoint PocketBase: `/api/collections/users/auth-with-password`
- Données extraites du champ `info` (cpu, mp, g, dt, la, etc.)
- Pattern Valves Pydantic pour configuration
### LLM Optimization (gemma4:e4b) ✅
- Installation gemma4:e4b (9.6GB, 128K context, MMLU 82.6%)
- Service systemd Ollama propre (User=folzo, auto-start)
- Variables: OLLAMA_FLASH_ATTENTION=1, OLLAMA_KV_CACHE_TYPE=q8_0, OLLAMA_CONTEXT_LENGTH=32768
- Note: gemma4:26b (18GB) ne tient pas dans 17GB VRAM RX 7800 XT
### RAG Optimization ✅
- effi-server: RAG_SYSTEM_CONTEXT=True (meilleur KV cache pour RAG)
- effi-server: ENABLE_RAG_HYBRID_SEARCH=True
**Améliorations futures**:
- Support historique (données des N dernières minutes)
- Filtres par période
- Graphiques et alertes automatisées
**Mars 2026** - Génération Audio
- Intégration ACE-Step pour génération musicale
- ComfyUI avec ROCm
**Février 2026** - Stack Vocale **Février 2026** - Stack Vocale
- effi-stt avec Whisper large-v3 - effi-stt avec Whisper large-v3
@@ -292,4 +335,4 @@ effi/
--- ---
*Dernière mise à jour : Août 2026* *Dernière mise à jour : Avril 2026*
+75
View File
@@ -0,0 +1,75 @@
# ═══════════════════════════════════════════════════════════
# OVERRIDE jean-router — Routing dynamique Jean + Ollama
#
# Usage (depuis /home/effi/effi sur serveur Effi) :
# docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d --build
#
# jean-router remplace LiteLLM :
# - Interroge dynamiquement Jean pour lister les presets
# - Route vers Jean ou Ollama selon le modèle demandé
# - Switch automatique de preset quand nécessaire
# - Nouveaux presets Jean automatiquement visibles dans OpenWebUI
# ═══════════════════════════════════════════════════════════
services:
# ─────────────────────────────────────────────────────────
# open-webui — repointe vers jean-router (API OpenAI directe)
# jean-router expose dynamiquement les presets Jean + modèles Ollama
# Plus besoin de LiteLLM, routing direct vers jean-router:3014
# ─────────────────────────────────────────────────────────
open-webui:
environment:
- OLLAMA_BASE_URL=http://host.docker.internal:3011
- OPENAI_API_BASE_URL=http://host.docker.internal:3014/v1
- OPENAI_API_KEY=any
- ENABLE_OPENAI_API=True
# ─────────────────────────────────────────────────────────
# jean-proxy — Reverse-proxy vers JeanOrchestrator (.11:8080) + WOL
# L'orchestrateur gere le switch auto MoE/27B.
# Ecoute :3012 sur le host (network_mode: host)
# ─────────────────────────────────────────────────────────
jean-proxy:
build: ./jean-proxy
container_name: jean-proxy
restart: unless-stopped
network_mode: host
environment:
- TARGET_HOST=${OLLAMA_HOST}
- TARGET_PORT=8080
- TARGET_MAC=${PC_GAMING_MAC}
- LISTEN_PORT=3012
- WAKE_TIMEOUT=60
- CHECK_INTERVAL=5
- BROADCAST_IP=${BROADCAST_IP}
- HEALTH_PATH=/health
# ─────────────────────────────────────────────────────────
# jean-router — Proxy dynamique qui interroge Jean pour
# lister les presets et route les requêtes vers Jean
# ou Ollama selon le modèle demandé.
# Ecoute :3014 sur le host (network_mode: host)
# ─────────────────────────────────────────────────────────
jean-router:
build: ./jean-router
container_name: jean-router
restart: unless-stopped
network_mode: host
environment:
- JEAN_WEB_HOST=${OLLAMA_HOST}
- JEAN_WEB_PORT=8090
- JEAN_PROXY_HOST=localhost
- JEAN_PROXY_PORT=3012
- OLLAMA_PROXY_HOST=localhost
- OLLAMA_PROXY_PORT=3011
- OLLAMA_DIRECT_HOST=${OLLAMA_HOST}
- OLLAMA_DIRECT_PORT=11434
- ENABLE_OLLAMA=true
- ENABLE_VRAM_MANAGEMENT=true
- OLLAMA_KEEP_ALIVE_MINUTES=5
- LISTEN_PORT=3014
- SWITCH_TIMEOUT=90
- CHECK_INTERVAL=2
- JEAN_STOP_TIMEOUT=30
- OLLAMA_UNLOAD_TIMEOUT=15
+13 -29
View File
@@ -37,56 +37,40 @@ services:
- "3000:8080" - "3000:8080"
environment: environment:
- OLLAMA_BASE_URL=http://host.docker.internal:3011 - OLLAMA_BASE_URL=http://host.docker.internal:3011
- OPENAI_API_BASE_URL=http://host.docker.internal:3012/v1
- OPENAI_API_KEY=any
- ENABLE_OPENAI_API=True
- WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY} - WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}
- VECTOR_DB=qdrant - VECTOR_DB=qdrant
- QDRANT_URI=http://qdrant:6333 - QDRANT_URI=http://qdrant:6333
- DEFAULT_LOCALE=fr-FR - DEFAULT_LOCALE=fr-FR
- ENABLE_MEMORIES=true - ENABLE_MEMORIES=true
- AUDIO_STT_ENGINE=openai - AUDIO_STT_ENGINE=openai
- AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:3015/v1 - AUDIO_STT_OPENAI_API_BASE_URL=http://192.168.1.11:8200/v1
- AUDIO_STT_OPENAI_API_KEY=anything - AUDIO_STT_OPENAI_API_KEY=anything
- AUDIO_STT_MODEL=Systran/faster-whisper-large-v3 - AUDIO_STT_MODEL=Systran/faster-whisper-large-v3
volumes: volumes:
- open-webui-data:/app/backend/data - open-webui-data:/app/backend/data
depends_on: depends_on:
- qdrant - qdrant
- llm-proxy - wol-proxy
networks: networks:
- effi-net - effi-net
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
# 2. LLM PROXY - Proxy unique vers le PC Gaming + Wake-on-LAN # 2. WOL PROXY - Réveil PC Gaming avant appel Ollama
# :3011 → Ollama (.11:11434) # → Proxy :3011 → PC Gaming :11434
# :3012 → llama.cpp (route virtuelle : instance active, alias qwen3.8-27b)
# :3015 → STT (.11:8200)
# :3016 → TTS (serveur local :8100, pas de WOL)
# :3017 → UI de gestion (switch d'instance llama.cpp + tests)
# Si le PC est éteint, un paquet WoL est envoyé avant l'attente.
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
llm-proxy: wol-proxy:
build: ./llm-proxy build: ./wol-proxy
container_name: llm-proxy container_name: wol-proxy
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
environment: environment:
- PC_HOST=${OLLAMA_HOST} - TARGET_HOST=${OLLAMA_HOST}
- PC_MAC=${PC_GAMING_MAC} - TARGET_PORT=11434
- BROADCAST_IP=${BROADCAST_IP} - TARGET_MAC=${PC_GAMING_MAC}
- WAKE_TIMEOUT=${WAKE_TIMEOUT:-300} - LISTEN_PORT=3011
- SERVICE_TIMEOUT=120 - WAKE_TIMEOUT=${WAKE_TIMEOUT:-180}
- CHECK_INTERVAL=5 - CHECK_INTERVAL=5
- LLAMACPP_A_PORT=${LLAMACPP_A_PORT:-8080} - BROADCAST_IP=${BROADCAST_IP}
- LLAMACPP_A_MODEL=${LLAMACPP_A_MODEL:-qwen3.8-27b}
- LLAMACPP_A_LABEL=${LLAMACPP_A_LABEL:-Q4_K_M}
- LLAMACPP_B_PORT=${LLAMACPP_B_PORT:-8081}
- LLAMACPP_B_MODEL=${LLAMACPP_B_MODEL:-qwen3.8-27b-iq4xs}
- LLAMACPP_B_LABEL=${LLAMACPP_B_LABEL:-IQ4_XS}
- DEFAULT_ACTIVE=${DEFAULT_ACTIVE:-qwen3.8-27b-iq4xs}
volumes:
- ./llm-proxy/state:/app/state
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
# 3. MCPO - Proxy outils MCP (Obsidian, etc.) # 3. MCPO - Proxy outils MCP (Obsidian, etc.)
@@ -0,0 +1,256 @@
# Session 2026-07-20 : jean-router et gestion VRAM dynamique
## Résumé
Création de **jean-router**, un proxy dynamique qui remplace LiteLLM pour router intelligemment entre Jean (llama.cpp) et Ollama, avec gestion automatique de la VRAM partagée.
## Contexte
### Problème initial
- OpenWebUI ne voyait pas les modèles Jean (Ornith) car configuré uniquement pour Ollama
- LiteLLM avait une configuration statique des modèles (codée en dur dans `litellm/config.yaml`)
- Nouveaux presets Jean nécessitaient de modifier manuellement la config LiteLLM
- Jean et Ollama partagent la même VRAM (RX 7800 XT, 16GB) → contention mémoire
### Architecture avant
```
OpenWebUI → LiteLLM:4000 (config statique)
├→ jean-proxy:3012 → Jean (.11:8080)
└→ wol-proxy:3011 → Ollama (.11:11434)
```
**Problèmes :**
- LiteLLM ne faisait pas de pass-through de `/v1/models`
- Configuration statique : `qwen3.6-reason`, `qwen3.6-27b`, etc. codés en dur
- Jean faisait tourner **Ornith** mais LiteLLM attendait Qwen3.6
- Pas de gestion VRAM : Jean et Ollama pouvaient charger simultanément → OOM
## Solution : jean-router
### Architecture après
```
OpenWebUI → jean-router:3014 (routing dynamique)
├→ jean-proxy:3012 → Jean (.11:8080)
└→ wol-proxy:3011 → Ollama (.11:11434)
```
### Fonctionnalités
**1. Découverte dynamique des modèles**
- Interroge `/api/presets` de Jean (port 8090) pour lister les presets disponibles
- Interroge `/api/tags` d'Ollama (port 11434) pour lister les modèles
- Expose tout via `/v1/models` (API OpenAI-compatible)
- **Nouveaux presets Jean automatiquement visibles dans OpenWebUI**
**2. Routing intelligent**
- Si le modèle demandé est un preset Jean → switch automatique + route vers Jean
- Si c'est un modèle Ollama → route vers Ollama
- Sinon → erreur 404
**3. Gestion VRAM (RX 7800 XT, 16GB partagés)**
| Backend | Modèle typique | VRAM utilisée |
|---------|---------------|---------------|
| Jean | Ornith 35B Q6_K | ~28.5GB (déborde en RAM) |
| Ollama | mistral/qwen3.5 | ~4-6GB |
**Logique de gestion :**
**Switch Jean → Ollama :**
1. `stop_jean()` arrête Jean via son API web (port 8090)
2. Attend confirmation que Jean est arrêté (max 30s)
3. Route la requête vers Ollama
**Switch Ollama → Jean :**
1. Programme le déchargement d'Ollama dans 5 minutes (timer)
2. `start_jean()` redémarre Jean
3. Attend que le modèle soit chargé (max 90s)
4. Route la requête vers Jean
**Timer Ollama :**
- Ollama reste en mémoire pendant **5 minutes** après le dernier usage
- Chaque requête vers Ollama annule le timer
- Si retour sur Ollama avant expiration → déchargement annulé
- Après 5 min d'inactivité → déchargement automatique pour libérer VRAM
## Fichiers créés/modifiés
### Nouveau : `jean-router/`
- `app.py` (320 lignes) : Service Flask avec routing dynamique + gestion VRAM
- `Dockerfile` : Container Python 3.12-slim
### Modifiés :
- `docker-compose.litellm.yml` :
- Ajout service jean-router
- Suppression service litellm
- OpenWebUI pointe directement vers jean-router:3014
- `litellm/config.yaml` : Supprimé (plus nécessaire)
### Configuration jean-router
```yaml
environment:
- JEAN_WEB_HOST=${OLLAMA_HOST}
- JEAN_WEB_PORT=8090
- JEAN_PROXY_HOST=localhost
- JEAN_PROXY_PORT=3012
- OLLAMA_PROXY_HOST=localhost
- OLLAMA_PROXY_PORT=3011
- OLLAMA_DIRECT_HOST=${OLLAMA_HOST}
- OLLAMA_DIRECT_PORT=11434
- ENABLE_OLLAMA=true
- ENABLE_VRAM_MANAGEMENT=true
- OLLAMA_KEEP_ALIVE_MINUTES=5
- LISTEN_PORT=3014
- SWITCH_TIMEOUT=90
- CHECK_INTERVAL=2
- JEAN_STOP_TIMEOUT=30
- OLLAMA_UNLOAD_TIMEOUT=15
```
## Déploiement sur effi-server
### Initialisation du repo git
Le dossier `/home/effi/effi` n'était pas un repo git. Initialisation :
```bash
cd /home/effi/effi
git init
git remote add origin https://gitea.folzocorp.fr/Folzo/Effi.git
git fetch origin
git checkout -b main origin/main
```
**Note :** Conflits avec fichiers existants (docker-compose.yml, jean-proxy/, etc.) résolus en les déplaçant temporairement dans `/tmp/effi-backup/`.
### Commandes de déploiement
```bash
cd /home/effi/effi
git pull
docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d --build jean-router
docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d --force-recreate open-webui
docker stop litellm && docker rm litellm # Suppression LiteLLM
```
## Tests et validation
### Modèles visibles dans OpenWebUI
**Jean (5 presets) :**
- ornith-35b-q6 (actif, Q6_K, reasoning)
- qwen3.6-27b-mtp (IQ4_XS, reasoning)
- qwen3.6-35b-moe-compact
- qwen3.6-35b-moe-compact-reason (reasoning)
- qwen3.6-35b-moe-nano
**Ollama :**
- mistral (7.2B, Q4_K_M, tools)
- qwen3.5:9b (9.7B, Q4_K_M, vision, thinking)
### Logs observés
**Switch Jean → Ollama :**
```
[VRAM] Switch Jean → Ollama
[VRAM] Arrêt de Jean pour libérer la VRAM...
[VRAM] Jean arrêté via API web
[VRAM] Jean confirmé arrêté
[Router] → Ollama (gemma4:e4b)
```
**Switch Ollama → Jean :**
```
[VRAM] Switch Ollama → Jean
[VRAM] Déchargement Ollama programmé dans 5 minutes
[VRAM] Démarrage de Jean...
[VRAM] Jean prêt après 45s
[Router] → Jean (ornith-35b-q6)
```
**Timer Ollama :**
```
[VRAM] Timer écoulé (5min), déchargement Ollama...
[VRAM] Ollama déchargé via API
```
## Avantages
1. **Dynamique** : Nouveaux presets Jean automatiquement visibles sans modifier de config
2. **Simplifié** : Une couche de moins (LiteLLM supprimé)
3. **VRAM-safe** : Pas de contention mémoire entre Jean et Ollama
4. **Intelligent** : Ollama reste en mémoire 5 min pour les usages répétés
5. **Transparent** : OpenWebUI voit tous les modèles via un seul endpoint
## Limitations et points d'attention
### 1. Jean n'a pas d'auto-unload natif
- Ollama décharge automatiquement après `OLLAMA_KEEP_ALIVE` (défaut 5min)
- Jean garde son modèle chargé tant que le service tourne
- **Solution actuelle** : Arrêt complet de Jean via API web
### 2. Latence de switch
- Jean → Ollama : ~3s (arrêt Jean)
- Ollama → Jean : ~45-90s (démarrage + chargement modèle)
- **Impact** : Première requête après switch plus lente
### 3. Timer Ollama non-persistant
- Si jean-router redémarre, le timer est perdu
- Ollama restera en mémoire indéfiniment (jusqu'à son propre timeout)
- **Acceptable** : Ollama gère son propre `keep_alive`
### 4. Pas de failover
- Si Jean échoue (timeout, erreur), pas de fallback automatique vers Ollama
- **À considérer** : Ajouter un fallback dans jean-router si besoin
## Commits Gitea
```
8b7c4fb feat: add VRAM management for Jean/Ollama switching
20767c2 feat: keep Ollama models in memory for 5 minutes
babe4ee chore: add OLLAMA_KEEP_ALIVE_MINUTES=5 to docker-compose
43aacc0 refactor: OpenWebUI points directly to jean-router (no LiteLLM needed)
856ba4a refactor: remove LiteLLM, jean-router handles all routing
fcad415 feat: jean-router — routing dynamique Jean + Ollama
b441f34 fix: jean-router syntax error (missing except block)
```
## Réflexions et décisions
### Pourquoi supprimer LiteLLM ?
- LiteLLM ne faisait pas de pass-through de `/v1/models`
- Configuration statique incompatible avec les presets dynamiques de Jean
- Couche supplémentaire inutile : jean-router fait le même travail
- **Décision** : OpenWebUI pointe directement vers jean-router:3014
### Pourquoi garder Ollama en mémoire 5 min ?
- Ollama sert principalement pour la vision (modèles que Jean ne supporte pas)
- Usage typique : quelques requêtes vision consécutives, puis retour à Jean
- 5 min = bon compromis entre réactivité et libération VRAM
- **Configurable** : `OLLAMA_KEEP_ALIVE_MINUTES` dans docker-compose
### Pourquoi arrêter Jean complètement ?
- Jean n'a pas d'API pour décharger le modèle sans arrêter le service
- Le modèle reste en VRAM tant que llama-server tourne
- **Alternative envisagée** : `systemctl stop jean` (mais nécessite sudo)
- **Solution retenue** : API web de Jean (`/api/stop`) plus simple
### Architecture future possible
- **JeanOrchestrator** : Jean pourrait gérer lui-même le switch entre presets
- **VRAM monitoring** : jean-router pourrait vérifier la VRAM disponible avant de router
- **Fallback automatique** : Si Jean échoue, router vers Ollama
- **Metrics** : Exposer des statistiques (temps de switch, VRAM utilisée, etc.)
## Prochaines étapes
1. **Monitorer** : Vérifier que la gestion VRAM fonctionne bien en production
2. **Ajuster** : Modifier `OLLAMA_KEEP_ALIVE_MINUTES` selon l'usage réel
3. **Documenter** : Mettre à jour `docs/ARCHITECTURE-LITELLM.md` (à renommer `ARCHITECTURE-JEAN-ROUTER.md`)
4. **Tester** : Valider que les nouveaux presets Jean apparaissent bien automatiquement
## Conclusion
**jean-router** remplace avantageusement LiteLLM avec :
- Routing dynamique basé sur les presets Jean
- Gestion automatique de la VRAM partagée
- Architecture simplifiée (une couche de moins)
- Nouveaux modèles automatiquement disponibles
L'architecture est maintenant prête pour Effi : quand un nouveau preset Jean est créé, il apparaît immédiatement dans OpenWebUI sans aucune configuration supplémentaire.
+313
View File
@@ -0,0 +1,313 @@
# Architecture LLM — jean-router (routing dynamique + gestion VRAM)
## Vue d'ensemble
```
┌─────────────────────────────────────────────────────────────────┐
│ OpenWebUI (:3000) │
│ http://192.168.1.27:3000 │
└────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ jean-router (:3014) │
│ Routing dynamique + Gestion VRAM automatique │
│ │
│ • Interroge Jean /api/presets (découverte dynamique) │
│ • Interroge Ollama /api/tags (modèles vision) │
│ • Switch automatique de preset Jean │
│ • Arrête Jean quand on passe sur Ollama (libère VRAM) │
│ • Garde Ollama 5 min en mémoire avant déchargement │
└────────┬──────────────────────────────────────┬────────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ jean-proxy (:3012) │ │ wol-proxy (:3011) │
│ + WOL si PC éteint │ │ + WOL si PC éteint │
└────────┬───────────────┘ └────────┬───────────────┘
│ │
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Jean (.11:8080) │ │ Ollama (.11:11434) │
│ llama.cpp server │ │ Modèles vision │
│ Presets dynamiques │ │ mistral, qwen3.5 │
│ Ornith, Qwen3.6... │ │ │
└────────────────────────┘ └────────────────────────┘
```
## Composants
### jean-router (`/jean-router/`) — Proxy dynamique
**Rôle :** Router intelligemment entre Jean et Ollama avec gestion VRAM
**Fonctionnalités :**
1. **Découverte dynamique** : Interroge `/api/presets` (Jean) et `/api/tags` (Ollama)
2. **Routing intelligent** : Route vers Jean ou Ollama selon le modèle demandé
3. **Switch automatique** : Change de preset Jean si nécessaire
4. **Gestion VRAM** : Évite la contention mémoire entre Jean et Ollama
**Endpoints :**
- `GET /v1/models` : Liste tous les modèles (Jean + Ollama)
- `POST /v1/chat/completions` : Route vers le bon backend
**Configuration :**
```bash
JEAN_WEB_HOST=192.168.1.11 # Jean Web UI (API presets)
JEAN_WEB_PORT=8090
JEAN_PROXY_HOST=localhost # jean-proxy (backend Jean)
JEAN_PROXY_PORT=3012
OLLAMA_PROXY_HOST=localhost # wol-proxy (backend Ollama)
OLLAMA_PROXY_PORT=3011
OLLAMA_DIRECT_HOST=192.168.1.11 # Ollama direct (pour unload)
OLLAMA_DIRECT_PORT=11434
ENABLE_OLLAMA=true
ENABLE_VRAM_MANAGEMENT=true
OLLAMA_KEEP_ALIVE_MINUTES=5 # Ollama reste 5 min en mémoire
SWITCH_TIMEOUT=90 # Timeout chargement Jean (s)
JEAN_STOP_TIMEOUT=30 # Timeout arrêt Jean (s)
LISTEN_PORT=3014
```
### jean-proxy (`/jean-proxy/`) — Reverse-proxy Jean + WOL
**Rôle :** Proxy vers Jean avec Wake-on-LAN si le PC gaming est éteint
**Fonctionnalités :**
- Écoute `:3012` sur le host (`network_mode: host`)
- Health-check : `GET /health` sur Jean
- Si cible DOWN → envoi paquets WOL vers `TARGET_MAC`
- Attente jusqu'à `WAKE_TIMEOUT` (60s)
- Route toutes les requêtes vers `http://${OLLAMA_HOST}:8080`
**Configuration :**
```bash
TARGET_HOST=192.168.1.11
TARGET_PORT=8080
TARGET_MAC=04:D9:F5:D1:EC:DF
LISTEN_PORT=3012
WAKE_TIMEOUT=60
CHECK_INTERVAL=5
BROADCAST_IP=192.168.1.255
HEALTH_PATH=/health
```
### wol-proxy (`/wol-proxy/`) — Reverse-proxy Ollama + WOL
**Rôle :** Proxy vers Ollama avec Wake-on-LAN
**Fonctionnalités :**
- Écoute `:3011` sur le host
- Health-check : `GET /api/version` sur Ollama
- Si cible DOWN → envoi paquets WOL
- Route toutes les requêtes vers `http://${OLLAMA_HOST}:11434`
**Configuration :**
```bash
TARGET_HOST=192.168.1.11
TARGET_PORT=11434
TARGET_MAC=04:D9:F5:D1:EC:DF
LISTEN_PORT=3011
WAKE_TIMEOUT=180
CHECK_INTERVAL=5
BROADCAST_IP=192.168.1.255
```
## Gestion VRAM
### Problème
Jean et Ollama partagent la même carte graphique (RX 7800 XT, 16GB VRAM). Charger les deux simultanément cause une contention mémoire.
| Backend | Modèle typique | VRAM utilisée |
|---------|---------------|---------------|
| Jean | Ornith 35B Q6_K | ~28.5GB (déborde en RAM) |
| Ollama | mistral 7B Q4_K_M | ~4GB |
| Ollama | qwen3.5:9b Q4_K_M | ~6GB |
### Solution : Switch automatique
**Switch Jean → Ollama :**
```
1. Arrêt de Jean via API web (/api/stop)
2. Attente confirmation (max 30s)
3. Route requête vers Ollama
```
**Switch Ollama → Jean :**
```
1. Programme déchargement Ollama dans 5 min (timer)
2. Démarrage de Jean via API web (/api/start)
3. Attente chargement modèle (max 90s)
4. Route requête vers Jean
```
**Timer Ollama :**
- Ollama reste en mémoire pendant **5 minutes** après le dernier usage
- Chaque requête vers Ollama annule le timer
- Si retour sur Ollama avant expiration → déchargement annulé
- Après 5 min d'inactivité → déchargement automatique
### Logs observés
**Switch Jean → Ollama :**
```
[VRAM] Switch Jean → Ollama
[VRAM] Arrêt de Jean pour libérer la VRAM...
[VRAM] Jean arrêté via API web
[VRAM] Jean confirmé arrêté
[Router] → Ollama (gemma4:e4b)
```
**Switch Ollama → Jean :**
```
[VRAM] Switch Ollama → Jean
[VRAM] Déchargement Ollama programmé dans 5 minutes
[VRAM] Démarrage de Jean...
[VRAM] Jean prêt après 45s
[Router] → Jean (ornith-35b-q6)
```
**Timer Ollama :**
```
[VRAM] Timer écoulé (5min), déchargement Ollama...
[VRAM] Ollama déchargé via API
```
## Modèles disponibles
### Jean (presets dynamiques)
Les presets sont découverts automatiquement via `/api/presets` :
- **ornith-35b-q6** (actif, Q6_K, reasoning)
- **qwen3.6-27b-mtp** (IQ4_XS, reasoning)
- **qwen3.6-35b-moe-compact**
- **qwen3.6-35b-moe-compact-reason** (reasoning)
- **qwen3.6-35b-moe-nano**
**Note :** Nouveaux presets Jean apparaissent automatiquement dans OpenWebUI sans configuration.
### Ollama (modèles vision)
- **mistral** (7.2B, Q4_K_M, tools)
- **qwen3.5:9b** (9.7B, Q4_K_M, vision, thinking)
**Note :** Ollama sert principalement pour les modèles avec vision que Jean ne supporte pas.
## Déploiement
### Commandes (sur effi-server .27)
```bash
cd /home/effi/effi
git pull
# Build et démarrage jean-router
docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d --build jean-router
# Recréer open-webui avec nouvelle config
docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d --force-recreate open-webui
# Vérifier
docker logs jean-router --tail 20
curl -s http://localhost:3014/v1/models | jq
```
### Tests
**1. Liste des modèles :**
```bash
curl -s http://localhost:3014/v1/models | python3 -m json.tool
```
**2. Requête Jean :**
```bash
curl -X POST http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ornith-35b-q6",
"messages": [{"role": "user", "content": "Bonjour"}],
"max_tokens": 50
}'
```
**3. Requête Ollama :**
```bash
curl -X POST http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "mistral",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 30
}'
```
**4. Vérifier logs VRAM :**
```bash
docker logs jean-router -f | grep -E "VRAM|Router"
```
## Avantages vs LiteLLM
| Aspect | LiteLLM | jean-router |
|--------|---------|-------------|
| Configuration | Statique (config.yaml) | Dynamique (API Jean) |
| Nouveaux presets | Modifier config + restart | Automatique |
| Pass-through /v1/models | Non | Oui |
| Gestion VRAM | Aucune | Automatique |
| Couches | 2 (LiteLLM + jean-proxy) | 1 (jean-router) |
| Complexité | Élevée | Faible |
## Limitations
### 1. Latence de switch
- Jean → Ollama : ~3s (arrêt Jean)
- Ollama → Jean : ~45-90s (démarrage + chargement modèle)
- **Impact** : Première requête après switch plus lente
### 2. Timer Ollama non-persistant
- Si jean-router redémarre, le timer est perdu
- Ollama restera en mémoire indéfiniment (jusqu'à son propre timeout)
- **Acceptable** : Ollama gère son propre `keep_alive`
### 3. Pas de failover
- Si Jean échoue (timeout, erreur), pas de fallback automatique vers Ollama
- **À considérer** : Ajouter un fallback dans jean-router si besoin
### 4. Jean n'a pas d'auto-unload natif
- Ollama décharge automatiquement après `OLLAMA_KEEP_ALIVE` (défaut 5min)
- Jean garde son modèle chargé tant que le service tourne
- **Solution actuelle** : Arrêt complet de Jean via API web
## Évolution possible
### Court terme
- **Monitorer** : Vérifier que la gestion VRAM fonctionne bien en production
- **Ajuster** : Modifier `OLLAMA_KEEP_ALIVE_MINUTES` selon l'usage réel
- **Tester** : Valider que les nouveaux presets Jean apparaissent bien automatiquement
### Moyen terme
- **VRAM monitoring** : jean-router pourrait vérifier la VRAM disponible avant de router
- **Fallback automatique** : Si Jean échoue, router vers Ollama
- **Metrics** : Exposer des statistiques (temps de switch, VRAM utilisée, etc.)
### Long terme
- **JeanOrchestrator** : Jean pourrait gérer lui-même le switch entre presets
- **Multi-GPU** : Si plusieurs GPU, router vers celui avec le plus de VRAM libre
- **Load balancing** : Si plusieurs instances Jean/Ollama, répartir la charge
## Historique
**2026-06-27** : Architecture initiale avec LiteLLM
- Routing statique par nom de modèle
- Configuration codée en dur dans `litellm/config.yaml`
**2026-07-20** : Migration vers jean-router
- Routing dynamique basé sur les presets Jean
- Gestion automatique de la VRAM
- Suppression de LiteLLM (une couche de moins)
- Nouveaux modèles automatiquement disponibles
## Références
- **Code jean-router** : `/jean-router/app.py`
- **Docker compose** : `/docker-compose.litellm.yml`
- **Journal de session** : `/docs/05-journal/SESSION-2026-07-20-JEAN-ROUTER.md`
- **Repo Gitea** : https://gitea.folzocorp.fr/Folzo/Effi
+1 -13
View File
@@ -8,23 +8,11 @@ Guide pour s'assurer que tous les services Effi démarrent automatiquement aprè
| Service | Port | Docker | Dépendance WOL | | Service | Port | Docker | Dépendance WOL |
|---------|------|--------|----------------| |---------|------|--------|----------------|
| **llama.cpp** (instance A — qwen3.8-27b Q4_K_M) | 8080 | Native (systemd) | Oui |
| **llama.cpp** (instance B — qwen3.8-27b-iq4xs) | 8081 | Native (systemd) | Oui |
| **Ollama** | 11434 | ✅ | Oui | | **Ollama** | 11434 | ✅ | Oui |
| **Effi-STT** | 8200 | ✅ | Oui | | **Effi-STT** | 8200 | ✅ | Oui |
| **openedai-speech** | 8100 | ✅ | Oui |
| **ComfyUI** | 8188 | ✅ | Oui | | **ComfyUI** | 8188 | ✅ | Oui |
> **Note :** le TTS (openedai-speech, port 8100) tourne désormais sur l'effi-server,
> pas sur folzo-Corp. Il n'a donc pas besoin de WOL.
> **Note VRAM :** les 2 instances llama.cpp partagent les 32 Go de VRAM. Une seule
> doit tourner à la fois : démarre l'instance voulue sur le PC, et active-la dans
> l'UI du proxy (http://192.168.1.27:3017).
Le réveil est géré automatiquement par **`llm-proxy`** (effi-server) : au premier appel
vers une cible éteinte, il envoie 3 paquets magic (MAC du PC) puis attend que le service
soit prêt. Aucun script manuel nécessaire.
--- ---
## 🚀 Configuration du démarrage automatique ## 🚀 Configuration du démarrage automatique
+72
View File
@@ -0,0 +1,72 @@
# Roadmap : wol-proxy → Orchestrateur Ollama
## Vision
Transformer le wol-proxy en point d'entree central pour TOUS les appels a Ollama,
avec des fonctions d'orchestration.
## Fonctionnalites futures
### 1. Logs centralises
- Enregistrer chaque requete : modele, prompt (taille), duree, status
- Detecter les erreurs recurrentes (timeout, 502, VRAM full)
- Tableau de bord des performances par modele
### 2. Temporisation / File d attente
- Si Open WebUI utilise deja Ollama (GPU occupe), mettre en file d attente
- Empecher les conflits VRAM entre services
- File FIFO avec timeout max
### 3. Orchestration multi-services
- Open WebUI (port 3000)
- Hermes Agent (profil local)
- Scripts de test automatises
- Futurs outils (n8n, pipelines RAG...)
### 4. Cache intelligent
- Mettre en cache les reponses identiques (system prompt identique)
- Cache-hit pricing comme DeepSeek
- Invalidation par duree ou par modele
### 5. Mode degraded
- Si PC gaming en veille : renvoyer une reponse "PC en veille, reessayez"
- Planifier un reveil automatique (cron)
- Fallback vers un petit modele local sur le J5040 si PC indisponible
### 6. Healthcheck / Monitoring
- Ping regulier du PC pour savoir s'il est eveille
- Statistiques : temps de reveil moyen, duree des requetes
- Alertes si PC injoignable depuis plus de X minutes
## Stack technique envisagee
```yaml
# Proposition d evolution du docker-compose
wol-orchestrator:
build: ./wol-orchestrator
volumes:
- ./logs:/var/log/orchestrator
- ./cache:/var/cache/orchestrator
environment:
- REDIS_URL=redis://redis:6379 # File d attente
- LOG_LEVEL=info
- WAKE_TIMEOUT=180
- REQUEST_TIMEOUT=300
- MAX_QUEUE=10
```
## Priorite
| Fonction | Priorite | Effort | Impact |
|----------|----------|--------|--------|
| Logs | Haute | Faible | Utile tout de suite |
| File attente | Haute | Moyen | Evite conflits VRAM |
| Cache | Moyenne | Moyen | Economise tokens |
| Orchestration | Moyenne | Important | Architecture propre |
| Mode degrade | Basse | Moyen | Securite |
| Monitoring | Basse | Faible | Visibilite |
## Note
Idee originale de Folzo (juin 2026) — a implementer avec OpenCode Go ou un
langage adapte (Go, Rust ou Python selon les besoins de performance).
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
# dépendances
RUN pip install --no-cache-dir flask requests
COPY app.py .
ENV LISTEN_PORT=3012
EXPOSE 3012
CMD ["python", "app.py"]
+123
View File
@@ -0,0 +1,123 @@
import os
import time
import socket
import logging
from urllib.parse import urljoin
import requests
from flask import Flask, request, Response
TARGET_HOST = os.getenv("TARGET_HOST", "192.168.1.11")
TARGET_PORT = int(os.getenv("TARGET_PORT", "8080"))
TARGET_MAC = os.getenv("TARGET_MAC", "")
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3012"))
WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "60"))
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5"))
BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255")
# Endpoint de sante de la cible : Ollama = /api/version, Jean = /health
HEALTH_PATH = os.getenv("HEALTH_PATH", "/health")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
def send_wol(mac_address: str):
mac = mac_address.replace(":", "").replace("-", "")
if len(mac) != 12:
raise ValueError("MAC invalide: %s" % mac_address)
data = bytes.fromhex("FF" * 6 + mac * 16)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for i in range(3):
s.sendto(data, (BROADCAST_IP, 9))
log.info(f"[WoL] Paquet magic #{i+1} envoye -> MAC={mac_address} via {BROADCAST_IP}:9")
def target_alive() -> bool:
try:
url = f"http://{TARGET_HOST}:{TARGET_PORT}{HEALTH_PATH}"
r = requests.get(url, timeout=2)
return r.ok
except Exception:
return False
def wait_for_target():
if target_alive():
log.info(f"[WoL] Cible deja UP ({TARGET_HOST}:{TARGET_PORT}{HEALTH_PATH})")
return
if not TARGET_MAC:
log.warning("[WoL] Pas de TARGET_MAC, skip WoL, attente directe")
else:
log.info(f"[WoL] Cible DOWN -> envoi WoL vers {TARGET_MAC}")
send_wol(TARGET_MAC)
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
remaining = int(deadline - time.time())
if target_alive():
log.info(f"[WoL] Cible UP apres reveil (reste {remaining}s)")
return
log.info(f"[WoL] En attente de la cible... ({remaining}s restantes)")
time.sleep(CHECK_INTERVAL)
raise RuntimeError(f"Cible ne repond pas apres {WAKE_TIMEOUT}s")
def proxy_request(path: str):
wait_for_target()
base = f"http://{TARGET_HOST}:{TARGET_PORT}"
upstream_url = urljoin(base + "/", path)
headers = dict(request.headers)
headers.pop("Host", None)
refused_msg = None
try:
resp = requests.request(
method=request.method,
url=upstream_url,
headers=headers,
data=request.get_data(),
cookies=request.cookies,
params=request.args,
allow_redirects=False,
stream=True,
timeout=None,
)
except Exception as e:
refused_msg = str(e)
if "Connection refused" in refused_msg and not refused_msg.endswith(" apres WoL"):
# Le service n est pas encore pret (ex: Jean qui charge son modele) -> attendre + retry
log.warning(f"[Proxy] connexion refusee, attente {WAKE_TIMEOUT}s puis retry")
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
time.sleep(CHECK_INTERVAL)
try:
resp = requests.request(
method=request.method, url=upstream_url, headers=headers,
data=request.get_data(), params=request.args,
allow_redirects=False, stream=True, timeout=None,
)
break
except Exception as e2:
log.info(f"[Proxy] retry en cours... ({int(deadline-time.time())}s)")
else:
return Response(refused_msg, status=502)
else:
return Response(refused_msg, status=502)
excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"}
response_headers = [
(name, value)
for name, value in resp.headers.items()
if name.lower() not in excluded
]
return Response(
resp.raw,
status=resp.status_code,
headers=response_headers,
direct_passthrough=True,
)
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
def handle(path):
return proxy_request(path)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=LISTEN_PORT, threaded=True)
@@ -6,6 +6,7 @@ RUN pip install --no-cache-dir flask requests
COPY app.py . COPY app.py .
EXPOSE 3011 3012 3015 3016 3017 ENV LISTEN_PORT=3014
EXPOSE 3014
CMD ["python", "app.py"] CMD ["python", "app.py"]
+1410
View File
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
# Plan de test — Capacités des LLM (vision, thinking)
## 1. Vérification de /v1/models
```bash
# Vérifier que les champs sont bien présents
curl -s http://localhost:3014/v1/models | python3 -c "
import json, sys
data = json.load(sys.stdin)['data']
for m in data:
ctx = m.get('context_length', '?')
max_tok = m.get('max_tokens', '?')
max_in = m.get('max_input_tokens', '?')
caps = m.get('capabilities', [])
print(f'{m[\"id\"]:45s} | ctx={str(ctx):>8s} | max_tok={str(max_tok):>8s} | caps={caps}')
"
# Forcer le refresh
curl -s "http://localhost:3014/v1/models?refresh=true" | python3 -c "..."
```
**Checklist :**
- [ ] Tous les modèles ont `context_length` (pas `?` ni `None`)
- [ ] Tous les modèles ont `max_tokens` et `max_input_tokens`
- [ ] Les modèles ont `capabilities` (pas vide, min `["completion"]`)
- [ ] Le preset "Ornith 1.0 35b Q4 MOE" a des champs valides
---
## 2. Test de vision (multimodal)
### 2.1 — Modèles censés supporter la vision (selon `/v1/models`)
| Modèle Ollama | Vision annoncée |
|---------------|----------------|
| qwen3.5:9b | vision |
| qwen3.5:0.8b | vision |
| qwen3.5:4b | vision |
| qwen3.5:2b | vision |
| qwen3.5 (latest) | vision |
| gemma4:12b | vision |
| ministral-3:14b | vision |
```bash
# Script de test (enregistre comme test_vision.sh)
for MODEL in qwen3.5:9b qwen3.5:4b qwen3.5:2b gemma4:12b ministral-3:14b; do
echo "=== Test vision: $MODEL ==="
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$MODEL\",
\"messages\": [{
\"role\": \"user\",
\"content\": \"Décris ce que tu vois dans l'image.\"
}],
\"max_tokens\": 100
}" | python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'error' in d:
print(f' ERREUR: {d[\"error\"]}')
else:
content = d['choices'][0]['message']['content']
print(f' REPONSE: {content[:200]}')
"
echo ""
done
```
### 2.2 — Test avec image réelle
```bash
# Encoder une image en base64 (necessite une image de test)
IMAGE_B64=$(base64 -w0 /path/to/test-image.jpg)
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d "{
\"model\": \"qwen3.5:9b\",
\"messages\": [{
\"role\": \"user\",
\"content\": [
{\"type\": \"text\", \"text\": \"Décris cette image en une phrase.\"},
{\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/jpeg;base64,$IMAGE_B64\"}}
]
}],
\"max_tokens\": 200
}" | python3 -m json.tool
```
---
## 3. Test de thinking/reasoning
### 3.1 — Modèles censés supporter le thinking
| Modèle | Thinking annoncé |
|--------|-----------------|
| qwen3.5:9b | thinking |
| qwen3.5:0.8b | thinking |
| qwen3.5:4b | thinking |
| qwen3.5:2b | thinking |
| qwen3.5 (latest) | thinking |
| qwen3:8b | thinking |
| deepseek-r1:14b | thinking |
| deepseek-r1 | thinking |
| gemma4:12b | thinking |
| gemma4:26b | thinking |
| gemma4:e4b | thinking |
| magistral | thinking |
| gpt-oss | thinking |
| gemma4 (latest) | thinking |
```bash
# Test de raisonnement
for MODEL in deepseek-r1:14b qwen3.5:9b qwen3:8b gemma4:12b; do
echo "=== Test thinking: $MODEL ==="
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$MODEL\",
\"messages\": [{
\"role\": \"user\",
\"content\": \"Si un train part de Paris à 100 km/h et un autre de Lyon à 150 km/h, distance 400 km, combien de temps avant qu'ils se croisent ? Explique ton raisonnement étape par étape.\"
}],
\"max_tokens\": 500
}" | python3 -c "
import json, sys
d = json.load(sys.stdin)
if 'error' in d:
print(f' ERREUR: {d[\"error\"]}')
else:
content = d['choices'][0]['message']['content']
print(f' REPONSE: {content[:300]}')
# Vérifier si la réponse contient un raisonnement structuré
has_reasoning = any(kw in content.lower() for kw in ['étape', 'd\'abord', 'premièrement', 'step', 'first', 'raisonn'])
print(f' STRUCTURE_VISIBLE: {has_reasoning}')
"
echo ""
done
```
### 3.2 — Test thinking avec le paramètre OpenAI `reasoning_effort`
> Pour les modèles Jean avec `reasoning: "on"` (Ornith, qwen3.6-27b-mtp, etc.)
```bash
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ornith-35b-q6",
"messages": [{"role": "user", "content": "Combien de 'r' y a-t-il dans le mot \"strawberry\" ?"}],
"max_tokens": 300
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(json.dumps(d, indent=2)[:2000])
"
# Le modèle devrait répondre : 3 (s-t-r-a-w-b-e-r-r-y → 3 'r')
# Si thinking est actif, il devrait montrer son raisonnement
```
---
## 4. Test des modèles sans certaines capacités
```bash
# Tester un modèle sans vision — lui envoyer une image devrait échouer
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "mistral",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Décris cette image."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
]
}],
"max_tokens": 100
}' | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f'Status attendu: ERREUR → Reçu: {\"error\" in d}')
"
```
---
## 5. Test de routing (Jean ↔ Ollama)
```bash
# Tester un modèle Jean (doit déclencher le switch VRAM)
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ornith-35b-q6",
"messages": [{"role": "user", "content": "Bonjour, es-tu fonctionnel ?"}],
"max_tokens": 50
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('choices',[{}])[0].get('message',{}).get('content','ERREUR:'+json.dumps(d))[:300])"
# Puis tester un modèle Ollama (doit déclencher le switch inverse)
curl -s http://localhost:3014/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "mistral",
"messages": [{"role": "user", "content": "Bonjour, es-tu fonctionnel ?"}],
"max_tokens": 50
}' | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('choices',[{}])[0].get('message',{}).get('content','ERREUR:'+json.dumps(d))[:300])"
```
---
## 6. Synthèse
| Check | Méthode |
|-------|---------|
| `/v1/models` a `max_tokens` + `max_input_tokens` | curl + jq |
| `/v1/models` a `capabilities` non-vide | curl + jq |
| Preset "Ornith 1.0 35b Q4 MOE" existe avec champs valides | curl + jq |
| Modèle `vision=true` accepte image sans erreur | curl + image base64 |
| Modèle `vision=false` rejette image | curl + image base64 |
| Modèle `thinking=true` montre raisonnement structuré | curl + analyse réponse |
| Switch Jean→Ollama fonctionne | 2 requêtes séquentielles |
| `?refresh=true` force la mise à jour du cache | 2 requêtes avec timestamp |
---
## 7. Mise à jour OpenFox
```bash
openfox update # télécharge la nouvelle version
kill $(lsof -t -i :10369) # tue l'ancien processus (sinon EADDRINUSE)
openfox # redémarre
```
---
## 8. Mise à jour jean-router sur effi-server
```bash
# Depuis la machine locale
scp jean-router/app.py effi-server:/home/effi/effi/jean-router/app.py
# Sur effi-server
ssh effi-server
cd /home/effi/effi
docker build -t effi-jean-router ./jean-router
docker stop jean-router && docker rm jean-router
docker run -d --name jean-router --network host \
--env-file /home/effi/effi/.env \
-e JEAN_WEB_HOST=192.168.1.11 \
-e OLLAMA_DIRECT_HOST=192.168.1.11 \
effi-jean-router
```
-498
View File
@@ -1,498 +0,0 @@
import os
import json
import time
import socket
import logging
import threading
from urllib.parse import urljoin
import requests
from flask import Flask, Response, request, jsonify
PC_HOST = os.getenv("PC_HOST", "192.168.1.11")
PC_MAC = os.getenv("PC_MAC", "")
BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255")
WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "300"))
SERVICE_TIMEOUT = int(os.getenv("SERVICE_TIMEOUT", "120"))
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5"))
STATE_FILE = os.getenv("STATE_FILE", "/app/state/active.json")
LISTEN_UI = int(os.getenv("LISTEN_UI", "3017"))
MODEL_ALIAS = os.getenv("MODEL_ALIAS", "qwen3.8-27b")
LLM_INSTANCES = [
{"port": int(os.getenv("LLAMACPP_A_PORT", "8080")),
"real": os.getenv("LLAMACPP_A_MODEL", "qwen3.8-27b"),
"label": os.getenv("LLAMACPP_A_LABEL", "Q4_K_M")},
{"port": int(os.getenv("LLAMACPP_B_PORT", "8081")),
"real": os.getenv("LLAMACPP_B_MODEL", "qwen3.8-27b-iq4xs"),
"label": os.getenv("LLAMACPP_B_LABEL", "IQ4_XS")},
]
DEFAULT_ACTIVE = os.getenv("DEFAULT_ACTIVE", "qwen3.8-27b-iq4xs")
ROUTES = [
{"name": "ollama", "port": int(os.getenv("OLLAMA_PORT", "11434")),
"listen": int(os.getenv("LISTEN_OLLAMA", "3011")), "health": "/api/version", "wol": True},
{"name": "llamacpp", "port": None,
"listen": int(os.getenv("LISTEN_LLAMACPP", "3012")), "health": "/health", "wol": True,
"alias": MODEL_ALIAS},
{"name": "stt", "port": int(os.getenv("STT_PORT", "8200")),
"listen": int(os.getenv("LISTEN_STT", "3015")), "health": "/health", "wol": True},
{"name": "tts", "host": os.getenv("TTS_HOST", "127.0.0.1"),
"port": int(os.getenv("TTS_PORT", "8100")),
"listen": int(os.getenv("LISTEN_TTS", "3016")), "health": "/v1/models", "wol": False},
]
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("llm-proxy")
wol_lock = threading.Lock()
wol_state = {"last_sent": 0.0}
state_lock = threading.Lock()
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
EXCLUDED_HEADERS = {"content-encoding", "content-length", "transfer-encoding", "connection"}
ALIAS_PATHS = ("v1/chat/completions", "v1/completions", "v1/models", "v1/embeddings")
def load_active():
try:
with open(STATE_FILE) as f:
data = json.load(f)
a = data.get("active", DEFAULT_ACTIVE)
if any(i["real"] == a for i in LLM_INSTANCES):
return a
except Exception:
pass
return DEFAULT_ACTIVE
def save_active(alias):
if not any(i["real"] == alias for i in LLM_INSTANCES):
raise ValueError("instance inconnue: %s" % alias)
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
tmp = STATE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump({"active": alias}, f)
os.replace(tmp, STATE_FILE)
def active_instance():
a = load_active()
for i in LLM_INSTANCES:
if i["real"] == a:
return i
return LLM_INSTANCES[0]
def send_wol():
mac = PC_MAC.replace(":", "").replace("-", "")
if len(mac) != 12:
raise ValueError("MAC invalide: %s" % PC_MAC)
data = bytes.fromhex("FF" * 6 + mac * 16)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for i in range(3):
s.sendto(data, (BROADCAST_IP, 9))
time.sleep(0.5)
log.info(f"[WoL] 3 paquets magic envoyes vers {PC_MAC} via {BROADCAST_IP}:9")
def service_alive(route) -> bool:
if route.get("port") is None:
return False
host = route.get("host", PC_HOST)
try:
r = requests.get(f"http://{host}:{route['port']}{route['health']}", timeout=3)
return r.ok
except Exception:
return False
def pc_alive() -> bool:
return any(service_alive(r) for r in ROUTES if r.get("wol"))
def wait_for(route, timeout: int) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if service_alive(route):
return True
log.info(f"[{route['name']}] en attente du service... ({int(deadline - time.time())}s restantes)")
time.sleep(CHECK_INTERVAL)
return service_alive(route)
def ensure_up(route):
name = route["name"]
if service_alive(route):
return
if not route["wol"]:
raise RuntimeError(f"service {name} inatteignable ({route.get('host', PC_HOST)}:{route['port']})")
with wol_lock:
if pc_alive():
fast = int(os.getenv("FAST_FAIL_TIMEOUT", "20"))
log.info(f"[WoL] PC deja UP, attente du service {name} (fail fast {fast}s)")
if not wait_for(route, fast):
raise RuntimeError(f"service {name} ne repond pas apres {fast}s (instance arretee ?)")
return
now = time.time()
if now - wol_state["last_sent"] > WAKE_TIMEOUT:
log.info(f"[WoL] PC DOWN -> envoi WOL vers {PC_MAC}")
send_wol()
wol_state["last_sent"] = now
else:
log.info(f"[WoL] reveil deja en cours, attente sans re-envoi")
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
if pc_alive():
log.info(f"[WoL] PC UP apres reveil")
break
log.info(f"[WoL] en attente du PC... ({int(deadline - time.time())}s restantes)")
time.sleep(CHECK_INTERVAL)
else:
raise RuntimeError(f"PC ne repond pas apres {WAKE_TIMEOUT}s")
if not wait_for(route, SERVICE_TIMEOUT):
raise RuntimeError(f"service {route['name']} ne repond pas apres le reveil du PC")
def rewrite_request_body(body: bytes, real: str) -> bytes:
try:
obj = json.loads(body)
except Exception:
return body
if isinstance(obj, dict) and obj.get("model") == MODEL_ALIAS:
obj["model"] = real
return json.dumps(obj, ensure_ascii=False).encode()
return body
def rewrite_json(obj, real: str) -> bool:
changed = False
if isinstance(obj, dict):
if obj.get("model") == real:
obj["model"] = MODEL_ALIAS
changed = True
for key in ("data", "models"):
items = obj.get(key)
if not isinstance(items, list):
continue
for m in items:
if not isinstance(m, dict):
continue
if m.get("id") == real:
m["id"] = MODEL_ALIAS
changed = True
for field in ("name", "model"):
if m.get(field) == real:
m[field] = MODEL_ALIAS
changed = True
aliases = m.get("aliases")
if isinstance(aliases, list) and real in aliases:
m["aliases"] = [MODEL_ALIAS if a == real else a for a in aliases]
changed = True
return changed
def rewrite_sse_line(line: bytes, real: str) -> bytes:
if line.startswith(b"data:"):
payload = line[5:].lstrip(b" ")
if payload and payload != b"[DONE]":
try:
obj = json.loads(payload)
if isinstance(obj, dict) and obj.get("model") == real:
obj["model"] = MODEL_ALIAS
return b"data: " + json.dumps(obj, ensure_ascii=False).encode()
except Exception:
pass
return line
def sse_response(resp, real: str):
def generate():
buf = b""
for chunk in resp.iter_content(chunk_size=4096):
if not chunk:
continue
buf += chunk
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
yield rewrite_sse_line(line, real) + b"\n"
if buf:
yield rewrite_sse_line(buf, real) + b"\n"
headers = [(n, v) for n, v in resp.headers.items() if n.lower() not in EXCLUDED_HEADERS]
return Response(generate(), status=resp.status_code, headers=headers,
content_type="text/event-stream; charset=utf-8")
def proxy_request(route, path: str):
real = None
alias_path = None
if route.get("alias"):
inst = active_instance()
route = dict(route)
route["port"] = inst["port"]
real = inst["real"]
norm = path.lstrip("/")
if any(norm == p or norm.startswith(p + "/") for p in ALIAS_PATHS):
alias_path = norm
try:
ensure_up(route)
except Exception as e:
return Response(str(e), status=503)
host = route.get("host", PC_HOST)
base = f"http://{host}:{route['port']}"
upstream_url = urljoin(base + "/", path)
headers = dict(request.headers)
headers.pop("Host", None)
body = request.get_data()
want_stream = False
if alias_path and request.method == "POST" and body:
try:
jd = json.loads(body)
want_stream = bool(jd.get("stream"))
except Exception:
pass
body = rewrite_request_body(body, real)
try:
resp = requests.request(
method=request.method,
url=upstream_url,
headers=headers,
data=body,
cookies=request.cookies,
params=request.args,
allow_redirects=False,
stream=True,
timeout=None,
)
except Exception as e:
log.warning(f"[{route['name']}] erreur upstream: {e}")
return Response(str(e), status=502)
response_headers = [(n, v) for n, v in resp.headers.items() if n.lower() not in EXCLUDED_HEADERS]
if alias_path and resp.status_code == 200:
ctype = resp.headers.get("content-type", "")
if "application/json" in ctype:
data = resp.content
try:
obj = json.loads(data)
if rewrite_json(obj, real):
data = json.dumps(obj, ensure_ascii=False).encode()
except Exception:
pass
return Response(data, status=resp.status_code, headers=response_headers,
content_type="application/json")
if want_stream and "text/event-stream" in ctype:
return sse_response(resp, real)
return Response(
resp.raw,
status=resp.status_code,
headers=response_headers,
direct_passthrough=True,
)
def make_proxy_app(route):
app = Flask(route["name"])
@app.route("/", defaults={"path": ""}, methods=METHODS)
@app.route("/<path:path>", methods=METHODS)
def handle(path):
return proxy_request(route, path)
return app
def instance_up(inst) -> bool:
try:
r = requests.get(f"http://{PC_HOST}:{inst['port']}/health", timeout=3)
return r.ok
except Exception:
return False
def instance_info(inst):
info = {"port": inst["port"], "real": inst["real"], "label": inst["label"],
"up": instance_up(inst), "model": None}
if info["up"]:
try:
r = requests.get(f"http://{PC_HOST}:{inst['port']}/v1/models", timeout=5)
r.raise_for_status()
m = (r.json().get("data") or [{}])[0]
d = m.get("details", {}) or {}
meta = m.get("meta", {}) or {}
info["model"] = {"id": m.get("id"),
"n_ctx": d.get("n_ctx") or meta.get("n_ctx"),
"size": d.get("size") or meta.get("size"),
"ftype": d.get("ftype") or meta.get("ftype")}
except Exception:
pass
return info
INDEX_HTML = """<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llm-proxy — instances llama.cpp</title>
<style>
body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:24px}
h1{font-size:20px;margin:0 0 4px}
.small{font-size:12px;color:#94a3b8}
.card{background:#1e293b;border:1px solid #334155;border-radius:10px;padding:16px}
.card.active{border-color:#22c55e;box-shadow:0 0 0 1px #22c55e}
.row{display:flex;gap:16px;flex-wrap:wrap;margin-top:12px}
.card{flex:1;min-width:260px}
.tag{font-size:12px;padding:2px 8px;border-radius:99px;background:#334155;display:inline-block}
.tag.up{background:#14532d;color:#86efac}
.tag.down{background:#7f1d1d;color:#fca5a5}
button{background:#2563eb;border:0;color:#fff;padding:8px 14px;border-radius:8px;cursor:pointer;font-size:13px;margin-top:10px}
button:hover{background:#1d4ed8}
button.warn{background:#7c2d12}
button.warn:hover{background:#9a3412}
.model{font-size:13px;color:#cbd5e1;margin-top:8px;min-height:16px}
.warnbox{background:#451a03;border:1px solid #92400e;border-radius:8px;padding:10px 14px;margin-top:16px;font-size:13px}
.result{margin-top:8px;font-size:12px;white-space:pre-wrap;background:#0f172a;border-radius:6px;padding:8px;display:none}
</style>
</head>
<body>
<h1>llm-proxy — switch d'instances llama.cpp</h1>
<div class="small">Route :3012 &rarr; instance active &middot; alias mod&egrave;le <b>qwen3.8-27b</b> &middot; <span id="upd"></span></div>
<div class="row" id="instances"></div>
<div class="warnbox">&#9888;&#65039; Une seule instance &agrave; la fois : chaque instance occupe ~toute la VRAM (32 Go).
D&eacute;marrer l'instance choisie sur le PC (et arr&ecirc;ter l'autre) avant de l'activer ici.</div>
<script>
async function refresh(){
try{
const s = await (await fetch('/api/status')).json();
const box = document.getElementById('instances');
box.innerHTML = '';
s.instances.forEach(i=>{
const active = i.real === s.active;
const m = i.model || {};
const d = document.createElement('div');
d.className = 'card' + (active ? ' active' : '');
const ctx = m.n_ctx ? 'ctx ' + m.n_ctx : '';
const size = m.size ? Math.round(m.size/1e9) + ' GB' : '';
const detail = i.up ? (m.id + ' &middot; ' + [ctx,size].filter(Boolean).join(' &middot; ')) : 'ne repond pas';
d.innerHTML =
'<div><b>' + i.label + '</b> <span class="tag">:' + i.port + '</span> ' +
'<span class="tag ' + (i.up ? 'up' : 'down') + '">' + (i.up ? 'UP' : 'DOWN') + '</span> ' +
(active ? '<span class="tag up">ACTIVE</span>' : '') + '</div>' +
'<div class="model">' + detail + '</div>' +
'<div>' + (active ? '' : '<button onclick="activate(\\'' + i.real + '\\')">Activer</button>') +
'<button class="warn" onclick="doTest(\\'' + i.real + '\\',' + i.port + ')">Tester</button></div>' +
'<div class="result" id="res-' + i.port + '"></div>';
box.appendChild(d);
});
document.getElementById('upd').textContent = 'maj ' + new Date().toLocaleTimeString();
}catch(e){
document.getElementById('upd').textContent = 'erreur: ' + e.message;
}
}
async function activate(real){
await fetch('/api/activate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({real})});
refresh();
}
async function doTest(real, port){
const el = document.getElementById('res-' + port);
el.style.display = 'block';
el.textContent = 'test en cours...';
try{
const r = await (await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({real})})).json();
el.textContent = r.ok ? ('OK en ' + r.latency_ms + ' ms \\u2192 ' + r.content) : ('ERREUR: ' + (r.error || '?'));
}catch(e){
el.textContent = 'ERREUR: ' + e.message;
}
}
setInterval(refresh, 3000);
refresh();
</script>
</body>
</html>
"""
def make_ui_app():
app = Flask("ui")
@app.route("/")
def index():
return Response(INDEX_HTML, content_type="text/html; charset=utf-8")
@app.route("/api/status")
def status():
return jsonify({"active": active_instance()["real"],
"instances": [instance_info(i) for i in LLM_INSTANCES]})
@app.route("/api/activate", methods=["POST"])
def activate():
data = request.get_json(silent=True) or {}
real = data.get("real")
if not any(i["real"] == real for i in LLM_INSTANCES):
return jsonify({"ok": False, "error": "instance inconnue"}), 400
with state_lock:
save_active(real)
log.info(f"[UI] instance active changee -> {real}")
return jsonify({"ok": True, "active": real})
@app.route("/api/test", methods=["POST"])
def test_chat():
data = request.get_json(silent=True) or {}
real = data.get("real")
inst = next((i for i in LLM_INSTANCES if i["real"] == real), None)
if not inst:
return jsonify({"ok": False, "error": "instance inconnue"}), 400
t0 = time.time()
try:
r = requests.post(
f"http://{PC_HOST}:{inst['port']}/v1/chat/completions",
json={"model": real,
"messages": [{"role": "user", "content": "Reponds uniquement : OK"}],
"max_tokens": 16, "stream": False,
"chat_template_kwargs": {"enable_thinking": False}},
timeout=SERVICE_TIMEOUT)
latency = int((time.time() - t0) * 1000)
if not r.ok:
return jsonify({"ok": False, "latency_ms": latency,
"error": f"HTTP {r.status_code}: {r.text[:200]}"})
obj = r.json()
content = obj.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
return jsonify({"ok": True, "latency_ms": latency, "content": content[:200]})
except Exception as e:
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/wake", methods=["POST"])
def wake():
with wol_lock:
if pc_alive():
return jsonify({"ok": True, "message": "PC deja UP"})
now = time.time()
if now - wol_state["last_sent"] > WAKE_TIMEOUT:
send_wol()
wol_state["last_sent"] = now
return jsonify({"ok": True, "message": "WOL envoye"})
return jsonify({"ok": True, "message": "reveil deja en cours"})
return app
def serve(app, port):
app.run(host="0.0.0.0", port=port, threaded=True)
if __name__ == "__main__":
for r in ROUTES:
target = f"{r.get('host', PC_HOST)}:{r['port']}" if r.get("port") is not None \
else f"instance active (etat: {STATE_FILE})"
log.info(f"[{r['name']}] :{r['listen']} -> {target} (wol={r['wol']})")
threading.Thread(target=serve, args=(make_proxy_app(r), r["listen"]), daemon=True).start()
log.info(f"[ui] :{LISTEN_UI} -> interface de gestion")
threading.Thread(target=serve, args=(make_ui_app(), LISTEN_UI), daemon=True).start()
while True:
time.sleep(60)
-1
View File
@@ -1 +0,0 @@
{}
-50
View File
@@ -1,50 +0,0 @@
[botdetection]
# The prefix defines the number of leading bits in an address that are compared
# to determine whether or not an address is part of a (client) network.
ipv4_prefix = 32
ipv6_prefix = 48
# If the request IP is in trusted_proxies list, the client IP address is
# extracted from the X-Forwarded-For and X-Real-IP headers. This should be
# used if SearXNG is behind a reverse proxy or load balancer.
trusted_proxies = [
'127.0.0.0/8',
'::1',
'172.19.0.0/16',
# '192.168.0.0/16',
# '172.16.0.0/12',
# '10.0.0.0/8',
# 'fd00::/8',
]
[botdetection.ip_limit]
# To get unlimited access in a local network, by default link-local addresses
# (networks) are not monitored by the ip_limit
filter_link_local = false
# activate link_token method in the ip_limit method
link_token = false
[botdetection.ip_lists]
# In the limiter, the ip_lists method has priority over all other methods -> if
# an IP is in the pass_ip list, it has unrestricted access and it is also not
# checked if e.g. the "user agent" suggests a bot (e.g. curl).
block_ip = [
# '93.184.216.34', # IPv4 of example.org
# '257.1.1.1', # invalid IP --> will be ignored, logged in ERROR class
]
pass_ip = [
# '192.168.0.0/16', # IPv4 private network
# 'fe80::/10' # IPv6 linklocal / wins over botdetection.ip_limit.filter_link_local
]
# Activate passlist of (hardcoded) IPs from the SearXNG organization,
# e.g. `check.searx.space`.
pass_searxng_org = true
-61
View File
@@ -1,61 +0,0 @@
use_default_settings: true
general:
instance_name: "Effi Search"
debug: false
search:
format: json
max_results: 15
default_lang: fr
safe_search: 0
autocomplete: "google"
default_locale: fr
formats:
- html
- json
server:
secret_key: "${SEARXNG_SECRET_KEY}"
bind_address: "0.0.0.0"
port: 8080
limiter: true
image_proxy: true
public_instance: false
method: "GET"
default_http_headers:
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Robots-Tag: noindex, nofollow
Referrer-Policy: no-referrer
valkey:
url: "valkey://172.19.0.12:6379/0"
limiter:
enabled: true
search_url: false
key:
- "client_ip"
limits:
search: "5/minute;60/hour;500/day"
other: "10/minute;100/hour"
whitelist: []
ui:
default_theme: simple
static_use_hash: true
default_view: index
infinite_scroll: false
outgoing:
request_timeout: 10.0
max_request_timeout: 15.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
useragent_suffix: ""
enable_obfuscation: false
enable_http2: true
-26
View File
@@ -1,26 +0,0 @@
# Redis configuration for SearXNG rate limiting
bind 0.0.0.0
protected-mode no
port 6379
tcp-backlog 511
timeout 0
tcp-keepalive 300
daemonize no
supervised no
pidfile /var/run/redis_6379.pid
loglevel notice
databases 1
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename dump.rdb
dir /data
appendonly no
appendfilename "appendonly.aof"
hz 10
dynamic-hz yes
aof-rewrite-incremental-fsync yes
rdb-save-incremental-fsync yes
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
# dépendances
RUN pip install --no-cache-dir flask requests
COPY app.py .
ENV LISTEN_PORT=3010
EXPOSE 3010
CMD ["python", "app.py"]
+98
View File
@@ -0,0 +1,98 @@
import os
import time
import socket
import logging
from urllib.parse import urljoin
import requests
from flask import Flask, request, Response
TARGET_HOST = os.getenv("TARGET_HOST", "192.168.1.10")
TARGET_PORT = int(os.getenv("TARGET_PORT", "11434"))
TARGET_MAC = os.getenv("TARGET_MAC", "02:76:C6:01:48:C8")
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3010"))
WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "60"))
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5"))
BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255") # ← broadcast dirigé
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
def send_wol(mac_address: str):
mac = mac_address.replace(":", "").replace("-", "")
if len(mac) != 12:
raise ValueError("MAC invalide: %s" % mac_address)
data = bytes.fromhex("FF" * 6 + mac * 16)
# Envoyer 3 paquets pour fiabilité
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for i in range(3):
s.sendto(data, (BROADCAST_IP, 9))
log.info(f"[WoL] Paquet magic #{i+1} envoyé → MAC={mac_address} via {BROADCAST_IP}:9")
def ollama_alive() -> bool:
try:
url = f"http://{TARGET_HOST}:{TARGET_PORT}/api/version"
r = requests.get(url, timeout=2)
return r.ok
except Exception:
return False
def wait_for_ollama():
if ollama_alive():
log.info("[WoL] Ollama déjà UP, skip WoL")
return
log.info(f"[WoL] Ollama DOWN → envoi WoL vers {TARGET_MAC}")
send_wol(TARGET_MAC)
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
remaining = int(deadline - time.time())
if ollama_alive():
log.info(f"[WoL] Ollama UP après réveil (reste {remaining}s)")
return
log.info(f"[WoL] En attente d'Ollama... ({remaining}s restantes)")
time.sleep(CHECK_INTERVAL)
raise RuntimeError("Ollama ne répond pas après WoL")
def proxy_request(path: str):
wait_for_ollama()
base = f"http://{TARGET_HOST}:{TARGET_PORT}"
upstream_url = urljoin(base + "/", path)
headers = dict(request.headers)
headers.pop("Host", None)
try:
resp = requests.request(
method=request.method,
url=upstream_url,
headers=headers,
data=request.get_data(),
cookies=request.cookies,
allow_redirects=False,
stream=True,
timeout=60,
)
except Exception as e:
return Response(str(e), status=502)
excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"}
response_headers = [
(name, value)
for name, value in resp.headers.items()
if name.lower() not in excluded
]
return Response(
resp.raw,
status=resp.status_code,
headers=response_headers,
direct_passthrough=True,
)
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
def handle(path):
return proxy_request(path)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=LISTEN_PORT)