Files
Effi/jean-router/app.py
T
Folzo 9439307e6a jean-router: contexte reel depuis llama-server + overrides
- Interroge llama-server:8080 pour le n_ctx du modele actif
- JEAN_CTX_OVERRIDES prioritaire si defini
- Fallback: Ollama matching → heuristique
- Plus de devinette, le contexte est precis
2026-08-02 10:25:51 +02:00

1411 lines
52 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"))
JEAN_CTX_OVERRIDES = json_mod.loads(os.getenv("JEAN_CTX_OVERRIDES", "{}"))
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) JEAN_CTX_OVERRIDES → 2) llama-server /v1/models → 3) Ollama matching → 4) heuristique"""
# 1 — Overrides utilisateur (priorite max)
for pid, ctx in JEAN_CTX_OVERRIDES.items():
jean_ctx_cache[pid] = ctx
# 2 — llama-server direct (port 8080, modele actif)
try:
r = requests.get(f"http://{JEAN_WEB_HOST}:8080/v1/models", timeout=3)
if r.ok:
for m in r.json().get("data", []):
meta = m.get("meta", {})
ctx = meta.get("n_ctx") or m.get("max_model_len") or m.get("context_length")
if ctx:
# Associer ce ctx au preset actif (meme nom ou alias)
active = get_active_preset()
if active:
jean_ctx_cache[active["id"]] = int(ctx)
log.info(f"[Jean] ctx reel via llama-server: {active['id']}={ctx}")
except Exception:
pass
# 3 — Jean proxy /v1/models (via jean-proxy, pour le modele actif)
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 and alias not in jean_ctx_cache:
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 pid in JEAN_CTX_OVERRIDES:
jean_ctx_cache[pid] = JEAN_CTX_OVERRIDES[pid]
elif 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');setTimeout(fetchStatus,800)">🔄 Actualiser modèles Jean + Ollama</button>
<button class="btn btn-danger" onclick="action('restart-jean')" title="Stop + Start Jean">🔁 Restart Jean</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)