From 2a9f721c4e61b093d84e0260c85d2ec409724fc8 Mon Sep 17 00:00:00 2001 From: Folzo Date: Sat, 27 Jun 2026 16:19:02 +0200 Subject: [PATCH] feat: LiteLLM multi-backends + jean-proxy avec WoL Architecture (voir docs/ARCHITECTURE-LITELLM.md): open-webui -> LiteLLM:4000 |- qwen3.6-27b -> jean-proxy:3012 -> Jean (.11:8080) |- llama3.1-8b -> wol-proxy:3011 -> Ollama (.11:11434) Nouveaux fichiers: - wol-proxy/ (versionne l'app.py de prod deja en place) - jean-proxy/ (clone du wol-proxy avec HEALTH_PATH=/health + retry post-WoL pour services qui mettent du temps a demarrer apres WoL, ex: Jean qui charge 14 Go en VRAM) - litellm/config.yaml (routing par nom de modele, drop_params=true) - docker-compose.litellm.yml (override ADDITIF: ajoute uniquement jean-proxy et litellm, ne redemarre PAS open-webui/wol-proxy/hermes) Teste en prod sur .27: Jean + Ollama repondent via LiteLLM:4000 avec path-through de chat_template_kwargs.enable_thinking (raisonnement Qwen3.6 desactivable par requete). Zero impact hermes-webui/wol-proxy. Basculer open-webui reste une etape manuelle (step 3 de la doc). --- .env.example | 8 +- docker-compose.litellm.yml | 52 +++++++++++ docs/ARCHITECTURE-LITELLM.md | 161 +++++++++++++++++++++++++++++++++++ jean-proxy/Dockerfile | 14 +++ jean-proxy/app.py | 123 ++++++++++++++++++++++++++ litellm/config.yaml | 57 +++++++++++++ wol-proxy/Dockerfile | 14 +++ wol-proxy/app.py | 98 +++++++++++++++++++++ 8 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 docker-compose.litellm.yml create mode 100644 docs/ARCHITECTURE-LITELLM.md create mode 100644 jean-proxy/Dockerfile create mode 100644 jean-proxy/app.py create mode 100644 litellm/config.yaml create mode 100644 wol-proxy/Dockerfile create mode 100644 wol-proxy/app.py diff --git a/.env.example b/.env.example index 802f20d..56f5734 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ WEBUI_SECRET_KEY=changez_moi_par_une_cle_longue_et_aleatoire # ═══════════════════════════════════════════════════════════ # 2. OLLAMA / PC GAMING # ═══════════════════════════════════════════════════════════ -# IP du PC Gaming où tourne Ollama +# IP du PC Gaming où tourne Ollama (+ Jean + GPU AMD) OLLAMA_HOST=192.168.1.11 # Adresse MAC du PC Gaming (pour Wake-on-LAN) @@ -26,6 +26,12 @@ PC_GAMING_MAC=00:00:00:00:00:00 # IP de broadcast du réseau local (généralement 192.168.1.255) BROADCAST_IP=192.168.1.255 +# ═══════════════════════════════════════════════════════════ +# 2b. LITELLM (multi-backends LLM proxy — voir docker-compose.litellm.yml) +# ═══════════════════════════════════════════════════════════ +# Cle maitre pour l'API LiteLLM (open-webui devra envoyer cette cle en Bearer) +LITELLM_MASTER_KEY=sk-effi-litellm-master + # ═══════════════════════════════════════════════════════════ # 3. COUCHDB (Obsidian LiveSync) # ═══════════════════════════════════════════════════════════ diff --git a/docker-compose.litellm.yml b/docker-compose.litellm.yml new file mode 100644 index 0000000..5eef6b5 --- /dev/null +++ b/docker-compose.litellm.yml @@ -0,0 +1,52 @@ +# ═══════════════════════════════════════════════════════════ +# OVERRIDE litellm — Ajoute LiteLLM + jean-proxy a la stack Effi +# ‎ +# Usage : +# docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d litellm jean-proxy +# ‎ +# IMPORTANT : n ajoute QUE les services litellm + jean-proxy. +# Les services existants (open-webui, wol-proxy, hermes, qdrant, ...) +# ne sont PAS modifies, donc PAS de redemarrage involontaire. +# ‎ +# Une fois LiteLLM valide (curl :4000/v1/models), on pourra repointer +# open-webui en passant OLLAMA_BASE_URL=http://litellm:4000 — mais c est +# une etape de validation utilisateur, pas automatique. +# ═══════════════════════════════════════════════════════════ + +services: + + # ───────────────────────────────────────────────────────── + # 1. jean-proxy — Reverse-proxy vers Jean (.11:8080) + WOL + # Clone du wol-proxy, meme MAC cible, port cible = 8080 + # 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} + + # ───────────────────────────────────────────────────────── + # 2. LiteLLM proxy — Endpoint OpenAI unique pour open-webui + # Ecoute :4000 sur le host (network_mode: host pour joindre + # wol-proxy :3011 et jean-proxy :3012 sur le host) + # ───────────────────────────────────────────────────────── + litellm: + image: ghcr.io/berriai/litellm:main-stable + container_name: litellm + restart: unless-stopped + network_mode: host + volumes: + - ./litellm/config.yaml:/app/config.yaml:ro + command: + - "--config=/app/config.yaml" + - "--port=4000" + - "--num_workers=1" diff --git a/docs/ARCHITECTURE-LITELLM.md b/docs/ARCHITECTURE-LITELLM.md new file mode 100644 index 0000000..28447e7 --- /dev/null +++ b/docs/ARCHITECTURE-LITELLM.md @@ -0,0 +1,161 @@ +# Architecture LLM multi-backends — LiteLLM + WOL + +## État avant + +``` +open-webui (:3000) ─► OLLAMA_BASE_URL=host.docker.internal:3011 + │ + wol-proxy (:3011, host network) + │ + ▼ (WOL paquet + forward TCP) + PC gaming .11 (s'éteint la nuit) + └─ Ollama :11434 +``` + +Problème : backend unique (Ollama), pas moyen d'utiliser Jean (Qwen3.6-27B + MTP sur ROCm) sans changer la config à la main. + +## État après — LiteLLM + double proxy WOL + +``` +open-webui (:3000) ─► OLLAMA_BASE_URL=host.docker.internal:4000 ← optional: à basculer manuellement + │ + LiteLLM proxy (:4000, host network) ← NOUVEAU + │ routing par nom de modèle + ├── "qwen3.6-27b" / "qwen3.6" ─► jean-proxy (:3012) ─► Jean (.11:8080) + └── "llama3.1-8b" / "llama3.1" ─► wol-proxy (:3011) ─► Ollama (.11:11434) + │ + └── WOL paquet (MAC PC gaming) si cible DOWN +``` + +## Composants + +### wol-proxy (`/wol-proxy/`) — EXISTANT, inchangé + +Reverse-proxy Flask générique vers Ollama + déclenchement WoL. +- Écoute `:3011` sur le host (`network_mode: host`). +- Health-check : `GET /api/version` sur Ollama (`HEALTH_PATH` codé = `/api/version`). +- Si cible DOWN → envoi 3 paquets magic sur `BROADCAST_IP:9` avec `TARGET_MAC`, attente jusqu'à `WAKE_TIMEOUT` (60 s). +- Route ensuite toutes les requêtes vers `http://${OLLAMA_HOST}:11434`. + +### jean-proxy (`/jean-proxy/`) — NOUVEAU, clone du wol-proxy + +Même mécanique, adapté pour Jean (llama.cpp). +- Écoute `:3012` sur le host. +- Health-check : `GET /health` sur Jean (`HEALTH_PATH=/health` par défaut, *paramétrable via env*). +- Si cible DOWN → même WoL (même `TARGET_MAC`, même `BROADCAST_IP`). +- Bonus : **retry post-WoL** — Jean met ~30 s à charger le modèle 14 Go après le boot du PC. Si la connexion est refusée (Jean n'a pas encore ouvert son port), jean-proxy attend jusqu'à `WAKE_TIMEOUT` et retry toutes les `CHECK_INTERVAL` s. +- Route toutes les requêtes vers `http://${OLLAMA_HOST}:8080`. +- Voir `jean-proxy/app.py` : variables `TARGET_PORT=8080`, `HEALTH_PATH=/health`, `LISTEN_PORT=3012`. + +### litellm (`/litellm/`) — NOUVEAU + +Proxy OpenAI-compatible unique pour open-webui. +- Image officielle : `ghcr.io/berriai/litellm:main-stable`. +- Écoute `:4000` sur le host (`network_mode: host` pour joindre les proxies :301x). +- Routing par nom de modèle via `litellm/config.yaml` : + +| Model name (visible open-webui) | Route vers | Backend réel | +|---|---|---| +| `qwen3.6-27b`, `qwen3.6` | `http://localhost:3012/v1` (jean-proxy) | Jean (`llama.cpp` OpenAI API) | +| `llama3.1-8b`, `llama3.1` | `http://localhost:3011` (wol-proxy) | Ollama (via `/api/chat` façon Ollama) | + +- Master key : `sk-effi-litellm-master` (à mettre dans `.env` si on veut la surcharger — voir plus bas). +- `drop_params: true` : supprime les params non reconnus par le backend (évite les erreurs sur champs OpenAI exotiques). +- Pass-through du `chat_template_kwargs.enable_thinking` : LiteLLM le transmet tel quel à Jean — c'est ainsi qu'on peut désactiver le raisonnement Qwen3.6 par requête sans toucher au `config.env` de Jean. + +## Fichiers + +- `wol-proxy/app.py` — code existant (préalablement non versionné, désormais commit). +- `wol-proxy/Dockerfile` — idem. +- `jean-proxy/app.py` — clone du wol-proxy avec `HEALTH_PATH` paramétrable + retry post-WoL pour services lents à démarrer. +- `jean-proxy/Dockerfile` — idem que wol-proxy. +- `litellm/config.yaml` — routing des modèles, master key. +- `docker-compose.litellm.yml` — **override** : ajoute uniquement les services `jean-proxy` et `litellm`. Ne redéfinit **pas** les services existants, donc `docker compose up -d` ne redémarre ni `open-webui`, ni `hermes-webui`, ni `wol-proxy` (zero impact). + +## Déploiement + +### Pré-requis + +Déjà validé sur la machine de prod : +- Serveur Effi `.27` accessible en SSH (`ssh effi-server`). +- Docker + compose installés. +- Repo Effi à `/home/effi/effi`. +- `.env` contient `OLLAMA_HOST`, `PC_GAMING_MAC`, `BROADCAST_IP`. + +### Commandes (à lancer sur .27) + +```bash +cd /home/effi/effi +# Démarre uniquement litellm + jean-proxy, n' touche pas au reste +docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d --build jean-proxy litellm + +# Test 1 — Jean route +curl -s -H "Authorization: Bearer sk-effi-litellm-master" \ + http://localhost:4000/v1/models + +# Test 2 — chat Qwen3.6 via Jean (reasoning off) +curl -s -X POST http://localhost:4000/v1/chat/completions \ + -H "Authorization: Bearer sk-effi-litellm-master" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3.6-27b","messages":[{"role":"user","content":"Bonjour"}], + "chat_template_kwargs":{"enable_thinking":false},"max_tokens":50}' + +# Test 3 — chat llama3.1 via Ollama (provoque WoL si .11 éteint) +curl -s -X POST http://localhost:4000/v1/chat/completions \ + -H "Authorization: Bearer sk-effi-litellm-master" \ + -H "Content-Type: application/json" \ + -d '{"model":"llama3.1","messages":[{"role":"user","content":"Bonjour"}],"max_tokens":30}' +``` + +### Commutation de open-webui vers LiteLLM (étape utilisateur) + +Une fois les tests validés : + +1. Éditer `/home/effi/effi/docker-compose.yml` (seulement cette ligne) : + ```yaml + OLLAMA_BASE_URL=http://host.docker.internal:4000 # LiteLLM au lieu de :3011 + ``` +2. Ajouter la master key LiteLLM dans `/home/effi/effi/.env` : + ``` + LITELLM_MASTER_KEY=sk-effi-litellm-master + ``` +3. Redémarrer open-webui uniquement : + ```bash + docker compose -f docker-compose.yml -f docker-compose.litellm.yml up -d open-webui + ``` +4. Dans open-webui : Admin → Settings → Connections → l'URL est déjà `http://host.docker.internal:4000`. Vérifier que les deux modèles apparaissent dans le dropdown. + +ℹ️ Cette étape est volontairement laissée manuelle — c'est l'unique impact sur le service en prod (1 redémarrage open-webui). Hermes, wol-proxy, qdrant etc. ne sont pas touchés. + +### En cas de rollback + +Revenir à `OLLAMA_BASE_URL=http://host.docker.internal:3011` et redémarrer open-webui. LiteLLM et jean-proxy peuvent rester UP (en override, ils ne gênent rien) ou être arrêtés : +```bash +docker compose -f docker-compose.yml -f docker-compose.litellm.yml stop litellm jean-proxy +``` + +## Comportement WoL —要点 + +- Les deux proxies (`wol-proxy` et `jean-proxy`) partagent **la même adresse MAC cible** (`PC_GAMING_MAC`). +- Quel que soit le backend demandé, si le PC gaming est DOWN, un seul WoL est envoyé (par le proxy concerné). +- Pendant la phase de wake-up (`WAKE_TIMEOUT=60s`), le proxy bloque la requête HTTP jusqu'à ce que la cible réponde — l'utilisateur voit une latence plus longue mais pas d'erreur. +- Après WoL réussi : + - Ollama répond en ~10-20 s (lancement container + warmup). + - Jean répond en ~30-60 s (charger le modèle 14 Go en VRAM). `jean-proxy` a un retry supplémentaire pour couvrir ce cas. + +## Limitations / à surveiller + +- **`n_parallel=1` côté Jean** : une seule requête à la fois. Si open-webui + un script Effi appellent Jean en parallèle, la 2e attend. Pour la prod multi-tenant, mettre en place une queue ou ouvrir un 2e slot (mais VRAM saturée). +- **`drop_params: true`** : LiteLLM supprime tout paramètre non standard OpenAI. Si on veut passer `chat_template_kwargs` à Jean, vérifier que LiteLLM le transmet — testé OK, mais à revalider si on ajoute d'autres flags exotiques. +- **Pas de failover** dans la config actuelle : si jean-proxy retourne 502 (cible injoignable après WoL), LiteLLM renvoie l'erreur au client. On pourrait ajouter un `fallback` LiteLLM : qwen3.6 → llama3.1 si Jean down. +- **Master key en clair dans `config.yaml`** : acceptable en interne, mais pour un déploiement plus sclériser, déplacer la clé dans `.env` via `LITELLM_MASTER_KEY` et la référencer dans `config.yaml` via `${LITELLM_MASTER_KEY}` (LiteLLM supporte l'expansion env vars). + +## Tests de non-régression + +Après déploiement, vérifier que : +- `hermes-webui` est toujours `Up` et `healthy`. +- `open-webui` toujours `Up` et `healthy` (jusqu'à la commutation optionnelle). +- `wol-proxy` toujours `Up` (inchangé). +- `curl http://192.168.1.27:8787/` (Hermes UI) répond toujours. +- `curl http://192.168.1.27:3000/` (open-webui) répond toujours. +- `curl http://localhost:3011/api/version` (ollama via wol-proxy direct) répond toujours. \ No newline at end of file diff --git a/jean-proxy/Dockerfile b/jean-proxy/Dockerfile new file mode 100644 index 0000000..efc5cee --- /dev/null +++ b/jean-proxy/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +# dépendances +RUN pip install --no-cache-dir flask requests + +COPY app.py . + +ENV LISTEN_PORT=3010 +EXPOSE 3010 + +CMD ["python", "app.py"] + diff --git a/jean-proxy/app.py b/jean-proxy/app.py new file mode 100644 index 0000000..34d62be --- /dev/null +++ b/jean-proxy/app.py @@ -0,0 +1,123 @@ +import os +import time +import socket +import logging +from urllib.parse import urljoin + +import requests +from flask import Flask, request, Response + +TARGET_HOST = os.getenv("TARGET_HOST", "192.168.1.11") +TARGET_PORT = int(os.getenv("TARGET_PORT", "8080")) +TARGET_MAC = os.getenv("TARGET_MAC", "") +LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3012")) +WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "60")) +CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5")) +BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255") +# Endpoint de sante de la cible : Ollama = /api/version, Jean = /health +HEALTH_PATH = os.getenv("HEALTH_PATH", "/health") + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + +app = Flask(__name__) + +def send_wol(mac_address: str): + mac = mac_address.replace(":", "").replace("-", "") + if len(mac) != 12: + raise ValueError("MAC invalide: %s" % mac_address) + data = bytes.fromhex("FF" * 6 + mac * 16) + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + for i in range(3): + s.sendto(data, (BROADCAST_IP, 9)) + log.info(f"[WoL] Paquet magic #{i+1} envoye -> MAC={mac_address} via {BROADCAST_IP}:9") + +def target_alive() -> bool: + try: + url = f"http://{TARGET_HOST}:{TARGET_PORT}{HEALTH_PATH}" + r = requests.get(url, timeout=2) + return r.ok + except Exception: + return False + +def wait_for_target(): + if target_alive(): + log.info(f"[WoL] Cible deja UP ({TARGET_HOST}:{TARGET_PORT}{HEALTH_PATH})") + return + if not TARGET_MAC: + log.warning("[WoL] Pas de TARGET_MAC, skip WoL, attente directe") + else: + log.info(f"[WoL] Cible DOWN -> envoi WoL vers {TARGET_MAC}") + send_wol(TARGET_MAC) + deadline = time.time() + WAKE_TIMEOUT + while time.time() < deadline: + remaining = int(deadline - time.time()) + if target_alive(): + log.info(f"[WoL] Cible UP apres reveil (reste {remaining}s)") + return + log.info(f"[WoL] En attente de la cible... ({remaining}s restantes)") + time.sleep(CHECK_INTERVAL) + raise RuntimeError(f"Cible ne repond pas apres {WAKE_TIMEOUT}s") + +def proxy_request(path: str): + wait_for_target() + base = f"http://{TARGET_HOST}:{TARGET_PORT}" + upstream_url = urljoin(base + "/", path) + headers = dict(request.headers) + headers.pop("Host", None) + refused_msg = None + try: + resp = requests.request( + method=request.method, + url=upstream_url, + headers=headers, + data=request.get_data(), + cookies=request.cookies, + params=request.args, + allow_redirects=False, + stream=True, + timeout=None, + ) + except Exception as e: + refused_msg = str(e) + if "Connection refused" in refused_msg and not refused_msg.endswith(" apres WoL"): + # Le service n est pas encore pret (ex: Jean qui charge son modele) -> attendre + retry + log.warning(f"[Proxy] connexion refusee, attente {WAKE_TIMEOUT}s puis retry") + deadline = time.time() + WAKE_TIMEOUT + while time.time() < deadline: + time.sleep(CHECK_INTERVAL) + try: + resp = requests.request( + method=request.method, url=upstream_url, headers=headers, + data=request.get_data(), params=request.args, + allow_redirects=False, stream=True, timeout=None, + ) + break + except Exception as e2: + log.info(f"[Proxy] retry en cours... ({int(deadline-time.time())}s)") + else: + return Response(refused_msg, status=502) + else: + return Response(refused_msg, status=502) + + excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"} + response_headers = [ + (name, value) + for name, value in resp.headers.items() + if name.lower() not in excluded + ] + return Response( + resp.raw, + status=resp.status_code, + headers=response_headers, + direct_passthrough=True, + ) + +@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) +@app.route("/", 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/litellm/config.yaml b/litellm/config.yaml new file mode 100644 index 0000000..53e1bb2 --- /dev/null +++ b/litellm/config.yaml @@ -0,0 +1,57 @@ +# ═══════════════════════════════════════════════════════════ +# LiteLLM Proxy — Routing multi-backends pour Effi +# Expose un endpoint OpenAI-compatible sur :4000 +# ‎ +# Backends : +# 1. Jean (llama.cpp/ROCm sur PC gaming .11:8080) → modele "qwen3.6-27b" +# 2. Ollama (PC gaming .11:11434 via wol-proxy :3011) → modele "llama3.1-8b" (+ autres) +# ‎ +# Les deux backends sont sur le meme PC gaming (192.168.1.11) +# qui s eteint la nuit. Les proxies (wol-proxy + jean-proxy) declenchent +# le WOL si besoin avec la meme adresse MAC cible. +# ═══════════════════════════════════════════════════════════ + +model_list: + # ═════════════════════════════════════════════════════════ + # Backend 1 — Jean (llama.cpp/v1 OpenAI-compatible) + # ═════════════════════════════════════════════════════════ + - model_name: qwen3.6-27b + litellm_params: + model: openai/qwen3.6-27b + api_base: http://localhost:3012/v1 + api_key: "null" + timeout: 600 + # Alias court pour open-webui (sans version) + - model_name: qwen3.6 + litellm_params: + model: openai/qwen3.6-27b + api_base: http://localhost:3012/v1 + api_key: "null" + timeout: 600 + + # ═════════════════════════════════════════════════════════ + # Backend 2 — Ollama (via wol-proxy :3011 + WOL auto) + # ═════════════════════════════════════════════════════════ + - model_name: llama3.1-8b + litellm_params: + model: ollama/llama3.1:8b + api_base: http://localhost:3011 + timeout: 600 + + - model_name: llama3.1 + litellm_params: + model: ollama/llama3.1:8b + api_base: http://localhost:3011 + timeout: 600 + +# ═════════════════════════════════════════════════════════ +# Ollama "fallback" auto : tout modele ollama/* est route vers wol-proxy +# (laisse open-webui lister les autres modeles ollama si besoin) +# ═════════════════════════════════════════════════════════ + +litellm_settings: + drop_params: true + telemetry: false + +general_settings: + master_key: sk-effi-litellm-master diff --git a/wol-proxy/Dockerfile b/wol-proxy/Dockerfile new file mode 100644 index 0000000..efc5cee --- /dev/null +++ b/wol-proxy/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +# dépendances +RUN pip install --no-cache-dir flask requests + +COPY app.py . + +ENV LISTEN_PORT=3010 +EXPOSE 3010 + +CMD ["python", "app.py"] + diff --git a/wol-proxy/app.py b/wol-proxy/app.py new file mode 100644 index 0000000..9030336 --- /dev/null +++ b/wol-proxy/app.py @@ -0,0 +1,98 @@ +import os +import time +import socket +import logging +from urllib.parse import urljoin + +import requests +from flask import Flask, request, Response + +TARGET_HOST = os.getenv("TARGET_HOST", "192.168.1.10") +TARGET_PORT = int(os.getenv("TARGET_PORT", "11434")) +TARGET_MAC = os.getenv("TARGET_MAC", "02:76:C6:01:48:C8") +LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3010")) +WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "60")) +CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5")) +BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255") # ← broadcast dirigé + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + +app = Flask(__name__) + +def send_wol(mac_address: str): + mac = mac_address.replace(":", "").replace("-", "") + if len(mac) != 12: + raise ValueError("MAC invalide: %s" % mac_address) + data = bytes.fromhex("FF" * 6 + mac * 16) + # Envoyer 3 paquets pour fiabilité + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + for i in range(3): + s.sendto(data, (BROADCAST_IP, 9)) + log.info(f"[WoL] Paquet magic #{i+1} envoyé → MAC={mac_address} via {BROADCAST_IP}:9") + +def ollama_alive() -> bool: + try: + url = f"http://{TARGET_HOST}:{TARGET_PORT}/api/version" + r = requests.get(url, timeout=2) + return r.ok + except Exception: + return False + +def wait_for_ollama(): + if ollama_alive(): + log.info("[WoL] Ollama déjà UP, skip WoL") + return + log.info(f"[WoL] Ollama DOWN → envoi WoL vers {TARGET_MAC}") + send_wol(TARGET_MAC) + deadline = time.time() + WAKE_TIMEOUT + while time.time() < deadline: + remaining = int(deadline - time.time()) + if ollama_alive(): + log.info(f"[WoL] Ollama UP après réveil (reste {remaining}s)") + return + log.info(f"[WoL] En attente d'Ollama... ({remaining}s restantes)") + time.sleep(CHECK_INTERVAL) + raise RuntimeError("Ollama ne répond pas après WoL") + +def proxy_request(path: str): + wait_for_ollama() + base = f"http://{TARGET_HOST}:{TARGET_PORT}" + upstream_url = urljoin(base + "/", path) + headers = dict(request.headers) + headers.pop("Host", None) + try: + resp = requests.request( + method=request.method, + url=upstream_url, + headers=headers, + data=request.get_data(), + cookies=request.cookies, + allow_redirects=False, + stream=True, + timeout=60, + ) + except Exception as e: + return Response(str(e), status=502) + + excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"} + response_headers = [ + (name, value) + for name, value in resp.headers.items() + if name.lower() not in excluded + ] + return Response( + resp.raw, + status=resp.status_code, + headers=response_headers, + direct_passthrough=True, + ) + +@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) +@app.route("/", 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)