diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 16a25bb5..33379171 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -10,9 +10,9 @@ When working on this project, always refer to the dedicated **NotebookLM Compani - Local model benchmark metrics and `llama-server` configurations ### Notebook Details -- **Notebook Name:** `TriageGate-Architect-KB` +- **Notebook Name:** `LLM-Routing-KB` - **Notebook ID:** `llm-triage-gateway` -- **Notebook URL:** [TriageGate-Architect-KB](https://notebooklm.google.com/notebook/826cbd87-7969-4b0e-a38e-5517b5ab7d28) +- **Notebook URL:** [LLM-Routing-KB](https://notebooklm.google.com/notebook/826cbd87-7969-4b0e-a38e-5517b5ab7d28) ### How to Query Use the `notebooklm` MCP tools to search or ask questions about this codebase and stack: @@ -26,3 +26,59 @@ To prevent directory reorganization regressions, outdated file restorations, or 2. **Directory Rename Safety**: If Git reports conflicts related to moved directories or files, do not manually stage deletions of tracked files from moved directories (e.g., under the old `tests/` or `scripts/` paths) or re-create files at the root level. Resolve conflicts by directing all changes and file operations to the newly refactored paths. 3. **Verify Security Credentials**: Never accept resolutions that overwrite configuration files (`pod.yaml`, `start-stack.sh`) with hardcoded default passwords. Ensure placeholder-based configurations are preserved. 4. **Enforce Test Suite Count**: Run the full unit test suite (`pytest`) after conflict resolution. Verify that the total number of passing tests is equal to or greater than before the resolution. + +## Production Deployment Checklist (boy user) + +### One-Time Host Prerequisites (already configured on x570.vendeuvre.lan) +- `net.ipv4.ip_unprivileged_port_start=80` persisted in `/etc/sysctl.d/99-unprivileged-ports.conf` +- Host firewall ports `80/tcp` and `443/tcp` opened in `firewalld` (e.g. `sudo firewall-cmd --zone=public --add-port=80/tcp --permanent && sudo firewall-cmd --zone=public --add-port=443/tcp --permanent && sudo firewall-cmd --reload`) +- `boy` SSH host alias in `~/.ssh/config` — use `ssh boy` / `rsync ... boy:` throughout +- Required mount directories created under `boy`'s home: + - `/mnt/DATA/boy/.gemini/` + - `/mnt/DATA/boy/.local/bin/agy` (copy of the `agy` binary) + - `/mnt/DATA/boy/.local/share/goose/` + - `/mnt/DATA/boy/.local/share/keyrings/` +- HAProxy SSL cert: `/mnt/DATA/boy/haproxy/certs/vendeuvre.pem` +- HAProxy config: `/mnt/DATA/boy/haproxy/haproxy.cfg` + +### Fresh Deploy Steps (after a PR is merged to master) +```bash +# 1. Clean up old deploy on boy +ssh boy "rm -rf /mnt/DATA/boy/LLM-Routing" + +# 2. Clone fresh from master +ssh boy "git clone https://github.com/sheepdestroyer/LLM-Routing.git /mnt/DATA/boy/LLM-Routing" + +# 3. Start the full stack (builds and launches all containers) +ssh boy "cd /mnt/DATA/boy/LLM-Routing && ./start-stack.sh --full-rebuild" + +# 4. Start (or restart) production HAProxy +ssh boy "podman rm -f production-haproxy || true" +ssh boy "podman run -d --name production-haproxy --restart always --net host \ + -v /mnt/DATA/boy/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro \ + -v /mnt/DATA/boy/haproxy/certs:/usr/local/etc/haproxy/certs:ro \ + docker.io/library/haproxy:alpine" + +# 5. Start the host-side agy daemon +ssh boy "pkill -f host_agy_daemon.py || true" +ssh boy "nohup python3 /mnt/DATA/boy/LLM-Routing/scripts/host_agy_daemon.py >/tmp/agy-daemon.log 2>&1 None: logger = logging.getLogger("agy-proxy") -# In container: mounted from host /home/gpav/.local/bin/agy +# In container: mounted from host ~/.local/bin/agy AGY_BINARY = os.environ.get("AGY_BINARY_PATH", "/usr/local/bin/agy") if not os.path.exists(AGY_BINARY): AGY_BINARY = os.path.expanduser("~/.local/bin/agy") diff --git a/router/free_models_roster.json b/router/free_models_roster.json index 5f1dfe0c..8a71d4a5 100644 --- a/router/free_models_roster.json +++ b/router/free_models_roster.json @@ -42,6 +42,18 @@ "score": 28.0, "context_length": 256000 }, + { + "id": "tencent/hy3:free", + "name": "Tencent: Hy3 (free)", + "score": 25.0, + "context_length": 262144 + }, + { + "id": "poolside/laguna-xs-2.1:free", + "name": "Poolside: Laguna XS 2.1 (free)", + "score": 25.0, + "context_length": 262144 + }, { "id": "cohere/north-mini-code:free", "name": "Cohere: North Mini Code (free)", @@ -54,12 +66,7 @@ "score": 25.0, "context_length": 128000 }, - { - "id": "openrouter/owl-alpha", - "name": "Owl Alpha", - "score": 25.0, - "context_length": 1048756 - }, + { "id": "google/lyria-3-pro-preview", "name": "Google: Lyria 3 Pro Preview", @@ -139,6 +146,6 @@ "context_length": 32768 } ], - "updated_at": "2026-06-24T18:40:15.482014Z", - "count": 23 + "updated_at": "2026-07-08T22:55:47.271165Z", + "count": 24 } \ No newline at end of file diff --git a/router/main.py b/router/main.py index d8be6cfb..952758ed 100644 --- a/router/main.py +++ b/router/main.py @@ -21,6 +21,7 @@ from circuit_breaker import get_breaker from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, RootModel from typing import Dict, Optional, Union +from urllib.parse import urlparse LITELLM_URL = (os.getenv("LITELLM_ADMIN_URL") or "http://127.0.0.1:4000").rstrip("/") LLAMA_SERVER_URL = (os.getenv("LLAMA_SERVER_URL") or "http://127.0.0.1:8080").rstrip( @@ -3070,8 +3071,46 @@ async def get_dashboard_stats(): @app.get("/dashboard", response_class=HTMLResponse) -async def get_dashboard(): +async def get_dashboard(request: Request): """Render the router main dashboard HTML showing system metrics, health checks, and recent token usage.""" + external_host_env = os.getenv("BASEURL") or os.getenv("BASE_URL") + if external_host_env: + if "://" not in external_host_env: + parsed = urlparse(f"http://{external_host_env}") + else: + parsed = urlparse(external_host_env) + external_host = parsed.hostname or "localhost" + external_netloc = parsed.netloc or "localhost" + else: + external_host = request.base_url.hostname or "localhost" + external_netloc = request.base_url.netloc or "localhost" + + domain = os.getenv("ROUTING_DOMAIN") or "vendeuvre.lan" + if not isinstance(external_host, str) or not re.match(r"^[a-zA-Z0-9.-]+$", external_host): + external_host = "localhost" + if not isinstance(external_netloc, str) or not re.match(r"^[a-zA-Z0-9.-]+(?::\d+)?$", external_netloc): + external_netloc = "localhost" + + # Enforce strict domain validation to prevent loose substring match bypasses (e.g., attacker-vendeuvre.lan) + is_valid_external = external_host == domain or external_host.endswith("." + domain) + is_valid_base = request.base_url.hostname == domain or (request.base_url.hostname or "").endswith("." + domain) + + if is_valid_external: + langfuse_url = f"https://{external_netloc}/llm-routing/langfuse" + litellm_url = f"https://{external_netloc}/llm-routing/litellm/ui" + llama_url = f"https://{external_netloc}/llm-routing/llama/" + elif is_valid_base: + scheme = request.url.scheme if re.match(r"^(?:http|https)$", request.url.scheme) else "https" + netloc = request.url.netloc if re.match(r"^[a-zA-Z0-9.-]+(?::\d+)?$", request.url.netloc) else "localhost" + base = f"{scheme}://{netloc}" + langfuse_url = f"{base}/llm-routing/langfuse" + litellm_url = f"{base}/llm-routing/litellm/ui" + llama_url = f"{base}/llm-routing/llama/" + else: + langfuse_url = f"http://{external_host}:3001" + litellm_url = f"http://{external_host}:4000/ui" + llama_url = f"http://{external_host}:8080" + data = await get_dashboard_data() # Unpack data for the f-string template @@ -3710,7 +3749,7 @@ async def get_dashboard():

Per-model usage, token consumption & cost are tracked with full trace detail in Langfuse.

- Open Langfuse Observability → + Open Langfuse Observability →
@@ -3846,15 +3885,15 @@ async def get_dashboard():
- + {src_badge("LANGFUSE", "#e879f9")} Observability UI - + {src_badge("LITELLM", "#34d399")} Admin UI - + {src_badge("LLAMA.CPP", "#fb923c")} Server Router UI diff --git a/start-stack.sh b/start-stack.sh index 7a1a7bac..34f50603 100755 --- a/start-stack.sh +++ b/start-stack.sh @@ -69,6 +69,20 @@ if [ -f "$ENV_FILE" ]; then set +a fi +# Derive public/local base URLs from env/config with sensible defaults, removing trailing slash +PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-${BASE_URL:-${BASEURL:-https://x570.vendeuvre.lan/llm-routing}}}" +if [[ ! "$PUBLIC_BASE_URL" =~ ^https?:// ]]; then + PUBLIC_BASE_URL="https://${PUBLIC_BASE_URL}" +fi +if [[ ! "$PUBLIC_BASE_URL" =~ /llm-routing ]]; then + PUBLIC_BASE_URL="${PUBLIC_BASE_URL%/}/llm-routing" +fi +PUBLIC_BASE_URL="${PUBLIC_BASE_URL%/}" +LOCAL_BASE_URL="${LOCAL_BASE_URL:-http://localhost:5000}" +LOCAL_BASE_URL="${LOCAL_BASE_URL%/}" +export PUBLIC_BASE_URL LOCAL_BASE_URL + + # Ensure openssl is installed if we need to generate passwords/keys if [ -z "$POSTGRES_PASSWORD" ] || [ -z "$NEXTAUTH_SECRET" ] || [ -z "$SALT" ] || [ -z "$ENCRYPTION_KEY" ] || [ -z "$LITELLM_MASTER_KEY" ] || [ -z "$ROUTER_API_KEY" ] || [ -z "$MINIO_ROOT_USER" ] || [ -z "$MINIO_ROOT_PASSWORD" ] || [ -z "$LANGFUSE_INIT_USER_PASSWORD" ] || [ -z "$REDIS_AUTH" ] || [ -z "$CLICKHOUSE_PASSWORD" ] || [ -z "$LANGFUSE_PUBLIC_KEY" ] || [ -z "$LANGFUSE_SECRET_KEY" ]; then if ! command -v openssl &>/dev/null; then @@ -511,7 +525,7 @@ if podman pod exists agent-router-pod 2>/dev/null; then fi render_pod_yaml() { - export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY LANGFUSE_INIT_USER_PASSWORD MINIO_ROOT_USER MINIO_ROOT_PASSWORD OLLAMA_API_KEY LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY CLASSIFIER_INPUT_MAX_CHARS REDIS_AUTH CLICKHOUSE_PASSWORD + export WORKDIR HOME LITELLM_MASTER_KEY POSTGRES_PASSWORD NEXTAUTH_SECRET SALT ENCRYPTION_KEY LANGFUSE_INIT_USER_PASSWORD MINIO_ROOT_USER MINIO_ROOT_PASSWORD OLLAMA_API_KEY LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY CLASSIFIER_INPUT_MAX_CHARS REDIS_AUTH CLICKHOUSE_PASSWORD PUBLIC_BASE_URL python3 - "$WORKDIR/pod.yaml" <<'PY' import os, sys, urllib.parse, json uid = os.getuid() @@ -522,9 +536,9 @@ def yaml_scalar(val): return json.dumps(val) placeholders = [ - "/home/gpav/Vrac/LAB/AI/LLM-Routing", - "/home/gpav/", - "/run/user/1000", + "WORKDIR_PLACEHOLDER", + "HOME_PLACEHOLDER", + "RUN_USER_PLACEHOLDER", "LITELLM_MASTER_KEY_PLACEHOLDER", "POSTGRES_PASSWORD_RAW_PLACEHOLDER", "POSTGRES_PASSWORD_ENCODED_PLACEHOLDER", @@ -538,15 +552,16 @@ placeholders = [ "MINIO_PASSWORD_PLACEHOLDER", "LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER", "REDIS_AUTH_PLACEHOLDER", - "CLICKHOUSE_PASSWORD_PLACEHOLDER" + "CLICKHOUSE_PASSWORD_PLACEHOLDER", + "PROXY_BASE_URL_PLACEHOLDER" ] for ph in placeholders: if ph not in text: sys.stderr.write(f"Error: Required placeholder '{ph}' not found in pod.yaml. Ensure you are using the latest version of the template.\n") sys.exit(1) -text = text.replace("/home/gpav/Vrac/LAB/AI/LLM-Routing", os.environ["WORKDIR"]) -text = text.replace("/home/gpav/", os.environ["HOME"] + "/") -text = text.replace("/run/user/1000", f"/run/user/{uid}") +text = text.replace("WORKDIR_PLACEHOLDER", os.environ["WORKDIR"]) +text = text.replace("HOME_PLACEHOLDER", os.environ["HOME"]) +text = text.replace("RUN_USER_PLACEHOLDER", f"/run/user/{uid}") text = text.replace("LITELLM_MASTER_KEY_PLACEHOLDER", yaml_scalar(os.environ["LITELLM_MASTER_KEY"])) text = text.replace("POSTGRES_PASSWORD_RAW_PLACEHOLDER", yaml_scalar(os.environ["POSTGRES_PASSWORD"])) # URL-encode the postgres password for DSN insertion @@ -563,6 +578,10 @@ text = text.replace("MINIO_PASSWORD_PLACEHOLDER", yaml_scalar(os.environ["MINIO_ text = text.replace("LANGFUSE_INIT_USER_PASSWORD_PLACEHOLDER", yaml_scalar(os.environ["LANGFUSE_INIT_USER_PASSWORD"])) text = text.replace("REDIS_AUTH_PLACEHOLDER", yaml_scalar(os.environ["REDIS_AUTH"])) text = text.replace("CLICKHOUSE_PASSWORD_PLACEHOLDER", yaml_scalar(os.environ["CLICKHOUSE_PASSWORD"])) +# Derive PROXY_BASE_URL from PUBLIC_BASE_URL +public_base_url = os.environ["PUBLIC_BASE_URL"].rstrip("/") +proxy_base_url = f"{public_base_url}/litellm" +text = text.replace("PROXY_BASE_URL_PLACEHOLDER", yaml_scalar(proxy_base_url)) import re unresolved = sorted(set(re.findall(r"\b[A-Z0-9_]+_PLACEHOLDER\b", text))) if unresolved: @@ -596,13 +615,15 @@ if podman pod exists agent-router-pod 2>/dev/null; then podman pod restart agent-router-pod setup_minio_buckets verify_stack_health + echo "" echo "=========================================================================" echo "🎉 SUCCESS: LLM Triage Gateway restarted!" - echo "📍 Entry endpoint : http://localhost:5000/v1" - echo "⚙️ Dashboard URL : http://localhost:5000/dashboard" + echo "📍 Entry endpoint : ${PUBLIC_BASE_URL}/v1" + echo " (local) : ${LOCAL_BASE_URL}/v1" + echo "⚙️ Dashboard URL : ${PUBLIC_BASE_URL}/dashboard" echo "🔑 Gateway API Key : gateway-pass" - echo "🔐 LiteLLM Admin UI: http://localhost:4000/ui" + echo "🔐 LiteLLM Admin UI: ${PUBLIC_BASE_URL}/litellm/ui" echo " Username: admin | Password: $LITELLM_MASTER_KEY" echo "=========================================================================" exit 0 @@ -619,11 +640,13 @@ else verify_stack_health fi + echo "=========================================================================" echo "🎉 SUCCESS: LLM Triage Gateway successfully deployed!" -echo "📍 Entry endpoint : http://localhost:5000/v1" -echo "⚙️ Dashboard URL : http://localhost:5000/dashboard" +echo "📍 Entry endpoint : ${PUBLIC_BASE_URL}/v1" +echo " (local) : ${LOCAL_BASE_URL}/v1" +echo "⚙️ Dashboard URL : ${PUBLIC_BASE_URL}/dashboard" echo "🔑 Gateway API Key : gateway-pass" -echo "🔐 LiteLLM Admin UI: http://localhost:4000/ui" +echo "🔐 LiteLLM Admin UI: ${PUBLIC_BASE_URL}/litellm/ui" echo " Username: admin | Password: $LITELLM_MASTER_KEY" echo "========================================================================="