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).
This commit is contained in:
Folzo
2026-06-27 16:19:08 +02:00
parent 2f270aeca8
commit 2a9f721c4e
8 changed files with 526 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
# dépendances
RUN pip install --no-cache-dir flask requests
COPY app.py .
ENV LISTEN_PORT=3010
EXPOSE 3010
CMD ["python", "app.py"]
+123
View File
@@ -0,0 +1,123 @@
import os
import time
import socket
import logging
from urllib.parse import urljoin
import requests
from flask import Flask, request, Response
TARGET_HOST = os.getenv("TARGET_HOST", "192.168.1.11")
TARGET_PORT = int(os.getenv("TARGET_PORT", "8080"))
TARGET_MAC = os.getenv("TARGET_MAC", "")
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3012"))
WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "60"))
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "5"))
BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255")
# Endpoint de sante de la cible : Ollama = /api/version, Jean = /health
HEALTH_PATH = os.getenv("HEALTH_PATH", "/health")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
def send_wol(mac_address: str):
mac = mac_address.replace(":", "").replace("-", "")
if len(mac) != 12:
raise ValueError("MAC invalide: %s" % mac_address)
data = bytes.fromhex("FF" * 6 + mac * 16)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for i in range(3):
s.sendto(data, (BROADCAST_IP, 9))
log.info(f"[WoL] Paquet magic #{i+1} envoye -> MAC={mac_address} via {BROADCAST_IP}:9")
def target_alive() -> bool:
try:
url = f"http://{TARGET_HOST}:{TARGET_PORT}{HEALTH_PATH}"
r = requests.get(url, timeout=2)
return r.ok
except Exception:
return False
def wait_for_target():
if target_alive():
log.info(f"[WoL] Cible deja UP ({TARGET_HOST}:{TARGET_PORT}{HEALTH_PATH})")
return
if not TARGET_MAC:
log.warning("[WoL] Pas de TARGET_MAC, skip WoL, attente directe")
else:
log.info(f"[WoL] Cible DOWN -> envoi WoL vers {TARGET_MAC}")
send_wol(TARGET_MAC)
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
remaining = int(deadline - time.time())
if target_alive():
log.info(f"[WoL] Cible UP apres reveil (reste {remaining}s)")
return
log.info(f"[WoL] En attente de la cible... ({remaining}s restantes)")
time.sleep(CHECK_INTERVAL)
raise RuntimeError(f"Cible ne repond pas apres {WAKE_TIMEOUT}s")
def proxy_request(path: str):
wait_for_target()
base = f"http://{TARGET_HOST}:{TARGET_PORT}"
upstream_url = urljoin(base + "/", path)
headers = dict(request.headers)
headers.pop("Host", None)
refused_msg = None
try:
resp = requests.request(
method=request.method,
url=upstream_url,
headers=headers,
data=request.get_data(),
cookies=request.cookies,
params=request.args,
allow_redirects=False,
stream=True,
timeout=None,
)
except Exception as e:
refused_msg = str(e)
if "Connection refused" in refused_msg and not refused_msg.endswith(" apres WoL"):
# Le service n est pas encore pret (ex: Jean qui charge son modele) -> attendre + retry
log.warning(f"[Proxy] connexion refusee, attente {WAKE_TIMEOUT}s puis retry")
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
time.sleep(CHECK_INTERVAL)
try:
resp = requests.request(
method=request.method, url=upstream_url, headers=headers,
data=request.get_data(), params=request.args,
allow_redirects=False, stream=True, timeout=None,
)
break
except Exception as e2:
log.info(f"[Proxy] retry en cours... ({int(deadline-time.time())}s)")
else:
return Response(refused_msg, status=502)
else:
return Response(refused_msg, status=502)
excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"}
response_headers = [
(name, value)
for name, value in resp.headers.items()
if name.lower() not in excluded
]
return Response(
resp.raw,
status=resp.status_code,
headers=response_headers,
direct_passthrough=True,
)
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
def handle(path):
return proxy_request(path)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=LISTEN_PORT, threaded=True)