diff --git a/.gitignore b/.gitignore index 59f2126..9b9aa41 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ pipelines-data/ tts-voices/ tts-config/ mcpo-config/ -wol-proxy/ # Fichiers de base de données *.db @@ -86,3 +85,8 @@ comfyui/input/*.png comfyui/input/*.jpg comfyui/input/*.jpeg comfyui/input/*.webp + +# llm-proxy +llm-proxy/state/* +!llm-proxy/state/.gitkeep +llm-proxy/__pycache__/ diff --git a/README.md b/README.md index d6c1cd7..fe5190f 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,19 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis │ 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 │ │ │ │ -│ └──────────────┘ └────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ │ +│ │ Open WebUI │ │ llm-proxy │ │ │ │ llama.cpp A │ │ Ollama │ │ +│ │ Port 3000 │ │ :3011 Ollama │ │ │ │ :8080 │ │ :11434 │ │ +│ └──────────────┘ │ :3012 llama.cpp│ │ │ │ (Q4_K_M) │ │ (vision, │ │ +│ ┌──────────────┐ │ :3015 STT │ │ │ └──────────────┘ │ embeddings) │ │ +│ │ Qdrant │ │ :3016 TTS │ │ │ ┌──────────────┐ └──────────────┘ │ +│ │ Port 6333 │ │ :3017 UI switch│ │ │ │ llama.cpp B │ │ +│ └──────────────┘ │ + Wake-on-LAN │ │ │ │ :8081 │ │ +│ └────────────────┘ │ │ │ (IQ4_XS) │ │ +│ │ │ └──────────────┘ │ +│ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ ComfyUI │ │ effi-stt │ │ -│ ┌──────────────┐ ┌──────────────┐ │ │ │ Port 8188 │ │ Port 8200 │ │ -│ │ n8n │ │ openedai │ │ │ │ Image/Audio │ │ Whisper STT │ │ -│ │ Port 5678 │ │ TTS :8100 │ │ │ └──────────────┘ └──────────────┘ │ -│ └──────────────┘ └──────────────┘ │ │ │ +│ │ │ │ :8188 │ │ :8200 │ │ +│ │ │ └──────────────┘ └──────────────┘ │ └───────────────────────────────────────┘ └─────────────────────────────────────────┘ ``` @@ -53,7 +54,8 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis | Service | Port | Image | Rôle | RAM/VRAM | Auto-unload | |---------|------|-------|------|----------|-------------| -| **llama.cpp** | 8080 | Native | LLM principal (qwen3.8-27b) | ~28GB | Démarré au boot | +| **llama.cpp A** | 8080 | Native | LLM principal — qwen3.8-27b **Q4_K_M** (17,5GB) | ~28GB | Démarré au boot | +| **llama.cpp B** | 8081 | Native | LLM alternatif — qwen3.8-27b **IQ4_XS** (14,2GB) | ~24GB | 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 | @@ -67,7 +69,7 @@ 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 | -| **llm-proxy** | 3011/3012/3015/3016 | Build local | Proxy unique + Wake-on-LAN | +| **llm-proxy** | 3011/3012/3015/3016/3017 | Build local | Proxy unique + Wake-on-LAN + UI switch | | **openedai-speech** | 8100 | `ghcr.io/matatonic/openedai-speech` | TTS | | **beszel-agent** | - | `henrygd/beszel-agent:latest` | Monitoring | @@ -83,16 +85,34 @@ Effi est un assistant IA personnel **100% auto-hébergé**, avec mémoire persis ### 💬 Chat IA - **Interface**: Open WebUI -- **LLM principal**: llama.cpp (qwen3.8-27b) via `llm-proxy:3012` +- **LLM principal**: llama.cpp (`qwen3.8-27b`) via `llm-proxy:3012` +- **Switch d'instance** : 2 instances llama.cpp (8080 Q4_K_M, 8081 IQ4_XS), bascule via l'UI du proxy sans toucher aux configs - **LLM vision / embeddings**: Ollama via `llm-proxy:3011` - **Wake-on-LAN automatique**: le proxy réveille la station GPU si elle est éteinte -### 🔌 llm-proxy — Proxy unique + Wake-on-LAN -- **4 routes** : Ollama (`:3011`), llama.cpp (`:3012`), STT (`:3015`), TTS (`:3016`) +### 🔌 llm-proxy — Proxy unique + Wake-on-LAN + UI switch +- **5 routes** : Ollama (`:3011`), llama.cpp (`:3012`), STT (`:3015`), TTS (`:3016`), UI de gestion (`:3017`) +- **Route virtuelle `:3012`** : pointe vers l'instance llama.cpp active (8080 ou 8081), choix persisté dans `llm-proxy/state/active.json` +- **Alias modèle** : les harnais envoient toujours `qwen3.8-27b` ; le proxy réécrit l'id réel dans les 2 sens (requêtes, `/v1/models`, streaming SSE) → le switch est invisible pour OpenWebUI/OpenFox/Hermes - **Wake-on-LAN** : si le PC est éteint, envoie 3 paquets magic (MAC `04:D9:F5:D1:EC:DF`) puis attend le service - **Anti-spam WOL** : un seul réveil en cours à la fois, pas de re-envoi pendant l'attente +- **Fail-fast** : PC up mais instance cible éteinte → 503 après 20s (pas de WOL) - **API OpenAI-compatible** : `/v1/models` et `/v1/chat/completions` sur les routes LLM -- **Streaming** : passthrough des réponses SSE +- **Streaming** : passthrough des réponses SSE (alias réécrit par ligne) +- **UI `:3017`** : statut des 2 instances, bouton *Activer* (persisté), bouton *Tester* (latence + réponse) + +#### Switcher entre les 2 versions de qwen3.8 + +1. Sur le PC : démarrer l'instance voulue (et arrêter l'autre — une seule tient dans la VRAM) +2. Ouvrir http://192.168.1.27:3017 → *Tester* pour vérifier que l'instance répond → *Activer* +3. Terminé : `:3012` sert désormais la nouvelle instance, zéro changement de config côté harnais + +```bash +# Alternative sans UI +curl -X POST http://192.168.1.27:3017/api/activate -d '{"real":"qwen3.8-27b"}' # instance A (8080) +curl -X POST http://192.168.1.27:3017/api/activate -d '{"real":"qwen3.8-27b-iq4xs"}' # instance B (8081) +curl http://192.168.1.27:3017/api/status +``` ### 🎤 Pipeline Vocal - **STT**: Whisper large-v3 via effi-stt (CPU, déchargement auto) @@ -186,9 +206,10 @@ python3 scripts/beszel_client.py --system folzocorp --check stats ``` effi/ -├── llm-proxy/ # Proxy unique + Wake-on-LAN +├── llm-proxy/ # Proxy unique + Wake-on-LAN + UI switch │ ├── app.py -│ └── Dockerfile +│ ├── Dockerfile +│ └── state/ # Persistance instance active (active.json) │ ├── beszel/ # Configuration Beszel │ ├── docker-compose-hub.yml # Hub (Synology) @@ -234,6 +255,12 @@ effi/ ## 📝 Historique +**Septembre 2026** - Switch d'instance llama.cpp +- 2 instances llama.cpp sur folzo-Corp (8080 Q4_K_M, 8081 IQ4_XS) +- Route virtuelle `:3012` + alias modèle `qwen3.8-27b` : switch transparent, zéro re-config harnais +- UI de gestion `:3017` (statut, Activer, Tester) +- Fail-fast 503 (20s) quand l'instance active est éteinte, sans spam WOL + **Août 2026** - Simplification : proxy unique + Wake-on-LAN ### llm-proxy ✅ diff --git a/docker-compose.yml b/docker-compose.yml index 0e833fe..7f295d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,9 +60,10 @@ services: # ───────────────────────────────────────────────────────── # 2. LLM PROXY - Proxy unique vers le PC Gaming + Wake-on-LAN # :3011 → Ollama (.11:11434) - # :3012 → llama.cpp (.11:8080) + # :3012 → llama.cpp (route virtuelle : instance active, alias qwen3.8-27b) # :3015 → STT (.11:8200) # :3016 → TTS (serveur local :8100, pas de WOL) + # :3017 → UI de gestion (switch d'instance llama.cpp + tests) # Si le PC est éteint, un paquet WoL est envoyé avant l'attente. # ───────────────────────────────────────────────────────── llm-proxy: @@ -77,6 +78,15 @@ services: - WAKE_TIMEOUT=${WAKE_TIMEOUT:-300} - SERVICE_TIMEOUT=120 - CHECK_INTERVAL=5 + - LLAMACPP_A_PORT=${LLAMACPP_A_PORT:-8080} + - LLAMACPP_A_MODEL=${LLAMACPP_A_MODEL:-qwen3.8-27b} + - LLAMACPP_A_LABEL=${LLAMACPP_A_LABEL:-Q4_K_M} + - LLAMACPP_B_PORT=${LLAMACPP_B_PORT:-8081} + - LLAMACPP_B_MODEL=${LLAMACPP_B_MODEL:-qwen3.8-27b-iq4xs} + - LLAMACPP_B_LABEL=${LLAMACPP_B_LABEL:-IQ4_XS} + - DEFAULT_ACTIVE=${DEFAULT_ACTIVE:-qwen3.8-27b-iq4xs} + volumes: + - ./llm-proxy/state:/app/state # ───────────────────────────────────────────────────────── # 3. MCPO - Proxy outils MCP (Obsidian, etc.) diff --git a/docs/WOL-ET-DEMARRAGE-AUTO.md b/docs/WOL-ET-DEMARRAGE-AUTO.md index de70326..fe024e1 100644 --- a/docs/WOL-ET-DEMARRAGE-AUTO.md +++ b/docs/WOL-ET-DEMARRAGE-AUTO.md @@ -8,7 +8,8 @@ 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 | +| **llama.cpp** (instance A — qwen3.8-27b Q4_K_M) | 8080 | Native (systemd) | Oui | +| **llama.cpp** (instance B — qwen3.8-27b-iq4xs) | 8081 | Native (systemd) | Oui | | **Ollama** | 11434 | ✅ | Oui | | **Effi-STT** | 8200 | ✅ | Oui | | **ComfyUI** | 8188 | ✅ | Oui | @@ -16,6 +17,10 @@ Guide pour s'assurer que tous les services Effi démarrent automatiquement aprè > **Note :** le TTS (openedai-speech, port 8100) tourne désormais sur l'effi-server, > pas sur folzo-Corp. Il n'a donc pas besoin de WOL. +> **Note VRAM :** les 2 instances llama.cpp partagent les 32 Go de VRAM. Une seule +> doit tourner à la fois : démarre l'instance voulue sur le PC, et active-la dans +> l'UI du proxy (http://192.168.1.27:3017). + Le réveil est géré automatiquement par **`llm-proxy`** (effi-server) : au premier appel vers une cible éteinte, il envoie 3 paquets magic (MAC du PC) puis attend que le service soit prêt. Aucun script manuel nécessaire. diff --git a/llm-proxy/Dockerfile b/llm-proxy/Dockerfile index 49c06b9..8eebd86 100644 --- a/llm-proxy/Dockerfile +++ b/llm-proxy/Dockerfile @@ -6,6 +6,6 @@ RUN pip install --no-cache-dir flask requests COPY app.py . -EXPOSE 3011 3012 3015 3016 +EXPOSE 3011 3012 3015 3016 3017 CMD ["python", "app.py"] diff --git a/llm-proxy/app.py b/llm-proxy/app.py index 058013c..4abdd4b 100644 --- a/llm-proxy/app.py +++ b/llm-proxy/app.py @@ -1,4 +1,5 @@ import os +import json import time import socket import logging @@ -6,7 +7,7 @@ import threading from urllib.parse import urljoin import requests -from flask import Flask, Response, request +from flask import Flask, Response, request, jsonify PC_HOST = os.getenv("PC_HOST", "192.168.1.11") PC_MAC = os.getenv("PC_MAC", "") @@ -14,12 +15,27 @@ BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255") WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "300")) SERVICE_TIMEOUT = int(os.getenv("SERVICE_TIMEOUT", "120")) CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5")) +STATE_FILE = os.getenv("STATE_FILE", "/app/state/active.json") +LISTEN_UI = int(os.getenv("LISTEN_UI", "3017")) + +MODEL_ALIAS = os.getenv("MODEL_ALIAS", "qwen3.8-27b") + +LLM_INSTANCES = [ + {"port": int(os.getenv("LLAMACPP_A_PORT", "8080")), + "real": os.getenv("LLAMACPP_A_MODEL", "qwen3.8-27b"), + "label": os.getenv("LLAMACPP_A_LABEL", "Q4_K_M")}, + {"port": int(os.getenv("LLAMACPP_B_PORT", "8081")), + "real": os.getenv("LLAMACPP_B_MODEL", "qwen3.8-27b-iq4xs"), + "label": os.getenv("LLAMACPP_B_LABEL", "IQ4_XS")}, +] +DEFAULT_ACTIVE = os.getenv("DEFAULT_ACTIVE", "qwen3.8-27b-iq4xs") ROUTES = [ {"name": "ollama", "port": int(os.getenv("OLLAMA_PORT", "11434")), "listen": int(os.getenv("LISTEN_OLLAMA", "3011")), "health": "/api/version", "wol": True}, - {"name": "llamacpp", "port": int(os.getenv("LLAMACPP_PORT", "8080")), - "listen": int(os.getenv("LISTEN_LLAMACPP", "3012")), "health": "/health", "wol": True}, + {"name": "llamacpp", "port": None, + "listen": int(os.getenv("LISTEN_LLAMACPP", "3012")), "health": "/health", "wol": True, + "alias": MODEL_ALIAS}, {"name": "stt", "port": int(os.getenv("STT_PORT", "8200")), "listen": int(os.getenv("LISTEN_STT", "3015")), "health": "/health", "wol": True}, {"name": "tts", "host": os.getenv("TTS_HOST", "127.0.0.1"), @@ -32,8 +48,41 @@ log = logging.getLogger("llm-proxy") wol_lock = threading.Lock() wol_state = {"last_sent": 0.0} +state_lock = threading.Lock() METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] +EXCLUDED_HEADERS = {"content-encoding", "content-length", "transfer-encoding", "connection"} +ALIAS_PATHS = ("v1/chat/completions", "v1/completions", "v1/models", "v1/embeddings") + + +def load_active(): + try: + with open(STATE_FILE) as f: + data = json.load(f) + a = data.get("active", DEFAULT_ACTIVE) + if any(i["real"] == a for i in LLM_INSTANCES): + return a + except Exception: + pass + return DEFAULT_ACTIVE + + +def save_active(alias): + if not any(i["real"] == alias for i in LLM_INSTANCES): + raise ValueError("instance inconnue: %s" % alias) + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + tmp = STATE_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump({"active": alias}, f) + os.replace(tmp, STATE_FILE) + + +def active_instance(): + a = load_active() + for i in LLM_INSTANCES: + if i["real"] == a: + return i + return LLM_INSTANCES[0] def send_wol(): @@ -50,6 +99,8 @@ def send_wol(): def service_alive(route) -> bool: + if route.get("port") is None: + return False host = route.get("host", PC_HOST) try: r = requests.get(f"http://{host}:{route['port']}{route['health']}", timeout=3) @@ -59,7 +110,7 @@ def service_alive(route) -> bool: def pc_alive() -> bool: - return any(service_alive(r) for r in ROUTES if r["wol"]) + return any(service_alive(r) for r in ROUTES if r.get("wol")) def wait_for(route, timeout: int) -> bool: @@ -80,9 +131,10 @@ def ensure_up(route): 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") + fast = int(os.getenv("FAST_FAIL_TIMEOUT", "20")) + log.info(f"[WoL] PC deja UP, attente du service {name} (fail fast {fast}s)") + if not wait_for(route, fast): + raise RuntimeError(f"service {name} ne repond pas apres {fast}s (instance arretee ?)") return now = time.time() if now - wol_state["last_sent"] > WAKE_TIMEOUT: @@ -101,10 +153,90 @@ def ensure_up(route): 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") + raise RuntimeError(f"service {route['name']} ne repond pas apres le reveil du PC") + + +def rewrite_request_body(body: bytes, real: str) -> bytes: + try: + obj = json.loads(body) + except Exception: + return body + if isinstance(obj, dict) and obj.get("model") == MODEL_ALIAS: + obj["model"] = real + return json.dumps(obj, ensure_ascii=False).encode() + return body + + +def rewrite_json(obj, real: str) -> bool: + changed = False + if isinstance(obj, dict): + if obj.get("model") == real: + obj["model"] = MODEL_ALIAS + changed = True + for key in ("data", "models"): + items = obj.get(key) + if not isinstance(items, list): + continue + for m in items: + if not isinstance(m, dict): + continue + if m.get("id") == real: + m["id"] = MODEL_ALIAS + changed = True + for field in ("name", "model"): + if m.get(field) == real: + m[field] = MODEL_ALIAS + changed = True + aliases = m.get("aliases") + if isinstance(aliases, list) and real in aliases: + m["aliases"] = [MODEL_ALIAS if a == real else a for a in aliases] + changed = True + return changed + + +def rewrite_sse_line(line: bytes, real: str) -> bytes: + if line.startswith(b"data:"): + payload = line[5:].lstrip(b" ") + if payload and payload != b"[DONE]": + try: + obj = json.loads(payload) + if isinstance(obj, dict) and obj.get("model") == real: + obj["model"] = MODEL_ALIAS + return b"data: " + json.dumps(obj, ensure_ascii=False).encode() + except Exception: + pass + return line + + +def sse_response(resp, real: str): + def generate(): + buf = b"" + for chunk in resp.iter_content(chunk_size=4096): + if not chunk: + continue + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + yield rewrite_sse_line(line, real) + b"\n" + if buf: + yield rewrite_sse_line(buf, real) + b"\n" + + headers = [(n, v) for n, v in resp.headers.items() if n.lower() not in EXCLUDED_HEADERS] + return Response(generate(), status=resp.status_code, headers=headers, + content_type="text/event-stream; charset=utf-8") def proxy_request(route, path: str): + real = None + alias_path = None + if route.get("alias"): + inst = active_instance() + route = dict(route) + route["port"] = inst["port"] + real = inst["real"] + norm = path.lstrip("/") + if any(norm == p or norm.startswith(p + "/") for p in ALIAS_PATHS): + alias_path = norm try: ensure_up(route) except Exception as e: @@ -114,12 +246,21 @@ def proxy_request(route, path: str): upstream_url = urljoin(base + "/", path) headers = dict(request.headers) headers.pop("Host", None) + body = request.get_data() + want_stream = False + if alias_path and request.method == "POST" and body: + try: + jd = json.loads(body) + want_stream = bool(jd.get("stream")) + except Exception: + pass + body = rewrite_request_body(body, real) try: resp = requests.request( method=request.method, url=upstream_url, headers=headers, - data=request.get_data(), + data=body, cookies=request.cookies, params=request.args, allow_redirects=False, @@ -130,10 +271,23 @@ def proxy_request(route, path: str): 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 - ] + response_headers = [(n, v) for n, v in resp.headers.items() if n.lower() not in EXCLUDED_HEADERS] + + if alias_path and resp.status_code == 200: + ctype = resp.headers.get("content-type", "") + if "application/json" in ctype: + data = resp.content + try: + obj = json.loads(data) + if rewrite_json(obj, real): + data = json.dumps(obj, ensure_ascii=False).encode() + except Exception: + pass + return Response(data, status=resp.status_code, headers=response_headers, + content_type="application/json") + if want_stream and "text/event-stream" in ctype: + return sse_response(resp, real) + return Response( resp.raw, status=resp.status_code, @@ -142,7 +296,7 @@ def proxy_request(route, path: str): ) -def make_app(route): +def make_proxy_app(route): app = Flask(route["name"]) @app.route("/", defaults={"path": ""}, methods=METHODS) @@ -153,13 +307,192 @@ def make_app(route): return app -def serve(route): - make_app(route).run(host="0.0.0.0", port=route["listen"], threaded=True) +def instance_up(inst) -> bool: + try: + r = requests.get(f"http://{PC_HOST}:{inst['port']}/health", timeout=3) + return r.ok + except Exception: + return False + + +def instance_info(inst): + info = {"port": inst["port"], "real": inst["real"], "label": inst["label"], + "up": instance_up(inst), "model": None} + if info["up"]: + try: + r = requests.get(f"http://{PC_HOST}:{inst['port']}/v1/models", timeout=5) + r.raise_for_status() + m = (r.json().get("data") or [{}])[0] + d = m.get("details", {}) or {} + meta = m.get("meta", {}) or {} + info["model"] = {"id": m.get("id"), + "n_ctx": d.get("n_ctx") or meta.get("n_ctx"), + "size": d.get("size") or meta.get("size"), + "ftype": d.get("ftype") or meta.get("ftype")} + except Exception: + pass + return info + + +INDEX_HTML = """ + +
+ + +