AI Agent commited on
Commit
e1c98c1
Β·
1 Parent(s): bf11e18

Replace gen_llm with nvidia_llm and add frontend test

Browse files
Dockerfile CHANGED
@@ -50,7 +50,7 @@ RUN rm -f requirements_gpu.txt Dockerfile_bak
50
  RUN chmod +x start.sh
51
 
52
  # ── Environment configuration ─────────────────────────────────────────────────
53
- # HF_MODE=1 activates low-resource path in config.py, gen_llm.py, embed_llm.py
54
  ENV HF_MODE=1
55
  ENV ADMIN_MODE=1
56
  ENV PORT=7860
 
50
  RUN chmod +x start.sh
51
 
52
  # ── Environment configuration ─────────────────────────────────────────────────
53
+ # HF_MODE=1 activates low-resource path in config.py, nvidia_llm.py, embed_llm.py
54
  ENV HF_MODE=1
55
  ENV ADMIN_MODE=1
56
  ENV PORT=7860
README.md CHANGED
@@ -167,7 +167,7 @@ export HF_PRIVATE_TOKEN=$(secret-tool lookup api huggingface)
167
 
168
  **Terminal 1 - LLM Generation Server (port 8002):**
169
  ```bash
170
- python agents/gen_llm.py
171
  # Expected output:
172
  # * Running on http://127.0.0.1:8002
173
  ```
@@ -241,7 +241,7 @@ healthexpert/
241
  β”‚ β”œβ”€β”€ crew.py # Crew orchestration
242
  β”‚ β”œβ”€β”€ llm.py # LLM integration
243
  β”‚ β”œβ”€β”€ tools.py # Agent tools
244
- β”‚ β”œβ”€β”€ gen_llm.py # LLM generation server (port 8002)
245
  β”‚ └── embed_llm.py # Embedding server (port 8003)
246
  β”‚
247
  β”œβ”€β”€ pipeline/ # Data processing
 
167
 
168
  **Terminal 1 - LLM Generation Server (port 8002):**
169
  ```bash
170
+ python agents/nvidia_llm.py
171
  # Expected output:
172
  # * Running on http://127.0.0.1:8002
173
  ```
 
241
  β”‚ β”œβ”€β”€ crew.py # Crew orchestration
242
  β”‚ β”œβ”€β”€ llm.py # LLM integration
243
  β”‚ β”œβ”€β”€ tools.py # Agent tools
244
+ β”‚ β”œβ”€β”€ nvidia_llm.py # LLM generation server (port 8002)
245
  β”‚ └── embed_llm.py # Embedding server (port 8003)
246
  β”‚
247
  β”œβ”€β”€ pipeline/ # Data processing
agents/gen_llm.py DELETED
@@ -1,441 +0,0 @@
1
- # gen_llm.py
2
- # General-purpose LLM Generation Server β€” port 8002
3
- #
4
- # Modes:
5
- # CPU : llama.cpp on CPU (default; works without GPU)
6
- # GPU : llama.cpp with GPU layers (enabled per-request via use_gpu=true)
7
- #
8
- # Multi-user concurrency model:
9
- # A single inference worker thread owns all model.create_chat_completion calls.
10
- # HTTP requests enqueue a job (data + result holder + done_event) and block
11
- # until their result is ready. If the queue is full or the request times out
12
- # the endpoint returns 503 {"error": "Server busy β€” try again shortly."} so
13
- # the caller can retry without hanging indefinitely.
14
- #
15
- # Exposes: POST /v1/completions (OpenAI-compatible)
16
- # POST /v1/kv_cache (no-op β€” llama.cpp manages natively)
17
- # GET /health (includes queue_depth for the UI busy badge)
18
- #
19
- # GPU/CPU control:
20
- # - Pass use_gpu=true in the request body to run on GPU (if available).
21
- # - Pass cpu_threads=N in the request body to override thread count (CPU mode).
22
- # - If GPU is unavailable, use_gpu is silently ignored.
23
- #
24
- # Run: python agents/gen_llm.py
25
- # β†’ http://127.0.0.1:8002
26
-
27
- from __future__ import annotations
28
-
29
- import os
30
- import warnings
31
- warnings.filterwarnings("ignore")
32
- os.environ["PYTHONWARNINGS"] = "ignore"
33
- os.environ["LLAMA_NUMA"] = "1" # Enable NUMA optimizations
34
-
35
- import logging
36
- import threading
37
- import queue as _queue_module
38
- import time
39
- from flask import Flask, request, jsonify
40
-
41
- # ── Logging ───────────────────────────────────────────────────────────────────
42
- logging.basicConfig(
43
- level=logging.INFO,
44
- format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
45
- datefmt="%Y-%m-%d %H:%M:%S",
46
- )
47
- log = logging.getLogger("gen_llm")
48
- logging.getLogger("werkzeug").setLevel(logging.ERROR)
49
- logging.getLogger("httpx").setLevel(logging.WARNING)
50
-
51
- # ── Config ────────────────────────────────────────────────────────────────────
52
- MODEL_REPO = os.getenv("GEN_MODEL_ID", "Jackrong/Qwen3.5-2B-Claude-4.6-Opus-Reasoning-Distilled-GGUF")
53
- MODEL_FILE = os.getenv("GEN_MODEL_FILENAME", "Qwen3.5-2B.Q4_K_M.gguf")
54
- HOST = os.getenv("GEN_HOST", "127.0.0.1")
55
- PORT = int(os.getenv("GEN_PORT", "8002"))
56
-
57
- # Default CPU thread count (overridable per-request)
58
- DEFAULT_CPU_THREADS = int(os.getenv("GEN_CPU_THREADS", "2"))
59
-
60
- # Maximum number of requests that can wait in the inference queue.
61
- _QUEUE_MAX_SIZE = int(os.getenv("GEN_QUEUE_MAX", "8"))
62
-
63
- # Per-request timeout (seconds). Matches config.py LLM_TIMEOUT default.
64
- _REQUEST_TIMEOUT_S = int(os.getenv("LLM_TIMEOUT", "600"))
65
-
66
- # ── Device / GPU Detection ────────────────────────────────────────────────────
67
- _gpu_available = False
68
- _gpu_id = "cpu"
69
-
70
- try:
71
- import torch
72
- if torch.cuda.is_available():
73
- _cuda_idx = int(os.getenv("GEN_CUDA_DEVICE", "0"))
74
- _gpu_id = f"cuda:{_cuda_idx}"
75
- _gpu_available = True
76
- log.info("GPU DETECTED β€” %s available for on-demand inference", _gpu_id)
77
- else:
78
- log.info("No CUDA GPU detected β€” CPU-only inference available")
79
- except ImportError:
80
- log.info("torch not available β€” GPU detection skipped, CPU-only mode")
81
-
82
- # Do NOT set CUDA_VISIBLE_DEVICES="" here β€” we need GPU access to be possible.
83
- # GPU layers are set per-model-instance (see _load_model below).
84
-
85
- log.info("━" * 60)
86
- log.info("gen_llm starting β€” gpu_available=%s model=%s (%s)", _gpu_available, MODEL_REPO, MODEL_FILE)
87
- log.info("Default CPU threads=%d Queue: max_size=%d request_timeout=%ds",
88
- DEFAULT_CPU_THREADS, _QUEUE_MAX_SIZE, _REQUEST_TIMEOUT_S)
89
- log.info("━" * 60)
90
-
91
-
92
- # ── Model Loading ─────────────────────────────────────────────────────────────
93
- # We maintain up to two model instances: CPU and (optionally) GPU.
94
- # This avoids full model reload on every request while allowing GPU offload.
95
-
96
- _model_lock = threading.Lock()
97
- _models: dict[str, object] = {} # key: "cpu" or "gpu"
98
- _model_ready = threading.Event()
99
- _model_path = None
100
-
101
-
102
- def _load_model(use_gpu: bool = False):
103
- """Load and cache a model instance. Returns the cached instance if already loaded."""
104
- mode_key = "gpu" if (use_gpu and _gpu_available) else "cpu"
105
-
106
- with _model_lock:
107
- if mode_key in _models:
108
- return _models[mode_key]
109
-
110
- global _model_path
111
- if _model_path is None:
112
- log.info("Downloading/Locating model from Hub: %s/%s", MODEL_REPO, MODEL_FILE)
113
- from huggingface_hub import hf_hub_download
114
- _model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
115
-
116
- n_gpu_layers = -1 if (use_gpu and _gpu_available) else 0
117
- cpu_threads = DEFAULT_CPU_THREADS
118
-
119
- log.info("Loading GGUF model β€” mode=%s n_gpu_layers=%d threads=%d",
120
- mode_key, n_gpu_layers, cpu_threads)
121
-
122
- from llama_cpp import Llama
123
- m = Llama(
124
- model_path=_model_path,
125
- n_ctx=8192,
126
- n_batch=512,
127
- n_threads=cpu_threads,
128
- n_gpu_layers=n_gpu_layers,
129
- use_mmap=True,
130
- use_mlock=True,
131
- numa=True,
132
- flash_attn=True,
133
- verbose=False,
134
- )
135
- _models[mode_key] = m
136
- log.info("Model instance [%s] ready!", mode_key)
137
- return m
138
-
139
-
140
- # Pre-load CPU model at startup (always available)
141
- try:
142
- _load_model(use_gpu=False)
143
- _model_ready.set()
144
- log.info("CPU model pre-loaded and ready.")
145
- except Exception as e:
146
- log.error("Failed to load CPU model: %s", e)
147
- raise
148
-
149
-
150
- # ── Flask app ─────────────────────────────────────────────────────────────────
151
- app = Flask(__name__)
152
-
153
- # ── Inference Queue (multi-user serialization) ─────────────────────────────────
154
- _inference_queue: _queue_module.Queue = _queue_module.Queue(maxsize=_QUEUE_MAX_SIZE)
155
-
156
-
157
- def _run_inference(data: dict) -> dict:
158
- """Execute one completion request. Called only from the inference worker thread."""
159
- raw_prompt = data.get("prompt", "")
160
- if not raw_prompt:
161
- return {"error": "Field 'prompt' is required."}
162
-
163
- prompts = raw_prompt if isinstance(raw_prompt, list) and (len(raw_prompt) == 0 or not isinstance(raw_prompt[0], dict)) else [raw_prompt]
164
-
165
- use_gpu = bool(data.get("use_gpu", False))
166
- cpu_threads = int(data.get("cpu_threads", DEFAULT_CPU_THREADS))
167
-
168
- # Select model instance (GPU if requested and available, else CPU)
169
- model = _load_model(use_gpu=use_gpu)
170
- active_device = _gpu_id if (use_gpu and _gpu_available) else "cpu"
171
-
172
- # Apply cpu_threads override if running on CPU and different from default
173
- # Note: llama_cpp doesn't support live thread changes; we log the intent.
174
- if not (use_gpu and _gpu_available) and cpu_threads != DEFAULT_CPU_THREADS:
175
- log.info("cpu_threads=%d requested (model loaded with %d β€” static per-instance)",
176
- cpu_threads, DEFAULT_CPU_THREADS)
177
-
178
- _default_max = 1024
179
- max_new_tokens = int( data.get("max_tokens", _default_max))
180
-
181
- # Reasoning models need large token budgets for internal monologue.
182
- # Enforce a minimum of 2048 tokens to prevent truncation, unless
183
- # explicitly asking for very small probe tests (<100 tokens).
184
- if 100 < max_new_tokens < 2048:
185
- max_new_tokens = 2048
186
- temperature = float(data.get("temperature", 0.7))
187
- top_p = float(data.get("top_p", 0.95))
188
- top_k = int( data.get("top_k", 40))
189
- repeat_penalty = float(data.get("repeat_penalty", 1.15))
190
- freq_penalty = float(data.get("frequency_penalty", 0.1))
191
-
192
- choices = []
193
- total_prompt_tokens = 0
194
- total_completion_tokens = 0
195
-
196
- # ── Sliding-window repetition detector (mirrors ai_workbench) ──
197
- REP_WINDOW = 120 # characters to treat as one "phrase"
198
- REP_THRESHOLD = 2 # how many duplicate occurrences to tolerate
199
-
200
- def _is_repeating(text: str) -> bool:
201
- if len(text) < REP_WINDOW * (REP_THRESHOLD + 1):
202
- return False
203
- tail = text[-REP_WINDOW:]
204
- preceding = text[: -REP_WINDOW]
205
- count = 0
206
- start = 0
207
- while True:
208
- idx = preceding.find(tail, start)
209
- if idx == -1:
210
- break
211
- count += 1
212
- if count >= REP_THRESHOLD:
213
- return True
214
- start = idx + 1
215
- return False
216
-
217
- for i, prompt in enumerate(prompts):
218
- if isinstance(prompt, list):
219
- messages = prompt
220
- else:
221
- messages = [
222
- {"role": "system", "content": "You are a helpful, respectful and honest assistant."},
223
- {"role": "user", "content": prompt}
224
- ]
225
-
226
- # Call chat completion API using streaming with full sampling controls
227
- stream = model.create_chat_completion(
228
- messages=messages,
229
- max_tokens=max_new_tokens,
230
- temperature=temperature if temperature > 0.15 else 0.0,
231
- top_p=top_p,
232
- top_k=top_k,
233
- repeat_penalty=repeat_penalty,
234
- frequency_penalty=freq_penalty,
235
- stream=True,
236
- )
237
-
238
- full_output = ""
239
- prompt_len = len(str(messages)) // 4
240
- completion_len = 0
241
-
242
- print(f"\n[CONSOLE STREAM] Generating for: {MODEL_REPO}")
243
- print("-" * 30)
244
-
245
- for chunk in stream:
246
- if "choices" in chunk and len(chunk["choices"]) > 0:
247
- choice = chunk["choices"][0]
248
- text_part = choice.get("delta", {}).get("content", "")
249
- if not text_part:
250
- text_part = choice.get("text", "") # fallback if delta not present
251
-
252
- if text_part:
253
- print(text_part, end="", flush=True)
254
- full_output += text_part
255
- completion_len += 1
256
-
257
- if _is_repeating(full_output):
258
- print("\n[CONSOLE STREAM] Repetition detected β€” cutting off generation.")
259
- full_output = full_output[:-REP_WINDOW].strip()
260
- break
261
-
262
- print("\n" + "-" * 30)
263
-
264
- answer_text = full_output.strip()
265
-
266
- total_prompt_tokens += prompt_len
267
- total_completion_tokens += completion_len
268
-
269
- # Strip <think>...</think> block robustly (handles 4 failure modes)
270
- think_text = ""
271
- think_end = answer_text.find("</think>")
272
- think_start = answer_text.find("<think>")
273
-
274
- if think_end != -1:
275
- # Case 1: Both <think> and </think> present
276
- if think_start != -1 and think_start < think_end:
277
- think_text = answer_text[think_start + len("<think>"):think_end].strip()
278
- answer_text = (answer_text[:think_start] + "\n" + answer_text[think_end + len("</think>"):]).strip()
279
- else:
280
- # Case 2: Only </think> found β€” model started thinking implicitly
281
- think_text = answer_text[:think_end].strip()
282
- answer_text = answer_text[think_end + len("</think>"):].strip()
283
- elif think_start != -1:
284
- # Case 3: Orphaned <think> with NO </think> β€” model exhausted tokens mid-thought
285
- think_text = answer_text[think_start + len("<think>"):].strip()
286
- answer_text = answer_text[:think_start].strip()
287
-
288
- # Case 4: No tags at all β€” detect untagged thinking patterns from tiny models
289
- if not answer_text or (not think_text and answer_text):
290
- _THINK_PREFIXES = (
291
- "Thinking Process:", "Let me analyze", "Let me think",
292
- "I need to analyze", "Let me break this down",
293
- "Let me review", "Let me examine", "Let me consider",
294
- "I'll analyze", "Step 1:", "1. **Analyze",
295
- )
296
- stripped = answer_text.lstrip("\n ")
297
- for prefix in _THINK_PREFIXES:
298
- if stripped.startswith(prefix):
299
- think_text = stripped
300
- answer_text = ""
301
- break
302
-
303
- log.info("Prompt %d β†’ %d new tokens (device=%s, gpu=%s, threads=%d)",
304
- i, completion_len, active_device, use_gpu and _gpu_available, cpu_threads)
305
-
306
- choices.append({
307
- "index": i,
308
- "text": answer_text,
309
- "thinking": think_text,
310
- })
311
-
312
- return {
313
- "model": MODEL_REPO,
314
- "choices": choices,
315
- "usage": {
316
- "prompt_tokens": total_prompt_tokens,
317
- "completion_tokens": total_completion_tokens,
318
- },
319
- "device": active_device,
320
- }
321
-
322
-
323
- def _inference_worker() -> None:
324
- log.info("Inference worker thread started (pid=%d)", os.getpid())
325
-
326
- while True:
327
- try:
328
- item = _inference_queue.get(timeout=1.0)
329
- except _queue_module.Empty:
330
- continue
331
-
332
- req_data, result_holder, done_event = item
333
- try:
334
- result_holder[0] = _run_inference(req_data)
335
- except Exception as exc:
336
- log.error("Inference worker error: %s", exc)
337
- result_holder[0] = {"error": f"Inference failed: {exc}"}
338
- finally:
339
- done_event.set()
340
- _inference_queue.task_done()
341
-
342
-
343
- _worker_thread = threading.Thread(target=_inference_worker, name="inference-worker", daemon=True)
344
- _worker_thread.start()
345
-
346
-
347
- # ── Routes ────────────────────────────────────────────────────────────────────
348
-
349
- @app.route("/v1/kv_cache", methods=["POST"])
350
- def kv_cache():
351
- """KV cache is managed natively by llama.cpp. This is a no-op."""
352
- return jsonify({"status": "skipped", "reason": "llama.cpp manages KV cache natively"})
353
-
354
-
355
- @app.route("/health", methods=["GET"])
356
- def health():
357
- import psutil
358
- mem = psutil.virtual_memory()
359
- ram_used_gb = round((mem.total - mem.available) / 1024 ** 3, 2)
360
- ram_total_gb = round(mem.total / 1024 ** 3, 2)
361
-
362
- queue_depth = _inference_queue.qsize()
363
- is_ready = _model_ready.is_set()
364
- loaded_modes = list(_models.keys())
365
-
366
- return jsonify({
367
- "status": "ok" if is_ready else "loading",
368
- "model": MODEL_REPO,
369
- "gpu_available": _gpu_available,
370
- "gpu_id": _gpu_id,
371
- "loaded_modes": loaded_modes,
372
- "default_threads": DEFAULT_CPU_THREADS,
373
- "kv_cache_length": 0,
374
- "kv_cache_enabled": False,
375
- "torch_compile": False,
376
- "vram_free_gib": 0.0,
377
- "ram_used_gb": ram_used_gb,
378
- "ram_total_gb": ram_total_gb,
379
- "queue_depth": queue_depth,
380
- "queue_max": _QUEUE_MAX_SIZE,
381
- "model_ready": is_ready,
382
- })
383
-
384
-
385
- @app.route("/v1/completions", methods=["POST"])
386
- def completions():
387
- if not _model_ready.is_set():
388
- return jsonify({
389
- "error": "Model is still loading β€” please try again in a few seconds.",
390
- "retry_after": 5,
391
- }), 503
392
-
393
- data: dict = request.get_json(force=True) or {}
394
-
395
- current_depth = _inference_queue.qsize()
396
- if current_depth >= _QUEUE_MAX_SIZE:
397
- log.warning("Inference queue full (%d/%d) β€” rejecting request.", current_depth, _QUEUE_MAX_SIZE)
398
- return jsonify({
399
- "error": "Server busy β€” all inference slots are occupied. Please try again shortly.",
400
- "retry_after": max(5, current_depth * 3),
401
- "queue_depth": current_depth,
402
- "queue_max": _QUEUE_MAX_SIZE,
403
- }), 503
404
-
405
- result_holder: list = [None]
406
- done_event = threading.Event()
407
-
408
- try:
409
- _inference_queue.put_nowait((data, result_holder, done_event))
410
- except _queue_module.Full:
411
- return jsonify({
412
- "error": "Server busy β€” inference queue full. Please try again shortly.",
413
- "retry_after": 5,
414
- }), 503
415
-
416
- completed = done_event.wait(timeout=_REQUEST_TIMEOUT_S)
417
-
418
- if not completed:
419
- return jsonify({
420
- "error": f"Request timed out after {_REQUEST_TIMEOUT_S}s. ",
421
- "retry_after": 10,
422
- }), 503
423
-
424
- result = result_holder[0]
425
- if result is None:
426
- return jsonify({"error": "Internal error: inference worker returned no result."}), 500
427
-
428
- if "error" in result:
429
- return jsonify(result), 500
430
-
431
- return jsonify(result)
432
-
433
-
434
- if __name__ == "__main__":
435
- import signal, sys
436
-
437
- def sigint_handler(sig, frame):
438
- sys.exit(0)
439
- signal.signal(signal.SIGINT, sigint_handler)
440
-
441
- app.run(host=HOST, port=PORT, debug=False, threaded=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
agents/llm.py CHANGED
@@ -68,7 +68,7 @@ class LocalLLM(BaseLLM):
68
  except requests.exceptions.ConnectionError:
69
  self._last_prompt_tokens = 0
70
  self._last_completion_tokens = 0
71
- return "[LLM OFFLINE] Cannot connect to the inference server. Is gen_llm.py running?"
72
  except requests.exceptions.Timeout:
73
  self._last_prompt_tokens = 0
74
  self._last_completion_tokens = 0
 
68
  except requests.exceptions.ConnectionError:
69
  self._last_prompt_tokens = 0
70
  self._last_completion_tokens = 0
71
+ return "[LLM OFFLINE] Cannot connect to the inference server. Is nvidia_llm.py running?"
72
  except requests.exceptions.Timeout:
73
  self._last_prompt_tokens = 0
74
  self._last_completion_tokens = 0
agents/nvidia_llm.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import threading
4
+ import queue as _queue_module
5
+ import time
6
+ from flask import Flask, request, jsonify
7
+ from openai import OpenAI
8
+
9
+ # ── Logging ───────────────────────────────────────────────────────────────────
10
+ logging.basicConfig(
11
+ level=logging.INFO,
12
+ format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
13
+ datefmt="%Y-%m-%d %H:%M:%S",
14
+ )
15
+ log = logging.getLogger("nvidia_llm")
16
+ logging.getLogger("werkzeug").setLevel(logging.ERROR)
17
+ logging.getLogger("httpx").setLevel(logging.WARNING)
18
+
19
+ # ── Config ────────────────────────────────────────────────────────────────────
20
+ HOST = os.getenv("NVIDIA_HOST", "127.0.0.1")
21
+ PORT = int(os.getenv("NVIDIA_PORT", "8002"))
22
+ NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY")
23
+
24
+ _QUEUE_MAX_SIZE = int(os.getenv("NVIDIA_QUEUE_MAX", "8"))
25
+ _REQUEST_TIMEOUT_S = int(os.getenv("NVIDIA_LLM_TIMEOUT", "600"))
26
+
27
+ DEFAULT_MODEL = "MuXodious/Qwen2.5-7B-Instruct-1M-Thinking-Claude-Gemini-GPT5.2-DISTILL-PaperWitch-heresy"
28
+
29
+ # ── Flask app ─────────────────────────────────────────────────────────────────
30
+ app = Flask(__name__)
31
+
32
+ @app.after_request
33
+ def after_request(response):
34
+ response.headers.add('Access-Control-Allow-Origin', '*')
35
+ response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
36
+ response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
37
+ return response
38
+
39
+ # ── Inference Queue (multi-user serialization) ─────────────────────────────────
40
+ _inference_queue: _queue_module.Queue = _queue_module.Queue(maxsize=_QUEUE_MAX_SIZE)
41
+
42
+ def _run_inference(data: dict) -> dict:
43
+ raw_prompt = data.get("prompt", "")
44
+ if not raw_prompt:
45
+ return {"error": "Field 'prompt' is required."}
46
+
47
+ prompts = raw_prompt if isinstance(raw_prompt, list) and (len(raw_prompt) == 0 or not isinstance(raw_prompt[0], dict)) else [raw_prompt]
48
+
49
+ max_tokens = int(data.get("max_tokens", 1024))
50
+ temperature = float(data.get("temperature", 0.5))
51
+ top_p = float(data.get("top_p", 1.0))
52
+ model_name = data.get("model", DEFAULT_MODEL)
53
+
54
+ if not NVIDIA_API_KEY:
55
+ log.warning("NVIDIA_API_KEY is not set. API calls might fail if the token is required.")
56
+
57
+ client = OpenAI(
58
+ base_url="https://nim.api.nvidia.com/v1",
59
+ api_key=NVIDIA_API_KEY or "dummy-key-if-not-required-locally"
60
+ )
61
+
62
+ choices = []
63
+
64
+ for i, prompt in enumerate(prompts):
65
+ if isinstance(prompt, list):
66
+ messages = prompt
67
+ else:
68
+ messages = [
69
+ {"role": "user", "content": prompt}
70
+ ]
71
+
72
+ try:
73
+ completion = client.chat.completions.create(
74
+ model=model_name,
75
+ messages=messages,
76
+ temperature=temperature,
77
+ top_p=top_p,
78
+ max_tokens=max_tokens,
79
+ stream=True
80
+ )
81
+
82
+ full_output = ""
83
+ print(f"\n[CONSOLE STREAM] Generating via NVIDIA for: {model_name}")
84
+ print("-" * 30)
85
+
86
+ for chunk in completion:
87
+ if chunk.choices and chunk.choices[0].delta.content is not None:
88
+ content = chunk.choices[0].delta.content
89
+ print(content, end="", flush=True)
90
+ full_output += content
91
+
92
+ print("\n" + "-" * 30)
93
+
94
+ choices.append({
95
+ "index": i,
96
+ "text": full_output.strip(),
97
+ "thinking": ""
98
+ })
99
+
100
+ except Exception as e:
101
+ log.error(f"Error calling NVIDIA API: {e}")
102
+ choices.append({
103
+ "index": i,
104
+ "text": f"Error: {str(e)}",
105
+ "thinking": ""
106
+ })
107
+
108
+ return {
109
+ "model": model_name,
110
+ "choices": choices,
111
+ "device": "cloud_nvidia"
112
+ }
113
+
114
+ def _inference_worker() -> None:
115
+ log.info("Inference worker thread started (pid=%d)", os.getpid())
116
+ while True:
117
+ try:
118
+ item = _inference_queue.get(timeout=1.0)
119
+ except _queue_module.Empty:
120
+ continue
121
+
122
+ req_data, result_holder, done_event = item
123
+ try:
124
+ result_holder[0] = _run_inference(req_data)
125
+ except Exception as exc:
126
+ log.error("Inference worker error: %s", exc)
127
+ result_holder[0] = {"error": f"Inference failed: {exc}"}
128
+ finally:
129
+ done_event.set()
130
+ _inference_queue.task_done()
131
+
132
+ _worker_thread = threading.Thread(target=_inference_worker, name="inference-worker", daemon=True)
133
+ _worker_thread.start()
134
+
135
+ # ── Routes ────────────────────────────────────────────────────────────────────
136
+
137
+ @app.route("/health", methods=["GET"])
138
+ def health():
139
+ return jsonify({
140
+ "status": "ok",
141
+ "queue_depth": _inference_queue.qsize(),
142
+ "queue_max": _QUEUE_MAX_SIZE
143
+ })
144
+
145
+ @app.route("/v1/completions", methods=["POST", "OPTIONS"])
146
+ def completions():
147
+ if request.method == "OPTIONS":
148
+ return jsonify({}), 200
149
+
150
+ data: dict = request.get_json(force=True) or {}
151
+
152
+ current_depth = _inference_queue.qsize()
153
+ if current_depth >= _QUEUE_MAX_SIZE:
154
+ return jsonify({
155
+ "error": "Server busy β€” all inference slots are occupied. Please try again shortly.",
156
+ "retry_after": 5
157
+ }), 503
158
+
159
+ result_holder: list = [None]
160
+ done_event = threading.Event()
161
+
162
+ try:
163
+ _inference_queue.put_nowait((data, result_holder, done_event))
164
+ except _queue_module.Full:
165
+ return jsonify({
166
+ "error": "Server busy β€” inference queue full. Please try again shortly.",
167
+ "retry_after": 5,
168
+ }), 503
169
+
170
+ completed = done_event.wait(timeout=_REQUEST_TIMEOUT_S)
171
+
172
+ if not completed:
173
+ return jsonify({
174
+ "error": f"Request timed out after {_REQUEST_TIMEOUT_S}s. ",
175
+ "retry_after": 10,
176
+ }), 503
177
+
178
+ result = result_holder[0]
179
+ if result is None:
180
+ return jsonify({"error": "Internal error: inference worker returned no result."}), 500
181
+
182
+ if "error" in result:
183
+ return jsonify(result), 500
184
+
185
+ return jsonify(result)
186
+
187
+ if __name__ == "__main__":
188
+ import signal, sys
189
+
190
+ def sigint_handler(sig, frame):
191
+ sys.exit(0)
192
+ signal.signal(signal.SIGINT, sigint_handler)
193
+
194
+ log.info(f"Starting NVIDIA LLM agent on http://{HOST}:{PORT}")
195
+ app.run(host=HOST, port=PORT, debug=False, threaded=True)
app.py CHANGED
@@ -240,7 +240,7 @@ def get_session_token() -> str:
240
  return token
241
 
242
  def trigger_kv_cache_update(session_token: str = "admin"):
243
- """Fetches all text and sends it to gen_llm to update KV cache."""
244
  def _update(token):
245
  from pipeline import vector_store
246
  import requests
@@ -327,7 +327,7 @@ def status():
327
  vec_count = vector_store.count()
328
  graph_stat = graph_store.get_stats()
329
 
330
- # Probe gen_llm
331
  import requests as req
332
  gen_ok, embed_ok = False, False
333
  gen_info = {}
@@ -354,7 +354,7 @@ def status():
354
  return jsonify({
355
  "vector_db": {"status": "ok", "chunks": vec_count},
356
  "graph_db": graph_stat,
357
- "gen_llm": {
358
  "endpoint": config.LLM_BASE_URL,
359
  "online": gen_ok,
360
  "model": "-".join(gen_info.get("model", config.LLM_MODEL_ID).split("-")[:2]) if "-" in gen_info.get("model", config.LLM_MODEL_ID) else gen_info.get("model", config.LLM_MODEL_ID),
@@ -1041,7 +1041,7 @@ def ingest_v1_sync():
1041
 
1042
  @app.route("/api/probe/gen", methods=["POST"])
1043
  def probe_gen():
1044
- """Quick smoke-test for the gen_llm server."""
1045
  import requests as req
1046
  try:
1047
  r = req.post(
 
240
  return token
241
 
242
  def trigger_kv_cache_update(session_token: str = "admin"):
243
+ """Fetches all text and sends it to nvidia_llm to update KV cache."""
244
  def _update(token):
245
  from pipeline import vector_store
246
  import requests
 
327
  vec_count = vector_store.count()
328
  graph_stat = graph_store.get_stats()
329
 
330
+ # Probe nvidia_llm
331
  import requests as req
332
  gen_ok, embed_ok = False, False
333
  gen_info = {}
 
354
  return jsonify({
355
  "vector_db": {"status": "ok", "chunks": vec_count},
356
  "graph_db": graph_stat,
357
+ "nvidia_llm": {
358
  "endpoint": config.LLM_BASE_URL,
359
  "online": gen_ok,
360
  "model": "-".join(gen_info.get("model", config.LLM_MODEL_ID).split("-")[:2]) if "-" in gen_info.get("model", config.LLM_MODEL_ID) else gen_info.get("model", config.LLM_MODEL_ID),
 
1041
 
1042
  @app.route("/api/probe/gen", methods=["POST"])
1043
  def probe_gen():
1044
+ """Quick smoke-test for the nvidia_llm server."""
1045
  import requests as req
1046
  try:
1047
  r = req.post(
nvidia_frontend_test.html ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>NVIDIA LLM Local Endpoint Tester</title>
7
+ <style>
8
+ body {
9
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10
+ background-color: #121212;
11
+ color: #e0e0e0;
12
+ margin: 0;
13
+ padding: 20px;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ }
18
+ h1 {
19
+ color: #76b900; /* NVIDIA Green */
20
+ margin-bottom: 20px;
21
+ }
22
+ .container {
23
+ width: 100%;
24
+ max-width: 800px;
25
+ background-color: #1e1e1e;
26
+ border-radius: 10px;
27
+ padding: 20px;
28
+ box-shadow: 0 4px 15px rgba(0,0,0,0.5);
29
+ }
30
+ .form-group {
31
+ margin-bottom: 15px;
32
+ }
33
+ label {
34
+ display: block;
35
+ margin-bottom: 5px;
36
+ font-weight: bold;
37
+ color: #bbb;
38
+ }
39
+ textarea, input[type="text"], input[type="number"] {
40
+ width: 100%;
41
+ padding: 10px;
42
+ border: 1px solid #333;
43
+ border-radius: 5px;
44
+ background-color: #2a2a2a;
45
+ color: #fff;
46
+ box-sizing: border-box;
47
+ }
48
+ textarea {
49
+ resize: vertical;
50
+ height: 120px;
51
+ }
52
+ button {
53
+ background-color: #76b900;
54
+ color: #000;
55
+ border: none;
56
+ padding: 10px 20px;
57
+ border-radius: 5px;
58
+ font-weight: bold;
59
+ cursor: pointer;
60
+ transition: background-color 0.2s;
61
+ font-size: 16px;
62
+ }
63
+ button:hover {
64
+ background-color: #8ce100;
65
+ }
66
+ button:disabled {
67
+ background-color: #444;
68
+ color: #888;
69
+ cursor: not-allowed;
70
+ }
71
+ .response-area {
72
+ margin-top: 20px;
73
+ }
74
+ .response-box {
75
+ background-color: #2a2a2a;
76
+ border: 1px solid #444;
77
+ border-radius: 5px;
78
+ padding: 15px;
79
+ min-height: 100px;
80
+ white-space: pre-wrap;
81
+ overflow-x: auto;
82
+ color: #dcdcdc;
83
+ line-height: 1.5;
84
+ }
85
+ .error {
86
+ color: #ff5252;
87
+ font-weight: bold;
88
+ }
89
+ .spinner {
90
+ display: none;
91
+ margin-left: 10px;
92
+ border: 3px solid rgba(255,255,255,0.1);
93
+ border-top: 3px solid #76b900;
94
+ border-radius: 50%;
95
+ width: 15px;
96
+ height: 15px;
97
+ animation: spin 1s linear infinite;
98
+ vertical-align: middle;
99
+ }
100
+ @keyframes spin {
101
+ 0% { transform: rotate(0deg); }
102
+ 100% { transform: rotate(360deg); }
103
+ }
104
+ </style>
105
+ </head>
106
+ <body>
107
+
108
+ <h1>NVIDIA LLM Tester</h1>
109
+
110
+ <div class="container">
111
+ <div class="form-group">
112
+ <label for="endpoint">Local API Endpoint:</label>
113
+ <input type="text" id="endpoint" value="http://127.0.0.1:8003/v1/completions">
114
+ </div>
115
+
116
+ <div class="form-group">
117
+ <label for="prompt">Prompt:</label>
118
+ <textarea id="prompt" placeholder="Enter your prompt here...">What are the top 3 features of NVIDIA GPUs for AI workloads?</textarea>
119
+ </div>
120
+
121
+ <div class="form-group" style="display: flex; gap: 15px;">
122
+ <div style="flex: 1;">
123
+ <label for="max_tokens">Max Tokens:</label>
124
+ <input type="number" id="max_tokens" value="1024">
125
+ </div>
126
+ <div style="flex: 1;">
127
+ <label for="temperature">Temperature:</label>
128
+ <input type="number" id="temperature" value="0.5" step="0.1" min="0" max="2">
129
+ </div>
130
+ </div>
131
+
132
+ <button id="send-btn">
133
+ Send Request
134
+ <span class="spinner" id="spinner"></span>
135
+ </button>
136
+
137
+ <div class="response-area">
138
+ <label>Response:</label>
139
+ <div class="response-box" id="response-box">Awaiting input...</div>
140
+ </div>
141
+ </div>
142
+
143
+ <script>
144
+ document.getElementById('send-btn').addEventListener('click', async () => {
145
+ const endpoint = document.getElementById('endpoint').value.trim();
146
+ const promptText = document.getElementById('prompt').value.trim();
147
+ const maxTokens = parseInt(document.getElementById('max_tokens').value) || 1024;
148
+ const temperature = parseFloat(document.getElementById('temperature').value) || 0.5;
149
+ const responseBox = document.getElementById('response-box');
150
+ const sendBtn = document.getElementById('send-btn');
151
+ const spinner = document.getElementById('spinner');
152
+
153
+ if (!promptText) {
154
+ alert("Please enter a prompt.");
155
+ return;
156
+ }
157
+
158
+ // UI feedback
159
+ sendBtn.disabled = true;
160
+ spinner.style.display = 'inline-block';
161
+ responseBox.innerHTML = '<i>Processing request...</i>';
162
+ responseBox.classList.remove('error');
163
+
164
+ const payload = {
165
+ prompt: promptText,
166
+ max_tokens: maxTokens,
167
+ temperature: temperature
168
+ };
169
+
170
+ try {
171
+ const response = await fetch(endpoint, {
172
+ method: 'POST',
173
+ headers: {
174
+ 'Content-Type': 'application/json'
175
+ },
176
+ body: JSON.stringify(payload)
177
+ });
178
+
179
+ if (!response.ok) {
180
+ const errorData = await response.json().catch(() => null);
181
+ const errorMsg = errorData && errorData.error ? errorData.error : `HTTP error ${response.status}`;
182
+ throw new Error(errorMsg);
183
+ }
184
+
185
+ const data = await response.json();
186
+
187
+ if (data.choices && data.choices.length > 0) {
188
+ responseBox.textContent = data.choices[0].text;
189
+ } else if (data.error) {
190
+ throw new Error(data.error);
191
+ } else {
192
+ responseBox.textContent = "Received unexpected response format:\n" + JSON.stringify(data, null, 2);
193
+ }
194
+ } catch (err) {
195
+ responseBox.textContent = `Error: ${err.message}`;
196
+ responseBox.classList.add('error');
197
+ } finally {
198
+ sendBtn.disabled = false;
199
+ spinner.style.display = 'none';
200
+ }
201
+ });
202
+ </script>
203
+ </body>
204
+ </html>
start.sh CHANGED
@@ -7,7 +7,7 @@
7
  # bash start.sh -hf -noadmin # HF mode with admin controls disabled (public endpoint)
8
  #
9
  # Environment variables set by this script:
10
- # HF_MODE=1 β†’ Activates low-resource CPU path in config.py, gen_llm.py, embed_llm.py
11
  # ADMIN_MODE=0 β†’ Disables admin API routes and hides UI admin controls
12
  # GEN_MODEL_ID β†’ Overridden for HF mode (microsoft/Phi-3.5-mini-instruct)
13
  # EMBED_MODEL_ID β†’ Overridden for HF mode (bge-small-en-v1.5)
@@ -90,7 +90,7 @@ else
90
  fi
91
  done
92
  # Also kill by process name for orphaned workers
93
- pkill -9 -f "agents/gen_llm.py" 2>/dev/null || true
94
  pkill -9 -f "agents/embed_llm.py" 2>/dev/null || true
95
  sleep 2
96
  echo "[pre-flight] Done."
@@ -106,11 +106,11 @@ python agents/embed_llm.py &
106
  EMBED_PID=$!
107
  echo " embed_llm PID: $EMBED_PID"
108
 
109
- # ── Start gen_llm on port 8002 ────────────────────────────────────────────────
110
- echo "[2/3] Starting gen_llm (port 8002)..."
111
- python agents/gen_llm.py &
112
  GEN_PID=$!
113
- echo " gen_llm PID: $GEN_PID"
114
 
115
  # ── Wait for microservices to initialise ──────────────────────────────────────
116
  echo "Waiting for LLM microservices to initialise..."
 
7
  # bash start.sh -hf -noadmin # HF mode with admin controls disabled (public endpoint)
8
  #
9
  # Environment variables set by this script:
10
+ # HF_MODE=1 β†’ Activates low-resource CPU path in config.py, nvidia_llm.py, embed_llm.py
11
  # ADMIN_MODE=0 β†’ Disables admin API routes and hides UI admin controls
12
  # GEN_MODEL_ID β†’ Overridden for HF mode (microsoft/Phi-3.5-mini-instruct)
13
  # EMBED_MODEL_ID β†’ Overridden for HF mode (bge-small-en-v1.5)
 
90
  fi
91
  done
92
  # Also kill by process name for orphaned workers
93
+ pkill -9 -f "agents/nvidia_llm.py" 2>/dev/null || true
94
  pkill -9 -f "agents/embed_llm.py" 2>/dev/null || true
95
  sleep 2
96
  echo "[pre-flight] Done."
 
106
  EMBED_PID=$!
107
  echo " embed_llm PID: $EMBED_PID"
108
 
109
+ # ── Start nvidia_llm on port 8002 ────────────────────────────────────────────────
110
+ echo "[2/3] Starting nvidia_llm (port 8002)..."
111
+ python agents/nvidia_llm.py &
112
  GEN_PID=$!
113
+ echo " nvidia_llm PID: $GEN_PID"
114
 
115
  # ── Wait for microservices to initialise ──────────────────────────────────────
116
  echo "Waiting for LLM microservices to initialise..."
static/app.js CHANGED
@@ -112,7 +112,7 @@ async function refreshStatus() {
112
 
113
  const vecOk = (d.vector_db?.chunks ?? -1) >= 0;
114
  const graphOk = d.graph_db?.available;
115
- const genOk = d.gen_llm?.online;
116
  const embedOk = d.embed_llm?.online;
117
 
118
  state.isAdmin = !!d.is_admin;
@@ -128,13 +128,13 @@ async function refreshStatus() {
128
  setPill('status-graph', graphOk, graphOk ? `Kuzu DB Β· ${d.graph_db.nodes} nodes, ${d.graph_db.relationships} edges` : 'Kuzu DB Β· offline');
129
 
130
  const genStatus = genOk
131
- ? `Gen Β· ${(d.gen_llm?.model || '').split('/').pop()} (GPU: ${d.gen_llm?.gpu_id} | KV: ${d.gen_llm?.kv_cache_length} tkns)`
132
  : 'Gen LLM Β· offline';
133
  setPill('status-gen', genOk, genStatus);
134
 
135
  setPill('status-embed', embedOk, embedOk ? `Embed Β· ${(d.embed_llm?.model || '').split('/').pop()}` : 'Embed LLM Β· offline');
136
 
137
- if (!genOk) diag.warn('gen_llm server is offline');
138
  if (!embedOk) diag.warn('embed_llm server is offline');
139
  } catch(err) {
140
  diag.error('Status refresh failed:', err);
@@ -612,7 +612,7 @@ async function pollJobStatus(jobId) {
612
 
613
  // ── Default prompt buttons ─────────────────────────────────────────────────────
614
  $('preset-gen').addEventListener('click', async () => {
615
- diag.info('Probing gen_llm server…');
616
  notify('Testing Gen LLM server (port 8002)…', 'info', 3000);
617
  const btn = $('preset-gen');
618
  btn.disabled = true;
 
112
 
113
  const vecOk = (d.vector_db?.chunks ?? -1) >= 0;
114
  const graphOk = d.graph_db?.available;
115
+ const genOk = d.nvidia_llm?.online;
116
  const embedOk = d.embed_llm?.online;
117
 
118
  state.isAdmin = !!d.is_admin;
 
128
  setPill('status-graph', graphOk, graphOk ? `Kuzu DB Β· ${d.graph_db.nodes} nodes, ${d.graph_db.relationships} edges` : 'Kuzu DB Β· offline');
129
 
130
  const genStatus = genOk
131
+ ? `Gen Β· ${(d.nvidia_llm?.model || '').split('/').pop()} (GPU: ${d.nvidia_llm?.gpu_id} | KV: ${d.nvidia_llm?.kv_cache_length} tkns)`
132
  : 'Gen LLM Β· offline';
133
  setPill('status-gen', genOk, genStatus);
134
 
135
  setPill('status-embed', embedOk, embedOk ? `Embed Β· ${(d.embed_llm?.model || '').split('/').pop()}` : 'Embed LLM Β· offline');
136
 
137
+ if (!genOk) diag.warn('nvidia_llm server is offline');
138
  if (!embedOk) diag.warn('embed_llm server is offline');
139
  } catch(err) {
140
  diag.error('Status refresh failed:', err);
 
612
 
613
  // ── Default prompt buttons ─────────────────────────────────────────────────────
614
  $('preset-gen').addEventListener('click', async () => {
615
+ diag.info('Probing nvidia_llm server…');
616
  notify('Testing Gen LLM server (port 8002)…', 'info', 3000);
617
  const btn = $('preset-gen');
618
  btn.disabled = true;
templates/index.html CHANGED
@@ -399,7 +399,7 @@
399
  return;
400
  }
401
  const vecOk = (data.vector_db?.chunks ?? -1) >= 0;
402
- const genOk = data.gen_llm?.online;
403
  const embedOk = data.embed_llm?.online;
404
  // Check for active ingestion / graphdb tasks via our system info endpoint as well
405
  let isIngesting = false;
 
399
  return;
400
  }
401
  const vecOk = (data.vector_db?.chunks ?? -1) >= 0;
402
+ const genOk = data.nvidia_llm?.online;
403
  const embedOk = data.embed_llm?.online;
404
  // Check for active ingestion / graphdb tasks via our system info endpoint as well
405
  let isIngesting = false;