Spaces:
Paused
Paused
Upload 4 files
Browse files- entrypoint.sh +55 -0
- main.py +1854 -0
- package-lock.json +6 -0
- requirements.txt +36 -0
entrypoint.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ============================================================================
|
| 3 |
+
# NEXUS AI — Container entrypoint
|
| 4 |
+
# 1) Detects the GPU and launches the vLLM OpenAI-compatible server
|
| 5 |
+
# in the background (skipped automatically when MOCK_MODE=1 or no GPU).
|
| 6 |
+
# 2) Starts the FastAPI/uvicorn orchestrator on port 7860.
|
| 7 |
+
# ============================================================================
|
| 8 |
+
set -euo pipefail
|
| 9 |
+
|
| 10 |
+
log() { echo "[entrypoint $(date +%H:%M:%S)] $*"; }
|
| 11 |
+
|
| 12 |
+
log "=========================================================="
|
| 13 |
+
log " NEXUS AI platform boot sequence"
|
| 14 |
+
log " Model : ${MODEL_NAME}"
|
| 15 |
+
log " Max context : ${MAX_MODEL_LEN}"
|
| 16 |
+
log " GPU mem util : ${GPU_MEMORY_UTILIZATION}"
|
| 17 |
+
log " Mock mode : ${MOCK_MODE:-auto}"
|
| 18 |
+
log "=========================================================="
|
| 19 |
+
|
| 20 |
+
GPU_PRESENT=0
|
| 21 |
+
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L 2>/dev/null | grep -qi "GPU"; then
|
| 22 |
+
GPU_PRESENT=1
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
if [[ "${MOCK_MODE:-auto}" == "1" || "${MOCK_MODE:-auto}" == "true" ]]; then
|
| 26 |
+
log "MOCK_MODE forced -> built-in simulation engine will answer."
|
| 27 |
+
elif [[ "${GPU_PRESENT}" == "1" ]]; then
|
| 28 |
+
log "GPU detected -> launching vLLM OpenAI server on ${VLLM_HOST}:${VLLM_PORT}"
|
| 29 |
+
# shellcheck disable=SC2086
|
| 30 |
+
nohup vllm serve "${MODEL_NAME}" \
|
| 31 |
+
--host "${VLLM_HOST}" \
|
| 32 |
+
--port "${VLLM_PORT}" \
|
| 33 |
+
--dtype "${VLLM_DTYPE}" \
|
| 34 |
+
--max-model-len "${MAX_MODEL_LEN}" \
|
| 35 |
+
--gpu-memory-utilization "${GPU_MEMORY_UTILIZATION}" \
|
| 36 |
+
--enable-prefix-caching \
|
| 37 |
+
--disable-log-requests \
|
| 38 |
+
${EXTRA_VLLM_ARGS:-} \
|
| 39 |
+
> /home/user/app/vllm_server.log 2>&1 &
|
| 40 |
+
VLLM_PID=$!
|
| 41 |
+
log "vLLM server started (pid=${VLLM_PID}). Logs: vllm_server.log"
|
| 42 |
+
log "FastAPI will answer with the simulation engine until vLLM is healthy."
|
| 43 |
+
else
|
| 44 |
+
log "No GPU visible -> running with the built-in simulation engine."
|
| 45 |
+
fi
|
| 46 |
+
|
| 47 |
+
log "Starting FastAPI orchestrator on 0.0.0.0:${PORT}"
|
| 48 |
+
exec uvicorn main:app \
|
| 49 |
+
--host 0.0.0.0 \
|
| 50 |
+
--port "${PORT}" \
|
| 51 |
+
--workers 1 \
|
| 52 |
+
--loop uvloop \
|
| 53 |
+
--http httptools \
|
| 54 |
+
--timeout-keep-alive 75 \
|
| 55 |
+
--log-level info
|
main.py
ADDED
|
@@ -0,0 +1,1854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# ============================================================================
|
| 3 |
+
# NEXUS AI — Elite AI Chat Platform · FastAPI Orchestrator
|
| 4 |
+
# ----------------------------------------------------------------------------
|
| 5 |
+
# • Three-tier AI engine ........ Flash / Max (deep reasoning) / Agents swarm
|
| 6 |
+
# • Local inference ............. vLLM OpenAI-compatible server (A100 tuned)
|
| 7 |
+
# • Persistence ................. Turso (libSQL) with automatic local fallback
|
| 8 |
+
# • Security .................... master-token + IP + GPS audit + JWT sessions
|
| 9 |
+
# • Observability ............... live CPU/RAM/GPU/token telemetry (WebSocket)
|
| 10 |
+
# • Self-healing ................ AI log analyzer + on-the-fly patch applier
|
| 11 |
+
# • Sandbox ..................... jailed web terminal + file manager + uploads
|
| 12 |
+
# ============================================================================
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import asyncio
|
| 16 |
+
import collections
|
| 17 |
+
import hashlib
|
| 18 |
+
import hmac
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import math
|
| 22 |
+
import os
|
| 23 |
+
import re
|
| 24 |
+
import secrets
|
| 25 |
+
import shutil
|
| 26 |
+
import sqlite3
|
| 27 |
+
import sys
|
| 28 |
+
import threading
|
| 29 |
+
import time
|
| 30 |
+
import traceback
|
| 31 |
+
import uuid
|
| 32 |
+
from datetime import datetime, timedelta, timezone
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Any, AsyncGenerator, Deque, Dict, List, Optional, Tuple
|
| 35 |
+
|
| 36 |
+
import jwt
|
| 37 |
+
import psutil
|
| 38 |
+
import requests
|
| 39 |
+
from fastapi import (
|
| 40 |
+
Body,
|
| 41 |
+
Depends,
|
| 42 |
+
FastAPI,
|
| 43 |
+
File,
|
| 44 |
+
HTTPException,
|
| 45 |
+
Query,
|
| 46 |
+
Request,
|
| 47 |
+
UploadFile,
|
| 48 |
+
WebSocket,
|
| 49 |
+
WebSocketDisconnect,
|
| 50 |
+
)
|
| 51 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 52 |
+
from fastapi.staticfiles import StaticFiles
|
| 53 |
+
from pydantic import BaseModel, Field
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Optional dependencies (never crash the boot if one is missing)
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
try:
|
| 59 |
+
import GPUtil # type: ignore
|
| 60 |
+
except Exception: # pragma: no cover
|
| 61 |
+
GPUtil = None
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
import libsql_client # type: ignore
|
| 65 |
+
except Exception: # pragma: no cover
|
| 66 |
+
libsql_client = None
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
from bs4 import BeautifulSoup # type: ignore
|
| 70 |
+
except Exception: # pragma: no cover
|
| 71 |
+
BeautifulSoup = None
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
from pypdf import PdfReader # type: ignore
|
| 75 |
+
except Exception: # pragma: no cover
|
| 76 |
+
PdfReader = None
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
import pytesseract # type: ignore
|
| 80 |
+
from PIL import Image # type: ignore
|
| 81 |
+
except Exception: # pragma: no cover
|
| 82 |
+
pytesseract = None
|
| 83 |
+
Image = None
|
| 84 |
+
|
| 85 |
+
# ============================================================================
|
| 86 |
+
# 1. CONFIGURATION
|
| 87 |
+
# ============================================================================
|
| 88 |
+
APP_ROOT = Path(__file__).resolve().parent
|
| 89 |
+
STATIC_DIR = APP_ROOT / "static"
|
| 90 |
+
SANDBOX_DIR = Path(os.environ.get("SANDBOX_DIR", str(APP_ROOT / "sandbox_workspace"))).resolve()
|
| 91 |
+
UPLOAD_DIR = SANDBOX_DIR / "uploads"
|
| 92 |
+
LOCAL_DB_PATH = os.environ.get("LOCAL_DB_PATH", str(APP_ROOT / "nexus_local.db"))
|
| 93 |
+
|
| 94 |
+
for _d in (SANDBOX_DIR, UPLOAD_DIR):
|
| 95 |
+
_d.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class CFG:
|
| 99 |
+
"""Central runtime configuration (all overridable via environment)."""
|
| 100 |
+
|
| 101 |
+
APP_NAME: str = os.environ.get("APP_NAME", "NEXUS AI")
|
| 102 |
+
VERSION: str = "3.1.0"
|
| 103 |
+
PORT: int = int(os.environ.get("PORT", "7860"))
|
| 104 |
+
|
| 105 |
+
# --- Security -----------------------------------------------------------
|
| 106 |
+
ADMIN_API_KEY: str = os.environ.get("ADMIN_API_KEY", "").strip()
|
| 107 |
+
JWT_SECRET: str = os.environ.get("JWT_SECRET", "").strip()
|
| 108 |
+
JWT_ALG: str = "HS256"
|
| 109 |
+
JWT_TTL_HOURS: int = int(os.environ.get("JWT_TTL_HOURS", "168")) # 7 days
|
| 110 |
+
SESSION_COOKIE: str = "nexus_session"
|
| 111 |
+
|
| 112 |
+
# --- Geo shield ----------------------------------------------------------
|
| 113 |
+
SAFE_LAT: Optional[float] = (
|
| 114 |
+
float(os.environ["SAFE_LAT"]) if os.environ.get("SAFE_LAT") else None
|
| 115 |
+
)
|
| 116 |
+
SAFE_LON: Optional[float] = (
|
| 117 |
+
float(os.environ["SAFE_LON"]) if os.environ.get("SAFE_LON") else None
|
| 118 |
+
)
|
| 119 |
+
SAFE_RADIUS_KM: float = float(os.environ.get("SAFE_RADIUS_KM", "50"))
|
| 120 |
+
STRICT_GEO: bool = os.environ.get("STRICT_GEO", "0") in ("1", "true", "yes")
|
| 121 |
+
|
| 122 |
+
# --- Database ------------------------------------------------------------
|
| 123 |
+
TURSO_URL: str = os.environ.get("TURSO_DATABASE_URL", "").strip()
|
| 124 |
+
TURSO_TOKEN: str = os.environ.get("TURSO_AUTH_TOKEN", "").strip()
|
| 125 |
+
|
| 126 |
+
# --- Inference -----------------------------------------------------------
|
| 127 |
+
VLLM_URL: str = (
|
| 128 |
+
f"http://{os.environ.get('VLLM_HOST', '127.0.0.1')}:"
|
| 129 |
+
f"{os.environ.get('VLLM_PORT', '8000')}/v1"
|
| 130 |
+
)
|
| 131 |
+
MODEL_NAME: str = os.environ.get(
|
| 132 |
+
"MODEL_NAME", "deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct"
|
| 133 |
+
)
|
| 134 |
+
HF_TOKEN: str = os.environ.get("HF_TOKEN", "").strip()
|
| 135 |
+
MOCK_MODE: bool = os.environ.get("MOCK_MODE", "auto").lower() in ("1", "true", "yes")
|
| 136 |
+
|
| 137 |
+
# --- Generation defaults -------------------------------------------------
|
| 138 |
+
DEFAULT_MAX_TOKENS: int = int(os.environ.get("DEFAULT_MAX_TOKENS", "4096"))
|
| 139 |
+
DEFAULT_CONTEXT_WINDOW: int = int(os.environ.get("MAX_MODEL_LEN", "32768"))
|
| 140 |
+
LLM_TIMEOUT_S: int = int(os.environ.get("LLM_TIMEOUT_S", "600"))
|
| 141 |
+
|
| 142 |
+
# --- Sandbox terminal ----------------------------------------------------
|
| 143 |
+
TERMINAL_TIMEOUT_S: int = int(os.environ.get("TERMINAL_TIMEOUT_S", "180"))
|
| 144 |
+
TERMINAL_MAX_OUTPUT: int = int(os.environ.get("TERMINAL_MAX_OUTPUT", str(256 * 1024)))
|
| 145 |
+
|
| 146 |
+
# --- Web research ---------------------------------------------------------
|
| 147 |
+
SEARCH_TIMEOUT_S: int = int(os.environ.get("SEARCH_TIMEOUT_S", "15"))
|
| 148 |
+
FETCH_MAX_BYTES: int = int(os.environ.get("FETCH_MAX_BYTES", str(120 * 1024)))
|
| 149 |
+
|
| 150 |
+
LOG_BUFFER_SIZE: int = 2000
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if not CFG.ADMIN_API_KEY:
|
| 154 |
+
CFG.ADMIN_API_KEY = secrets.token_urlsafe(24)
|
| 155 |
+
_AUTO_KEY = True
|
| 156 |
+
else:
|
| 157 |
+
_AUTO_KEY = False
|
| 158 |
+
|
| 159 |
+
if not CFG.JWT_SECRET:
|
| 160 |
+
CFG.JWT_SECRET = hashlib.sha256(f"nexus::{CFG.ADMIN_API_KEY}".encode()).hexdigest()
|
| 161 |
+
|
| 162 |
+
# ============================================================================
|
| 163 |
+
# 2. LOGGING — console + rolling in-memory buffer + async DB mirroring
|
| 164 |
+
# ============================================================================
|
| 165 |
+
LOG_BUFFER: Deque[Dict[str, Any]] = collections.deque(maxlen=CFG.LOG_BUFFER_SIZE)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class RingBufferHandler(logging.Handler):
|
| 169 |
+
"""Keeps the last N records in RAM and mirrors them into Turso (best effort)."""
|
| 170 |
+
|
| 171 |
+
def emit(self, record: logging.LogRecord) -> None:
|
| 172 |
+
try:
|
| 173 |
+
entry = {
|
| 174 |
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 175 |
+
"level": record.levelname,
|
| 176 |
+
"logger": record.name,
|
| 177 |
+
"message": self.format(record),
|
| 178 |
+
}
|
| 179 |
+
LOG_BUFFER.append(entry)
|
| 180 |
+
try:
|
| 181 |
+
loop = asyncio.get_running_loop()
|
| 182 |
+
loop.create_task(self._persist(entry))
|
| 183 |
+
except RuntimeError:
|
| 184 |
+
pass # no running loop yet (early boot)
|
| 185 |
+
except Exception:
|
| 186 |
+
pass
|
| 187 |
+
|
| 188 |
+
@staticmethod
|
| 189 |
+
async def _persist(entry: Dict[str, Any]) -> None:
|
| 190 |
+
try:
|
| 191 |
+
if DB.ready:
|
| 192 |
+
await DB.execute(
|
| 193 |
+
"INSERT INTO system_logs(level, logger, message) VALUES(?,?,?)",
|
| 194 |
+
(entry["level"], entry["logger"], entry["message"][:4000]),
|
| 195 |
+
)
|
| 196 |
+
except Exception:
|
| 197 |
+
pass
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
logging.basicConfig(
|
| 201 |
+
level=logging.INFO,
|
| 202 |
+
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
| 203 |
+
stream=sys.stdout,
|
| 204 |
+
)
|
| 205 |
+
_ring = RingBufferHandler()
|
| 206 |
+
_ring.setFormatter(logging.Formatter("%(message)s"))
|
| 207 |
+
logging.getLogger().addHandler(_ring)
|
| 208 |
+
log = logging.getLogger("nexus")
|
| 209 |
+
|
| 210 |
+
# ============================================================================
|
| 211 |
+
# 3. DATABASE — Turso (libSQL) with automatic local-file fallback
|
| 212 |
+
# ============================================================================
|
| 213 |
+
SCHEMA_STATEMENTS: Tuple[str, ...] = (
|
| 214 |
+
"""CREATE TABLE IF NOT EXISTS users (
|
| 215 |
+
id TEXT PRIMARY KEY,
|
| 216 |
+
username TEXT UNIQUE,
|
| 217 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 218 |
+
)""",
|
| 219 |
+
"""CREATE TABLE IF NOT EXISTS chats (
|
| 220 |
+
id TEXT PRIMARY KEY,
|
| 221 |
+
user_id TEXT,
|
| 222 |
+
title TEXT NOT NULL DEFAULT 'New chat',
|
| 223 |
+
mode TEXT NOT NULL DEFAULT 'flash',
|
| 224 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 225 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 226 |
+
)""",
|
| 227 |
+
"""CREATE TABLE IF NOT EXISTS messages (
|
| 228 |
+
id TEXT PRIMARY KEY,
|
| 229 |
+
chat_id TEXT NOT NULL,
|
| 230 |
+
role TEXT NOT NULL,
|
| 231 |
+
content TEXT NOT NULL,
|
| 232 |
+
mode TEXT DEFAULT 'flash',
|
| 233 |
+
tokens INTEGER DEFAULT 0,
|
| 234 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 235 |
+
)""",
|
| 236 |
+
"""CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id)""",
|
| 237 |
+
"""CREATE TABLE IF NOT EXISTS memories (
|
| 238 |
+
id TEXT PRIMARY KEY,
|
| 239 |
+
key TEXT UNIQUE NOT NULL,
|
| 240 |
+
value TEXT NOT NULL,
|
| 241 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 242 |
+
)""",
|
| 243 |
+
"""CREATE TABLE IF NOT EXISTS system_logs (
|
| 244 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 245 |
+
level TEXT,
|
| 246 |
+
logger TEXT,
|
| 247 |
+
message TEXT,
|
| 248 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 249 |
+
)""",
|
| 250 |
+
"""CREATE TABLE IF NOT EXISTS sandbox_projects (
|
| 251 |
+
id TEXT PRIMARY KEY,
|
| 252 |
+
name TEXT UNIQUE NOT NULL,
|
| 253 |
+
files_json TEXT NOT NULL DEFAULT '{}',
|
| 254 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 255 |
+
)""",
|
| 256 |
+
"""CREATE TABLE IF NOT EXISTS auth_audit (
|
| 257 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 258 |
+
ip TEXT,
|
| 259 |
+
latitude REAL,
|
| 260 |
+
longitude REAL,
|
| 261 |
+
success INTEGER DEFAULT 0,
|
| 262 |
+
reason TEXT,
|
| 263 |
+
user_agent TEXT,
|
| 264 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 265 |
+
)""",
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class _SQLiteShim:
|
| 270 |
+
"""Tiny stdlib fallback exposing the subset of the libsql API we use."""
|
| 271 |
+
|
| 272 |
+
def __init__(self, path: str) -> None:
|
| 273 |
+
self.conn = sqlite3.connect(path, check_same_thread=False)
|
| 274 |
+
self._lock = threading.Lock()
|
| 275 |
+
|
| 276 |
+
def execute(self, sql: str, params: Any = None):
|
| 277 |
+
with self._lock:
|
| 278 |
+
cur = self.conn.execute(sql, tuple(params or ()))
|
| 279 |
+
self.conn.commit()
|
| 280 |
+
|
| 281 |
+
class RS:
|
| 282 |
+
columns = [d[0] for d in (cur.description or [])]
|
| 283 |
+
rows = cur.fetchall()
|
| 284 |
+
rows_affected = cur.rowcount
|
| 285 |
+
|
| 286 |
+
return RS()
|
| 287 |
+
|
| 288 |
+
def close(self) -> None:
|
| 289 |
+
self.conn.close()
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class _Database:
|
| 293 |
+
"""Async façade around the (synchronous) libsql/sqlite clients."""
|
| 294 |
+
|
| 295 |
+
def __init__(self) -> None:
|
| 296 |
+
self.client: Any = None
|
| 297 |
+
self.backend: str = "none"
|
| 298 |
+
self.ready: bool = False
|
| 299 |
+
|
| 300 |
+
async def connect(self) -> None:
|
| 301 |
+
if CFG.TURSO_URL and libsql_client is not None:
|
| 302 |
+
try:
|
| 303 |
+
self.client = libsql_client.create_client(
|
| 304 |
+
CFG.TURSO_URL, auth_token=CFG.TURSO_TOKEN or None
|
| 305 |
+
)
|
| 306 |
+
await asyncio.to_thread(self.client.execute, "SELECT 1")
|
| 307 |
+
self.backend = "turso"
|
| 308 |
+
log.info("Connected to Turso database: %s", CFG.TURSO_URL)
|
| 309 |
+
except Exception as exc: # pragma: no cover
|
| 310 |
+
log.error("Turso connection failed (%s) — falling back to local DB", exc)
|
| 311 |
+
self.client = None
|
| 312 |
+
|
| 313 |
+
if self.client is None:
|
| 314 |
+
if libsql_client is not None:
|
| 315 |
+
self.client = libsql_client.create_client(f"file:{LOCAL_DB_PATH}")
|
| 316 |
+
else:
|
| 317 |
+
self.client = _SQLiteShim(LOCAL_DB_PATH)
|
| 318 |
+
self.backend = "local"
|
| 319 |
+
log.warning("Using LOCAL embedded database at %s", LOCAL_DB_PATH)
|
| 320 |
+
|
| 321 |
+
for stmt in SCHEMA_STATEMENTS:
|
| 322 |
+
await asyncio.to_thread(self.client.execute, stmt)
|
| 323 |
+
self.ready = True
|
| 324 |
+
log.info("Database schema verified (%s backend)", self.backend)
|
| 325 |
+
|
| 326 |
+
# -- helpers -------------------------------------------------------------
|
| 327 |
+
async def execute(self, sql: str, params: Any = None):
|
| 328 |
+
if not self.ready:
|
| 329 |
+
raise RuntimeError("database not ready")
|
| 330 |
+
return await asyncio.to_thread(self.client.execute, sql, tuple(params or ()))
|
| 331 |
+
|
| 332 |
+
async def query(self, sql: str, params: Any = None) -> List[Dict[str, Any]]:
|
| 333 |
+
rs = await self.execute(sql, params)
|
| 334 |
+
cols = list(rs.columns)
|
| 335 |
+
return [dict(zip(cols, row)) for row in rs.rows]
|
| 336 |
+
|
| 337 |
+
async def query_one(self, sql: str, params: Any = None) -> Optional[Dict[str, Any]]:
|
| 338 |
+
rows = await self.query(sql, params)
|
| 339 |
+
return rows[0] if rows else None
|
| 340 |
+
|
| 341 |
+
def close(self) -> None:
|
| 342 |
+
try:
|
| 343 |
+
self.client.close()
|
| 344 |
+
except Exception:
|
| 345 |
+
pass
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
DB = _Database()
|
| 349 |
+
|
| 350 |
+
# ============================================================================
|
| 351 |
+
# 4. SMALL UTILITIES
|
| 352 |
+
# ============================================================================
|
| 353 |
+
def new_id() -> str:
|
| 354 |
+
return uuid.uuid4().hex
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def utcnow() -> str:
|
| 358 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def approx_tokens(text: str) -> int:
|
| 362 |
+
"""Fast heuristic token estimate (≈4 chars/token, min 1 for non-empty)."""
|
| 363 |
+
if not text:
|
| 364 |
+
return 0
|
| 365 |
+
return max(1, math.ceil(len(text) / 4))
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def clamp(value: float, lo: float, hi: float) -> float:
|
| 369 |
+
return max(lo, min(hi, value))
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 373 |
+
r = 6371.0
|
| 374 |
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
| 375 |
+
dp = math.radians(lat2 - lat1)
|
| 376 |
+
dl = math.radians(lon2 - lon1)
|
| 377 |
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 378 |
+
return 2 * r * math.asin(math.sqrt(a))
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def client_ip(request: Request) -> str:
|
| 382 |
+
fwd = request.headers.get("x-forwarded-for", "")
|
| 383 |
+
if fwd:
|
| 384 |
+
return fwd.split(",")[0].strip()
|
| 385 |
+
return request.client.host if request.client else "unknown"
|
| 386 |
+
|
| 387 |
+
# ============================================================================
|
| 388 |
+
# 5. SECURITY — JWT sessions, geo shield, dependency guards
|
| 389 |
+
# ============================================================================
|
| 390 |
+
def issue_token(ip: str) -> str:
|
| 391 |
+
now = datetime.now(timezone.utc)
|
| 392 |
+
payload = {
|
| 393 |
+
"sub": "nexus-admin",
|
| 394 |
+
"ip": ip,
|
| 395 |
+
"iat": int(now.timestamp()),
|
| 396 |
+
"exp": int((now + timedelta(hours=CFG.JWT_TTL_HOURS)).timestamp()),
|
| 397 |
+
"jti": new_id(),
|
| 398 |
+
}
|
| 399 |
+
return jwt.encode(payload, CFG.JWT_SECRET, algorithm=CFG.JWT_ALG)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
| 403 |
+
try:
|
| 404 |
+
return jwt.decode(token, CFG.JWT_SECRET, algorithms=[CFG.JWT_ALG])
|
| 405 |
+
except Exception:
|
| 406 |
+
return None
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def token_from_request(request: Request) -> Optional[str]:
|
| 410 |
+
auth = request.headers.get("authorization", "")
|
| 411 |
+
if auth.lower().startswith("bearer "):
|
| 412 |
+
return auth.split(" ", 1)[1].strip()
|
| 413 |
+
cookie = request.cookies.get(CFG.SESSION_COOKIE)
|
| 414 |
+
if cookie:
|
| 415 |
+
return cookie
|
| 416 |
+
return request.query_params.get("token")
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
async def require_session(request: Request) -> Dict[str, Any]:
|
| 420 |
+
token = token_from_request(request)
|
| 421 |
+
session = decode_token(token) if token else None
|
| 422 |
+
if not session:
|
| 423 |
+
raise HTTPException(status_code=401, detail="authentication required")
|
| 424 |
+
return session
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def ws_session(ws: WebSocket) -> Optional[Dict[str, Any]]:
|
| 428 |
+
token = ws.query_params.get("token") or ws.cookies.get(CFG.SESSION_COOKIE)
|
| 429 |
+
return decode_token(token) if token else None
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def geo_verdict(lat: Optional[float], lon: Optional[float]) -> Tuple[bool, str]:
|
| 433 |
+
"""Returns (accepted, reason). Registers everything for audit either way."""
|
| 434 |
+
if CFG.SAFE_LAT is None or CFG.SAFE_LON is None:
|
| 435 |
+
return True, "geo-fence not configured — audited"
|
| 436 |
+
if lat is None or lon is None:
|
| 437 |
+
return (not CFG.STRICT_GEO), "no coordinates supplied"
|
| 438 |
+
dist = haversine_km(CFG.SAFE_LAT, CFG.SAFE_LON, lat, lon)
|
| 439 |
+
if dist <= CFG.SAFE_RADIUS_KM:
|
| 440 |
+
return True, f"inside safe zone ({dist:.1f} km)"
|
| 441 |
+
if CFG.STRICT_GEO:
|
| 442 |
+
return False, f"outside safe zone ({dist:.1f} km > {CFG.SAFE_RADIUS_KM} km)"
|
| 443 |
+
return True, f"outside safe zone ({dist:.1f} km) — audited"
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
class AuthVerifyIn(BaseModel):
|
| 447 |
+
token: str = Field(..., min_length=1, max_length=512)
|
| 448 |
+
latitude: Optional[float] = None
|
| 449 |
+
longitude: Optional[float] = None
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ============================================================================
|
| 453 |
+
# 6. SAFETY ENGINE — three strictness profiles
|
| 454 |
+
# ============================================================================
|
| 455 |
+
class SafetyEngine:
|
| 456 |
+
"""Pattern-based protective filter pipeline.
|
| 457 |
+
|
| 458 |
+
Profiles
|
| 459 |
+
--------
|
| 460 |
+
very_severe : hyper-sensitive — blocks injection, malware, violence, NSFW,
|
| 461 |
+
jailbreak attempts and redacts PII on input AND output.
|
| 462 |
+
balanced : default protection — blocks malware/violence/injection,
|
| 463 |
+
warns + redacts PII.
|
| 464 |
+
none : every filter disabled (unrestricted developer operations).
|
| 465 |
+
"""
|
| 466 |
+
|
| 467 |
+
PATTERNS: Dict[str, str] = {
|
| 468 |
+
"prompt_injection": (
|
| 469 |
+
r"(?i)\b(ignore\s+(all|any|previous|prior)\s+(instructions|prompts|rules)"
|
| 470 |
+
r"|disregard\s+(your|all)\s+(instructions|training)"
|
| 471 |
+
r"|reveal\s+(your|the)\s+(system\s+prompt|instructions)"
|
| 472 |
+
r"|\bDAN\b|\bjailbreak\b|bypass\s+(the\s+)?(safety|filters?))"
|
| 473 |
+
),
|
| 474 |
+
"malware": (
|
| 475 |
+
r"(?i)\b(ransomware|keylogger|botnet|rat\s+trojan|exploit\s+kit"
|
| 476 |
+
r"|reverse\s+shell\s+payload|dropper\s+script|rootkit|cryptominer\s+payload)\b"
|
| 477 |
+
),
|
| 478 |
+
"violence": (
|
| 479 |
+
r"(?i)\b(build|make|assemble)\s+(a\s+)?(bomb|explosive|pipe\s+bomb|molotov)"
|
| 480 |
+
r"|\b(assassination\s+plan|school\s+shooting\s+plan)\b"
|
| 481 |
+
),
|
| 482 |
+
"nsfw": r"(?i)\b(pornograph|explicit\s+sexual\s+content|\bxxx\b)\b",
|
| 483 |
+
"pii_email": r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
|
| 484 |
+
"pii_ssn": r"\b\d{3}-\d{2}-\d{4}\b",
|
| 485 |
+
"pii_card": r"\b(?:\d[ -]*?){13,16}\b",
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
BLOCK_RULES: Dict[str, Tuple[str, ...]] = {
|
| 489 |
+
"very_severe": (
|
| 490 |
+
"prompt_injection", "malware", "violence", "nsfw",
|
| 491 |
+
"pii_email", "pii_ssn", "pii_card",
|
| 492 |
+
),
|
| 493 |
+
"balanced": ("prompt_injection", "malware", "violence"),
|
| 494 |
+
"none": (),
|
| 495 |
+
}
|
| 496 |
+
REDACT_RULES: Dict[str, Tuple[str, ...]] = {
|
| 497 |
+
"very_severe": ("pii_email", "pii_ssn", "pii_card"),
|
| 498 |
+
"balanced": ("pii_email", "pii_ssn", "pii_card"),
|
| 499 |
+
"none": (),
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
def __init__(self) -> None:
|
| 503 |
+
self._compiled = {k: re.compile(v) for k, v in self.PATTERNS.items()}
|
| 504 |
+
|
| 505 |
+
def scan(
|
| 506 |
+
self, text: str, profile: str
|
| 507 |
+
) -> Tuple[bool, List[str], str]:
|
| 508 |
+
profile = (profile or "balanced").lower()
|
| 509 |
+
if profile not in self.BLOCK_RULES:
|
| 510 |
+
profile = "balanced"
|
| 511 |
+
violations: List[str] = []
|
| 512 |
+
sanitized = text
|
| 513 |
+
for name in self.BLOCK_RULES[profile]:
|
| 514 |
+
if self._compiled[name].search(text):
|
| 515 |
+
violations.append(name)
|
| 516 |
+
for name in self.REDACT_RULES[profile]:
|
| 517 |
+
sanitized = self._compiled[name].sub(f"[REDACTED-{name.upper()}]", sanitized)
|
| 518 |
+
blocked = bool(violations) and profile != "none"
|
| 519 |
+
return (not blocked), violations, sanitized
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
SAFETY = SafetyEngine()
|
| 523 |
+
|
| 524 |
+
# ============================================================================
|
| 525 |
+
# 7. WEB RESEARCH — DuckDuckGo HTML search + lightweight page fetching
|
| 526 |
+
# ============================================================================
|
| 527 |
+
TRUSTED_DOMAINS = (
|
| 528 |
+
"github.com", "stackoverflow.com", "stackexchange.com", "arxiv.org",
|
| 529 |
+
"docs.python.org", "developer.mozilla.org", "huggingface.co",
|
| 530 |
+
"pytorch.org", "wikipedia.org",
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def ddg_search(query: str, max_results: int = 5, trusted_only: bool = False) -> List[Dict[str, str]]:
|
| 535 |
+
"""Scrape DuckDuckGo's HTML endpoint. Returns [{title, url, snippet}]."""
|
| 536 |
+
results: List[Dict[str, str]] = []
|
| 537 |
+
try:
|
| 538 |
+
resp = requests.get(
|
| 539 |
+
"https://html.duckduckgo.com/html/",
|
| 540 |
+
params={"q": query},
|
| 541 |
+
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) NEXUS-Research/3.0"},
|
| 542 |
+
timeout=CFG.SEARCH_TIMEOUT_S,
|
| 543 |
+
)
|
| 544 |
+
resp.raise_for_status()
|
| 545 |
+
if BeautifulSoup is None:
|
| 546 |
+
return results
|
| 547 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 548 |
+
for res in soup.select(".result"):
|
| 549 |
+
a = res.select_one(".result__a")
|
| 550 |
+
sn = res.select_one(".result__snippet")
|
| 551 |
+
if not a or not a.get("href"):
|
| 552 |
+
continue
|
| 553 |
+
url = a["href"]
|
| 554 |
+
m = re.search(r"uddg=([^&]+)", url)
|
| 555 |
+
if m:
|
| 556 |
+
from urllib.parse import unquote
|
| 557 |
+
url = unquote(m.group(1))
|
| 558 |
+
if trusted_only and not any(d in url for d in TRUSTED_DOMAINS):
|
| 559 |
+
continue
|
| 560 |
+
results.append(
|
| 561 |
+
{
|
| 562 |
+
"title": a.get_text(strip=True)[:200],
|
| 563 |
+
"url": url,
|
| 564 |
+
"snippet": (sn.get_text(strip=True)[:400] if sn else ""),
|
| 565 |
+
}
|
| 566 |
+
)
|
| 567 |
+
if len(results) >= max_results:
|
| 568 |
+
break
|
| 569 |
+
except Exception as exc:
|
| 570 |
+
log.warning("web search failed: %s", exc)
|
| 571 |
+
return results
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def fetch_page_text(url: str, max_chars: int = 6000) -> str:
|
| 575 |
+
"""Fetch a page and return cleaned visible text (bounded)."""
|
| 576 |
+
try:
|
| 577 |
+
resp = requests.get(
|
| 578 |
+
url,
|
| 579 |
+
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) NEXUS-Research/3.0"},
|
| 580 |
+
timeout=CFG.SEARCH_TIMEOUT_S,
|
| 581 |
+
stream=True,
|
| 582 |
+
)
|
| 583 |
+
resp.raise_for_status()
|
| 584 |
+
raw = resp.raw.read(CFG.FETCH_MAX_BYTES, decode_content=True)
|
| 585 |
+
if BeautifulSoup is None:
|
| 586 |
+
return raw.decode("utf-8", "ignore")[:max_chars]
|
| 587 |
+
soup = BeautifulSoup(raw, "lxml")
|
| 588 |
+
for tag in soup(["script", "style", "noscript", "header", "footer", "nav"]):
|
| 589 |
+
tag.decompose()
|
| 590 |
+
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
|
| 591 |
+
return text[:max_chars]
|
| 592 |
+
except Exception as exc:
|
| 593 |
+
log.warning("fetch failed for %s: %s", url, exc)
|
| 594 |
+
return ""
|
| 595 |
+
|
| 596 |
+
# ============================================================================
|
| 597 |
+
# 8. TOKEN METER — cumulative generation-rate accounting
|
| 598 |
+
# ============================================================================
|
| 599 |
+
class TokenMeter:
|
| 600 |
+
def __init__(self) -> None:
|
| 601 |
+
self.events: Deque[Tuple[float, int]] = collections.deque(maxlen=10000)
|
| 602 |
+
self.total: int = 0
|
| 603 |
+
|
| 604 |
+
def record(self, n: int) -> None:
|
| 605 |
+
if n <= 0:
|
| 606 |
+
return
|
| 607 |
+
self.events.append((time.time(), n))
|
| 608 |
+
self.total += n
|
| 609 |
+
|
| 610 |
+
def rate(self, window_s: float = 30.0) -> float:
|
| 611 |
+
cutoff = time.time() - window_s
|
| 612 |
+
return sum(n for ts, n in self.events if ts >= cutoff) / window_s
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
METER = TokenMeter()
|
| 616 |
+
|
| 617 |
+
# ============================================================================
|
| 618 |
+
# 9. LLM CLIENT — vLLM OpenAI-compatible backend + resilient simulation engine
|
| 619 |
+
# ============================================================================
|
| 620 |
+
class LLMClient:
|
| 621 |
+
def __init__(self) -> None:
|
| 622 |
+
self._health_cache: Tuple[float, bool] = (0.0, False)
|
| 623 |
+
|
| 624 |
+
# -- health ---------------------------------------------------------------
|
| 625 |
+
def _health_sync(self) -> bool:
|
| 626 |
+
try:
|
| 627 |
+
r = requests.get(f"{CFG.VLLM_URL}/models", timeout=2.5)
|
| 628 |
+
return r.status_code == 200
|
| 629 |
+
except Exception:
|
| 630 |
+
return False
|
| 631 |
+
|
| 632 |
+
async def healthy(self) -> bool:
|
| 633 |
+
if CFG.MOCK_MODE:
|
| 634 |
+
return False
|
| 635 |
+
ts, cached = self._health_cache
|
| 636 |
+
if time.time() - ts < 15:
|
| 637 |
+
return cached
|
| 638 |
+
ok = await asyncio.to_thread(self._health_sync)
|
| 639 |
+
self._health_cache = (time.time(), ok)
|
| 640 |
+
return ok
|
| 641 |
+
|
| 642 |
+
# -- payload ---------------------------------------------------------------
|
| 643 |
+
@staticmethod
|
| 644 |
+
def _payload(messages: List[Dict[str, str]], s: Dict[str, Any], stream: bool) -> Dict[str, Any]:
|
| 645 |
+
return {
|
| 646 |
+
"model": s.get("model") or CFG.MODEL_NAME,
|
| 647 |
+
"messages": messages,
|
| 648 |
+
"temperature": clamp(float(s.get("temperature", 0.7)), 0.0, 2.0),
|
| 649 |
+
"top_p": clamp(float(s.get("top_p", 0.95)), 0.0, 1.0),
|
| 650 |
+
"top_k": int(s.get("top_k", 40)),
|
| 651 |
+
"min_p": clamp(float(s.get("min_p", 0.0)), 0.0, 1.0),
|
| 652 |
+
"max_tokens": int(clamp(int(s.get("max_tokens", CFG.DEFAULT_MAX_TOKENS)), 16, 131072)),
|
| 653 |
+
"frequency_penalty": clamp(float(s.get("frequency_penalty", 0.0)), -2.0, 2.0),
|
| 654 |
+
"presence_penalty": clamp(float(s.get("presence_penalty", 0.0)), -2.0, 2.0),
|
| 655 |
+
"repetition_penalty": clamp(float(s.get("repetition_penalty", 1.0)), 0.0, 2.0),
|
| 656 |
+
"stream": stream,
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
@staticmethod
|
| 660 |
+
def _headers() -> Dict[str, str]:
|
| 661 |
+
key = CFG.HF_TOKEN or "EMPTY"
|
| 662 |
+
return {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
|
| 663 |
+
|
| 664 |
+
# -- non-streaming completion ----------------------------------------------
|
| 665 |
+
def _complete_sync(self, messages: List[Dict[str, str]], s: Dict[str, Any]) -> str:
|
| 666 |
+
r = requests.post(
|
| 667 |
+
f"{CFG.VLLM_URL}/chat/completions",
|
| 668 |
+
headers=self._headers(),
|
| 669 |
+
json=self._payload(messages, s, stream=False),
|
| 670 |
+
timeout=CFG.LLM_TIMEOUT_S,
|
| 671 |
+
)
|
| 672 |
+
r.raise_for_status()
|
| 673 |
+
data = r.json()
|
| 674 |
+
usage = data.get("usage") or {}
|
| 675 |
+
METER.record(int(usage.get("completion_tokens", 0)))
|
| 676 |
+
return (data["choices"][0]["message"].get("content") or "").strip()
|
| 677 |
+
|
| 678 |
+
async def acomplete(self, messages: List[Dict[str, str]], s: Dict[str, Any]) -> str:
|
| 679 |
+
if not await self.healthy():
|
| 680 |
+
return self._mock_response(messages)
|
| 681 |
+
try:
|
| 682 |
+
return await asyncio.to_thread(self._complete_sync, messages, s)
|
| 683 |
+
except Exception as exc:
|
| 684 |
+
log.error("LLM completion failed: %s", exc)
|
| 685 |
+
return self._mock_response(messages)
|
| 686 |
+
|
| 687 |
+
# -- streaming completion ----------------------------------------------------
|
| 688 |
+
def _stream_sync(self, messages: List[Dict[str, str]], s: Dict[str, Any], q: "queue.Queue"):
|
| 689 |
+
import queue as _q # local alias
|
| 690 |
+
try:
|
| 691 |
+
with requests.post(
|
| 692 |
+
f"{CFG.VLLM_URL}/chat/completions",
|
| 693 |
+
headers=self._headers(),
|
| 694 |
+
json=self._payload(messages, s, stream=True),
|
| 695 |
+
timeout=CFG.LLM_TIMEOUT_S,
|
| 696 |
+
stream=True,
|
| 697 |
+
) as r:
|
| 698 |
+
r.raise_for_status()
|
| 699 |
+
for line in r.iter_lines(decode_unicode=True):
|
| 700 |
+
if not line or not line.startswith("data:"):
|
| 701 |
+
continue
|
| 702 |
+
data = line[5:].strip()
|
| 703 |
+
if data == "[DONE]":
|
| 704 |
+
break
|
| 705 |
+
try:
|
| 706 |
+
chunk = json.loads(data)
|
| 707 |
+
delta = (chunk["choices"][0].get("delta") or {}).get("content")
|
| 708 |
+
if delta:
|
| 709 |
+
q.put(delta)
|
| 710 |
+
except Exception:
|
| 711 |
+
continue
|
| 712 |
+
except Exception as exc:
|
| 713 |
+
q.put(exc)
|
| 714 |
+
finally:
|
| 715 |
+
q.put(None)
|
| 716 |
+
|
| 717 |
+
async def astream(
|
| 718 |
+
self, messages: List[Dict[str, str]], s: Dict[str, Any]
|
| 719 |
+
) -> AsyncGenerator[str, None]:
|
| 720 |
+
if not await self.healthy():
|
| 721 |
+
async for piece in self._mock_stream(messages):
|
| 722 |
+
yield piece
|
| 723 |
+
return
|
| 724 |
+
|
| 725 |
+
import queue
|
| 726 |
+
q: "queue.Queue" = queue.Queue()
|
| 727 |
+
loop = asyncio.get_running_loop()
|
| 728 |
+
threading.Thread(target=self._stream_sync, args=(messages, s, q), daemon=True).start()
|
| 729 |
+
while True:
|
| 730 |
+
item = await asyncio.to_thread(q.get)
|
| 731 |
+
if item is None:
|
| 732 |
+
break
|
| 733 |
+
if isinstance(item, Exception):
|
| 734 |
+
log.error("LLM stream failed: %s", item)
|
| 735 |
+
async for piece in self._mock_stream(messages):
|
| 736 |
+
yield piece
|
| 737 |
+
return
|
| 738 |
+
METER.record(approx_tokens(item))
|
| 739 |
+
yield item
|
| 740 |
+
|
| 741 |
+
# -- simulation engine (no-GPU fallback, always available) -------------------
|
| 742 |
+
@staticmethod
|
| 743 |
+
def _last_user(messages: List[Dict[str, str]]) -> str:
|
| 744 |
+
for m in reversed(messages):
|
| 745 |
+
if m.get("role") == "user":
|
| 746 |
+
return m.get("content", "")
|
| 747 |
+
return ""
|
| 748 |
+
|
| 749 |
+
def _mock_response(self, messages: List[Dict[str, str]]) -> str:
|
| 750 |
+
user = self._last_user(messages).strip()
|
| 751 |
+
excerpt = user[:300] + ("…" if len(user) > 300 else "")
|
| 752 |
+
return (
|
| 753 |
+
"⚙️ **Simulation engine** — the vLLM server is not reachable yet "
|
| 754 |
+
"(model still loading, or no GPU attached).\n\n"
|
| 755 |
+
f"**You asked:**\n> {excerpt or '(empty prompt)'}\n\n"
|
| 756 |
+
"**What I can tell you right now:**\n"
|
| 757 |
+
"- The NEXUS orchestrator is healthy and all APIs are live.\n"
|
| 758 |
+
"- Once the local model finishes loading, Flash/Max/Agents will stream "
|
| 759 |
+
"real tokens from it automatically.\n"
|
| 760 |
+
"- Set `MOCK_MODE=0` (default) to keep waiting for vLLM, or check "
|
| 761 |
+
"`vllm_server.log` in the sandbox for boot progress.\n\n"
|
| 762 |
+
"```python\n# quick probe you can run in the NEXUS console\n"
|
| 763 |
+
"curl -s http://127.0.0.1:8000/v1/models | head\n```"
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
async def _mock_stream(self, messages: List[Dict[str, str]]) -> AsyncGenerator[str, None]:
|
| 767 |
+
text = self._mock_response(messages)
|
| 768 |
+
for i in range(0, len(text), 6):
|
| 769 |
+
piece = text[i : i + 6]
|
| 770 |
+
METER.record(approx_tokens(piece))
|
| 771 |
+
yield piece
|
| 772 |
+
await asyncio.sleep(0.012)
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
LLM = LLMClient()
|
| 776 |
+
|
| 777 |
+
# ============================================================================
|
| 778 |
+
# 10. PROMPT ASSEMBLY
|
| 779 |
+
# ============================================================================
|
| 780 |
+
BASE_SYSTEM = (
|
| 781 |
+
"You are NEXUS, an elite principal-level AI pair-programmer and systems architect "
|
| 782 |
+
"running locally on dedicated GPU infrastructure. You answer with precision, "
|
| 783 |
+
"write production-grade code with no placeholders, and explain trade-offs briefly. "
|
| 784 |
+
"Format answers in Markdown with fenced code blocks that include the language."
|
| 785 |
+
)
|
| 786 |
+
|
| 787 |
+
AGENT_ROLES: Dict[str, str] = {
|
| 788 |
+
"project_manager": (
|
| 789 |
+
"You are the PROJECT MANAGER agent. Decompose the request into a concrete, "
|
| 790 |
+
"ordered task list. Reply ONLY with a JSON array like "
|
| 791 |
+
'[{"agent":"coder","task":"..."}]. Valid agents: researcher, coder, debugger, '
|
| 792 |
+
"security, database, ui_ux. Max 5 tasks, no prose."
|
| 793 |
+
),
|
| 794 |
+
"researcher": (
|
| 795 |
+
"You are the RESEARCHER agent. Gather the key facts, APIs, and prior art "
|
| 796 |
+
"relevant to the task. Output concise bullet points with source names."
|
| 797 |
+
),
|
| 798 |
+
"coder": (
|
| 799 |
+
"You are the CODER agent. Write complete, production-ready code. Every file "
|
| 800 |
+
"in its own fenced block whose first line is a `# path: <filename>` comment."
|
| 801 |
+
),
|
| 802 |
+
"debugger": (
|
| 803 |
+
"You are the DEBUGGER agent. Review the produced artifacts for runtime errors, "
|
| 804 |
+
"edge cases, race conditions and logic bugs. List each issue with its fix."
|
| 805 |
+
),
|
| 806 |
+
"security": (
|
| 807 |
+
"You are the SECURITY agent. Audit the artifacts for injection, auth, secrets "
|
| 808 |
+
"handling, and unsafe defaults. Give a verdict plus concrete hardening steps."
|
| 809 |
+
),
|
| 810 |
+
"database": (
|
| 811 |
+
"You are the DATABASE agent. Design schemas, write optimized SQL/libSQL "
|
| 812 |
+
"queries and indexes for the task."
|
| 813 |
+
),
|
| 814 |
+
"ui_ux": (
|
| 815 |
+
"You are the UI/UX agent. Produce refined, responsive, accessible interface "
|
| 816 |
+
"code following the dark glassmorphic design system (#0B0F19, cyan accents)."
|
| 817 |
+
),
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
|
| 821 |
+
async def build_messages(payload: Dict[str, Any], s: Dict[str, Any]) -> List[Dict[str, str]]:
|
| 822 |
+
parts: List[str] = [BASE_SYSTEM]
|
| 823 |
+
|
| 824 |
+
if s.get("persistent_instructions_enabled") and s.get("persistent_instructions"):
|
| 825 |
+
parts.append(f"Persistent instructions:\n{s['persistent_instructions']}")
|
| 826 |
+
if s.get("system_prompt_enabled") and s.get("system_prompt"):
|
| 827 |
+
parts.append(str(s["system_prompt"]))
|
| 828 |
+
if s.get("developer_prompt_enabled") and s.get("developer_prompt"):
|
| 829 |
+
parts.append(f"[Developer directive] {s['developer_prompt']}")
|
| 830 |
+
if s.get("user_profile"):
|
| 831 |
+
parts.append(f"[User profile] {s['user_profile']}")
|
| 832 |
+
if s.get("strict_instructions"):
|
| 833 |
+
parts.append("Follow every user instruction strictly and literally.")
|
| 834 |
+
lang = (s.get("language") or "auto").lower()
|
| 835 |
+
if lang != "auto":
|
| 836 |
+
parts.append(f"Always answer in {lang}.")
|
| 837 |
+
|
| 838 |
+
if s.get("use_memory"):
|
| 839 |
+
try:
|
| 840 |
+
rows = await DB.query("SELECT key, value FROM memories ORDER BY updated_at DESC LIMIT 10")
|
| 841 |
+
if rows:
|
| 842 |
+
mem = "\n".join(f"- {r['key']}: {r['value']}" for r in rows)
|
| 843 |
+
parts.append(f"[Long-term memory]\n{mem}")
|
| 844 |
+
except Exception:
|
| 845 |
+
pass
|
| 846 |
+
|
| 847 |
+
if s.get("citations") and s.get("web_search"):
|
| 848 |
+
parts.append("Cite sources inline as [1], [2]… matching the provided source list.")
|
| 849 |
+
|
| 850 |
+
messages: List[Dict[str, str]] = [{"role": "system", "content": "\n\n".join(parts)}]
|
| 851 |
+
|
| 852 |
+
# --- context window management (approximate) ------------------------------
|
| 853 |
+
budget = int(s.get("context_window", CFG.DEFAULT_CONTEXT_WINDOW)) - int(
|
| 854 |
+
s.get("max_tokens", CFG.DEFAULT_MAX_TOKENS)
|
| 855 |
+
)
|
| 856 |
+
budget_chars = max(4000, budget * 4)
|
| 857 |
+
history = list(payload.get("messages", []))
|
| 858 |
+
kept: List[Dict[str, str]] = []
|
| 859 |
+
total = 0
|
| 860 |
+
for m in reversed(history):
|
| 861 |
+
c = len(m.get("content", ""))
|
| 862 |
+
if total + c > budget_chars and kept:
|
| 863 |
+
break
|
| 864 |
+
kept.append(m)
|
| 865 |
+
total += c
|
| 866 |
+
kept.reverse()
|
| 867 |
+
for m in kept:
|
| 868 |
+
role = m.get("role") if m.get("role") in ("user", "assistant", "system") else "user"
|
| 869 |
+
messages.append({"role": role, "content": str(m.get("content", ""))[:60000]})
|
| 870 |
+
return messages
|
| 871 |
+
|
| 872 |
+
# ============================================================================
|
| 873 |
+
# 11. WEBSOCKET SEND HELPER
|
| 874 |
+
# ============================================================================
|
| 875 |
+
async def ws_send(ws: WebSocket, event: Dict[str, Any]) -> bool:
|
| 876 |
+
try:
|
| 877 |
+
await ws.send_text(json.dumps(event, ensure_ascii=False))
|
| 878 |
+
return True
|
| 879 |
+
except Exception:
|
| 880 |
+
return False
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
# ============================================================================
|
| 884 |
+
# 12. ENGINE — FLASH (direct low-latency streaming)
|
| 885 |
+
# ============================================================================
|
| 886 |
+
async def engine_flash(ws: WebSocket, payload: Dict[str, Any], s: Dict[str, Any]) -> str:
|
| 887 |
+
messages = await build_messages(payload, s)
|
| 888 |
+
acc = ""
|
| 889 |
+
async for piece in LLM.astream(messages, s):
|
| 890 |
+
acc += piece
|
| 891 |
+
if not await ws_send(ws, {"type": "token", "content": piece}):
|
| 892 |
+
break
|
| 893 |
+
return acc
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
# ============================================================================
|
| 897 |
+
# 13. ENGINE — MAX (visible deep-reasoning pipeline)
|
| 898 |
+
# ============================================================================
|
| 899 |
+
async def engine_max(ws: WebSocket, payload: Dict[str, Any], s: Dict[str, Any]) -> Tuple[str, List[Dict[str, str]]]:
|
| 900 |
+
user_q = ""
|
| 901 |
+
for m in reversed(payload.get("messages", [])):
|
| 902 |
+
if m.get("role") == "user":
|
| 903 |
+
user_q = m.get("content", "")
|
| 904 |
+
break
|
| 905 |
+
|
| 906 |
+
thinking_budget = int(s.get("thinking_budget", 1200))
|
| 907 |
+
think_s = dict(s)
|
| 908 |
+
think_s["max_tokens"] = thinking_budget
|
| 909 |
+
think_s["temperature"] = min(float(s.get("temperature", 0.7)), 0.5)
|
| 910 |
+
sources: List[Dict[str, str]] = []
|
| 911 |
+
|
| 912 |
+
# -- Step 1 · Planning ------------------------------------------------------
|
| 913 |
+
await ws_send(ws, {"type": "thinking", "step": "planning", "status": "working",
|
| 914 |
+
"content": "Decomposing the problem into a reasoning plan…"})
|
| 915 |
+
plan = await LLM.acomplete(
|
| 916 |
+
[
|
| 917 |
+
{"role": "system", "content": "You are a meticulous planning engine. "
|
| 918 |
+
"Produce a numbered step-by-step plan to solve the user's request. "
|
| 919 |
+
"Be concrete and technical."},
|
| 920 |
+
{"role": "user", "content": user_q},
|
| 921 |
+
],
|
| 922 |
+
think_s,
|
| 923 |
+
)
|
| 924 |
+
await ws_send(ws, {"type": "thinking", "step": "planning", "status": "done", "content": plan})
|
| 925 |
+
|
| 926 |
+
# -- Step 2 · Web research (optional) ---------------------------------------
|
| 927 |
+
research_digest = ""
|
| 928 |
+
if s.get("web_search"):
|
| 929 |
+
await ws_send(ws, {"type": "thinking", "step": "searching", "status": "working",
|
| 930 |
+
"content": f"Searching the web for: {user_q[:120]}"})
|
| 931 |
+
depth = int(clamp(int(s.get("search_depth", 5)), 1, 10))
|
| 932 |
+
trusted_only = bool(s.get("trusted_sources")) and not s.get("deep_research")
|
| 933 |
+
sources = await asyncio.to_thread(ddg_search, user_q, depth, trusted_only)
|
| 934 |
+
|
| 935 |
+
digests: List[str] = []
|
| 936 |
+
if s.get("deep_research"):
|
| 937 |
+
for item in sources[:4]:
|
| 938 |
+
text = await asyncio.to_thread(fetch_page_text, item["url"], 4000)
|
| 939 |
+
if text:
|
| 940 |
+
item["fetched"] = True
|
| 941 |
+
digests.append(f"### {item['title']}\n{text[:2500]}")
|
| 942 |
+
else:
|
| 943 |
+
digests.append(f"### {item['title']}\n{item['snippet']}")
|
| 944 |
+
else:
|
| 945 |
+
digests = [f"- {r['title']} — {r['snippet']} ({r['url']})" for r in sources]
|
| 946 |
+
research_digest = "\n\n".join(digests)
|
| 947 |
+
await ws_send(ws, {"type": "thinking", "step": "searching", "status": "done",
|
| 948 |
+
"content": f"Collected {len(sources)} source(s)."
|
| 949 |
+
+ (f"\n\n{research_digest[:2000]}" if research_digest else "")})
|
| 950 |
+
await ws_send(ws, {"type": "citations", "items": sources})
|
| 951 |
+
|
| 952 |
+
# -- Step 3 · Deep reasoning --------------------------------------------------
|
| 953 |
+
await ws_send(ws, {"type": "thinking", "step": "reasoning", "status": "working",
|
| 954 |
+
"content": "Reasoning through the plan with gathered evidence…"})
|
| 955 |
+
reasoning_input = (
|
| 956 |
+
f"PLAN:\n{plan}\n\n"
|
| 957 |
+
+ (f"WEB EVIDENCE:\n{research_digest[:8000]}\n\n" if research_digest else "")
|
| 958 |
+
+ f"USER REQUEST:\n{user_q}"
|
| 959 |
+
)
|
| 960 |
+
draft = await LLM.acomplete(
|
| 961 |
+
[
|
| 962 |
+
{"role": "system", "content": "You are a deep reasoning engine. Work through "
|
| 963 |
+
"the plan step by step and produce a thorough draft answer. Show your work."},
|
| 964 |
+
{"role": "user", "content": reasoning_input},
|
| 965 |
+
],
|
| 966 |
+
think_s,
|
| 967 |
+
)
|
| 968 |
+
await ws_send(ws, {"type": "thinking", "step": "reasoning", "status": "done", "content": draft})
|
| 969 |
+
|
| 970 |
+
# -- Step 4 · Self-correction / reflection --------------------------------------
|
| 971 |
+
if s.get("reflection", True):
|
| 972 |
+
await ws_send(ws, {"type": "thinking", "step": "reflection", "status": "working",
|
| 973 |
+
"content": "Critiquing the draft for errors and gaps…"})
|
| 974 |
+
critique = await LLM.acomplete(
|
| 975 |
+
[
|
| 976 |
+
{"role": "system", "content": "You are a ruthless self-critic. Review the "
|
| 977 |
+
"draft answer: find factual errors, missing edge cases, and weak spots. "
|
| 978 |
+
"Then list precise corrections."},
|
| 979 |
+
{"role": "user", "content": f"REQUEST:\n{user_q}\n\nDRAFT:\n{draft}"},
|
| 980 |
+
],
|
| 981 |
+
think_s,
|
| 982 |
+
)
|
| 983 |
+
await ws_send(ws, {"type": "thinking", "step": "reflection", "status": "done", "content": critique})
|
| 984 |
+
else:
|
| 985 |
+
critique = ""
|
| 986 |
+
|
| 987 |
+
# -- Step 5 · Final streamed answer ----------------------------------------------
|
| 988 |
+
final_msgs = await build_messages(payload, s)
|
| 989 |
+
final_msgs.append(
|
| 990 |
+
{"role": "system", "content":
|
| 991 |
+
"You have already produced a plan, research digest, draft and self-critique "
|
| 992 |
+
"(provided below). Now write the FINAL polished answer for the user. "
|
| 993 |
+
"Incorporate the corrections; do not mention this meta-process.\n\n"
|
| 994 |
+
f"PLAN:\n{plan[:3000]}\n\nDRAFT:\n{draft[:6000]}\n\nCRITIQUE:\n{critique[:3000]}"}
|
| 995 |
+
)
|
| 996 |
+
acc = ""
|
| 997 |
+
async for piece in LLM.astream(final_msgs, s):
|
| 998 |
+
acc += piece
|
| 999 |
+
if not await ws_send(ws, {"type": "token", "content": piece}):
|
| 1000 |
+
break
|
| 1001 |
+
return acc, sources
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
# ============================================================================
|
| 1005 |
+
# 14. ENGINE — AGENTS (autonomous specialist swarm)
|
| 1006 |
+
# ============================================================================
|
| 1007 |
+
DEFAULT_PLAN = [
|
| 1008 |
+
{"agent": "researcher", "task": "Clarify requirements and gather key technical facts."},
|
| 1009 |
+
{"agent": "coder", "task": "Implement the complete solution as production-ready files."},
|
| 1010 |
+
{"agent": "debugger", "task": "Review the implementation for bugs and edge cases."},
|
| 1011 |
+
{"agent": "security", "task": "Audit the implementation for vulnerabilities."},
|
| 1012 |
+
]
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
def _parse_plan(text: str) -> List[Dict[str, str]]:
|
| 1016 |
+
try:
|
| 1017 |
+
m = re.search(r"\[.*\]", text, re.S)
|
| 1018 |
+
if m:
|
| 1019 |
+
data = json.loads(m.group(0))
|
| 1020 |
+
plan = [
|
| 1021 |
+
{"agent": str(t.get("agent", "coder")), "task": str(t.get("task", ""))[:500]}
|
| 1022 |
+
for t in data if isinstance(t, dict) and t.get("task")
|
| 1023 |
+
]
|
| 1024 |
+
if plan:
|
| 1025 |
+
return plan[:5]
|
| 1026 |
+
except Exception:
|
| 1027 |
+
pass
|
| 1028 |
+
return list(DEFAULT_PLAN)
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
async def engine_agents(ws: WebSocket, payload: Dict[str, Any], s: Dict[str, Any]) -> str:
|
| 1032 |
+
user_q = ""
|
| 1033 |
+
for m in reversed(payload.get("messages", [])):
|
| 1034 |
+
if m.get("role") == "user":
|
| 1035 |
+
user_q = m.get("content", "")
|
| 1036 |
+
break
|
| 1037 |
+
|
| 1038 |
+
agent_s = dict(s)
|
| 1039 |
+
agent_s["max_tokens"] = min(int(s.get("max_tokens", CFG.DEFAULT_MAX_TOKENS)), 3000)
|
| 1040 |
+
agent_s["temperature"] = min(float(s.get("temperature", 0.7)), 0.4)
|
| 1041 |
+
|
| 1042 |
+
# -- PM routing ---------------------------------------------------------------
|
| 1043 |
+
await ws_send(ws, {"type": "agent", "agent": "project_manager", "status": "working",
|
| 1044 |
+
"content": "Analyzing the mission and assembling the swarm…"})
|
| 1045 |
+
if s.get("multi_agent", True):
|
| 1046 |
+
plan_text = await LLM.acomplete(
|
| 1047 |
+
[
|
| 1048 |
+
{"role": "system", "content": AGENT_ROLES["project_manager"]},
|
| 1049 |
+
{"role": "user", "content": user_q},
|
| 1050 |
+
],
|
| 1051 |
+
agent_s,
|
| 1052 |
+
)
|
| 1053 |
+
plan = _parse_plan(plan_text)
|
| 1054 |
+
else:
|
| 1055 |
+
plan = [{"agent": "coder", "task": user_q[:400]}]
|
| 1056 |
+
pretty = "\n".join(f"{i+1}. **{t['agent']}** → {t['task']}" for i, t in enumerate(plan))
|
| 1057 |
+
await ws_send(ws, {"type": "agent", "agent": "project_manager", "status": "done",
|
| 1058 |
+
"content": f"Mission plan ({len(plan)} task(s)):\n{pretty}"})
|
| 1059 |
+
|
| 1060 |
+
# -- Execution routing loop -----------------------------------------------------
|
| 1061 |
+
artifacts: List[Dict[str, str]] = []
|
| 1062 |
+
for idx, task in enumerate(plan, 1):
|
| 1063 |
+
role = task["agent"] if task["agent"] in AGENT_ROLES else "coder"
|
| 1064 |
+
await ws_send(ws, {"type": "agent", "agent": role, "status": "working",
|
| 1065 |
+
"content": f"[{idx}/{len(plan)}] {task['task']}"})
|
| 1066 |
+
prior = ""
|
| 1067 |
+
if artifacts:
|
| 1068 |
+
prior = "\n\n".join(
|
| 1069 |
+
f"[Output of {a['agent']}]\n{a['output'][:1500]}" for a in artifacts[-3:]
|
| 1070 |
+
)
|
| 1071 |
+
out = await LLM.acomplete(
|
| 1072 |
+
[
|
| 1073 |
+
{"role": "system", "content": AGENT_ROLES[role]},
|
| 1074 |
+
{"role": "user", "content":
|
| 1075 |
+
f"MISSION:\n{user_q}\n\nYOUR TASK:\n{task['task']}\n\n"
|
| 1076 |
+
+ (f"PRIOR AGENT OUTPUTS:\n{prior}" if prior else "")},
|
| 1077 |
+
],
|
| 1078 |
+
agent_s,
|
| 1079 |
+
)
|
| 1080 |
+
artifacts.append({"agent": role, "task": task["task"], "output": out})
|
| 1081 |
+
await ws_send(ws, {"type": "agent", "agent": role, "status": "done", "content": out})
|
| 1082 |
+
|
| 1083 |
+
# -- Final compilation ------------------------------------------------------------
|
| 1084 |
+
await ws_send(ws, {"type": "status", "content": "Compiling the swarm's final deliverable…"})
|
| 1085 |
+
compiled_context = "\n\n".join(
|
| 1086 |
+
f"===== AGENT: {a['agent']} (task: {a['task']}) =====\n{a['output']}" for a in artifacts
|
| 1087 |
+
)
|
| 1088 |
+
final_msgs = [
|
| 1089 |
+
{"role": "system", "content":
|
| 1090 |
+
"You are the lead integrator of an AI agent swarm. Compile all agent outputs "
|
| 1091 |
+
"into ONE coherent final deliverable: a short executive summary followed by the "
|
| 1092 |
+
"complete artifacts (code files in fenced blocks with `# path:` headers). "
|
| 1093 |
+
"Do not lose any code."},
|
| 1094 |
+
{"role": "user", "content": f"MISSION:\n{user_q}\n\n{compiled_context[:20000]}"},
|
| 1095 |
+
]
|
| 1096 |
+
acc = ""
|
| 1097 |
+
async for piece in LLM.astream(final_msgs, s):
|
| 1098 |
+
acc += piece
|
| 1099 |
+
if not await ws_send(ws, {"type": "token", "content": piece}):
|
| 1100 |
+
break
|
| 1101 |
+
|
| 1102 |
+
# -- Persist the project snapshot ---------------------------------------------------
|
| 1103 |
+
try:
|
| 1104 |
+
files = dict(re.findall(r"```[\w+-]*\n#\s*path:\s*(\S+)\n(.*?)```", acc, re.S))
|
| 1105 |
+
await DB.execute(
|
| 1106 |
+
"INSERT INTO sandbox_projects(id, name, files_json, updated_at) VALUES(?,?,?,datetime('now')) "
|
| 1107 |
+
"ON CONFLICT(name) DO UPDATE SET files_json=excluded.files_json, updated_at=datetime('now')",
|
| 1108 |
+
(new_id(), f"mission-{int(time.time())}", json.dumps(files, ensure_ascii=False)),
|
| 1109 |
+
)
|
| 1110 |
+
except Exception as exc:
|
| 1111 |
+
log.warning("sandbox project persist failed: %s", exc)
|
| 1112 |
+
return acc
|
| 1113 |
+
|
| 1114 |
+
# ============================================================================
|
| 1115 |
+
# 15. FASTAPI APPLICATION
|
| 1116 |
+
# ============================================================================
|
| 1117 |
+
app = FastAPI(title=CFG.APP_NAME, version=CFG.VERSION, docs_url=None, redoc_url=None)
|
| 1118 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 1119 |
+
|
| 1120 |
+
METRICS_CLIENTS: set = set()
|
| 1121 |
+
APP_START_TS = time.time()
|
| 1122 |
+
|
| 1123 |
+
|
| 1124 |
+
# ---------------------------------------------------------------------------
|
| 1125 |
+
# Middleware: hard-block any direct .html access without a valid session
|
| 1126 |
+
# ---------------------------------------------------------------------------
|
| 1127 |
+
@app.middleware("http")
|
| 1128 |
+
async def html_guard(request: Request, call_next):
|
| 1129 |
+
path = request.url.path
|
| 1130 |
+
if path.startswith("/static/") and path.endswith(".html") and not path.endswith("lockscreen.html"):
|
| 1131 |
+
token = token_from_request(request)
|
| 1132 |
+
if not token or not decode_token(token):
|
| 1133 |
+
return FileResponse(str(STATIC_DIR / "lockscreen.html"), status_code=200)
|
| 1134 |
+
return await call_next(request)
|
| 1135 |
+
|
| 1136 |
+
|
| 1137 |
+
# ---------------------------------------------------------------------------
|
| 1138 |
+
# Page serving (every page requires a valid session cookie/token)
|
| 1139 |
+
# ---------------------------------------------------------------------------
|
| 1140 |
+
def _page_or_lock(filename: str, request: Request):
|
| 1141 |
+
token = token_from_request(request)
|
| 1142 |
+
if token and decode_token(token):
|
| 1143 |
+
return FileResponse(str(STATIC_DIR / filename))
|
| 1144 |
+
return FileResponse(str(STATIC_DIR / "lockscreen.html"))
|
| 1145 |
+
|
| 1146 |
+
|
| 1147 |
+
@app.get("/", response_class=HTMLResponse)
|
| 1148 |
+
async def page_index(request: Request):
|
| 1149 |
+
return _page_or_lock("index.html", request)
|
| 1150 |
+
|
| 1151 |
+
|
| 1152 |
+
@app.get("/usage", response_class=HTMLResponse)
|
| 1153 |
+
async def page_usage(request: Request):
|
| 1154 |
+
return _page_or_lock("usage.html", request)
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
@app.get("/logs", response_class=HTMLResponse)
|
| 1158 |
+
async def page_logs(request: Request):
|
| 1159 |
+
return _page_or_lock("logs.html", request)
|
| 1160 |
+
|
| 1161 |
+
|
| 1162 |
+
@app.get("/console", response_class=HTMLResponse)
|
| 1163 |
+
async def page_console(request: Request):
|
| 1164 |
+
return _page_or_lock("console.html", request)
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
# ---------------------------------------------------------------------------
|
| 1168 |
+
# Auth API
|
| 1169 |
+
# ---------------------------------------------------------------------------
|
| 1170 |
+
@app.post("/api/auth/verify")
|
| 1171 |
+
async def auth_verify(body: AuthVerifyIn, request: Request):
|
| 1172 |
+
ip = client_ip(request)
|
| 1173 |
+
ua = request.headers.get("user-agent", "")[:300]
|
| 1174 |
+
ok_token = hmac.compare_digest(body.token.strip(), CFG.ADMIN_API_KEY)
|
| 1175 |
+
geo_ok, geo_reason = geo_verdict(body.latitude, body.longitude)
|
| 1176 |
+
success = ok_token and geo_ok
|
| 1177 |
+
reason = "ok" if success else ("bad master token" if not ok_token else geo_reason)
|
| 1178 |
+
|
| 1179 |
+
try:
|
| 1180 |
+
await DB.execute(
|
| 1181 |
+
"INSERT INTO auth_audit(ip, latitude, longitude, success, reason, user_agent) "
|
| 1182 |
+
"VALUES(?,?,?,?,?,?)",
|
| 1183 |
+
(ip, body.latitude, body.longitude, 1 if success else 0, reason, ua),
|
| 1184 |
+
)
|
| 1185 |
+
except Exception as exc:
|
| 1186 |
+
log.warning("auth audit insert failed: %s", exc)
|
| 1187 |
+
|
| 1188 |
+
if not success:
|
| 1189 |
+
log.warning("AUTH FAIL ip=%s reason=%s", ip, reason)
|
| 1190 |
+
raise HTTPException(status_code=403, detail=f"access denied: {reason}")
|
| 1191 |
+
|
| 1192 |
+
token = issue_token(ip)
|
| 1193 |
+
log.info("AUTH OK ip=%s geo=%s", ip, geo_reason)
|
| 1194 |
+
resp = JSONResponse(
|
| 1195 |
+
{
|
| 1196 |
+
"ok": True,
|
| 1197 |
+
"token": token,
|
| 1198 |
+
"expires_in_hours": CFG.JWT_TTL_HOURS,
|
| 1199 |
+
"geo": geo_reason,
|
| 1200 |
+
"ip": ip,
|
| 1201 |
+
}
|
| 1202 |
+
)
|
| 1203 |
+
resp.set_cookie(
|
| 1204 |
+
CFG.SESSION_COOKIE, token,
|
| 1205 |
+
max_age=CFG.JWT_TTL_HOURS * 3600, httponly=False, samesite="lax",
|
| 1206 |
+
)
|
| 1207 |
+
return resp
|
| 1208 |
+
|
| 1209 |
+
|
| 1210 |
+
@app.get("/api/auth/session")
|
| 1211 |
+
async def auth_session(session: Dict[str, Any] = Depends(require_session)):
|
| 1212 |
+
return {
|
| 1213 |
+
"ok": True,
|
| 1214 |
+
"sub": session.get("sub"),
|
| 1215 |
+
"ip": session.get("ip"),
|
| 1216 |
+
"exp": session.get("exp"),
|
| 1217 |
+
"model": CFG.MODEL_NAME,
|
| 1218 |
+
"mock_mode": CFG.MOCK_MODE,
|
| 1219 |
+
"db": DB.backend,
|
| 1220 |
+
"vllm": await LLM.healthy(),
|
| 1221 |
+
"version": CFG.VERSION,
|
| 1222 |
+
}
|
| 1223 |
+
|
| 1224 |
+
|
| 1225 |
+
@app.post("/api/auth/logout")
|
| 1226 |
+
async def auth_logout():
|
| 1227 |
+
resp = JSONResponse({"ok": True})
|
| 1228 |
+
resp.delete_cookie(CFG.SESSION_COOKIE)
|
| 1229 |
+
return resp
|
| 1230 |
+
|
| 1231 |
+
|
| 1232 |
+
# ---------------------------------------------------------------------------
|
| 1233 |
+
# Health / info
|
| 1234 |
+
# ---------------------------------------------------------------------------
|
| 1235 |
+
@app.get("/api/health")
|
| 1236 |
+
async def health():
|
| 1237 |
+
return {
|
| 1238 |
+
"status": "ok",
|
| 1239 |
+
"app": CFG.APP_NAME,
|
| 1240 |
+
"version": CFG.VERSION,
|
| 1241 |
+
"uptime_s": round(time.time() - APP_START_TS, 1),
|
| 1242 |
+
"db": DB.backend,
|
| 1243 |
+
"vllm": await LLM.healthy(),
|
| 1244 |
+
"mock_mode": CFG.MOCK_MODE,
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
|
| 1248 |
+
# ---------------------------------------------------------------------------
|
| 1249 |
+
# Chat persistence API
|
| 1250 |
+
# ---------------------------------------------------------------------------
|
| 1251 |
+
class ChatCreateIn(BaseModel):
|
| 1252 |
+
title: str = Field("New chat", max_length=200)
|
| 1253 |
+
mode: str = Field("flash", max_length=20)
|
| 1254 |
+
|
| 1255 |
+
|
| 1256 |
+
@app.get("/api/chats")
|
| 1257 |
+
async def list_chats(session: Dict[str, Any] = Depends(require_session)):
|
| 1258 |
+
return await DB.query(
|
| 1259 |
+
"SELECT id, title, mode, created_at, updated_at FROM chats "
|
| 1260 |
+
"ORDER BY updated_at DESC LIMIT 100"
|
| 1261 |
+
)
|
| 1262 |
+
|
| 1263 |
+
|
| 1264 |
+
@app.post("/api/chats")
|
| 1265 |
+
async def create_chat(body: ChatCreateIn, session: Dict[str, Any] = Depends(require_session)):
|
| 1266 |
+
cid = new_id()
|
| 1267 |
+
await DB.execute(
|
| 1268 |
+
"INSERT INTO chats(id, user_id, title, mode) VALUES(?,?,?,?)",
|
| 1269 |
+
(cid, session.get("sub", "admin"), body.title.strip() or "New chat", body.mode),
|
| 1270 |
+
)
|
| 1271 |
+
return {"id": cid, "title": body.title, "mode": body.mode}
|
| 1272 |
+
|
| 1273 |
+
|
| 1274 |
+
@app.get("/api/chats/{chat_id}/messages")
|
| 1275 |
+
async def chat_messages(chat_id: str, session: Dict[str, Any] = Depends(require_session)):
|
| 1276 |
+
return await DB.query(
|
| 1277 |
+
"SELECT id, role, content, mode, tokens, created_at FROM messages "
|
| 1278 |
+
"WHERE chat_id=? ORDER BY created_at ASC, rowid ASC LIMIT 500",
|
| 1279 |
+
(chat_id,),
|
| 1280 |
+
)
|
| 1281 |
+
|
| 1282 |
+
|
| 1283 |
+
@app.delete("/api/chats/{chat_id}")
|
| 1284 |
+
async def delete_chat(chat_id: str, session: Dict[str, Any] = Depends(require_session)):
|
| 1285 |
+
await DB.execute("DELETE FROM messages WHERE chat_id=?", (chat_id,))
|
| 1286 |
+
await DB.execute("DELETE FROM chats WHERE id=?", (chat_id,))
|
| 1287 |
+
return {"ok": True}
|
| 1288 |
+
|
| 1289 |
+
|
| 1290 |
+
@app.patch("/api/chats/{chat_id}")
|
| 1291 |
+
async def rename_chat(chat_id: str, body: ChatCreateIn, session: Dict[str, Any] = Depends(require_session)):
|
| 1292 |
+
await DB.execute(
|
| 1293 |
+
"UPDATE chats SET title=?, updated_at=datetime('now') WHERE id=?",
|
| 1294 |
+
(body.title.strip() or "New chat", chat_id),
|
| 1295 |
+
)
|
| 1296 |
+
return {"ok": True}
|
| 1297 |
+
|
| 1298 |
+
|
| 1299 |
+
# ---------------------------------------------------------------------------
|
| 1300 |
+
# Memory API (Turso-backed long-term memory)
|
| 1301 |
+
# ---------------------------------------------------------------------------
|
| 1302 |
+
class MemoryIn(BaseModel):
|
| 1303 |
+
key: str = Field(..., min_length=1, max_length=120)
|
| 1304 |
+
value: str = Field(..., min_length=1, max_length=4000)
|
| 1305 |
+
|
| 1306 |
+
|
| 1307 |
+
@app.get("/api/memory")
|
| 1308 |
+
async def list_memory(session: Dict[str, Any] = Depends(require_session)):
|
| 1309 |
+
return await DB.query("SELECT id, key, value, updated_at FROM memories ORDER BY updated_at DESC LIMIT 100")
|
| 1310 |
+
|
| 1311 |
+
|
| 1312 |
+
@app.post("/api/memory")
|
| 1313 |
+
async def upsert_memory(body: MemoryIn, session: Dict[str, Any] = Depends(require_session)):
|
| 1314 |
+
await DB.execute(
|
| 1315 |
+
"INSERT INTO memories(id, key, value, updated_at) VALUES(?,?,?,datetime('now')) "
|
| 1316 |
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
|
| 1317 |
+
(new_id(), body.key.strip(), body.value.strip()),
|
| 1318 |
+
)
|
| 1319 |
+
return {"ok": True}
|
| 1320 |
+
|
| 1321 |
+
|
| 1322 |
+
@app.delete("/api/memory/{key}")
|
| 1323 |
+
async def delete_memory(key: str, session: Dict[str, Any] = Depends(require_session)):
|
| 1324 |
+
await DB.execute("DELETE FROM memories WHERE key=?", (key,))
|
| 1325 |
+
return {"ok": True}
|
| 1326 |
+
|
| 1327 |
+
|
| 1328 |
+
# ---------------------------------------------------------------------------
|
| 1329 |
+
# Logs API + AI diagnostics + self-healing patch
|
| 1330 |
+
# ---------------------------------------------------------------------------
|
| 1331 |
+
@app.get("/api/logs")
|
| 1332 |
+
async def get_logs(
|
| 1333 |
+
limit: int = Query(300, ge=1, le=2000),
|
| 1334 |
+
level: str = Query("", max_length=10),
|
| 1335 |
+
session: Dict[str, Any] = Depends(require_session),
|
| 1336 |
+
):
|
| 1337 |
+
entries = list(LOG_BUFFER)
|
| 1338 |
+
if level:
|
| 1339 |
+
lv = level.upper()
|
| 1340 |
+
entries = [e for e in entries if e["level"] == lv]
|
| 1341 |
+
return entries[-limit:]
|
| 1342 |
+
|
| 1343 |
+
|
| 1344 |
+
class LogAnalyzeIn(BaseModel):
|
| 1345 |
+
focus: str = Field("", max_length=1000)
|
| 1346 |
+
|
| 1347 |
+
|
| 1348 |
+
@app.post("/api/logs/analyze")
|
| 1349 |
+
async def analyze_logs(body: LogAnalyzeIn, session: Dict[str, Any] = Depends(require_session)):
|
| 1350 |
+
entries = [e for e in LOG_BUFFER if e["level"] in ("ERROR", "WARNING", "CRITICAL")][-80:]
|
| 1351 |
+
if not entries:
|
| 1352 |
+
entries = list(LOG_BUFFER)[-40:]
|
| 1353 |
+
blob = "\n".join(f"[{e['ts']}] {e['level']} {e['logger']}: {e['message']}" for e in entries)
|
| 1354 |
+
analysis = await LLM.acomplete(
|
| 1355 |
+
[
|
| 1356 |
+
{"role": "system", "content":
|
| 1357 |
+
"You are the NEXUS self-healing diagnostics agent. Analyze the system logs "
|
| 1358 |
+
"below. Identify root causes, then propose concrete fixes (Python, Docker, "
|
| 1359 |
+
"or configuration). If a fix requires rewriting a file, output it in a fenced "
|
| 1360 |
+
"code block whose first line is `# path: <absolute path>`. Be surgical."
|
| 1361 |
+
+ (f"\nUser focus: {body.focus}" if body.focus else "")},
|
| 1362 |
+
{"role": "user", "content": blob[:20000]},
|
| 1363 |
+
],
|
| 1364 |
+
{"max_tokens": 1500, "temperature": 0.3, "model": CFG.MODEL_NAME},
|
| 1365 |
+
)
|
| 1366 |
+
return {"analyzed_entries": len(entries), "analysis": analysis}
|
| 1367 |
+
|
| 1368 |
+
|
| 1369 |
+
class PatchIn(BaseModel):
|
| 1370 |
+
path: str = Field(..., min_length=1, max_length=500)
|
| 1371 |
+
content: str = Field(..., max_length=400000)
|
| 1372 |
+
confirm: bool = False
|
| 1373 |
+
|
| 1374 |
+
|
| 1375 |
+
ALLOWED_PATCH_ROOTS = (APP_ROOT, SANDBOX_DIR)
|
| 1376 |
+
|
| 1377 |
+
|
| 1378 |
+
def _resolve_jailed(path: str, roots=ALLOWED_PATCH_ROOTS) -> Path:
|
| 1379 |
+
p = Path(path).resolve()
|
| 1380 |
+
if not any(str(p).startswith(str(r)) for r in roots):
|
| 1381 |
+
raise HTTPException(status_code=403, detail="path outside allowed roots")
|
| 1382 |
+
return p
|
| 1383 |
+
|
| 1384 |
+
|
| 1385 |
+
@app.post("/api/logs/apply-patch")
|
| 1386 |
+
async def apply_patch(body: PatchIn, session: Dict[str, Any] = Depends(require_session)):
|
| 1387 |
+
if not body.confirm:
|
| 1388 |
+
raise HTTPException(status_code=400, detail="set confirm=true to apply the patch")
|
| 1389 |
+
target = _resolve_jailed(body.path)
|
| 1390 |
+
if not target.exists():
|
| 1391 |
+
raise HTTPException(status_code=404, detail="target file does not exist")
|
| 1392 |
+
backup = target.with_suffix(target.suffix + f".bak.{int(time.time())}")
|
| 1393 |
+
shutil.copy2(target, backup)
|
| 1394 |
+
target.write_text(body.content, encoding="utf-8")
|
| 1395 |
+
log.warning("SELF-HEAL patch applied to %s (backup: %s)", target, backup)
|
| 1396 |
+
return {"ok": True, "patched": str(target), "backup": str(backup)}
|
| 1397 |
+
|
| 1398 |
+
|
| 1399 |
+
@app.get("/api/logs/read-file")
|
| 1400 |
+
async def read_file_for_patch(path: str, session: Dict[str, Any] = Depends(require_session)):
|
| 1401 |
+
target = _resolve_jailed(path)
|
| 1402 |
+
if not target.is_file():
|
| 1403 |
+
raise HTTPException(status_code=404, detail="not a file")
|
| 1404 |
+
return {"path": str(target), "content": target.read_text(encoding="utf-8", errors="replace")[:400000]}
|
| 1405 |
+
|
| 1406 |
+
|
| 1407 |
+
# ---------------------------------------------------------------------------
|
| 1408 |
+
# Sandbox file manager + uploads (vision / OCR / PDF / code parsing)
|
| 1409 |
+
# ---------------------------------------------------------------------------
|
| 1410 |
+
TEXT_EXTS = {".py", ".js", ".ts", ".html", ".css", ".json", ".md", ".txt", ".sql",
|
| 1411 |
+
".csv", ".log", ".sh", ".yml", ".yaml", ".toml", ".env", ".cfg", ".xml"}
|
| 1412 |
+
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
|
| 1413 |
+
|
| 1414 |
+
|
| 1415 |
+
@app.get("/api/sandbox/tree")
|
| 1416 |
+
async def sandbox_tree(session: Dict[str, Any] = Depends(require_session)):
|
| 1417 |
+
tree: List[Dict[str, Any]] = []
|
| 1418 |
+
for p in sorted(SANDBOX_DIR.rglob("*")):
|
| 1419 |
+
if p.is_file() and len(tree) < 500:
|
| 1420 |
+
tree.append({
|
| 1421 |
+
"path": str(p.relative_to(SANDBOX_DIR)),
|
| 1422 |
+
"size": p.stat().st_size,
|
| 1423 |
+
"modified": datetime.fromtimestamp(p.stat().st_mtime, timezone.utc).isoformat(timespec="seconds"),
|
| 1424 |
+
})
|
| 1425 |
+
return tree
|
| 1426 |
+
|
| 1427 |
+
|
| 1428 |
+
class SandboxFileIn(BaseModel):
|
| 1429 |
+
path: str = Field(..., min_length=1, max_length=300)
|
| 1430 |
+
content: str = Field("", max_length=400000)
|
| 1431 |
+
|
| 1432 |
+
|
| 1433 |
+
@app.get("/api/sandbox/file")
|
| 1434 |
+
async def sandbox_read(path: str, session: Dict[str, Any] = Depends(require_session)):
|
| 1435 |
+
target = _resolve_jailed(str(SANDBOX_DIR / path), roots=(SANDBOX_DIR,))
|
| 1436 |
+
if not target.is_file():
|
| 1437 |
+
raise HTTPException(status_code=404, detail="not a file")
|
| 1438 |
+
return {"path": path, "content": target.read_text(encoding="utf-8", errors="replace")[:400000]}
|
| 1439 |
+
|
| 1440 |
+
|
| 1441 |
+
@app.post("/api/sandbox/file")
|
| 1442 |
+
async def sandbox_write(body: SandboxFileIn, session: Dict[str, Any] = Depends(require_session)):
|
| 1443 |
+
target = _resolve_jailed(str(SANDBOX_DIR / body.path), roots=(SANDBOX_DIR,))
|
| 1444 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 1445 |
+
target.write_text(body.content, encoding="utf-8")
|
| 1446 |
+
log.info("sandbox file written: %s (%d bytes)", target, len(body.content))
|
| 1447 |
+
return {"ok": True, "path": str(target.relative_to(SANDBOX_DIR))}
|
| 1448 |
+
|
| 1449 |
+
|
| 1450 |
+
@app.post("/api/upload")
|
| 1451 |
+
async def upload_file(file: UploadFile = File(...), session: Dict[str, Any] = Depends(require_session)):
|
| 1452 |
+
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file.filename or "upload.bin")[:120]
|
| 1453 |
+
dest = UPLOAD_DIR / f"{int(time.time())}_{safe_name}"
|
| 1454 |
+
data = await file.read()
|
| 1455 |
+
if len(data) > 40 * 1024 * 1024:
|
| 1456 |
+
raise HTTPException(status_code=413, detail="file too large (40 MB max)")
|
| 1457 |
+
dest.write_bytes(data)
|
| 1458 |
+
ext = dest.suffix.lower()
|
| 1459 |
+
kind, preview = "binary", ""
|
| 1460 |
+
|
| 1461 |
+
try:
|
| 1462 |
+
if ext in TEXT_EXTS:
|
| 1463 |
+
kind = "text"
|
| 1464 |
+
preview = data.decode("utf-8", "replace")[:30000]
|
| 1465 |
+
elif ext == ".pdf" and PdfReader is not None:
|
| 1466 |
+
kind = "pdf"
|
| 1467 |
+
reader = PdfReader(str(dest))
|
| 1468 |
+
preview = "\n".join((pg.extract_text() or "") for pg in reader.pages[:20])[:30000]
|
| 1469 |
+
elif ext in IMG_EXTS:
|
| 1470 |
+
kind = "image"
|
| 1471 |
+
if pytesseract is not None and Image is not None:
|
| 1472 |
+
try:
|
| 1473 |
+
preview = pytesseract.image_to_string(Image.open(dest))[:10000]
|
| 1474 |
+
kind = "image+ocr"
|
| 1475 |
+
except Exception:
|
| 1476 |
+
preview = ""
|
| 1477 |
+
except Exception as exc:
|
| 1478 |
+
log.warning("upload parse failed (%s): %s", safe_name, exc)
|
| 1479 |
+
|
| 1480 |
+
log.info("upload: %s kind=%s size=%d", safe_name, kind, len(data))
|
| 1481 |
+
return {
|
| 1482 |
+
"ok": True, "filename": safe_name, "kind": kind,
|
| 1483 |
+
"size": len(data), "stored": str(dest.relative_to(SANDBOX_DIR)),
|
| 1484 |
+
"preview": preview,
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
|
| 1488 |
+
# ---------------------------------------------------------------------------
|
| 1489 |
+
# Metrics snapshot + broadcaster
|
| 1490 |
+
# ---------------------------------------------------------------------------
|
| 1491 |
+
def _gpu_stats() -> List[Dict[str, Any]]:
|
| 1492 |
+
if GPUtil is None:
|
| 1493 |
+
return []
|
| 1494 |
+
try:
|
| 1495 |
+
return [
|
| 1496 |
+
{
|
| 1497 |
+
"name": g.name, "load": round(g.load * 100, 1),
|
| 1498 |
+
"mem_used_mb": round(g.memoryUsed, 1), "mem_total_mb": round(g.memoryTotal, 1),
|
| 1499 |
+
"mem_percent": round(g.memoryUtil * 100, 1),
|
| 1500 |
+
"temperature": round(g.temperature, 1),
|
| 1501 |
+
}
|
| 1502 |
+
for g in GPUtil.getGPUs()
|
| 1503 |
+
]
|
| 1504 |
+
except Exception:
|
| 1505 |
+
return []
|
| 1506 |
+
|
| 1507 |
+
|
| 1508 |
+
async def metrics_snapshot() -> Dict[str, Any]:
|
| 1509 |
+
vm = psutil.virtual_memory()
|
| 1510 |
+
proc = psutil.Process()
|
| 1511 |
+
return {
|
| 1512 |
+
"ts": utcnow(),
|
| 1513 |
+
"cpu_percent": psutil.cpu_percent(interval=None),
|
| 1514 |
+
"cpu_count": psutil.cpu_count(logical=True),
|
| 1515 |
+
"ram": {"total_gb": round(vm.total / 1e9, 1), "used_gb": round(vm.used / 1e9, 1),
|
| 1516 |
+
"percent": vm.percent},
|
| 1517 |
+
"gpu": _gpu_stats(),
|
| 1518 |
+
"threads": proc.num_threads(),
|
| 1519 |
+
"proc_mem_mb": round(proc.memory_info().rss / 1e6, 1),
|
| 1520 |
+
"tokens_per_sec": round(METER.rate(), 1),
|
| 1521 |
+
"total_tokens": METER.total,
|
| 1522 |
+
"uptime_s": round(time.time() - APP_START_TS, 0),
|
| 1523 |
+
"vllm": await LLM.healthy(),
|
| 1524 |
+
"db": DB.backend,
|
| 1525 |
+
"mock_mode": CFG.MOCK_MODE,
|
| 1526 |
+
"model": CFG.MODEL_NAME,
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
+
|
| 1530 |
+
@app.get("/api/metrics")
|
| 1531 |
+
async def metrics_http(session: Dict[str, Any] = Depends(require_session)):
|
| 1532 |
+
return await metrics_snapshot()
|
| 1533 |
+
|
| 1534 |
+
|
| 1535 |
+
async def metrics_broadcaster() -> None:
|
| 1536 |
+
psutil.cpu_percent(interval=None) # prime the counter
|
| 1537 |
+
while True:
|
| 1538 |
+
try:
|
| 1539 |
+
if METRICS_CLIENTS:
|
| 1540 |
+
snap = await metrics_snapshot()
|
| 1541 |
+
msg = json.dumps(snap)
|
| 1542 |
+
dead = []
|
| 1543 |
+
for ws in list(METRICS_CLIENTS):
|
| 1544 |
+
try:
|
| 1545 |
+
await ws.send_text(msg)
|
| 1546 |
+
except Exception:
|
| 1547 |
+
dead.append(ws)
|
| 1548 |
+
for ws in dead:
|
| 1549 |
+
METRICS_CLIENTS.discard(ws)
|
| 1550 |
+
except Exception as exc:
|
| 1551 |
+
log.warning("metrics broadcast error: %s", exc)
|
| 1552 |
+
await asyncio.sleep(2)
|
| 1553 |
+
|
| 1554 |
+
|
| 1555 |
+
@app.websocket("/ws/metrics")
|
| 1556 |
+
async def ws_metrics(ws: WebSocket):
|
| 1557 |
+
if not ws_session(ws):
|
| 1558 |
+
await ws.close(code=4401)
|
| 1559 |
+
return
|
| 1560 |
+
await ws.accept()
|
| 1561 |
+
METRICS_CLIENTS.add(ws)
|
| 1562 |
+
try:
|
| 1563 |
+
await ws.send_text(json.dumps(await metrics_snapshot()))
|
| 1564 |
+
while True:
|
| 1565 |
+
await ws.receive_text() # keepalive pings from the client
|
| 1566 |
+
except WebSocketDisconnect:
|
| 1567 |
+
pass
|
| 1568 |
+
except Exception:
|
| 1569 |
+
pass
|
| 1570 |
+
finally:
|
| 1571 |
+
METRICS_CLIENTS.discard(ws)
|
| 1572 |
+
|
| 1573 |
+
# ---------------------------------------------------------------------------
|
| 1574 |
+
# Chat WebSocket — one connection per generation, JSON event protocol
|
| 1575 |
+
# ---------------------------------------------------------------------------
|
| 1576 |
+
async def _persist_chat_turn(chat_id: str, mode: str, user_text: str,
|
| 1577 |
+
assistant_text: str, title_hint: str) -> None:
|
| 1578 |
+
try:
|
| 1579 |
+
exists = await DB.query_one("SELECT id, title FROM chats WHERE id=?", (chat_id,))
|
| 1580 |
+
if not exists:
|
| 1581 |
+
await DB.execute(
|
| 1582 |
+
"INSERT INTO chats(id, user_id, title, mode) VALUES(?,?,?,?)",
|
| 1583 |
+
(chat_id, "nexus-admin", (title_hint[:80] or "New chat"), mode),
|
| 1584 |
+
)
|
| 1585 |
+
elif exists.get("title") == "New chat" and title_hint:
|
| 1586 |
+
await DB.execute("UPDATE chats SET title=? WHERE id=?", (title_hint[:80], chat_id))
|
| 1587 |
+
await DB.execute(
|
| 1588 |
+
"INSERT INTO messages(id, chat_id, role, content, mode, tokens) VALUES(?,?,?,?,?,?)",
|
| 1589 |
+
(new_id(), chat_id, "user", user_text, mode, approx_tokens(user_text)),
|
| 1590 |
+
)
|
| 1591 |
+
await DB.execute(
|
| 1592 |
+
"INSERT INTO messages(id, chat_id, role, content, mode, tokens) VALUES(?,?,?,?,?,?)",
|
| 1593 |
+
(new_id(), chat_id, "assistant", assistant_text, mode, approx_tokens(assistant_text)),
|
| 1594 |
+
)
|
| 1595 |
+
await DB.execute("UPDATE chats SET updated_at=datetime('now'), mode=? WHERE id=?",
|
| 1596 |
+
(mode, chat_id))
|
| 1597 |
+
except Exception as exc:
|
| 1598 |
+
log.warning("chat persist failed: %s", exc)
|
| 1599 |
+
|
| 1600 |
+
|
| 1601 |
+
@app.websocket("/ws/chat")
|
| 1602 |
+
async def ws_chat(ws: WebSocket):
|
| 1603 |
+
session = ws_session(ws)
|
| 1604 |
+
if not session:
|
| 1605 |
+
await ws.close(code=4401)
|
| 1606 |
+
return
|
| 1607 |
+
await ws.accept()
|
| 1608 |
+
log.info("chat ws connected (%s)", session.get("sub"))
|
| 1609 |
+
|
| 1610 |
+
try:
|
| 1611 |
+
raw = await ws.receive_text()
|
| 1612 |
+
payload = json.loads(raw)
|
| 1613 |
+
mode = str(payload.get("mode", "flash")).lower()
|
| 1614 |
+
s: Dict[str, Any] = payload.get("settings") or {}
|
| 1615 |
+
profile = str(s.get("safety_profile", "balanced"))
|
| 1616 |
+
|
| 1617 |
+
# ---- Safety gate on the incoming user turn --------------------------
|
| 1618 |
+
user_text = ""
|
| 1619 |
+
for m in reversed(payload.get("messages", [])):
|
| 1620 |
+
if m.get("role") == "user":
|
| 1621 |
+
user_text = str(m.get("content", ""))
|
| 1622 |
+
break
|
| 1623 |
+
ok_in, violations, sanitized = SAFETY.scan(user_text, profile)
|
| 1624 |
+
if not ok_in:
|
| 1625 |
+
await ws_send(ws, {"type": "error",
|
| 1626 |
+
"content": f"🛡️ Blocked by the **{profile}** safety profile: "
|
| 1627 |
+
f"{', '.join(violations)}"})
|
| 1628 |
+
await ws_send(ws, {"type": "done", "chat_id": None, "usage": {}})
|
| 1629 |
+
await ws.close()
|
| 1630 |
+
return
|
| 1631 |
+
if sanitized != user_text:
|
| 1632 |
+
for m in payload.get("messages", []):
|
| 1633 |
+
if m.get("role") == "user" and m.get("content") == user_text:
|
| 1634 |
+
m["content"] = sanitized
|
| 1635 |
+
await ws_send(ws, {"type": "status", "content": "PII redacted by safety profile."})
|
| 1636 |
+
|
| 1637 |
+
await ws_send(ws, {"type": "status", "content": f"Engine `{mode}` engaged…"})
|
| 1638 |
+
|
| 1639 |
+
# ---- Route to the selected engine ------------------------------------
|
| 1640 |
+
sources: List[Dict[str, str]] = []
|
| 1641 |
+
if mode == "max":
|
| 1642 |
+
answer, sources = await engine_max(ws, payload, s)
|
| 1643 |
+
elif mode == "agents":
|
| 1644 |
+
answer = await engine_agents(ws, payload, s)
|
| 1645 |
+
else:
|
| 1646 |
+
mode = "flash"
|
| 1647 |
+
answer = await engine_flash(ws, payload, s)
|
| 1648 |
+
|
| 1649 |
+
# ---- Safety gate on the outgoing answer --------------------------------
|
| 1650 |
+
ok_out, out_violations, clean_answer = SAFETY.scan(answer, profile)
|
| 1651 |
+
if not ok_out:
|
| 1652 |
+
await ws_send(ws, {"type": "replace",
|
| 1653 |
+
"content": f"🛡️ The generated answer was withheld by the "
|
| 1654 |
+
f"**{profile}** safety profile ({', '.join(out_violations)})."})
|
| 1655 |
+
answer = ""
|
| 1656 |
+
elif clean_answer != answer:
|
| 1657 |
+
answer = clean_answer
|
| 1658 |
+
await ws_send(ws, {"type": "replace", "content": answer})
|
| 1659 |
+
|
| 1660 |
+
# ---- Persist + finish ------------------------------------------------------
|
| 1661 |
+
chat_id = str(payload.get("chat_id") or new_id())
|
| 1662 |
+
await _persist_chat_turn(chat_id, mode, user_text, answer, user_text[:80])
|
| 1663 |
+
await ws_send(ws, {
|
| 1664 |
+
"type": "done",
|
| 1665 |
+
"chat_id": chat_id,
|
| 1666 |
+
"mode": mode,
|
| 1667 |
+
"citations": sources,
|
| 1668 |
+
"usage": {
|
| 1669 |
+
"completion_tokens_est": approx_tokens(answer),
|
| 1670 |
+
"session_total_tokens": METER.total,
|
| 1671 |
+
"tokens_per_sec": round(METER.rate(), 1),
|
| 1672 |
+
},
|
| 1673 |
+
})
|
| 1674 |
+
except WebSocketDisconnect:
|
| 1675 |
+
log.info("chat ws disconnected mid-generation")
|
| 1676 |
+
except json.JSONDecodeError:
|
| 1677 |
+
await ws_send(ws, {"type": "error", "content": "Malformed payload (expected JSON)."})
|
| 1678 |
+
except Exception as exc:
|
| 1679 |
+
log.error("chat ws error: %s\n%s", exc, traceback.format_exc())
|
| 1680 |
+
await ws_send(ws, {"type": "error", "content": f"Internal error: {exc}"})
|
| 1681 |
+
finally:
|
| 1682 |
+
try:
|
| 1683 |
+
await ws.close()
|
| 1684 |
+
except Exception:
|
| 1685 |
+
pass
|
| 1686 |
+
|
| 1687 |
+
|
| 1688 |
+
# ---------------------------------------------------------------------------
|
| 1689 |
+
# Terminal WebSocket — jailed bash emulator
|
| 1690 |
+
# ---------------------------------------------------------------------------
|
| 1691 |
+
TERMINAL_BLOCKLIST = re.compile(
|
| 1692 |
+
r"(rm\s+-[rf]{1,2}\s+/(?:\s|$)|\bmkfs\b|\bdd\s+if=|\bshutdown\b|\breboot\b"
|
| 1693 |
+
r"|\bhalt\b|\bpoweroff\b|:\(\)\s*\{|\bfork\b\s*\|\s*\bfork\b"
|
| 1694 |
+
r"|>\s*/dev/sd|chmod\s+-R\s+777\s+/)",
|
| 1695 |
+
re.IGNORECASE,
|
| 1696 |
+
)
|
| 1697 |
+
|
| 1698 |
+
SECRET_ENV_KEYS = ("TURSO_AUTH_TOKEN", "ADMIN_API_KEY", "JWT_SECRET", "HF_TOKEN")
|
| 1699 |
+
|
| 1700 |
+
|
| 1701 |
+
def _terminal_env() -> Dict[str, str]:
|
| 1702 |
+
env = dict(os.environ)
|
| 1703 |
+
for k in list(env):
|
| 1704 |
+
if any(sec in k for sec in SECRET_ENV_KEYS):
|
| 1705 |
+
env.pop(k, None)
|
| 1706 |
+
env["PS1"] = ""
|
| 1707 |
+
env["TERM"] = "xterm-256color"
|
| 1708 |
+
return env
|
| 1709 |
+
|
| 1710 |
+
|
| 1711 |
+
def _clamp_cwd(path: Path) -> Path:
|
| 1712 |
+
try:
|
| 1713 |
+
resolved = path.resolve()
|
| 1714 |
+
resolved.relative_to(SANDBOX_DIR)
|
| 1715 |
+
return resolved
|
| 1716 |
+
except Exception:
|
| 1717 |
+
return SANDBOX_DIR
|
| 1718 |
+
|
| 1719 |
+
|
| 1720 |
+
@app.websocket("/ws/terminal")
|
| 1721 |
+
async def ws_terminal(ws: WebSocket):
|
| 1722 |
+
if not ws_session(ws):
|
| 1723 |
+
await ws.close(code=4401)
|
| 1724 |
+
return
|
| 1725 |
+
await ws.accept()
|
| 1726 |
+
cwd = SANDBOX_DIR
|
| 1727 |
+
env = _terminal_env()
|
| 1728 |
+
await ws_send(ws, {"type": "banner",
|
| 1729 |
+
"content": f"NEXUS secure shell — jailed to {SANDBOX_DIR}\n"
|
| 1730 |
+
f"Type commands. `cd` is supported. Ctrl-D or `exit` closes.\r\n"})
|
| 1731 |
+
try:
|
| 1732 |
+
while True:
|
| 1733 |
+
data = json.loads(await ws.receive_text())
|
| 1734 |
+
cmd = str(data.get("cmd", "")).rstrip()
|
| 1735 |
+
if not cmd:
|
| 1736 |
+
continue
|
| 1737 |
+
if cmd.strip() in ("exit", "logout"):
|
| 1738 |
+
await ws_send(ws, {"type": "exit", "code": 0})
|
| 1739 |
+
break
|
| 1740 |
+
if TERMINAL_BLOCKLIST.search(cmd):
|
| 1741 |
+
await ws_send(ws, {"type": "output",
|
| 1742 |
+
"data": f"\r\n\x1b[31m[shield] command blocked for safety: {cmd}\x1b[0m\r\n"})
|
| 1743 |
+
await ws_send(ws, {"type": "exit", "code": 126, "cwd": str(cwd)})
|
| 1744 |
+
continue
|
| 1745 |
+
|
| 1746 |
+
# ---- built-in cd (persistent per connection) -----------------------
|
| 1747 |
+
m = re.match(r"^\s*cd(?:\s+(.*))?$", cmd)
|
| 1748 |
+
if m:
|
| 1749 |
+
target = (m.group(1) or "").strip().strip('"').strip("'")
|
| 1750 |
+
new_cwd = _clamp_cwd((cwd / target) if target else SANDBOX_DIR)
|
| 1751 |
+
if new_cwd.is_dir():
|
| 1752 |
+
cwd = new_cwd
|
| 1753 |
+
await ws_send(ws, {"type": "exit", "code": 0, "cwd": str(cwd)})
|
| 1754 |
+
else:
|
| 1755 |
+
await ws_send(ws, {"type": "output",
|
| 1756 |
+
"data": f"\r\ncd: no such directory: {target}\r\n"})
|
| 1757 |
+
await ws_send(ws, {"type": "exit", "code": 1, "cwd": str(cwd)})
|
| 1758 |
+
continue
|
| 1759 |
+
|
| 1760 |
+
# ---- execute ----------------------------------------------------------
|
| 1761 |
+
log.info("terminal exec: %s (cwd=%s)", cmd[:200], cwd)
|
| 1762 |
+
try:
|
| 1763 |
+
proc = await asyncio.create_subprocess_shell(
|
| 1764 |
+
cmd,
|
| 1765 |
+
stdout=asyncio.subprocess.PIPE,
|
| 1766 |
+
stderr=asyncio.subprocess.STDOUT,
|
| 1767 |
+
cwd=str(cwd),
|
| 1768 |
+
env=env,
|
| 1769 |
+
limit=CFG.TERMINAL_MAX_OUTPUT,
|
| 1770 |
+
)
|
| 1771 |
+
except Exception as exc:
|
| 1772 |
+
await ws_send(ws, {"type": "output", "data": f"\r\nspawn failed: {exc}\r\n"})
|
| 1773 |
+
await ws_send(ws, {"type": "exit", "code": 127, "cwd": str(cwd)})
|
| 1774 |
+
continue
|
| 1775 |
+
|
| 1776 |
+
produced = 0
|
| 1777 |
+
timed_out = False
|
| 1778 |
+
start = time.time()
|
| 1779 |
+
assert proc.stdout is not None
|
| 1780 |
+
while True:
|
| 1781 |
+
if time.time() - start > CFG.TERMINAL_TIMEOUT_S:
|
| 1782 |
+
timed_out = True
|
| 1783 |
+
try:
|
| 1784 |
+
proc.kill()
|
| 1785 |
+
except Exception:
|
| 1786 |
+
pass
|
| 1787 |
+
break
|
| 1788 |
+
try:
|
| 1789 |
+
chunk = await asyncio.wait_for(proc.stdout.read(4096), timeout=1.0)
|
| 1790 |
+
except asyncio.TimeoutError:
|
| 1791 |
+
if proc.returncode is not None:
|
| 1792 |
+
break
|
| 1793 |
+
continue
|
| 1794 |
+
if not chunk:
|
| 1795 |
+
break
|
| 1796 |
+
produced += len(chunk)
|
| 1797 |
+
if produced > CFG.TERMINAL_MAX_OUTPUT:
|
| 1798 |
+
await ws_send(ws, {"type": "output",
|
| 1799 |
+
"data": "\r\n\x1b[33m[truncated: output limit reached]\x1b[0m\r\n"})
|
| 1800 |
+
try:
|
| 1801 |
+
proc.kill()
|
| 1802 |
+
except Exception:
|
| 1803 |
+
pass
|
| 1804 |
+
break
|
| 1805 |
+
await ws_send(ws, {"type": "output",
|
| 1806 |
+
"data": chunk.decode("utf-8", "replace")})
|
| 1807 |
+
try:
|
| 1808 |
+
code = await asyncio.wait_for(proc.wait(), timeout=5)
|
| 1809 |
+
except Exception:
|
| 1810 |
+
code = proc.returncode if proc.returncode is not None else -9
|
| 1811 |
+
if timed_out:
|
| 1812 |
+
await ws_send(ws, {"type": "output",
|
| 1813 |
+
"data": f"\r\n\x1b[33m[killed: timeout {CFG.TERMINAL_TIMEOUT_S}s]\x1b[0m\r\n"})
|
| 1814 |
+
code = 124
|
| 1815 |
+
await ws_send(ws, {"type": "exit", "code": code, "cwd": str(cwd)})
|
| 1816 |
+
except WebSocketDisconnect:
|
| 1817 |
+
pass
|
| 1818 |
+
except Exception as exc:
|
| 1819 |
+
log.error("terminal ws error: %s", exc)
|
| 1820 |
+
finally:
|
| 1821 |
+
try:
|
| 1822 |
+
await ws.close()
|
| 1823 |
+
except Exception:
|
| 1824 |
+
pass
|
| 1825 |
+
|
| 1826 |
+
|
| 1827 |
+
# ---------------------------------------------------------------------------
|
| 1828 |
+
# Startup / shutdown
|
| 1829 |
+
# ---------------------------------------------------------------------------
|
| 1830 |
+
@app.on_event("startup")
|
| 1831 |
+
async def on_startup() -> None:
|
| 1832 |
+
log.info("=" * 70)
|
| 1833 |
+
log.info(" %s v%s booting…", CFG.APP_NAME, CFG.VERSION)
|
| 1834 |
+
log.info(" model=%s mock=%s vllm=%s", CFG.MODEL_NAME, CFG.MOCK_MODE, CFG.VLLM_URL)
|
| 1835 |
+
if _AUTO_KEY:
|
| 1836 |
+
log.warning("!" * 70)
|
| 1837 |
+
log.warning("ADMIN_API_KEY was NOT set — generated an ephemeral master key:")
|
| 1838 |
+
log.warning(" >>> %s <<<", CFG.ADMIN_API_KEY)
|
| 1839 |
+
log.warning("Set the ADMIN_API_KEY secret for a stable credential.")
|
| 1840 |
+
log.warning("!" * 70)
|
| 1841 |
+
await DB.connect()
|
| 1842 |
+
asyncio.create_task(metrics_broadcaster())
|
| 1843 |
+
log.info("startup complete — serving on port %d", CFG.PORT)
|
| 1844 |
+
|
| 1845 |
+
|
| 1846 |
+
@app.on_event("shutdown")
|
| 1847 |
+
async def on_shutdown() -> None:
|
| 1848 |
+
log.info("shutting down…")
|
| 1849 |
+
DB.close()
|
| 1850 |
+
|
| 1851 |
+
|
| 1852 |
+
if __name__ == "__main__":
|
| 1853 |
+
import uvicorn
|
| 1854 |
+
uvicorn.run("main:app", host="0.0.0.0", port=CFG.PORT, log_level="info")
|
package-lock.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "app",
|
| 3 |
+
"lockfileVersion": 3,
|
| 4 |
+
"requires": true,
|
| 5 |
+
"packages": {}
|
| 6 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# NEXUS AI — Python dependencies
|
| 3 |
+
# ============================================================================
|
| 4 |
+
|
| 5 |
+
# ---- Web framework / ASGI server ------------------------------------------
|
| 6 |
+
fastapi>=0.110.0,<1.0.0
|
| 7 |
+
uvicorn[standard]>=0.29.0
|
| 8 |
+
websockets>=12.0
|
| 9 |
+
python-multipart>=0.0.9 # file uploads (vision / code parsing)
|
| 10 |
+
|
| 11 |
+
# ---- Local LLM inference (OpenAI-compatible server, A100 optimized) -------
|
| 12 |
+
vllm>=0.6.3,<0.8.0 ; platform_system == "Linux"
|
| 13 |
+
|
| 14 |
+
# ---- Turso / libSQL database (falls back to local file when unset) --------
|
| 15 |
+
libsql-client>=0.3.1
|
| 16 |
+
|
| 17 |
+
# ---- Security --------------------------------------------------------------
|
| 18 |
+
pyjwt>=2.8.0
|
| 19 |
+
cryptography>=42.0.0
|
| 20 |
+
|
| 21 |
+
# ---- System monitoring -----------------------------------------------------
|
| 22 |
+
psutil>=5.9.8
|
| 23 |
+
GPUtil>=1.4.0
|
| 24 |
+
|
| 25 |
+
# ---- Web research (Max engine) --------------------------------------------
|
| 26 |
+
requests>=2.31.0
|
| 27 |
+
beautifulsoup4>=4.12.3
|
| 28 |
+
lxml>=5.1.0
|
| 29 |
+
|
| 30 |
+
# ---- File understanding (uploads) -----------------------------------------
|
| 31 |
+
pypdf>=4.2.0
|
| 32 |
+
pytesseract>=0.3.10
|
| 33 |
+
Pillow>=10.2.0
|
| 34 |
+
|
| 35 |
+
# ---- Validation ------------------------------------------------------------
|
| 36 |
+
pydantic>=2.6.0
|