Spaces:
Runtime error
fix: eagerly load LLM at startup so /api/health surfaces the real error
Browse filesRoot cause of persistent generic 'model not loaded' on the Space:
agents.get_llm() was lazy. The first /api/health call (before any
chat/insight/mentor hit) returned llm_status='uninitialized' and an
empty llm_error. The frontend fallback '(h.llm_error || model not loaded)'
showed the generic message. No real error reason ever reached the UI.
Fixes:
- app.py: eagerly call agents.get_llm() at startup after the download.
The Space's first health check now reflects the TRUE status
('loaded' or 'error' with the exception type+message). The model
load (~10-60s) happens during container startup, not on first
user request. If the download failed, get_llm() raises
FileNotFoundError immediately and the real reason is captured.
- download_model.py: retry each download mode (with-token, anonymous)
up to 3 times with exponential backoff to survive transient
network failures on cold starts.
- tests/e2e_test.py: inject a fake download_model so the test
process never tries to download 2.84 GB from the Hub.
- tests/verify_llm_status.py: Playwright-driven end-to-end check
that simulates 'model missing' (fake download returning a
nonexistent path) and confirms the real FileNotFoundError
reaches both /api/health and the frontend status line. Also
confirms chat replies via deterministic fallback with no
'error: format only' leak.
Verified via Playwright: status line now reads
'LLM offline (FileNotFoundError: Model file not found at ...).
Chat/mentor use deterministic fallbacks. Game still works.'
instead of the generic 'model not loaded'.
110 tests pass: 12 unit + 42 FastAPI E2E + 56 Playwright.
- app.py +10 -3
- download_model.py +27 -20
- tests/e2e_test.py +16 -0
- tests/verify_llm_status.py +168 -0
|
@@ -20,12 +20,19 @@ app = FastAPI(title="Retro Alpha")
|
|
| 20 |
ROOT = Path(__file__).resolve().parent
|
| 21 |
STATIC_DIR = ROOT / "static"
|
| 22 |
|
| 23 |
-
#
|
|
|
|
|
|
|
|
|
|
| 24 |
try:
|
| 25 |
agents.MODEL_PATH = download_model.download()
|
| 26 |
-
print(f"Model path
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
except Exception as e:
|
| 28 |
-
print(f"
|
| 29 |
|
| 30 |
|
| 31 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
|
|
| 20 |
ROOT = Path(__file__).resolve().parent
|
| 21 |
STATIC_DIR = ROOT / "static"
|
| 22 |
|
| 23 |
+
# Ensure the GGUF is on disk, then eagerly load it into RAM so
|
| 24 |
+
# /api/health reflects the REAL status (not "uninitialized") as soon
|
| 25 |
+
# as the container is up. Lazy-loading would race the first health
|
| 26 |
+
# check and surface a generic "model not loaded" with no error reason.
|
| 27 |
try:
|
| 28 |
agents.MODEL_PATH = download_model.download()
|
| 29 |
+
print(f"Model path: {agents.MODEL_PATH}")
|
| 30 |
+
print("Eagerly loading LLM into memory (this may take ~10-60s)...")
|
| 31 |
+
_ = agents.get_llm() # triggers Llama(...) load; sets status + error
|
| 32 |
+
err = agents.llm_error()
|
| 33 |
+
print(f"LLM status: {agents.llm_status()} ({err or 'ok'})")
|
| 34 |
except Exception as e:
|
| 35 |
+
print(f"Startup LLM init failed: {e}")
|
| 36 |
|
| 37 |
|
| 38 |
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
@@ -24,7 +24,11 @@ def _local_path() -> Path:
|
|
| 24 |
def download() -> str:
|
| 25 |
"""Ensure the GGUF model is available locally. Returns the local path
|
| 26 |
as a string. Never raises — returns the expected path even on
|
| 27 |
-
failure so callers can surface a precise error to the user.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
local = _local_path()
|
| 30 |
|
|
@@ -34,29 +38,32 @@ def download() -> str:
|
|
| 34 |
return str(local)
|
| 35 |
|
| 36 |
token = os.getenv("HF_TOKEN")
|
| 37 |
-
# Try with token first (for private repos), then anonymously (public).
|
| 38 |
for attempt_token, label in [(token, "with token"), (None, "anonymously")]:
|
| 39 |
if attempt_token == "":
|
| 40 |
attempt_token = None
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
# Download failed; return the expected path so the app can report
|
| 59 |
-
# a clear "model not found" error rather than crashing.
|
| 60 |
print(f"Model download failed. Expected at: {local}")
|
| 61 |
return str(local)
|
| 62 |
|
|
|
|
| 24 |
def download() -> str:
|
| 25 |
"""Ensure the GGUF model is available locally. Returns the local path
|
| 26 |
as a string. Never raises — returns the expected path even on
|
| 27 |
+
failure so callers can surface a precise error to the user.
|
| 28 |
+
|
| 29 |
+
Retries each download mode up to 3 times with exponential backoff
|
| 30 |
+
to survive transient network failures on cold starts."""
|
| 31 |
+
import time
|
| 32 |
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
| 33 |
local = _local_path()
|
| 34 |
|
|
|
|
| 38 |
return str(local)
|
| 39 |
|
| 40 |
token = os.getenv("HF_TOKEN")
|
|
|
|
| 41 |
for attempt_token, label in [(token, "with token"), (None, "anonymously")]:
|
| 42 |
if attempt_token == "":
|
| 43 |
attempt_token = None
|
| 44 |
+
for attempt in range(1, 4):
|
| 45 |
+
try:
|
| 46 |
+
print(f"Downloading {MODEL_FILE} from {MODEL_REPO} ({label}, attempt {attempt}/3)...")
|
| 47 |
+
from huggingface_hub import hf_hub_download
|
| 48 |
+
path = hf_hub_download(
|
| 49 |
+
repo_id=MODEL_REPO,
|
| 50 |
+
filename=MODEL_FILE,
|
| 51 |
+
local_dir=str(MODEL_DIR),
|
| 52 |
+
local_dir_use_symlinks=False,
|
| 53 |
+
token=attempt_token,
|
| 54 |
+
)
|
| 55 |
+
print(f"Download complete: {path}")
|
| 56 |
+
return str(path)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"Download attempt {attempt} failed ({label}): {type(e).__name__}: {e}")
|
| 59 |
+
if attempt < 3:
|
| 60 |
+
time.sleep(2 ** attempt) # 2s, 4s
|
| 61 |
+
else:
|
| 62 |
+
break
|
| 63 |
+
# If anonymous mode also failed, no point retrying it
|
| 64 |
+
if attempt_token is None:
|
| 65 |
+
break
|
| 66 |
|
|
|
|
|
|
|
| 67 |
print(f"Model download failed. Expected at: {local}")
|
| 68 |
return str(local)
|
| 69 |
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import sys
|
|
|
|
| 5 |
|
| 6 |
# Force UTF-8 stdout for ₹ symbol on Windows
|
| 7 |
try:
|
|
@@ -14,6 +15,21 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
| 14 |
# Force mock LLM for tests
|
| 15 |
os.environ["MOCK_LLM"] = "1"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
import agents
|
| 18 |
agents._llm = "mock"
|
| 19 |
agents._llm_status = "mock"
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import sys
|
| 5 |
+
import types
|
| 6 |
|
| 7 |
# Force UTF-8 stdout for ₹ symbol on Windows
|
| 8 |
try:
|
|
|
|
| 15 |
# Force mock LLM for tests
|
| 16 |
os.environ["MOCK_LLM"] = "1"
|
| 17 |
|
| 18 |
+
# Inject a fake download_model so app.py startup does NOT try to
|
| 19 |
+
# download the 2.84 GB model from the Hub. The MOCK_LLM flag means
|
| 20 |
+
# the model is never loaded into RAM, so the path is irrelevant.
|
| 21 |
+
_MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "models")
|
| 22 |
+
_FAKE_PATH = os.path.join(os.path.abspath(_MODEL_DIR), "TEST_FAKE_IGNORED.gguf")
|
| 23 |
+
for mod in list(sys.modules):
|
| 24 |
+
if mod in ("download_model",):
|
| 25 |
+
del sys.modules[mod]
|
| 26 |
+
fake_dm = types.ModuleType("download_model")
|
| 27 |
+
fake_dm.download = lambda: _FAKE_PATH
|
| 28 |
+
fake_dm.MODEL_REPO = "fake/test"
|
| 29 |
+
fake_dm.MODEL_FILE = "fake.gguf"
|
| 30 |
+
fake_dm.MODEL_DIR = os.path.abspath(_MODEL_DIR)
|
| 31 |
+
sys.modules["download_model"] = fake_dm
|
| 32 |
+
|
| 33 |
import agents
|
| 34 |
agents._llm = "mock"
|
| 35 |
agents._llm_status = "mock"
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify the LLM error reason reaches the UI when the model can't load.
|
| 2 |
+
|
| 3 |
+
This is the exact scenario the user is hitting on the Space: the model
|
| 4 |
+
file is missing (download failed / not present), so get_llm() raises.
|
| 5 |
+
We simulate it WITHOUT triggering a real 2.84 GB download by injecting
|
| 6 |
+
a fake download_model that returns a nonexistent path.
|
| 7 |
+
|
| 8 |
+
Confirms:
|
| 9 |
+
- /api/health returns llm='error' (not 'uninitialized') and a non-empty
|
| 10 |
+
llm_error describing the real failure
|
| 11 |
+
- the frontend status line shows the real error (not the generic
|
| 12 |
+
'model not loaded' fallback)
|
| 13 |
+
- chat still works via deterministic fallback (no 'error: format only'
|
| 14 |
+
sentinel leak)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
import socket
|
| 21 |
+
import types
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
sys.stdout.reconfigure(encoding="utf-8")
|
| 25 |
+
except Exception:
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 29 |
+
|
| 30 |
+
PORT = 7861
|
| 31 |
+
BASE_URL = f"http://localhost:{PORT}"
|
| 32 |
+
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "screenshots")
|
| 33 |
+
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
| 34 |
+
MODEL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "models")
|
| 35 |
+
FAKE_MISSING = os.path.join(MODEL_DIR, "FAKE_MISSING_MODEL.gguf")
|
| 36 |
+
|
| 37 |
+
PASSED = 0
|
| 38 |
+
FAILED = 0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def check(name, condition, detail=""):
|
| 42 |
+
global PASSED, FAILED
|
| 43 |
+
if condition:
|
| 44 |
+
PASSED += 1
|
| 45 |
+
print(f" PASS: {name}")
|
| 46 |
+
else:
|
| 47 |
+
FAILED += 1
|
| 48 |
+
print(f" FAIL: {name} {detail}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def wait_for_http(url, timeout=30):
|
| 52 |
+
"""Wait until the URL returns any HTTP response (not just port open)."""
|
| 53 |
+
import urllib.request
|
| 54 |
+
deadline = time.time() + timeout
|
| 55 |
+
last = None
|
| 56 |
+
while time.time() < deadline:
|
| 57 |
+
try:
|
| 58 |
+
with urllib.request.urlopen(url, timeout=2) as r:
|
| 59 |
+
r.read(1)
|
| 60 |
+
return True
|
| 61 |
+
except Exception as e:
|
| 62 |
+
last = e
|
| 63 |
+
time.sleep(0.3)
|
| 64 |
+
print(f" (wait_for_http timed out: {last})")
|
| 65 |
+
return False
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# Clear cached modules
|
| 69 |
+
for mod in list(sys.modules):
|
| 70 |
+
if mod in ("app", "agents", "download_model", "mentor", "engine", "events"):
|
| 71 |
+
del sys.modules[mod]
|
| 72 |
+
|
| 73 |
+
# Inject fake download_model -> returns nonexistent path
|
| 74 |
+
fake_dm = types.ModuleType("download_model")
|
| 75 |
+
fake_dm.download = lambda: FAKE_MISSING
|
| 76 |
+
fake_dm.MODEL_REPO = "fake/repo"
|
| 77 |
+
fake_dm.MODEL_FILE = "fake.gguf"
|
| 78 |
+
fake_dm.MODEL_DIR = MODEL_DIR
|
| 79 |
+
sys.modules["download_model"] = fake_dm
|
| 80 |
+
|
| 81 |
+
# Ensure MOCK_LLM is off so the real llama_cpp loader runs (and fails)
|
| 82 |
+
for k in ("MOCK_LLM", "HF_TOKEN", "MODEL_PATH"):
|
| 83 |
+
os.environ.pop(k, None)
|
| 84 |
+
|
| 85 |
+
import uvicorn
|
| 86 |
+
import app as app_module # noqa: E402
|
| 87 |
+
|
| 88 |
+
print("Starting server (MOCK_LLM=0, fake missing model)...")
|
| 89 |
+
config = uvicorn.Config(app_module.app, host="127.0.0.1", port=PORT, log_level="warning")
|
| 90 |
+
server = uvicorn.Server(config)
|
| 91 |
+
import threading
|
| 92 |
+
thread = threading.Thread(target=server.run, daemon=True)
|
| 93 |
+
thread.start()
|
| 94 |
+
|
| 95 |
+
if not wait_for_http(BASE_URL + "/", timeout=20):
|
| 96 |
+
print("Server failed to start. Stopping.")
|
| 97 |
+
server.should_exit = True
|
| 98 |
+
sys.exit(1)
|
| 99 |
+
time.sleep(1.0) # let eager get_llm() complete (FileNotFoundError is instant)
|
| 100 |
+
print("Server ready.\n")
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
import urllib.request, json
|
| 104 |
+
with urllib.request.urlopen(BASE_URL + "/api/health", timeout=5) as r:
|
| 105 |
+
health = json.loads(r.read().decode())
|
| 106 |
+
print(f" /api/health response: {json.dumps(health, indent=2)}\n")
|
| 107 |
+
check("health.llm is 'error' (real load was attempted)",
|
| 108 |
+
health.get("llm") == "error", f"got '{health.get('llm')}'")
|
| 109 |
+
check("health.llm_error is NON-EMPTY (real reason)",
|
| 110 |
+
bool(health.get("llm_error")), f"got '{health.get('llm_error')}'")
|
| 111 |
+
check("health.llm_error mentions the file or the path",
|
| 112 |
+
"FAKE_MISSING" in str(health.get("llm_error", "")) or "not found" in str(health.get("llm_error", "")).lower(),
|
| 113 |
+
f"err='{health.get('llm_error')}'")
|
| 114 |
+
check("health.model_exists is False",
|
| 115 |
+
health.get("model_exists") is False)
|
| 116 |
+
|
| 117 |
+
from playwright.sync_api import sync_playwright
|
| 118 |
+
with sync_playwright() as p:
|
| 119 |
+
browser = p.chromium.launch(headless=True)
|
| 120 |
+
page = browser.new_context(viewport={"width": 1500, "height": 950}).new_page()
|
| 121 |
+
page.on("dialog", lambda d: d.accept())
|
| 122 |
+
page.goto(BASE_URL, wait_until="networkidle")
|
| 123 |
+
time.sleep(0.5)
|
| 124 |
+
|
| 125 |
+
status_text = page.locator("#status-line").inner_text()
|
| 126 |
+
print(f" status-line: '{status_text}'")
|
| 127 |
+
check("status-line shows 'LLM offline'", "offline" in status_text.lower())
|
| 128 |
+
check("status-line does NOT show generic 'model not loaded' fallback",
|
| 129 |
+
"model not loaded" not in status_text.lower(),
|
| 130 |
+
f"status='{status_text}'")
|
| 131 |
+
check("status-line mentions the fake model path (real error)",
|
| 132 |
+
"FAKE_MISSING" in status_text,
|
| 133 |
+
f"status='{status_text}'")
|
| 134 |
+
|
| 135 |
+
llm_tag = page.locator("#llm-status").inner_text()
|
| 136 |
+
check("topbar shows OFFLINE", "OFFLINE" in llm_tag)
|
| 137 |
+
tip = page.locator("#llm-status").get_attribute("title") or ""
|
| 138 |
+
check("topbar tooltip has real error", len(tip) > 10 and "FAKE" in tip,
|
| 139 |
+
f"tooltip='{tip}'")
|
| 140 |
+
|
| 141 |
+
# Chat via deterministic fallback (no 'error: format only' leak)
|
| 142 |
+
page.fill("#chat-input", "Should I buy Nifty?")
|
| 143 |
+
page.click("#chat-form button")
|
| 144 |
+
page.wait_for_function(
|
| 145 |
+
"() => document.getElementById('chat-log').children.length >= 1",
|
| 146 |
+
timeout=8000,
|
| 147 |
+
)
|
| 148 |
+
chat_text = page.locator("#chat-log").inner_text()
|
| 149 |
+
print(f" chat reply: '{chat_text[:120]}'")
|
| 150 |
+
check("chat returned a reply", len(chat_text.strip()) > 0)
|
| 151 |
+
check("chat did NOT leak 'error: format only'",
|
| 152 |
+
"error: format only" not in chat_text,
|
| 153 |
+
f"chat='{chat_text[:120]}'")
|
| 154 |
+
check("chat reply is a real, useful sentence",
|
| 155 |
+
len(chat_text.split()) >= 4, f"chat='{chat_text[:120]}'")
|
| 156 |
+
|
| 157 |
+
page.screenshot(path=os.path.join(SCREENSHOT_DIR, "llm_missing.png"), full_page=True)
|
| 158 |
+
browser.close()
|
| 159 |
+
finally:
|
| 160 |
+
server.should_exit = True
|
| 161 |
+
time.sleep(0.5)
|
| 162 |
+
|
| 163 |
+
print(f"\n{'='*60}")
|
| 164 |
+
print(f"VERIFY LLM STATUS — PASSED: {PASSED} FAILED: {FAILED}")
|
| 165 |
+
print(f"{'='*60}")
|
| 166 |
+
if FAILED:
|
| 167 |
+
sys.exit(1)
|
| 168 |
+
print("ALL CHECKS PASSED — the real LLM error reason reaches the UI.")
|