- 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)
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
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("/<path:path>", 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)
|