- dashboard_status utilise le cache presets au lieu d'appeler Jean - jean_service_ok/ollama_service_ok silencieux (pas de warning log) - slot check uniquement si jean est UP - [Presets] Erreur ne vient plus du dashboard mais d'OpenFox/OpenWebUI
1389 lines
50 KiB
Python
1389 lines
50 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Jean Router — Proxy dynamique pour Jean presets
|
|
Interroge Jean pour lister les presets disponibles et route les requêtes
|
|
vers le bon preset en faisant le switch automatique si nécessaire.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import logging
|
|
import threading
|
|
import socket
|
|
import queue
|
|
import json as json_mod
|
|
from collections import deque
|
|
from typing import Optional
|
|
|
|
import requests
|
|
from flask import Flask, request, Response, jsonify, stream_with_context
|
|
|
|
JEAN_WEB_HOST = os.getenv("JEAN_WEB_HOST", "192.168.1.11")
|
|
JEAN_WEB_PORT = int(os.getenv("JEAN_WEB_PORT", "8090"))
|
|
JEAN_PROXY_HOST = os.getenv("JEAN_PROXY_HOST", "localhost")
|
|
JEAN_PROXY_PORT = int(os.getenv("JEAN_PROXY_PORT", "3012"))
|
|
OLLAMA_PROXY_HOST = os.getenv("OLLAMA_PROXY_HOST", "localhost")
|
|
OLLAMA_PROXY_PORT = int(os.getenv("OLLAMA_PROXY_PORT", "3011"))
|
|
OLLAMA_DIRECT_HOST = os.getenv("OLLAMA_DIRECT_HOST", JEAN_WEB_HOST)
|
|
OLLAMA_DIRECT_PORT = int(os.getenv("OLLAMA_DIRECT_PORT", "11434"))
|
|
ENABLE_OLLAMA = os.getenv("ENABLE_OLLAMA", "true").lower() == "true"
|
|
ENABLE_VRAM_MANAGEMENT = os.getenv("ENABLE_VRAM_MANAGEMENT", "true").lower() == "true"
|
|
OLLAMA_KEEP_ALIVE_MINUTES = int(os.getenv("OLLAMA_KEEP_ALIVE_MINUTES", "5"))
|
|
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "3014"))
|
|
SWITCH_TIMEOUT = int(os.getenv("SWITCH_TIMEOUT", "90"))
|
|
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "2"))
|
|
JEAN_STOP_TIMEOUT = int(os.getenv("JEAN_STOP_TIMEOUT", "30"))
|
|
OLLAMA_UNLOAD_TIMEOUT = int(os.getenv("OLLAMA_UNLOAD_TIMEOUT", "15"))
|
|
ENABLE_WOL = os.getenv("ENABLE_WOL", "false").lower() == "true"
|
|
TARGET_MAC = os.getenv("TARGET_MAC", os.getenv("PC_GAMING_MAC", ""))
|
|
BROADCAST_IP = os.getenv("BROADCAST_IP", "192.168.1.255")
|
|
WAKE_TIMEOUT = int(os.getenv("WAKE_TIMEOUT", "180"))
|
|
wol_in_progress = threading.Lock()
|
|
jean_alive_cache = {"alive": False, "timestamp": 0}
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
logging.getLogger("requests").setLevel(logging.WARNING)
|
|
log = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
|
|
# --- Log buffer pour le dashboard SSE ---
|
|
log_queue = queue.Queue()
|
|
log_buffer = deque(maxlen=5000)
|
|
|
|
class QueueHandler(logging.Handler):
|
|
def emit(self, record):
|
|
msg = self.format(record)
|
|
log_buffer.append(msg)
|
|
try:
|
|
log_queue.put_nowait(msg)
|
|
except queue.Full:
|
|
pass
|
|
|
|
queue_handler = QueueHandler()
|
|
queue_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
|
queue_handler.setLevel(logging.INFO)
|
|
logging.getLogger().addHandler(queue_handler)
|
|
|
|
# --- Compteur de requêtes ---
|
|
request_stats = {"total": 0, "jean": 0, "ollama": 0, "errors": 0, "last_requests": deque(maxlen=50)}
|
|
stats_lock = threading.Lock()
|
|
|
|
presets_cache = {"data": [], "timestamp": 0}
|
|
ollama_cache = {"data": [], "timestamp": 0}
|
|
ollama_detail_cache = {}
|
|
jean_ctx_cache = {}
|
|
jean_caps_cache = {}
|
|
CACHE_TTL = int(os.getenv("CACHE_TTL", "15"))
|
|
OLLAMA_DETAIL_CACHE_TTL = 300
|
|
switch_lock = threading.Lock()
|
|
vram_lock = threading.Lock()
|
|
vram_state = {"active_backend": "jean"}
|
|
last_active_preset = None
|
|
ollama_unload_timer = None
|
|
|
|
|
|
def get_presets(force_refresh=False):
|
|
"""Récupère la liste des presets depuis Jean"""
|
|
now = time.time()
|
|
if not force_refresh and presets_cache["data"] and (now - presets_cache["timestamp"]) < CACHE_TTL:
|
|
return presets_cache["data"]
|
|
|
|
try:
|
|
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/presets"
|
|
r = requests.get(url, timeout=3)
|
|
if r.ok:
|
|
presets_cache["data"] = r.json()
|
|
presets_cache["timestamp"] = now
|
|
return presets_cache["data"]
|
|
except Exception as e:
|
|
log.warning(f"[Presets] Erreur récupération: {e}")
|
|
if ENABLE_WOL and (not presets_cache["data"] or force_refresh):
|
|
wait_for_jean(trigger_wol=True)
|
|
try:
|
|
r = requests.get(f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/presets", timeout=5)
|
|
if r.ok:
|
|
presets_cache["data"] = r.json()
|
|
presets_cache["timestamp"] = now
|
|
return presets_cache["data"]
|
|
except Exception:
|
|
pass
|
|
|
|
return presets_cache["data"]
|
|
|
|
|
|
def send_wol():
|
|
"""Envoie un magic packet WoL vers TARGET_MAC"""
|
|
if not TARGET_MAC:
|
|
log.warning("[WoL] TARGET_MAC non defini, WoL desactive")
|
|
return
|
|
|
|
mac = TARGET_MAC.replace(":", "").replace("-", "")
|
|
if len(mac) != 12:
|
|
log.error(f"[WoL] MAC invalide: {TARGET_MAC}")
|
|
return
|
|
|
|
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] Magic packet #{i+1} envoye vers {TARGET_MAC}")
|
|
|
|
|
|
def jean_alive() -> bool:
|
|
"""Verifie si le PC gaming est allume (TCP connect vers Jean ou Ollama)"""
|
|
now = time.time()
|
|
if now - jean_alive_cache["timestamp"] < 2:
|
|
return jean_alive_cache["alive"]
|
|
|
|
alive = False
|
|
for port in [JEAN_WEB_PORT, OLLAMA_DIRECT_PORT]:
|
|
try:
|
|
sock = socket.create_connection((JEAN_WEB_HOST, port), timeout=2)
|
|
sock.close()
|
|
alive = True
|
|
break
|
|
except ConnectionRefusedError:
|
|
alive = True
|
|
break
|
|
except (socket.timeout, OSError):
|
|
continue
|
|
|
|
jean_alive_cache["alive"] = alive
|
|
jean_alive_cache["timestamp"] = now
|
|
return alive
|
|
|
|
|
|
def jean_service_ok() -> bool:
|
|
"""Verifie que le service Jean repond (HTTP) — silencieux"""
|
|
try:
|
|
r = requests.get(f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/presets", timeout=3)
|
|
return r.ok
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def ollama_service_ok() -> bool:
|
|
"""Verifie que le service Ollama repond (HTTP) — silencieux"""
|
|
try:
|
|
r = requests.get(f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/version", timeout=2)
|
|
return r.ok
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def wait_for_jean(trigger_wol=True):
|
|
"""Attend que Jean soit accessible. Si ENABLE_WOL et trigger_wol, envoie WoL d'abord."""
|
|
alive = jean_alive()
|
|
if alive:
|
|
return
|
|
|
|
if not ENABLE_WOL or not trigger_wol:
|
|
return
|
|
|
|
with wol_in_progress:
|
|
if jean_alive():
|
|
return
|
|
|
|
log.info("[WoL] Jean inaccessible, envoi WoL...")
|
|
send_wol()
|
|
|
|
deadline = time.time() + WAKE_TIMEOUT
|
|
while time.time() < deadline:
|
|
remaining = int(deadline - time.time())
|
|
if jean_alive():
|
|
jean_alive_cache["timestamp"] = 0
|
|
log.info(f"[WoL] Jean UP apres {int(time.time() - deadline + WAKE_TIMEOUT)}s")
|
|
return
|
|
log.info(f"[WoL] En attente... ({remaining}s restantes)")
|
|
time.sleep(max(CHECK_INTERVAL, 5))
|
|
|
|
log.error(f"[WoL] Jean ne repond pas apres {WAKE_TIMEOUT}s")
|
|
|
|
|
|
def stream_wake_then_forward(upstream_url, data, headers):
|
|
"""Stream des messages d'attente pendant le reveil, puis forward la vraie reponse"""
|
|
import uuid
|
|
chat_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
|
|
|
def wake_chunk(content):
|
|
return (
|
|
f'data: {{"id":"{chat_id}","object":"chat.completion.chunk",'
|
|
f'"created":{int(time.time())},"model":"wake","choices":['
|
|
f'{{"delta":{{"content":"{content} "}},"index":0}}]}}\n\n'
|
|
).encode()
|
|
|
|
def generate():
|
|
yield wake_chunk("🖥️ Le PC de jeu se réveille...")
|
|
|
|
if not jean_alive() and ENABLE_WOL:
|
|
with wol_in_progress:
|
|
if not jean_alive():
|
|
log.info("[WoL-Stream] Envoi WoL + attente...")
|
|
send_wol()
|
|
yield wake_chunk("⚡ Magic packet envoyé, démarrage en cours...")
|
|
|
|
deadline = time.time() + WAKE_TIMEOUT
|
|
last_pct = 0
|
|
while time.time() < deadline:
|
|
if jean_alive():
|
|
jean_alive_cache["timestamp"] = 0
|
|
yield wake_chunk("✅ PC allumé, chargement du modèle...")
|
|
break
|
|
pct = int((1 - (deadline - time.time()) / WAKE_TIMEOUT) * 100)
|
|
if pct >= last_pct + 10:
|
|
yield wake_chunk(f"⏳ Démarrage... {min(pct, 95)}%")
|
|
last_pct = pct
|
|
time.sleep(CHECK_INTERVAL)
|
|
else:
|
|
yield wake_chunk("❌ Le PC n'a pas répondu, réessayez.")
|
|
yield b"data: [DONE]\n\n"
|
|
return
|
|
|
|
# Attendre que Jean soit vraiment prêt (proxy + modele)
|
|
deadline = time.time() + SWITCH_TIMEOUT
|
|
while time.time() < deadline:
|
|
try:
|
|
r = requests.get(f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/health", timeout=2)
|
|
if r.ok and r.json().get("status") == "ok":
|
|
break
|
|
except Exception:
|
|
pass
|
|
yield wake_chunk(".")
|
|
time.sleep(2)
|
|
|
|
yield wake_chunk("🚀 Modèle prêt, voici la réponse :\n\n")
|
|
|
|
# Forward la vraie requête et streamer
|
|
try:
|
|
resp = requests.request(
|
|
method="POST", url=upstream_url, headers=headers,
|
|
json=data, stream=True, timeout=None,
|
|
)
|
|
for line in resp.iter_lines():
|
|
if line:
|
|
yield line + b"\n"
|
|
yield b"data: [DONE]\n\n"
|
|
except Exception as e:
|
|
yield wake_chunk(f"❌ Erreur: {str(e)[:100]}")
|
|
yield b"data: [DONE]\n\n"
|
|
|
|
return Response(
|
|
stream_with_context(generate()),
|
|
content_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
def get_ollama_models(force_refresh=False):
|
|
"""Récupère la liste des modèles depuis Ollama"""
|
|
if not ENABLE_OLLAMA:
|
|
return []
|
|
|
|
now = time.time()
|
|
if not force_refresh and ollama_cache["data"] and (now - ollama_cache["timestamp"]) < CACHE_TTL:
|
|
return ollama_cache["data"]
|
|
|
|
try:
|
|
url = f"http://{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}/api/tags"
|
|
r = requests.get(url, timeout=3)
|
|
if r.ok:
|
|
data = r.json().get("models", [])
|
|
ollama_cache["data"] = data
|
|
ollama_cache["timestamp"] = now
|
|
return data
|
|
except Exception as e:
|
|
log.warning(f"[Ollama] Erreur récupération: {e}")
|
|
if ENABLE_WOL and (not ollama_cache["data"] or force_refresh):
|
|
wait_for_jean(trigger_wol=True)
|
|
try:
|
|
r = requests.get(f"http://{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}/api/tags", timeout=5)
|
|
if r.ok:
|
|
data = r.json().get("models", [])
|
|
ollama_cache["data"] = data
|
|
ollama_cache["timestamp"] = now
|
|
return data
|
|
except Exception:
|
|
pass
|
|
|
|
return ollama_cache["data"]
|
|
|
|
|
|
def _get_ollama_model_detail(model_name: str) -> dict:
|
|
"""Récupère les détails d'un modèle Ollama via /api/show, avec cache long"""
|
|
now = time.time()
|
|
cached = ollama_detail_cache.get(model_name)
|
|
if cached and (now - cached["timestamp"]) < OLLAMA_DETAIL_CACHE_TTL:
|
|
return cached["data"]
|
|
|
|
detail = {}
|
|
try:
|
|
clean_name = model_name.replace(":latest", "")
|
|
url = f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/show"
|
|
r = requests.post(url, json={"model": clean_name}, timeout=10)
|
|
if r.ok:
|
|
d = r.json()
|
|
model_info = d.get("model_info", {})
|
|
details = d.get("details", {})
|
|
|
|
for key, val in model_info.items():
|
|
if "context_length" in key.lower() or "ctx" in key.lower():
|
|
detail["context_length"] = int(val)
|
|
break
|
|
|
|
if not detail.get("context_length"):
|
|
detail["context_length"] = details.get("context_length")
|
|
|
|
detail["capabilities"] = d.get("capabilities", [])
|
|
detail["family"] = details.get("family", "")
|
|
|
|
ollama_detail_cache[model_name] = {"data": detail, "timestamp": now}
|
|
except Exception as e:
|
|
log.debug(f"[Ollama] /api/show echoue pour {model_name}: {e}")
|
|
|
|
if model_name not in ollama_detail_cache:
|
|
ollama_detail_cache[model_name] = {"data": {}, "timestamp": now}
|
|
|
|
return ollama_detail_cache[model_name]["data"]
|
|
|
|
|
|
def _enrich_ollama_model(m: dict) -> dict:
|
|
"""Enrichit un modele Ollama avec les donnees /api/show si necessaire"""
|
|
model_id = m.get("name", "").replace(":latest", "")
|
|
details = m.get("details", {})
|
|
has_ctx = bool(details.get("context_length"))
|
|
caps = m.get("capabilities", [])
|
|
|
|
if not has_ctx or len(caps) <= 1:
|
|
extra = _get_ollama_model_detail(model_id)
|
|
if extra.get("context_length"):
|
|
details = dict(details)
|
|
details["context_length"] = extra["context_length"]
|
|
m = dict(m)
|
|
m["details"] = details
|
|
if extra.get("capabilities") and len(extra["capabilities"]) > len(caps):
|
|
m = dict(m)
|
|
m["capabilities"] = extra["capabilities"]
|
|
if extra.get("family") and not details.get("family"):
|
|
details = dict(details)
|
|
details["family"] = extra["family"]
|
|
m = dict(m)
|
|
m["details"] = details
|
|
|
|
return m
|
|
|
|
def _guess_context_length(preset_id: str) -> int:
|
|
"""Devine le context_length depuis le nom du preset"""
|
|
pid = preset_id.lower()
|
|
if "qwen3.6" in pid or "qwen3.5" in pid:
|
|
return 262144
|
|
if "qwen3" in pid:
|
|
return 40960
|
|
if "qwen2.5" in pid:
|
|
return 32768
|
|
if "mistral" in pid or "nemo" in pid:
|
|
return 1024000
|
|
if "llama" in pid:
|
|
return 131072
|
|
if "ornith" in pid:
|
|
return 131072
|
|
if "gemma" in pid:
|
|
return 262144
|
|
if "deepseek" in pid:
|
|
return 131072
|
|
if "phi" in pid:
|
|
return 16384
|
|
if "35b" in pid or "27b" in pid:
|
|
return 131072
|
|
return 32768
|
|
|
|
|
|
def _guess_capabilities(preset_id: str) -> list:
|
|
"""Devine les capabilities depuis le nom du preset + metadonnees Jean"""
|
|
pid = preset_id.lower()
|
|
caps = ["completion"]
|
|
|
|
has_vision = any(kw in pid for kw in ["vision", "vl", "llava", "bakllava", "minicpm"])
|
|
has_thinking = any(kw in pid for kw in ["reason", "thinking", "deepseek-r1"])
|
|
has_tools = not any(kw in pid for kw in ["embed", "bge-", "nomic-"])
|
|
|
|
if has_vision:
|
|
caps.append("vision")
|
|
if has_thinking:
|
|
caps.append("thinking")
|
|
if has_tools:
|
|
caps.append("tools")
|
|
|
|
return caps
|
|
|
|
|
|
def _match_preset_to_ollama(preset_id: str):
|
|
"""Tente de trouver un modele Ollama correspondant au preset Jean"""
|
|
ollama_models = get_ollama_models()
|
|
pid_lower = preset_id.lower()
|
|
|
|
def norm(s):
|
|
return s.lower().replace(":", "").replace("-", "").replace(" ", "").replace(".", "")
|
|
|
|
pid_norm = norm(pid_lower)
|
|
|
|
for m in ollama_models:
|
|
name = m.get("name", "").replace(":latest", "")
|
|
name_norm = norm(name)
|
|
|
|
if pid_norm == name_norm:
|
|
return m
|
|
|
|
best = None
|
|
best_score = 0
|
|
for m in ollama_models:
|
|
name = m.get("name", "").replace(":latest", "")
|
|
name_norm = norm(name)
|
|
score = 0
|
|
|
|
if len(pid_norm) >= 4 and pid_norm[:4] == name_norm[:4]:
|
|
score += 10
|
|
if len(pid_norm) >= 8 and pid_norm[:8] == name_norm[:8]:
|
|
score += 20
|
|
if name_norm in pid_norm or pid_norm in name_norm:
|
|
score += 15
|
|
|
|
parts_pid = set(pid_norm.split("_"))
|
|
parts_name = set(name_norm.split("_"))
|
|
common = parts_pid & parts_name
|
|
score += len(common) * 5
|
|
|
|
if score > best_score:
|
|
best_score = score
|
|
best = m
|
|
|
|
if best and best_score >= 5:
|
|
return best
|
|
return None
|
|
|
|
|
|
def get_jean_metadata():
|
|
"""Resout dynamiquement context_length et capabilities pour les presets Jean.
|
|
1) Ollama matching → 2) Jean /v1/models → 3) heuristique"""
|
|
now = time.time()
|
|
|
|
try:
|
|
url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/models"
|
|
r = requests.get(url, timeout=2)
|
|
if r.ok:
|
|
for m in r.json().get("data", []):
|
|
alias = m.get("id", "")
|
|
meta = m.get("meta", {})
|
|
n_ctx = meta.get("n_ctx") or m.get("max_model_len")
|
|
if n_ctx and alias:
|
|
jean_ctx_cache[alias] = int(n_ctx)
|
|
except Exception:
|
|
pass
|
|
|
|
ollama_models = get_ollama_models()
|
|
ollama_lookup = {}
|
|
for m in ollama_models:
|
|
m = _enrich_ollama_model(m)
|
|
name = m.get("name", "").replace(":latest", "")
|
|
details = m.get("details", {})
|
|
ctx = details.get("context_length")
|
|
caps = m.get("capabilities", [])
|
|
ollama_lookup[name] = {"context_length": ctx, "capabilities": caps}
|
|
|
|
presets = get_presets()
|
|
for p in presets:
|
|
pid = p["id"]
|
|
|
|
matched_ollama = _match_preset_to_ollama(pid)
|
|
ollama_name = matched_ollama.get("name", "").replace(":latest", "") if matched_ollama else None
|
|
|
|
if pid not in jean_ctx_cache:
|
|
if ollama_name and ollama_lookup.get(ollama_name, {}).get("context_length"):
|
|
jean_ctx_cache[pid] = ollama_lookup[ollama_name]["context_length"]
|
|
else:
|
|
jean_ctx_cache[pid] = _guess_context_length(pid)
|
|
|
|
if pid not in jean_caps_cache:
|
|
if p.get("reasoning") == "on":
|
|
caps = ["completion", "tools", "thinking"]
|
|
else:
|
|
caps = ["completion", "tools"]
|
|
|
|
if ollama_name and ollama_lookup.get(ollama_name, {}).get("capabilities"):
|
|
ollama_caps = ollama_lookup[ollama_name]["capabilities"]
|
|
caps = list(dict.fromkeys(caps + ollama_caps))
|
|
|
|
jean_caps_cache[pid] = caps
|
|
|
|
return jean_ctx_cache, jean_caps_cache
|
|
|
|
|
|
def get_active_preset():
|
|
"""Retourne le preset actuellement actif"""
|
|
presets = get_presets()
|
|
for p in presets:
|
|
if p.get("active"):
|
|
return p
|
|
return None
|
|
|
|
|
|
def switch_preset(preset_id: str) -> bool:
|
|
"""Switch vers un preset spécifique par son ID"""
|
|
global last_active_preset
|
|
presets = get_presets(force_refresh=True)
|
|
|
|
for i, p in enumerate(presets):
|
|
if p["id"] == preset_id:
|
|
if p.get("active"):
|
|
log.info(f"[Switch] Preset {preset_id} déjà actif")
|
|
return True
|
|
|
|
log.info(f"[Switch] Activation preset {preset_id} (index {i+1})")
|
|
success = False
|
|
for attempt in range(2):
|
|
try:
|
|
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/switch"
|
|
r = requests.post(url, json={"n": i + 1}, timeout=15)
|
|
if r.ok:
|
|
success = True
|
|
break
|
|
log.error(f"[Switch] Erreur API (tentative {attempt+1}): {r.text}")
|
|
except Exception as e:
|
|
log.error(f"[Switch] Erreur requête (tentative {attempt+1}): {e}")
|
|
if attempt == 0:
|
|
time.sleep(3)
|
|
if not success:
|
|
return False
|
|
|
|
log.info(f"[Switch] Attente chargement modèle...")
|
|
deadline = time.time() + SWITCH_TIMEOUT
|
|
while time.time() < deadline:
|
|
try:
|
|
health_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/health"
|
|
hr = requests.get(health_url, timeout=2)
|
|
if hr.ok:
|
|
models_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/models"
|
|
mr = requests.get(models_url, timeout=2)
|
|
if mr.ok:
|
|
models = mr.json().get("data", [])
|
|
if models:
|
|
log.info(f"[Switch] Modèle chargé après {int(time.time() - deadline + SWITCH_TIMEOUT)}s")
|
|
last_active_preset = preset_id
|
|
presets_cache["timestamp"] = 0
|
|
return True
|
|
except Exception:
|
|
pass
|
|
time.sleep(CHECK_INTERVAL)
|
|
|
|
log.error(f"[Switch] Timeout après {SWITCH_TIMEOUT}s")
|
|
return False
|
|
|
|
log.error(f"[Switch] Preset {preset_id} non trouvé")
|
|
return False
|
|
|
|
|
|
def stop_jean() -> bool:
|
|
"""Arrête le service Jean pour libérer la VRAM"""
|
|
log.info("[VRAM] Arrêt de Jean pour libérer la VRAM...")
|
|
|
|
try:
|
|
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/stop"
|
|
r = requests.post(url, timeout=5)
|
|
if r.ok:
|
|
log.info("[VRAM] Jean arrêté via API web")
|
|
else:
|
|
log.warning(f"[VRAM] API stop a retourné {r.status_code}, tentative via health check")
|
|
except Exception as e:
|
|
log.warning(f"[VRAM] Erreur API stop: {e}")
|
|
|
|
deadline = time.time() + JEAN_STOP_TIMEOUT
|
|
while time.time() < deadline:
|
|
try:
|
|
health_url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/health"
|
|
r = requests.get(health_url, timeout=2)
|
|
if not r.ok:
|
|
log.info("[VRAM] Jean confirmé arrêté")
|
|
return True
|
|
except Exception:
|
|
log.info("[VRAM] Jean confirmé arrêté (connexion refusée)")
|
|
return True
|
|
time.sleep(CHECK_INTERVAL)
|
|
|
|
log.error(f"[VRAM] Timeout: Jean ne s'est pas arrêté après {JEAN_STOP_TIMEOUT}s")
|
|
return False
|
|
|
|
|
|
def start_jean() -> bool:
|
|
"""Démarre le service Jean"""
|
|
log.info("[VRAM] Démarrage de Jean...")
|
|
|
|
try:
|
|
url = f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/api/start"
|
|
r = requests.post(url, timeout=5)
|
|
if r.ok:
|
|
log.info("[VRAM] Jean démarré via API web")
|
|
else:
|
|
log.warning(f"[VRAM] API start a retourné {r.status_code}")
|
|
except Exception as e:
|
|
log.warning(f"[VRAM] Erreur API start: {e}")
|
|
|
|
log.info("[VRAM] Attente du chargement du modèle Jean...")
|
|
deadline = time.time() + SWITCH_TIMEOUT
|
|
while time.time() < deadline:
|
|
try:
|
|
health_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/health"
|
|
hr = requests.get(health_url, timeout=2)
|
|
if hr.ok:
|
|
models_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/models"
|
|
mr = requests.get(models_url, timeout=2)
|
|
if mr.ok:
|
|
models = mr.json().get("data", [])
|
|
if models:
|
|
log.info(f"[VRAM] Jean prêt après {int(time.time() - deadline + SWITCH_TIMEOUT)}s")
|
|
return True
|
|
except Exception:
|
|
pass
|
|
time.sleep(CHECK_INTERVAL)
|
|
|
|
log.error(f"[VRAM] Timeout: Jean n'a pas démarré après {SWITCH_TIMEOUT}s")
|
|
return False
|
|
|
|
|
|
def unload_ollama() -> bool:
|
|
"""Force le déchargement des modèles Ollama pour libérer la VRAM"""
|
|
log.info("[VRAM] Déchargement des modèles Ollama...")
|
|
|
|
try:
|
|
url = f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/generate"
|
|
payload = {
|
|
"model": "mistral",
|
|
"keep_alive": 0,
|
|
"prompt": ""
|
|
}
|
|
r = requests.post(url, json=payload, timeout=5)
|
|
if r.ok:
|
|
log.info("[VRAM] Ollama déchargé via API")
|
|
return True
|
|
else:
|
|
log.warning(f"[VRAM] API unload a retourné {r.status_code}")
|
|
except Exception as e:
|
|
log.warning(f"[VRAM] Erreur API unload: {e}")
|
|
|
|
try:
|
|
url = f"http://{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}/api/ps"
|
|
r = requests.get(url, timeout=3)
|
|
if r.ok:
|
|
models = r.json().get("models", [])
|
|
if not models:
|
|
log.info("[VRAM] Ollama confirmé déchargé (aucun modèle actif)")
|
|
return True
|
|
else:
|
|
log.warning(f"[VRAM] Ollama a encore {len(models)} modèle(s) chargé(s)")
|
|
except Exception as e:
|
|
log.warning(f"[VRAM] Impossible de vérifier /api/ps: {e}")
|
|
|
|
time.sleep(2)
|
|
log.info("[VRAM] Ollama supposé déchargé (timeout)")
|
|
return True
|
|
|
|
|
|
def schedule_ollama_unload():
|
|
"""Programme le déchargement d'Ollama après OLLAMA_KEEP_ALIVE_MINUTES"""
|
|
global ollama_unload_timer
|
|
|
|
def delayed_unload():
|
|
log.info(f"[VRAM] Timer écoulé ({OLLAMA_KEEP_ALIVE_MINUTES}min), déchargement Ollama...")
|
|
unload_ollama()
|
|
|
|
if ollama_unload_timer:
|
|
ollama_unload_timer.cancel()
|
|
log.info("[VRAM] Timer précédent annulé")
|
|
|
|
ollama_unload_timer = threading.Timer(
|
|
OLLAMA_KEEP_ALIVE_MINUTES * 60,
|
|
delayed_unload
|
|
)
|
|
ollama_unload_timer.daemon = True
|
|
ollama_unload_timer.start()
|
|
log.info(f"[VRAM] Déchargement Ollama programmé dans {OLLAMA_KEEP_ALIVE_MINUTES} minutes")
|
|
|
|
|
|
def cancel_ollama_unload():
|
|
"""Annule le déchargement programmé d'Ollama"""
|
|
global ollama_unload_timer
|
|
if ollama_unload_timer:
|
|
ollama_unload_timer.cancel()
|
|
ollama_unload_timer = None
|
|
log.info("[VRAM] Timer de déchargement Ollama annulé")
|
|
|
|
|
|
def ensure_vram_for_backend(target_backend: str) -> bool:
|
|
"""
|
|
S'assure que la VRAM est disponible pour le backend cible.
|
|
- Si target=ollama et jean actif → arrête Jean, annule timer unload Ollama
|
|
- Si target=jean et ollama actif → programme déchargement Ollama dans X min, démarre Jean
|
|
"""
|
|
if not ENABLE_VRAM_MANAGEMENT:
|
|
return True
|
|
|
|
global last_active_preset
|
|
|
|
with vram_lock:
|
|
current = vram_state["active_backend"]
|
|
|
|
if current == target_backend:
|
|
return True
|
|
|
|
if target_backend == "ollama" and current == "jean":
|
|
log.info("[VRAM] Switch Jean → Ollama")
|
|
cancel_ollama_unload()
|
|
if not stop_jean():
|
|
return False
|
|
last_active_preset = None
|
|
vram_state["active_backend"] = "ollama"
|
|
return True
|
|
|
|
elif target_backend == "jean" and current == "ollama":
|
|
log.info("[VRAM] Switch Ollama → Jean")
|
|
schedule_ollama_unload()
|
|
if not start_jean():
|
|
return False
|
|
last_active_preset = None
|
|
vram_state["active_backend"] = "jean"
|
|
return True
|
|
|
|
return True
|
|
|
|
|
|
@app.route("/v1/models", methods=["GET"])
|
|
def list_models():
|
|
"""Expose tous les presets Jean + modèles Ollama comme modèles OpenAI"""
|
|
force_refresh = request.args.get("refresh", "").lower() in ("1", "true", "yes")
|
|
if force_refresh:
|
|
global presets_cache, ollama_cache
|
|
presets_cache["timestamp"] = 0
|
|
ollama_cache["timestamp"] = 0
|
|
log.info("[Models] Refresh force demande")
|
|
|
|
presets = get_presets()
|
|
jean_ctx, jean_caps = get_jean_metadata()
|
|
models = []
|
|
|
|
for p in presets:
|
|
model_id = p["id"]
|
|
model_name = p.get("name", model_id)
|
|
|
|
ctx_len = jean_ctx.get(model_id)
|
|
caps = jean_caps.get(model_id, ["completion", "tools"])
|
|
if p.get("reasoning") == "on" and "thinking" not in caps:
|
|
caps = list(dict.fromkeys(caps + ["thinking"]))
|
|
|
|
model_info = {
|
|
"id": model_name,
|
|
"object": "model",
|
|
"created": int(time.time()),
|
|
"owned_by": "jean",
|
|
"permission": [],
|
|
"root": model_name,
|
|
"parent": None,
|
|
}
|
|
|
|
if ctx_len:
|
|
model_info["context_length"] = ctx_len
|
|
model_info["context_window"] = ctx_len
|
|
model_info["max_model_len"] = ctx_len
|
|
model_info["max_tokens"] = ctx_len
|
|
model_info["max_input_tokens"] = ctx_len
|
|
|
|
model_info["capabilities"] = caps
|
|
|
|
meta = {}
|
|
if p.get("name") and p["name"] != model_id:
|
|
meta["display_name"] = p["name"]
|
|
if p.get("quant"):
|
|
meta["quantization"] = p["quant"]
|
|
if p.get("reasoning"):
|
|
meta["reasoning"] = p["reasoning"]
|
|
if p.get("active"):
|
|
meta["active"] = True
|
|
if p.get("bench"):
|
|
meta["bench"] = p["bench"]
|
|
if ctx_len:
|
|
meta["context_length"] = ctx_len
|
|
meta["capabilities"] = caps
|
|
|
|
if meta:
|
|
model_info["meta"] = meta
|
|
|
|
models.append(model_info)
|
|
|
|
if ENABLE_OLLAMA:
|
|
ollama_models = get_ollama_models()
|
|
for m in ollama_models:
|
|
m = _enrich_ollama_model(m)
|
|
model_id = m.get("name", "").replace(":latest", "")
|
|
if not model_id:
|
|
continue
|
|
|
|
caps = m.get("capabilities", ["completion"])
|
|
details = m.get("details", {})
|
|
ctx_len = details.get("context_length")
|
|
|
|
model_info = {
|
|
"id": model_id,
|
|
"object": "model",
|
|
"created": int(time.time()),
|
|
"owned_by": "ollama",
|
|
"permission": [],
|
|
"root": model_id,
|
|
"parent": None,
|
|
}
|
|
|
|
if ctx_len:
|
|
model_info["context_length"] = ctx_len
|
|
model_info["context_window"] = ctx_len
|
|
model_info["max_model_len"] = ctx_len
|
|
model_info["max_tokens"] = ctx_len
|
|
model_info["max_input_tokens"] = ctx_len
|
|
|
|
model_info["capabilities"] = caps
|
|
|
|
meta = {}
|
|
if details.get("parameter_size"):
|
|
meta["parameter_size"] = details["parameter_size"]
|
|
if details.get("quantization_level"):
|
|
meta["quantization"] = details["quantization_level"]
|
|
if details.get("family"):
|
|
meta["family"] = details["family"]
|
|
if ctx_len:
|
|
meta["context_length"] = ctx_len
|
|
meta["capabilities"] = caps
|
|
|
|
if meta:
|
|
model_info["meta"] = meta
|
|
|
|
models.append(model_info)
|
|
|
|
return jsonify({"object": "list", "data": models})
|
|
|
|
|
|
@app.route("/v1/chat/completions", methods=["POST"])
|
|
def chat_completions():
|
|
"""Route les requêtes chat vers Jean ou Ollama selon le modèle"""
|
|
data = request.get_json()
|
|
model_id = data.get("model")
|
|
|
|
with stats_lock:
|
|
request_stats["total"] += 1
|
|
request_stats["last_requests"].append({
|
|
"model": model_id,
|
|
"time": time.strftime("%H:%M:%S"),
|
|
"max_tokens": data.get("max_tokens", 0),
|
|
})
|
|
|
|
if not model_id:
|
|
return jsonify({"error": "model parameter required"}), 400
|
|
|
|
presets = get_presets()
|
|
jean_preset_ids = {p["id"] for p in presets}
|
|
jean_name_to_id = {p.get("name", p["id"]): p["id"] for p in presets}
|
|
jean_names = set(jean_name_to_id.keys())
|
|
|
|
if model_id in jean_preset_ids or model_id in jean_names:
|
|
internal_id = jean_name_to_id.get(model_id, model_id)
|
|
|
|
if not jean_alive() and ENABLE_WOL:
|
|
log.info("[WoL-Stream] PC down, demarrage stream d'attente...")
|
|
fwd_headers = dict(request.headers)
|
|
fwd_headers.pop("Host", None)
|
|
return stream_wake_then_forward(
|
|
f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/chat/completions",
|
|
data, fwd_headers
|
|
)
|
|
|
|
if not jean_service_ok():
|
|
log.warning(f"[Router] PC ON mais Jean DOWN, requete vers {model_id} risque d'echouer")
|
|
|
|
if not ensure_vram_for_backend("jean"):
|
|
return jsonify({"error": "Failed to prepare VRAM for Jean"}), 503
|
|
|
|
with switch_lock:
|
|
active = get_active_preset()
|
|
current_active = active["id"] if active else last_active_preset
|
|
if current_active != internal_id:
|
|
log.info(f"[Router] Switch Jean requis: {current_active or 'none'} → {internal_id} ({model_id})")
|
|
if not switch_preset(internal_id):
|
|
return jsonify({"error": f"Failed to switch to preset {model_id}"}), 503
|
|
else:
|
|
log.info(f"[Router] Preset {model_id} déjà actif (tracké)")
|
|
|
|
preset = next((p for p in presets if p["id"] == internal_id), {})
|
|
if preset.get("reasoning") == "on":
|
|
ctx, _ = get_jean_metadata()
|
|
ctx_len = ctx.get(internal_id, 131072)
|
|
req_max = data.get("max_tokens", 0)
|
|
if req_max and req_max > 0:
|
|
new_max = min(req_max * 2, ctx_len)
|
|
if new_max != req_max:
|
|
log.info(f"[Reasoning] max_tokens doublé: {req_max} → {new_max}")
|
|
data = dict(data)
|
|
data["max_tokens"] = new_max
|
|
|
|
upstream_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/v1/chat/completions"
|
|
log.info(f"[Router] → Jean ({model_id})")
|
|
with stats_lock:
|
|
request_stats["jean"] += 1
|
|
elif ENABLE_OLLAMA:
|
|
wait_for_jean(trigger_wol=True)
|
|
ollama_models = get_ollama_models()
|
|
ollama_ids = [m.get("name", "").replace(":latest", "") for m in ollama_models]
|
|
|
|
if model_id in ollama_ids:
|
|
if not ensure_vram_for_backend("ollama"):
|
|
return jsonify({"error": "Failed to prepare VRAM for Ollama"}), 503
|
|
|
|
cancel_ollama_unload()
|
|
|
|
upstream_url = f"http://{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}/v1/chat/completions"
|
|
log.info(f"[Router] → Ollama ({model_id})")
|
|
with stats_lock:
|
|
request_stats["ollama"] += 1
|
|
else:
|
|
return jsonify({"error": f"Unknown model: {model_id}"}), 404
|
|
else:
|
|
return jsonify({"error": f"Unknown model: {model_id}"}), 404
|
|
|
|
headers = dict(request.headers)
|
|
headers.pop("Host", None)
|
|
|
|
try:
|
|
resp = requests.request(
|
|
method=request.method,
|
|
url=upstream_url,
|
|
headers=headers,
|
|
json=data,
|
|
stream=True,
|
|
timeout=None,
|
|
)
|
|
except Exception as e:
|
|
log.error(f"[Router] Erreur upstream: {e}")
|
|
with stats_lock:
|
|
request_stats["errors"] += 1
|
|
if ENABLE_WOL:
|
|
log.info("[Router] Tentative WoL + retry apres echec upstream...")
|
|
wait_for_jean(trigger_wol=True)
|
|
try:
|
|
resp = requests.request(
|
|
method=request.method, url=upstream_url,
|
|
headers=headers, json=data, stream=True, timeout=None,
|
|
)
|
|
except Exception as e2:
|
|
log.error(f"[Router] Echec apres WoL: {e2}")
|
|
return jsonify({"error": str(e2)}), 502
|
|
else:
|
|
return jsonify({"error": str(e)}), 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,
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# DASHBOARD — Diagnostic & monitoring
|
|
# ═══════════════════════════════════════════════
|
|
|
|
@app.route("/dashboard")
|
|
def dashboard():
|
|
"""Page HTML de diagnostic"""
|
|
return Response(DASHBOARD_HTML, content_type="text/html; charset=utf-8")
|
|
|
|
|
|
@app.route("/api/dashboard/status")
|
|
def dashboard_status():
|
|
"""Etat complet en JSON"""
|
|
pc_alive = jean_alive()
|
|
jean_ok = jean_service_ok() if pc_alive else False
|
|
olla_ok = ollama_service_ok() if pc_alive else False
|
|
|
|
# Presets depuis le cache (evite d'appeler get_presets qui log un warning si down)
|
|
presets_raw = presets_cache.get("data", []) if jean_ok else []
|
|
|
|
slot_info = None
|
|
if jean_ok:
|
|
try:
|
|
r = requests.get(f"http://{JEAN_WEB_HOST}:{JEAN_WEB_PORT}/slots", timeout=1)
|
|
if r.ok:
|
|
slots = r.json()
|
|
if slots:
|
|
s = slots[0]
|
|
slot_info = {
|
|
"processing": s.get("is_processing", False),
|
|
"id_task": s.get("id_task"),
|
|
"n_prompt_tokens": s.get("n_prompt_tokens", 0),
|
|
"n_decoded": s.get("next_token", [{}])[0].get("n_decoded", 0) if s.get("next_token") else 0,
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
active = get_active_preset()
|
|
with stats_lock:
|
|
stats = dict(request_stats)
|
|
stats["last_requests"] = list(stats.get("last_requests", []))
|
|
|
|
return jsonify({
|
|
"pc": {
|
|
"alive": pc_alive,
|
|
"host": JEAN_WEB_HOST,
|
|
},
|
|
"jean": {
|
|
"alive": jean_ok,
|
|
"host": f"{JEAN_WEB_HOST}:{JEAN_WEB_PORT}",
|
|
"proxy": f"{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}",
|
|
"active_preset": active["id"] if active else (last_active_preset or "none"),
|
|
"presets": [{"id": p["id"], "name": p.get("name", p["id"]), "reasoning": p.get("reasoning")} for p in presets_raw],
|
|
"slot": slot_info,
|
|
"vram_backend": vram_state["active_backend"],
|
|
},
|
|
"ollama": {
|
|
"alive": olla_ok,
|
|
"host": f"{OLLAMA_DIRECT_HOST}:{OLLAMA_DIRECT_PORT}",
|
|
"proxy": f"{OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}",
|
|
},
|
|
"wol": {
|
|
"enabled": ENABLE_WOL,
|
|
"mac": TARGET_MAC,
|
|
"timeout": WAKE_TIMEOUT,
|
|
},
|
|
"cache": {
|
|
"ttl": CACHE_TTL,
|
|
"presets_age": int(time.time() - presets_cache["timestamp"]),
|
|
"ollama_age": int(time.time() - ollama_cache["timestamp"]),
|
|
},
|
|
"stats": stats,
|
|
})
|
|
|
|
|
|
@app.route("/api/dashboard/logs")
|
|
def dashboard_logs():
|
|
"""Derniers logs en JSON"""
|
|
limit = request.args.get("limit", 200, type=int)
|
|
return jsonify(list(log_buffer)[-limit:])
|
|
|
|
|
|
@app.route("/api/dashboard/logs/stream")
|
|
def dashboard_logs_stream():
|
|
"""Stream SSE des logs en temps reel"""
|
|
def generate():
|
|
yield "data: " + json_mod.dumps({"type": "init", "logs": list(log_buffer)[-100:]}) + "\n\n"
|
|
while True:
|
|
try:
|
|
msg = log_queue.get(timeout=5)
|
|
yield "data: " + json_mod.dumps({"type": "log", "message": msg}) + "\n\n"
|
|
except queue.Empty:
|
|
yield "data: " + json_mod.dumps({"type": "ping"}) + "\n\n"
|
|
return Response(
|
|
stream_with_context(generate()),
|
|
content_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@app.route("/api/dashboard/actions/refresh", methods=["POST"])
|
|
def dashboard_refresh():
|
|
"""Force le rafraichissement du cache des modeles"""
|
|
global presets_cache, ollama_cache
|
|
presets_cache["timestamp"] = 0
|
|
ollama_cache["timestamp"] = 0
|
|
jean_alive_cache["timestamp"] = 0
|
|
log.info("[Dashboard] Rafraichissement force du cache")
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/dashboard/actions/restart-jean", methods=["POST"])
|
|
def dashboard_restart_jean():
|
|
"""Redemarre Jean"""
|
|
if not ENABLE_WOL:
|
|
return jsonify({"ok": False, "error": "WoL non active"}), 400
|
|
try:
|
|
stop_jean()
|
|
start_jean()
|
|
log.info("[Dashboard] Jean redemarre via stop/start")
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"ok": False, "error": str(e)}), 500
|
|
|
|
|
|
DASHBOARD_HTML = r"""<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Jean Router — Dashboard</title>
|
|
<style>
|
|
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--green:#3fb950;--red:#f85149;--yellow:#d2991d;--blue:#58a6ff;--purple:#bc8cff;--muted:#8b949e}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{background:var(--bg);color:var(--text);font:14px/1.5 SFMono,Consolas,monospace;padding:16px}
|
|
h1{font-size:18px;color:var(--purple);margin-bottom:12px}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;margin-bottom:16px}
|
|
.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px}
|
|
.card h2{font-size:14px;color:var(--blue);margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:6px}
|
|
.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}
|
|
.on{background:var(--green);box-shadow:0 0 6px var(--green)}
|
|
.off{background:var(--red);box-shadow:0 0 6px var(--red)}
|
|
.warn{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
|
|
.row{display:flex;justify-content:space-between;padding:2px 0;font-size:13px}
|
|
.row .label{color:var(--muted)}
|
|
.row .value{font-weight:600}
|
|
.badge{padding:2px 8px;border-radius:4px;font-size:12px;display:inline-block;margin:2px}
|
|
.badge-green{background:#1a3a1a;color:var(--green);border:1px solid var(--green)}
|
|
.badge-red{background:#3a1a1a;color:var(--red);border:1px solid var(--red)}
|
|
.badge-blue{background:#1a2a3a;color:var(--blue);border:1px solid var(--blue)}
|
|
#logs{grid-column:1/-1;height:400px;overflow-y:auto;font-size:12px;line-height:1.6;border:1px solid var(--border);border-radius:6px;padding:10px;background:#0a0e13}
|
|
#logs .error{color:#ff7b72;background:#1a0a0a;padding:1px 4px;border-radius:3px;display:inline-block;max-width:100%;word-break:break-all}
|
|
#logs .warn{color:#ffa657;background:#1a1008;padding:1px 4px;border-radius:3px;display:inline-block;max-width:100%;word-break:break-all}
|
|
#logs .info{color:var(--muted)}
|
|
#logs .debug{color:#484f58}
|
|
#logs .highlight{color:var(--green);font-weight:700}
|
|
.btn{background:var(--card);color:var(--blue);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;margin:3px}
|
|
.btn:hover{background:#1f2937;border-color:var(--blue)}
|
|
.btn-danger{color:var(--red);border-color:var(--red)}
|
|
.btn-danger:hover{background:#3a1a1a}
|
|
.slot-bar{height:8px;background:#21262d;border-radius:4px;overflow:hidden;margin:6px 0}
|
|
.slot-fill{height:100%;background:var(--blue);border-radius:4px;transition:width .3s}
|
|
.slot-fill.warn{background:var(--yellow)}
|
|
.slot-fill.danger{background:var(--red)}
|
|
.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px}
|
|
#toast{position:fixed;top:16px;right:16px;background:var(--green);color:#000;padding:10px 18px;border-radius:8px;font-weight:700;opacity:0;transition:opacity .3s;z-index:100}
|
|
#toast.show{opacity:1}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🧠 Jean Router — Dashboard</h1>
|
|
<div id="toast"></div>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>🔌 Statut</h2>
|
|
<div class="row"><span class="label">PC Gaming</span><span id="pc-status">-</span></div>
|
|
<div class="row"><span class="label">Jean (8090)</span><span id="jean-status">-</span></div>
|
|
<div class="row"><span class="label">Ollama (11434)</span><span id="ollama-status">-</span></div>
|
|
<div class="row"><span class="label">VRAM</span><span id="vram-status">-</span></div>
|
|
<div class="row"><span class="label">Preset</span><span id="active-preset">-</span></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>🎰 Slot Jean</h2>
|
|
<div class="row"><span class="label">Status</span><span id="slot-status">-</span></div>
|
|
<div class="row"><span class="label">Tokens prompt</span><span id="slot-prompt">-</span></div>
|
|
<div class="row"><span class="label">Tokens decoded</span><span id="slot-decoded">-</span></div>
|
|
<div class="slot-bar"><div class="slot-fill" id="slot-bar" style="width:0%"></div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>📊 Stats</h2>
|
|
<div class="stats-grid">
|
|
<div class="row"><span class="label">Total</span><span id="stat-total" class="value">0</span></div>
|
|
<div class="row"><span class="label">Jean</span><span id="stat-jean" class="value">0</span></div>
|
|
<div class="row"><span class="label">Ollama</span><span id="stat-ollama" class="value">0</span></div>
|
|
<div class="row"><span class="label">Erreurs</span><span id="stat-errors" class="value">0</span></div>
|
|
</div>
|
|
<div style="margin-top:10px"><span class="label">Cache</span> <span id="cache-age" style="font-size:11px;color:var(--muted)">-</span></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>⚡ Actions</h2>
|
|
<button class="btn" onclick="action('refresh')">🔄 Refresh cache</button>
|
|
<button class="btn btn-danger" onclick="action('restart-jean')" title="Stop + Start Jean">🔁 Restart Jean</button>
|
|
<button class="btn" onclick="location.reload()">↻ Reload page</button>
|
|
<div style="margin-top:8px">
|
|
<span class="badge badge-blue" id="wol-badge">WoL: -</span>
|
|
<span class="badge badge-green" id="cache-badge">Cache: -</span>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="grid-column:1/-1">
|
|
<h2>🧩 Modèles Jean</h2>
|
|
<div id="preset-list">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>📜 Logs <span style="color:var(--muted);font-weight:400;font-size:12px">(auto-scroll)</span></h2>
|
|
<div id="logs"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = (s) => document.querySelector(s);
|
|
let autoScroll = true;
|
|
|
|
function fmt(n) { return n > 1000 ? Math.round(n/1000)+'k' : n; }
|
|
|
|
async function fetchStatus() {
|
|
try {
|
|
const r = await fetch('/api/dashboard/status');
|
|
const d = await r.json();
|
|
|
|
// Status dots
|
|
$('#pc-status').innerHTML = (d.pc.alive
|
|
? '<span class="status-dot on"></span> ON'
|
|
: '<span class="status-dot off"></span> OFF — WoL requis');
|
|
$('#jean-status').innerHTML = (d.jean.alive
|
|
? '<span class="status-dot on"></span> UP'
|
|
: (d.pc.alive ? '<span class="status-dot warn"></span> DOWN (PC ON)' : '<span class="status-dot off"></span> DOWN'));
|
|
$('#ollama-status').innerHTML = (d.ollama.alive
|
|
? '<span class="status-dot on"></span> UP'
|
|
: (d.pc.alive ? '<span class="status-dot warn"></span> DOWN (PC ON)' : '<span class="status-dot off"></span> DOWN'));
|
|
$('#vram-status').innerHTML = (d.jean.vram_backend === 'jean'
|
|
? '<span class="badge badge-blue">JEAN</span>'
|
|
: '<span class="badge badge-green">OLLAMA</span>');
|
|
$('#active-preset').innerHTML = '<span class="badge badge-green">' + d.jean.active_preset + '</span>';
|
|
|
|
// Slot
|
|
if (d.jean.slot) {
|
|
const s = d.jean.slot;
|
|
$('#slot-status').innerHTML = s.processing ? '<span class="status-dot warn"></span> PROCESSING' : '<span class="status-dot on"></span> IDLE';
|
|
$('#slot-prompt').innerText = fmt(s.n_prompt_tokens) + ' tokens';
|
|
$('#slot-decoded').innerText = fmt(s.n_decoded) + ' tokens';
|
|
const pct = Math.min(100, Math.round((s.n_decoded || 0) / 256000 * 100));
|
|
const bar = $('#slot-bar');
|
|
bar.style.width = pct + '%';
|
|
bar.className = 'slot-fill' + (pct > 80 ? ' danger' : pct > 50 ? ' warn' : '');
|
|
} else {
|
|
$('#slot-status').innerHTML = '<span class="status-dot off"></span> N/A';
|
|
$('#slot-prompt').innerText = '-';
|
|
$('#slot-decoded').innerText = '-';
|
|
$('#slot-bar').style.width = '0%';
|
|
}
|
|
|
|
// Stats
|
|
$('#stat-total').innerText = d.stats.total;
|
|
$('#stat-jean').innerText = d.stats.jean;
|
|
$('#stat-ollama').innerText = d.stats.ollama;
|
|
$('#stat-errors').innerText = d.stats.errors;
|
|
$('#cache-age').innerText = 'Presets: ' + d.cache.presets_age + 's | Ollama: ' + d.cache.ollama_age + 's';
|
|
$('#wol-badge').innerText = 'WoL: ' + (d.wol.enabled ? 'ON ' + d.wol.mac : 'OFF');
|
|
$('#cache-badge').innerText = 'Cache TTL: ' + d.cache.ttl + 's';
|
|
|
|
// Presets
|
|
$('#preset-list').innerHTML = d.jean.presets.map(p =>
|
|
'<span class="badge badge-blue">' + p.name +
|
|
(p.reasoning === 'on' ? ' 🧠' : '') + '</span>'
|
|
).join(' ') || '<span style="color:var(--muted)">Aucun</span>';
|
|
} catch(e) {
|
|
console.error('Status fetch error:', e);
|
|
}
|
|
}
|
|
|
|
function appendLog(msg) {
|
|
const div = document.createElement('div');
|
|
div.textContent = msg;
|
|
if (msg.includes('[ERROR]') || msg.includes('[Erreur]')) div.className = 'error';
|
|
else if (msg.includes('[WARNING]')) div.className = 'warn';
|
|
else if (msg.includes('highlight')) div.className = 'highlight';
|
|
else div.className = 'info';
|
|
$('#logs').appendChild(div);
|
|
if (autoScroll) {
|
|
$('#logs').scrollTop = $('#logs').scrollHeight;
|
|
}
|
|
}
|
|
|
|
$('#logs').addEventListener('scroll', () => {
|
|
const el = $('#logs');
|
|
autoScroll = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
});
|
|
|
|
// SSE Log stream
|
|
const evtSource = new EventSource('/api/dashboard/logs/stream');
|
|
evtSource.onmessage = function(e) {
|
|
const d = JSON.parse(e.data);
|
|
if (d.type === 'init') {
|
|
d.logs.forEach(msg => appendLog(msg));
|
|
} else if (d.type === 'log') {
|
|
appendLog(d.message);
|
|
}
|
|
};
|
|
evtSource.onerror = function() { console.log('SSE reconnect...'); };
|
|
|
|
// Initial load
|
|
fetchStatus();
|
|
|
|
// Buttons
|
|
async function action(name) {
|
|
try {
|
|
const r = await fetch('/api/dashboard/actions/' + name, {method:'POST'});
|
|
const j = await r.json();
|
|
const toast = $('#toast');
|
|
toast.textContent = j.ok ? '✓ ' + name + ' OK' : '✗ ' + (j.error || 'failed');
|
|
toast.className = 'show';
|
|
setTimeout(() => toast.className = '', 2000);
|
|
if (name === 'refresh') fetchStatus();
|
|
} catch(e) {
|
|
alert('Action failed: ' + e);
|
|
}
|
|
}
|
|
|
|
// Poll status every 10s
|
|
setInterval(fetchStatus, 10000);
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
|
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
|
def proxy(path):
|
|
"""Proxy générique pour les autres endpoints"""
|
|
if path.startswith("v1/chat/completions"):
|
|
return chat_completions()
|
|
|
|
upstream_url = f"http://{JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}/{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:
|
|
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,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
log.info(f"[Router] Démarrage sur port {LISTEN_PORT}")
|
|
log.info(f"[Router] Jean Web: {JEAN_WEB_HOST}:{JEAN_WEB_PORT}")
|
|
log.info(f"[Router] Jean Proxy: {JEAN_PROXY_HOST}:{JEAN_PROXY_PORT}")
|
|
if ENABLE_OLLAMA:
|
|
log.info(f"[Router] Ollama Proxy: {OLLAMA_PROXY_HOST}:{OLLAMA_PROXY_PORT}")
|
|
else:
|
|
log.info(f"[Router] Ollama: désactivé")
|
|
app.run(host="0.0.0.0", port=LISTEN_PORT, threaded=True)
|