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:
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user