From 5ab8242134041df9eb869eb6de7ae8f930b0f95a Mon Sep 17 00:00:00 2001 From: Effi Server Date: Sat, 22 Aug 2026 09:26:24 +0000 Subject: [PATCH] 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) --- README.md | 194 ++---- docker-compose.litellm.yml | 75 --- docker-compose.yml | 67 +-- .../SESSION-2026-07-20-JEAN-ROUTER.md | 256 -------- docs/ARCHITECTURE-JEAN-ROUTER.md | 313 ---------- docs/WOL-ET-DEMARRAGE-AUTO.md | 9 +- docs/wol-orchestrator-roadmap.md | 72 --- jean-proxy/Dockerfile | 14 - jean-proxy/app.py | 123 ---- jean-router/app.py | 553 ------------------ {jean-router => llm-proxy}/Dockerfile | 3 +- llm-proxy/app.py | 165 ++++++ proxy-config/nginx.conf | 22 - wol-proxy/Dockerfile | 14 - wol-proxy/app.py | 98 ---- 15 files changed, 260 insertions(+), 1718 deletions(-) delete mode 100644 docker-compose.litellm.yml delete mode 100644 docs/05-journal/SESSION-2026-07-20-JEAN-ROUTER.md delete mode 100644 docs/ARCHITECTURE-JEAN-ROUTER.md delete mode 100644 docs/wol-orchestrator-roadmap.md delete mode 100644 jean-proxy/Dockerfile delete mode 100644 jean-proxy/app.py delete mode 100644 jean-router/app.py rename {jean-router => llm-proxy}/Dockerfile (79%) create mode 100644 llm-proxy/app.py delete mode 100644 proxy-config/nginx.conf delete mode 100644 wol-proxy/Dockerfile delete mode 100644 wol-proxy/app.py diff --git a/README.md b/README.md index bd54b08..d6c1cd7 100644 --- a/README.md +++ b/README.md @@ -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* diff --git a/docker-compose.litellm.yml b/docker-compose.litellm.yml deleted file mode 100644 index 0a27568..0000000 --- a/docker-compose.litellm.yml +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 2d70b4d..0e833fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/05-journal/SESSION-2026-07-20-JEAN-ROUTER.md b/docs/05-journal/SESSION-2026-07-20-JEAN-ROUTER.md deleted file mode 100644 index cf7d8f2..0000000 --- a/docs/05-journal/SESSION-2026-07-20-JEAN-ROUTER.md +++ /dev/null @@ -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. diff --git a/docs/ARCHITECTURE-JEAN-ROUTER.md b/docs/ARCHITECTURE-JEAN-ROUTER.md deleted file mode 100644 index 6cb1339..0000000 --- a/docs/ARCHITECTURE-JEAN-ROUTER.md +++ /dev/null @@ -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 diff --git a/docs/WOL-ET-DEMARRAGE-AUTO.md b/docs/WOL-ET-DEMARRAGE-AUTO.md index d8d2a08..de70326 100644 --- a/docs/WOL-ET-DEMARRAGE-AUTO.md +++ b/docs/WOL-ET-DEMARRAGE-AUTO.md @@ -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 diff --git a/docs/wol-orchestrator-roadmap.md b/docs/wol-orchestrator-roadmap.md deleted file mode 100644 index 2163294..0000000 --- a/docs/wol-orchestrator-roadmap.md +++ /dev/null @@ -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). diff --git a/jean-proxy/Dockerfile b/jean-proxy/Dockerfile deleted file mode 100644 index 8177697..0000000 --- a/jean-proxy/Dockerfile +++ /dev/null @@ -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"] - diff --git a/jean-proxy/app.py b/jean-proxy/app.py deleted file mode 100644 index 34d62be..0000000 --- a/jean-proxy/app.py +++ /dev/null @@ -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("/", 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) diff --git a/jean-router/app.py b/jean-router/app.py deleted file mode 100644 index b18ef28..0000000 --- a/jean-router/app.py +++ /dev/null @@ -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("/", 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) diff --git a/jean-router/Dockerfile b/llm-proxy/Dockerfile similarity index 79% rename from jean-router/Dockerfile rename to llm-proxy/Dockerfile index f151b84..49c06b9 100644 --- a/jean-router/Dockerfile +++ b/llm-proxy/Dockerfile @@ -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"] diff --git a/llm-proxy/app.py b/llm-proxy/app.py new file mode 100644 index 0000000..058013c --- /dev/null +++ b/llm-proxy/app.py @@ -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("/", 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) diff --git a/proxy-config/nginx.conf b/proxy-config/nginx.conf deleted file mode 100644 index 5a0d981..0000000 --- a/proxy-config/nginx.conf +++ /dev/null @@ -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; - } - } -} diff --git a/wol-proxy/Dockerfile b/wol-proxy/Dockerfile deleted file mode 100644 index efc5cee..0000000 --- a/wol-proxy/Dockerfile +++ /dev/null @@ -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"] - diff --git a/wol-proxy/app.py b/wol-proxy/app.py deleted file mode 100644 index 9030336..0000000 --- a/wol-proxy/app.py +++ /dev/null @@ -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("/", 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)