refactor: remplacer les proxies Jean par un unique llm-proxy + WOL

- llm-proxy: 1 service, 4 routes (3011 Ollama, 3012 llama.cpp, 3015 STT, 3016 TTS)
- Wake-on-LAN integre avec anti-spam (3 paquets magic, timeout 300s)
- suppression de jean-router, jean-proxy, wol-proxy, litellm
- OpenWebUI repointe vers le proxy (OpenAI 3012 + Ollama 3011 + STT 3015)
- docs a jour (README, WOL)
This commit is contained in:
Effi Server
2026-08-22 09:26:24 +00:00
parent 14460d387b
commit 5ab8242134
15 changed files with 260 additions and 1718 deletions
+62 -132
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 |
|---------|-----|------|----------|
| **effi-server** | `192.168.1.27` | Serveur Docker principal | OpenWebUI, Qdrant, n8n, jean-router |
| **folzo-Corp** | `192.168.1.11` | LLM + STT + TTS + Gen | Jean, Ollama, ComfyUI, effi-stt, openedai-speech, kreuzberg |
| **effi-server** | `192.168.1.27` | Serveur Docker principal | OpenWebUI, Qdrant, n8n, llm-proxy, TTS |
| **folzo-Corp** | `192.168.1.11` | Station GPU (LLM + STT) | llama.cpp, Ollama, ComfyUI, effi-stt, kreuzberg |
| **NAS Synology** | `192.168.1.25` | Monitoring | Beszel Hub |
### Schéma Global
@@ -24,33 +24,26 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
│ │ │
│ Monitoring │ Monitoring │ Monitoring
▼ ▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ EFFI SERVER (192.168.1.27) │ │ FOLZO-CORP (192.168.1.11) │
│ │ │ │
│ ┌──────────────┐ ┌──────────┐ │ │ ┌──────────────┐ ┌──────────────┐ │
│ │ Open WebUI │ │ Qdrant │ │ │ │ Jean │ │ Ollama │ │
│ │ Port 3000 │ │ Port 6333│ │ │ │ Port 8080 │ │ Port 11434 │ │
│ └──────────────┘ └──────────┘ │ │ │ llama.cpp │ │ Vision │ │
│ │ │ │ Ornith 35B │ │ mistral, │ │
│ ┌──────────────┐ ┌──────────┐ │ │ │ Qwen3.6... │ │ qwen3.5:9b │ │
│ │ n8n │ │jean-router│ │ │ └──────────────┘ └──────────────┘ │
│ │ Port 5678 │ │ Port 3014│ │ │ │
│ └──────────────┘ └──────────┘ │ │ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ ComfyUI │ │ effi-stt │ │
│ ┌──────────────┐ ┌──────────┐ │ │ │ Port 8188 │ │ Port 8200 │ │
│ │ Beszel Agent │ │ Watchtower│ │ │ │ Image/Audio │ │ Whisper STT │ │
│ └──────────────┘ └──────────┘ │ │ └──────────────┘ └──────────────┘ │
└─────────────────────────────────┘ │ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ openedai │ │ kreuzberg │ │
│ │ Port 8100 │ │ Port 8300 │ │
│ │ TTS │ │ Doc Extract │ │
│ └──────────────┘ └──────────────┘ │
│ RX 7800 XT (VRAM 16GB) │
└─────────────────────────────────────────┘
┌───────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ EFFI SERVER (192.168.1.27) │ │ FOLZO-CORP (192.168.1.11) │
│ │ │ │
│ ┌──────────────┐ ┌────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ │
│ │ Open WebUI │ │ llm-proxy │ │ │ │ llama.cpp │ │ Ollama │ │
│ │ Port 3000 │ │ :3011 Ollama │ │ │ │ Port 8080 │ │ Port 11434 │ │
│ └──────────────┘ │ :3012 llama.cpp│ │ │ │ (modèle │ │ (vision, │ │
│ ┌──────────────┐ │ :3015 STT │ │ │ │ principal) │ │ embeddings) │ │
│ │ Qdrant │ │ :3016 TTS │ │ │ └──────────────┘ └──────────────┘ │
│ │ Port 6333 │ │ + Wake-on-LAN │ │ │ │
│ └──────────────┘ └────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ ComfyUI │ │ effi-stt │ │
│ ┌──────────────┐ ┌──────────────┐ │ │ │ Port 8188 │ │ Port 8200 │ │
│ │ n8n │ │ openedai │ │ │ │ Image/Audio │ │ Whisper STT │ │
│ │ Port 5678 │ │ TTS :8100 │ │ │ └──────────────┘ └──────────────┘ │
│ └──────────────┘ └──────────────┘ │ │ │
└───────────────────────────────────────┘ └─────────────────────────────────────────┘
```
**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.
**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).
---
@@ -60,16 +53,13 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
| Service | Port | Image | Rôle | RAM/VRAM | Auto-unload |
|---------|------|-------|------|----------|-------------|
| **Jean** | 8080 | Native | LLM inference (llama.cpp, presets dynamiques) | ~28GB | Arrêt via API |
| **Ollama** | 11434 | Native | LLM vision (mistral, qwen3.5:9b) | ~4-6GB | 5min après dernier usage |
| **llama.cpp** | 8080 | Native | LLM principal (qwen3.8-27b) | ~28GB | Démarré au boot |
| **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 |
| **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 |
| **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)
| Service | Port | Image | Rôle |
@@ -77,9 +67,8 @@ 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 |
| **Qdrant** | 6333 | `qdrant/qdrant:latest` | Vector DB (RAG) |
| **n8n** | 5678 | `docker.n8n.io/n8nio/n8n:latest` | Automatisation |
| **jean-router** | 3014 | Build local | Routing dynamique Jean/Ollama + gestion VRAM |
| **jean-proxy** | 3012 | Build local | Wake-on-LAN + Jean proxy |
| **wol-proxy** | 3011 | Build local | Wake-on-LAN + Ollama proxy |
| **llm-proxy** | 3011/3012/3015/3016 | Build local | Proxy unique + Wake-on-LAN |
| **openedai-speech** | 8100 | `ghcr.io/matatonic/openedai-speech` | TTS |
| **beszel-agent** | - | `henrygd/beszel-agent:latest` | Monitoring |
### NAS Synology (192.168.1.25)
@@ -94,35 +83,24 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis
### 💬 Chat IA
- **Interface**: Open WebUI
- **LLM principal**: Jean (llama.cpp) avec presets dynamiques
- **ornith-35b-q6** (35B, Q6_K, reasoning) — modèle principal
- **qwen3.6-27b-mtp** (27B, IQ4_XS, reasoning, MTP)
- **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 principal**: llama.cpp (qwen3.8-27b) via `llm-proxy:3012`
- **LLM vision / embeddings**: Ollama via `llm-proxy:3011`
- **Wake-on-LAN automatique**: le proxy réveille la station GPU si elle est éteinte
**Note :** Nouveaux presets Jean apparaissent automatiquement dans OpenWebUI sans configuration. Voir [ARCHITECTURE-JEAN-ROUTER.md](docs/ARCHITECTURE-JEAN-ROUTER.md) pour les détails.
### 🚀 jean-router — Routing dynamique LLM
- **Découverte automatique**: Interroge Jean et Ollama pour lister les modèles disponibles
- **Routing intelligent**: Route vers Jean (presets) ou Ollama (vision) selon le modèle demandé
- **Switch automatique**: Change de preset Jean si nécessaire (via API web)
- **Gestion VRAM**:
- Arrête Jean quand on passe sur Ollama (libère VRAM)
- Garde Ollama en mémoire 5 min avant déchargement automatique
- Évite la contention mémoire entre les deux backends
- **API OpenAI-compatible**: `/v1/models` et `/v1/chat/completions`
### 🔌 llm-proxy — Proxy unique + Wake-on-LAN
- **4 routes** : Ollama (`:3011`), llama.cpp (`:3012`), STT (`:3015`), TTS (`:3016`)
- **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
- **API OpenAI-compatible** : `/v1/models` et `/v1/chat/completions` sur les routes LLM
- **Streaming** : passthrough des réponses SSE
### 🎤 Pipeline Vocal
- **STT**: Whisper large-v3 via effi-stt (CPU, déchargement auto)
- **TTS**: XTTS v2 via openedai-speech (GPU ROCm)
- **TTS**: openedai-speech (serveur, voix Piper + XTTS)
### 🖼️ Génération Images/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
### 📄 Extraction Documents
@@ -148,12 +126,6 @@ Outil custom pour interroger Beszel depuis OpenWebUI via Effi.
- `get_system_stats` → retourne CPU, RAM, GPU (%)
- 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)
### Scripts Memory
@@ -166,22 +138,14 @@ Outil custom pour interroger Beszel depuis OpenWebUI via Effi.
### 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
# Statut services
sudo systemctl status comfyui-memory-manager kreuzberg-memory-manager
docker ps
docker logs -f llm-proxy
# Logs
journalctl -u comfyui-memory-manager -f
docker logs -f comfyui
# Tester le proxy (PC allumé)
curl http://192.168.1.27:3012/health
curl http://192.168.1.27:3011/api/version
# Métriques Beszel
python3 scripts/beszel_client.py --system folzocorp --check stats
@@ -191,8 +155,7 @@ python3 scripts/beszel_client.py --system folzocorp --check stats
## 📚 Documentation
- [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
- [WOL et démarrage auto](./docs/WOL-ET-DEMARRAGE-AUTO.md)
- [Installation Beszel](./docs/BESZEL-INSTALLATION.md)
- [Gestion Mémoire](./docs/MEMOIRE-GESTION.md)
- [Session 2026-04-12](./docs/SESSION-2026-04-12-RAM-BESZEL.md)
@@ -205,11 +168,11 @@ python3 scripts/beszel_client.py --system folzocorp --check stats
| Domaine | Technologies |
|---------|-------------|
| **LLM** | Jean (llama.cpp), Ollama (vision) |
| **Routing** | jean-router (Flask, routing dynamique + gestion VRAM) |
| **LLM** | llama.cpp (principal), Ollama (vision + embeddings) |
| **Proxy / WOL** | llm-proxy (Flask, proxy multi-cibles + Wake-on-LAN) |
| **Interface** | Open WebUI |
| **STT** | Faster Whisper (CPU) |
| **TTS** | XTTS v2 (ROCm GPU) |
| **TTS** | openedai-speech (Piper + XTTS) |
| **Image/Audio** | ComfyUI + ACE-Step |
| **Vector DB** | Qdrant |
| **Document** | Kreuzberg (docling) |
@@ -223,6 +186,10 @@ python3 scripts/beszel_client.py --system folzocorp --check stats
```
effi/
├── llm-proxy/ # Proxy unique + Wake-on-LAN
│ ├── app.py
│ └── Dockerfile
│
├── beszel/ # Configuration Beszel
│ ├── docker-compose-hub.yml # Hub (Synology)
│ ├── docker-compose-agent-folzocorp.yml # Agent FolzoCorp
@@ -252,75 +219,38 @@ effi/
├── systemd/ # Services systemd
│ ├── comfyui-memory-manager.service
│ ├── kreuzberg-memory-manager.service
│ ├── beszel-agent-wake.service
│ └── ollama.service # Ollama LLM (folzo-Corp)
│ └── beszel-agent-wake.service
│
├── docs/ # Documentation
│ ├── SESSION-2026-04-12-RAM-BESZEL.md
│ ├── WOL-ET-DEMARRAGE-AUTO.md
│ ├── BESZEL-INSTALLATION.md
│ ├── MEMOIRE-GESTION.md
│ └── TOOL-BESZEL-OPENWEBUI.md
│
└── docker-compose*.yml # Divers compose files (legacy/backup)
└── docker-compose.yml # Stack principale (effi-server)
```
---
## 📝 Historique
**Juillet 2026** - jean-router : routing dynamique LLM + gestion VRAM
**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 ✅
- Un seul service remplace jean-router, jean-proxy, jean-proxy-moe, wol-proxy
- 4 routes : Ollama (`:3011`), llama.cpp (`:3012`), STT (`:3015`), TTS (`:3016`)
- Wake-on-LAN intégré avec anti-spam (un seul réveil en cours)
- Suppression de tout le code Jean (plus besoin de routing dynamique)
- OpenWebUI pointe vers `llm-proxy:3011` (Ollama) et `llm-proxy:3012` (llama.cpp)
### Jean (llama.cpp) ✅
- Migration vers Jean comme backend LLM principal
- Presets dynamiques : ornith-35b-q6, qwen3.6-27b-mtp, qwen3.6-35b-moe-compact...
- Service systemd avec démarrage au boot (sans login)
- API web pour gestion (start/stop/switch presets)
**Juillet 2026** - Routing dynamique (abandonné)
- jean-router : routing dynamique Jean/Ollama + gestion VRAM (supprimé)
**Avril 2026** - Optimisation RAM + Monitoring + Tool OpenWebUI
### RAM Management ✅
- Scripts déchargement mémoire avec restart container (ComfyUI, Kreuzberg)
- Timeout inactivité 300s pour effi-stt avec déchargement Whisper
- Services systemd pour gestion automatique
### 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
- Beszel Monitoring (Hub NAS + agents FolzoCorp/EffiServer)
- Tool OpenWebUI Beszel
**Février 2026** - Stack Vocale
- effi-stt avec Whisper large-v3
@@ -335,4 +265,4 @@ effi/
---
*Dernière mise à jour : Avril 2026*
*Dernière mise à jour : Août 2026*
-75
View File
@@ -1,75 +0,0 @@
# ═══════════════════════════════════════════════════════════
# 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
+24 -43
View File
@@ -6,44 +6,20 @@
services:
# ─────────────────────────────────────────────────────────
# 0. VALKEY - Backend de rate limiting pour SearXNG
# → Port interne uniquement (non exposé)
# ─────────────────────────────────────────────────────────
valkey:
image: valkey/valkey:7-alpine
container_name: effi-valkey
restart: unless-stopped
command: valkey-server /usr/local/etc/valkey/valkey.conf
volumes:
- ./searxng-config/valkey.conf:/usr/local/etc/valkey/valkey.conf:ro
- valkey-data:/data/valkey-data
networks:
- effi-net
# ─────────────────────────────────────────────────────────
# 1. SEARXNG - Meta-search Engine (API JSON)
# → http://192.168.1.27:6060
# 0. SEARXNG - Meta-search Engine (Web Search)
# → http://192.168.1.27:11112
# ─────────────────────────────────────────────────────────
searxng:
image: searxng/searxng:latest
container_name: effi-searxng
restart: unless-stopped
ports:
- "6060:8080"
- "11112:8080"
volumes:
- ./searxng-config/settings.yml:/etc/searxng/settings.yml:ro
- ./searxng-data:/etc/searxng:rw
environment:
- SEARXNG_BASE_URL=http://192.168.1.27:6060/
- SEARXNG_BASE_URL=http://192.168.1.27:11112/
- SEARXNG_SECRET=${SEARXNG_SECRET_KEY}
- SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
- valkey
networks:
- effi-net
@@ -61,40 +37,46 @@ services:
- "3000:8080"
environment:
- 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}
- VECTOR_DB=qdrant
- QDRANT_URI=http://qdrant:6333
- DEFAULT_LOCALE=fr-FR
- ENABLE_MEMORIES=true
- AUDIO_STT_ENGINE=openai
- AUDIO_STT_OPENAI_API_BASE_URL=http://192.168.1.11:8200/v1
- AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:3015/v1
- AUDIO_STT_OPENAI_API_KEY=anything
- AUDIO_STT_MODEL=Systran/faster-whisper-large-v3
volumes:
- open-webui-data:/app/backend/data
depends_on:
- qdrant
- wol-proxy
- llm-proxy
networks:
- effi-net
# ─────────────────────────────────────────────────────────
# 2. WOL PROXY - Réveil PC Gaming avant appel Ollama
# → Proxy :3011 → PC Gaming :11434
# 2. LLM PROXY - Proxy unique vers le PC Gaming + Wake-on-LAN
# :3011 → Ollama (.11:11434)
# :3012 → llama.cpp (.11:8080)
# :3015 → STT (.11:8200)
# :3016 → TTS (serveur local :8100, pas de WOL)
# Si le PC est éteint, un paquet WoL est envoyé avant l'attente.
# ─────────────────────────────────────────────────────────
wol-proxy:
build: ./wol-proxy
container_name: wol-proxy
llm-proxy:
build: ./llm-proxy
container_name: llm-proxy
restart: unless-stopped
network_mode: host
environment:
- TARGET_HOST=${OLLAMA_HOST}
- TARGET_PORT=11434
- TARGET_MAC=${PC_GAMING_MAC}
- LISTEN_PORT=3011
- WAKE_TIMEOUT=${WAKE_TIMEOUT:-180}
- CHECK_INTERVAL=5
- PC_HOST=${OLLAMA_HOST}
- PC_MAC=${PC_GAMING_MAC}
- BROADCAST_IP=${BROADCAST_IP}
- WAKE_TIMEOUT=${WAKE_TIMEOUT:-300}
- SERVICE_TIMEOUT=120
- CHECK_INTERVAL=5
# ─────────────────────────────────────────────────────────
# 3. MCPO - Proxy outils MCP (Obsidian, etc.)
@@ -289,4 +271,3 @@ volumes:
open-webui-data:
pipelines-data:
whisper-cache:
redis-data:
@@ -1,256 +0,0 @@
# 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
@@ -1,313 +0,0 @@
# 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
+8 -1
View File
@@ -8,11 +8,18 @@ Guide pour s'assurer que tous les services Effi démarrent automatiquement aprè
| Service | Port | Docker | Dépendance WOL |
|---------|------|--------|----------------|
| **llama.cpp** | 8080 | Native (systemd) | Oui |
| **Ollama** | 11434 | ✅ | Oui |
| **Effi-STT** | 8200 | ✅ | Oui |
| **openedai-speech** | 8100 | ✅ | 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.
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
-72
View File
@@ -1,72 +0,0 @@
# 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
@@ -1,14 +0,0 @@
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
@@ -1,123 +0,0 @@
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)
-553
View File
@@ -1,553 +0,0 @@
#!/usr/bin/env python3
"""
Jean Router — Proxy dynamique pour Jean presets
Interroge Jean pour lister les presets disponibles et route les requêtes
vers le bon preset en faisant le switch automatique si nécessaire.
"""
import os
import time
import logging
import threading
from typing import Optional
import requests
from flask import Flask, request, Response, jsonify
JEAN_WEB_HOST = os.getenv("JEAN_WEB_HOST", "192.168.1.11")
JEAN_WEB_PORT = int(os.getenv("JEAN_WEB_PORT", "8090"))
JEAN_PROXY_HOST = os.getenv("JEAN_PROXY_HOST", "localhost")
JEAN_PROXY_PORT = int(os.getenv("JEAN_PROXY_PORT", "3012"))
OLLAMA_PROXY_HOST = os.getenv("OLLAMA_PROXY_HOST", "localhost")
OLLAMA_PROXY_PORT = int(os.getenv("OLLAMA_PROXY_PORT", "3011"))
OLLAMA_DIRECT_HOST = os.getenv("OLLAMA_DIRECT_HOST", JEAN_WEB_HOST)
OLLAMA_DIRECT_PORT = int(os.getenv("OLLAMA_DIRECT_PORT", "11434"))
ENABLE_OLLAMA = os.getenv("ENABLE_OLLAMA", "true").lower() == "true"
ENABLE_VRAM_MANAGEMENT = os.getenv("ENABLE_VRAM_MANAGEMENT", "true").lower() == "true"
OLLAMA_KEEP_ALIVE_MINUTES = int(os.getenv("OLLAMA_KEEP_ALIVE_MINUTES", "5"))
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3014"))
SWITCH_TIMEOUT = int(os.getenv("SWITCH_TIMEOUT", "90"))
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "2"))
JEAN_STOP_TIMEOUT = int(os.getenv("JEAN_STOP_TIMEOUT", "30"))
OLLAMA_UNLOAD_TIMEOUT = int(os.getenv("OLLAMA_UNLOAD_TIMEOUT", "15"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
presets_cache = {"data": [], "timestamp": 0}
ollama_cache = {"data": [], "timestamp": 0}
jean_ctx_cache = {}
CACHE_TTL = 30
switch_lock = threading.Lock()
vram_lock = threading.Lock()
vram_state = {"active_backend": "jean"}
ollama_unload_timer = None
def get_presets(force_refresh=False):
"""Récupère la liste des presets depuis Jean"""
now = time.time()
if not force_refresh and presets_cache["data"] and (now - presets_cache["timestamp"]) < CACHE_TTL:
return presets_cache["data"]
try:
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/presets"
r = requests.get(url, timeout=3)
if r.ok:
presets_cache["data"] = r.json()
presets_cache["timestamp"] = now
return presets_cache["data"]
except Exception as e:
log.warning(f"[Presets] Erreur récupération: {e}")
return presets_cache["data"]
def get_ollama_models(force_refresh=False):
"""Récupère la liste des modèles depuis Ollama"""
if not ENABLE_OLLAMA:
return []
now = time.time()
if not force_refresh and ollama_cache["data"] and (now - ollama_cache["timestamp"]) < CACHE_TTL:
return ollama_cache["data"]
try:
url = f"http://{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}/api/tags"
r = requests.get(url, timeout=3)
if r.ok:
data = r.json().get("models", [])
ollama_cache["data"] = data
ollama_cache["timestamp"] = now
return data
except Exception as e:
log.warning(f"[Ollama] Erreur récupération: {e}")
return ollama_cache["data"]
def get_jean_context_lengths():
"""Récupère les context_length depuis Jean /v1/models (avec cache)"""
preset_ctx_map = {
"ornith-35b-q6": 64000,
"qwen3.6-27b-mtp": 55000,
"qwen3.6-35b-moe-compact": 131072,
"qwen3.6-35b-moe-compact-reason": 131072,
"qwen3.6-35b-moe-nano": 100000,
}
try:
url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/models"
r = requests.get(url, timeout=2)
if r.ok:
models = r.json().get("data", [])
for m in models:
meta = m.get("meta", {})
n_ctx = meta.get("n_ctx")
if n_ctx:
alias = m.get("id", "")
jean_ctx_cache[alias] = n_ctx
log.debug(f"[Jean] Context length pour {alias}: {n_ctx}")
except Exception as e:
log.debug(f"[Jean] Impossible de récupérer context_length: {e}")
for preset_id, ctx in preset_ctx_map.items():
if preset_id not in jean_ctx_cache:
jean_ctx_cache[preset_id] = ctx
return jean_ctx_cache
def get_active_preset():
"""Retourne le preset actuellement actif"""
presets = get_presets()
for p in presets:
if p.get("active"):
return p
return None
def switch_preset(preset_id: str) -> bool:
"""Switch vers un preset spécifique par son ID"""
presets = get_presets(force_refresh=True)
for i, p in enumerate(presets):
if p["id"] == preset_id:
if p.get("active"):
log.info(f"[Switch] Preset {preset_id} déjà actif")
return True
log.info(f"[Switch] Activation preset {preset_id} (index {i+1})")
try:
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/switch"
r = requests.post(url, json={"n": i + 1}, timeout=5)
if not r.ok:
log.error(f"[Switch] Erreur API: {r.text}")
return False
except Exception as e:
log.error(f"[Switch] Erreur requête: {e}")
return False
log.info(f"[Switch] Attente chargement modèle...")
deadline = time.time() + SWITCH_TIMEOUT
while time.time() < deadline:
try:
health_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/health"
hr = requests.get(health_url, timeout=2)
if hr.ok:
models_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/models"
mr = requests.get(models_url, timeout=2)
if mr.ok:
models = mr.json().get("data", [])
if models:
log.info(f"[Switch] Modèle chargé après {int(time.time() - deadline + SWITCH_TIMEOUT)}s")
presets_cache["timestamp"] = 0
return True
except Exception:
pass
time.sleep(CHECK_INTERVAL)
log.error(f"[Switch] Timeout après {SWITCH_TIMEOUT}s")
return False
log.error(f"[Switch] Preset {preset_id} non trouvé")
return False
def stop_jean() -> bool:
"""Arrête le service Jean pour libérer la VRAM"""
log.info("[VRAM] Arrêt de Jean pour libérer la VRAM...")
try:
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/stop"
r = requests.post(url, timeout=5)
if r.ok:
log.info("[VRAM] Jean arrêté via API web")
else:
log.warning(f"[VRAM] API stop a retourné {r.status_code}, tentative via health check")
except Exception as e:
log.warning(f"[VRAM] Erreur API stop: {e}")
deadline = time.time() + JEAN_STOP_TIMEOUT
while time.time() < deadline:
try:
health_url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/health"
r = requests.get(health_url, timeout=2)
if not r.ok:
log.info("[VRAM] Jean confirmé arrêté")
return True
except Exception:
log.info("[VRAM] Jean confirmé arrêté (connexion refusée)")
return True
time.sleep(CHECK_INTERVAL)
log.error(f"[VRAM] Timeout: Jean ne s'est pas arrêté après {JEAN_STOP_TIMEOUT}s")
return False
def start_jean() -> bool:
"""Démarre le service Jean"""
log.info("[VRAM] Démarrage de Jean...")
try:
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/start"
r = requests.post(url, timeout=5)
if r.ok:
log.info("[VRAM] Jean démarré via API web")
else:
log.warning(f"[VRAM] API start a retourné {r.status_code}")
except Exception as e:
log.warning(f"[VRAM] Erreur API start: {e}")
log.info("[VRAM] Attente du chargement du modèle Jean...")
deadline = time.time() + SWITCH_TIMEOUT
while time.time() < deadline:
try:
health_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/health"
hr = requests.get(health_url, timeout=2)
if hr.ok:
models_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/models"
mr = requests.get(models_url, timeout=2)
if mr.ok:
models = mr.json().get("data", [])
if models:
log.info(f"[VRAM] Jean prêt après {int(time.time() - deadline + SWITCH_TIMEOUT)}s")
return True
except Exception:
pass
time.sleep(CHECK_INTERVAL)
log.error(f"[VRAM] Timeout: Jean n'a pas démarré après {SWITCH_TIMEOUT}s")
return False
def unload_ollama() -> bool:
"""Force le déchargement des modèles Ollama pour libérer la VRAM"""
log.info("[VRAM] Déchargement des modèles Ollama...")
try:
url = f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/generate"
payload = {
"model": "mistral",
"keep_alive": 0,
"prompt": ""
}
r = requests.post(url, json=payload, timeout=5)
if r.ok:
log.info("[VRAM] Ollama déchargé via API")
return True
else:
log.warning(f"[VRAM] API unload a retourné {r.status_code}")
except Exception as e:
log.warning(f"[VRAM] Erreur API unload: {e}")
try:
url = f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/ps"
r = requests.get(url, timeout=3)
if r.ok:
models = r.json().get("models", [])
if not models:
log.info("[VRAM] Ollama confirmé déchargé (aucun modèle actif)")
return True
else:
log.warning(f"[VRAM] Ollama a encore {len(models)} modèle(s) chargé(s)")
except Exception as e:
log.warning(f"[VRAM] Impossible de vérifier /api/ps: {e}")
time.sleep(2)
log.info("[VRAM] Ollama supposé déchargé (timeout)")
return True
def schedule_ollama_unload():
"""Programme le déchargement d'Ollama après OLLAMA_KEEP_ALIVE_MINUTES"""
global ollama_unload_timer
def delayed_unload():
log.info(f"[VRAM] Timer écoulé ({OLLAMA_KEEP_ALIVE_MINUTES}min), déchargement Ollama...")
unload_ollama()
if ollama_unload_timer:
ollama_unload_timer.cancel()
log.info("[VRAM] Timer précédent annulé")
ollama_unload_timer = threading.Timer(
OLLAMA_KEEP_ALIVE_MINUTES * 60,
delayed_unload
)
ollama_unload_timer.daemon = True
ollama_unload_timer.start()
log.info(f"[VRAM] Déchargement Ollama programmé dans {OLLAMA_KEEP_ALIVE_MINUTES} minutes")
def cancel_ollama_unload():
"""Annule le déchargement programmé d'Ollama"""
global ollama_unload_timer
if ollama_unload_timer:
ollama_unload_timer.cancel()
ollama_unload_timer = None
log.info("[VRAM] Timer de déchargement Ollama annulé")
def ensure_vram_for_backend(target_backend: str) -> bool:
"""
S'assure que la VRAM est disponible pour le backend cible.
- Si target=ollama et jean actif → arrête Jean, annule timer unload Ollama
- Si target=jean et ollama actif → programme déchargement Ollama dans X min, démarre Jean
"""
if not ENABLE_VRAM_MANAGEMENT:
return True
with vram_lock:
current = vram_state["active_backend"]
if current == target_backend:
return True
if target_backend == "ollama" and current == "jean":
log.info("[VRAM] Switch Jean → Ollama")
cancel_ollama_unload()
if not stop_jean():
return False
vram_state["active_backend"] = "ollama"
return True
elif target_backend == "jean" and current == "ollama":
log.info("[VRAM] Switch Ollama → Jean")
schedule_ollama_unload()
if not start_jean():
return False
vram_state["active_backend"] = "jean"
return True
return True
@app.route("/v1/models", methods=["GET"])
def list_models():
"""Expose tous les presets Jean + modèles Ollama comme modèles OpenAI"""
presets = get_presets()
jean_ctx = get_jean_context_lengths()
models = []
for p in presets:
model_id = p["id"]
model_name = p.get("name", model_id)
model_info = {
"id": model_id,
"object": "model",
"created": int(time.time()),
"owned_by": "jean",
"permission": [],
"root": model_id,
"parent": None,
}
meta = {}
if p.get("quant"):
meta["quantization"] = p["quant"]
if p.get("reasoning"):
meta["reasoning"] = p["reasoning"]
if p.get("active"):
meta["active"] = True
if p.get("bench"):
meta["bench"] = p["bench"]
ctx_len = jean_ctx.get(model_id)
if ctx_len:
meta["context_length"] = ctx_len
model_info["context_length"] = ctx_len
model_info["context_window"] = ctx_len
model_info["max_model_len"] = ctx_len
if meta:
model_info["meta"] = meta
models.append(model_info)
if ENABLE_OLLAMA:
ollama_models = get_ollama_models()
for m in ollama_models:
model_id = m.get("name", "").replace(":latest", "")
if not model_id:
continue
model_info = {
"id": model_id,
"object": "model",
"created": int(time.time()),
"owned_by": "ollama",
"permission": [],
"root": model_id,
"parent": None,
}
details = m.get("details", {})
meta = {}
if details.get("parameter_size"):
meta["parameter_size"] = details["parameter_size"]
if details.get("quantization_level"):
meta["quantization"] = details["quantization_level"]
if details.get("family"):
meta["family"] = details["family"]
if details.get("context_length"):
ctx_len = details["context_length"]
meta["context_length"] = ctx_len
model_info["context_length"] = ctx_len
model_info["context_window"] = ctx_len
model_info["max_model_len"] = ctx_len
if m.get("capabilities"):
meta["capabilities"] = m["capabilities"]
if meta:
model_info["meta"] = meta
models.append(model_info)
return jsonify({"object": "list", "data": models})
@app.route("/v1/chat/completions", methods=["POST"])
def chat_completions():
"""Route les requêtes chat vers Jean ou Ollama selon le modèle"""
data = request.get_json()
model_id = data.get("model")
if not model_id:
return jsonify({"error": "model parameter required"}), 400
presets = get_presets()
jean_preset_ids = [p["id"] for p in presets]
if model_id in jean_preset_ids:
if not ensure_vram_for_backend("jean"):
return jsonify({"error": "Failed to prepare VRAM for Jean"}), 503
with switch_lock:
active = get_active_preset()
if not active or active["id"] != model_id:
log.info(f"[Router] Switch Jean requis: {active['id'] if active else 'none'} → {model_id}")
if not switch_preset(model_id):
return jsonify({"error": f"Failed to switch to preset {model_id}"}), 503
upstream_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/chat/completions"
log.info(f"[Router] → Jean ({model_id})")
elif ENABLE_OLLAMA:
ollama_models = get_ollama_models()
ollama_ids = [m.get("name", "").replace(":latest", "") for m in ollama_models]
if model_id in ollama_ids:
if not ensure_vram_for_backend("ollama"):
return jsonify({"error": "Failed to prepare VRAM for Ollama"}), 503
cancel_ollama_unload()
upstream_url = f"http://{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}/v1/chat/completions"
log.info(f"[Router] → Ollama ({model_id})")
else:
return jsonify({"error": f"Unknown model: {model_id}"}), 404
else:
return jsonify({"error": f"Unknown model: {model_id}"}), 404
headers = dict(request.headers)
headers.pop("Host", None)
try:
resp = requests.request(
method=request.method,
url=upstream_url,
headers=headers,
json=data,
stream=True,
timeout=None,
)
except Exception as e:
log.error(f"[Router] Erreur upstream: {e}")
return jsonify({"error": str(e)}), 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 proxy(path):
"""Proxy générique pour les autres endpoints"""
if path.startswith("v1/chat/completions"):
return chat_completions()
upstream_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/{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,
params=request.args,
allow_redirects=False,
stream=True,
timeout=None,
)
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,
)
if __name__ == "__main__":
log.info(f"[Router] Démarrage sur port {LISTEN_PORT}")
log.info(f"[Router] Jean Web: {JEAN_WEB_HOST}:{JEAN_WEB_PORT}")
log.info(f"[Router] Jean Proxy: {JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}")
if ENABLE_OLLAMA:
log.info(f"[Router] Ollama Proxy: {OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}")
else:
log.info(f"[Router] Ollama: désactivé")
app.run(host="0.0.0.0", port=LISTEN_PORT, threaded=True)
@@ -6,7 +6,6 @@ RUN pip install --no-cache-dir flask requests
COPY app.py .
ENV LISTEN_PORT=3014
EXPOSE 3014
EXPOSE 3011 3012 3015 3016
CMD ["python", "app.py"]
+165
View File
@@ -0,0 +1,165 @@
import os
import time
import socket
import logging
import threading
from urllib.parse import urljoin
import requests
from flask import Flask, Response, request
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"))
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": int(os.getenv("LLAMACPP_PORT", "8080")),
"listen": int(os.getenv("LISTEN_LLAMACPP", "3012")), "health": "/health", "wol": True},
{"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}
METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
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:
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["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():
log.info(f"[WoL] PC deja UP, attente du service {name}")
if not wait_for(route, SERVICE_TIMEOUT):
raise RuntimeError(f"service {name} ne repond pas apres {SERVICE_TIMEOUT}s")
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 {name} ne repond pas apres le reveil du PC")
def proxy_request(route, path: str):
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)
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:
log.warning(f"[{route['name']}] erreur upstream: {e}")
return Response(str(e), status=502)
excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"}
response_headers = [
(n, v) for n, v in resp.headers.items() if n.lower() not in excluded
]
return Response(
resp.raw,
status=resp.status_code,
headers=response_headers,
direct_passthrough=True,
)
def make_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 serve(route):
make_app(route).run(host="0.0.0.0", port=route["listen"], threaded=True)
if __name__ == "__main__":
for r in ROUTES:
log.info(f"[{r['name']}] :{r['listen']} -> {r.get('host', PC_HOST)}:{r['port']} (wol={r['wol']})")
threading.Thread(target=serve, args=(r,), daemon=True).start()
while True:
time.sleep(60)
-22
View File
@@ -1,22 +0,0 @@
events {
worker_connections 1024;
}
http {
upstream searxng {
server effi-searxng:8080;
}
server {
listen 6060;
server_name localhost;
location / {
proxy_pass http://searxng;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,98 +0,0 @@
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)