sankalphs commited on
Commit
cec66cb
·
1 Parent(s): 1c2dd4b

fix: eagerly load LLM at startup so /api/health surfaces the real error

Browse files

Root 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.

Files changed (4) hide show
  1. app.py +10 -3
  2. download_model.py +27 -20
  3. tests/e2e_test.py +16 -0
  4. tests/verify_llm_status.py +168 -0
app.py CHANGED
@@ -20,12 +20,19 @@ app = FastAPI(title="Retro Alpha")
20
  ROOT = Path(__file__).resolve().parent
21
  STATIC_DIR = ROOT / "static"
22
 
23
- # Load the local LLM once at startup. All endpoints share this single LLM.
 
 
 
24
  try:
25
  agents.MODEL_PATH = download_model.download()
26
- print(f"Model path set to: {agents.MODEL_PATH}")
 
 
 
 
27
  except Exception as e:
28
- print(f"Model download wrapper failed: {e}. Endpoints will use deterministic fallbacks.")
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")
download_model.py CHANGED
@@ -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
- try:
42
- print(f"Downloading {MODEL_FILE} from {MODEL_REPO} ({label})...")
43
- from huggingface_hub import hf_hub_download
44
- path = hf_hub_download(
45
- repo_id=MODEL_REPO,
46
- filename=MODEL_FILE,
47
- local_dir=str(MODEL_DIR),
48
- local_dir_use_symlinks=False,
49
- token=attempt_token,
50
- )
51
- print(f"Download complete: {path}")
52
- return str(path)
53
- except Exception as e:
54
- print(f"Download attempt failed ({label}): {type(e).__name__}: {e}")
55
- if attempt_token is None:
56
- break # don't retry anonymous again
 
 
 
 
 
 
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
 
tests/e2e_test.py CHANGED
@@ -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"
tests/verify_llm_status.py ADDED
@@ -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.")