Che237 commited on
Commit
02419cd
·
verified ·
1 Parent(s): 91af9cd

Replace Gemini with in-Space open-source LLM (Qwen3.5-2B via llama.cpp)

Browse files
Files changed (3) hide show
  1. Dockerfile +1 -0
  2. app.py +313 -218
  3. requirements.txt +10 -4
Dockerfile CHANGED
@@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y \
10
  ffmpeg \
11
  libsm6 \
12
  libxext6 \
 
13
  && rm -rf /var/lib/apt/lists/* \
14
  && git lfs install
15
 
 
10
  ffmpeg \
11
  libsm6 \
12
  libxext6 \
13
+ libgomp1 \
14
  && rm -rf /var/lib/apt/lists/* \
15
  && git lfs install
16
 
app.py CHANGED
@@ -12,6 +12,8 @@ import json
12
  import os
13
  import subprocess
14
  import sys
 
 
15
  from pathlib import Path
16
  from datetime import datetime
17
  import logging
@@ -33,13 +35,7 @@ from huggingface_hub import HfApi, hf_hub_download, upload_file
33
  from fastapi import FastAPI, Request
34
  from fastapi.middleware.cors import CORSMiddleware
35
  from fastapi.responses import JSONResponse
36
-
37
- # Gemini AI (new SDK: google-genai)
38
- try:
39
- from google import genai
40
- GEMINI_AVAILABLE = True
41
- except ImportError:
42
- GEMINI_AVAILABLE = False
43
 
44
  logging.basicConfig(level=logging.INFO)
45
  logger = logging.getLogger(__name__)
@@ -60,11 +56,22 @@ TRAINING_DATA_DIR = APP_DIR / "training_data"
60
  TRAINING_DATA_DIR.mkdir(exist_ok=True)
61
 
62
  # Environment
63
- GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
64
- GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")
65
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
66
  HF_MODEL_REPO = os.environ.get("HF_MODEL_REPO", "Che237/cyberforge-models")
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  logger.info(f"APP_DIR: {APP_DIR}")
69
  logger.info(f"NOTEBOOKS_DIR: {NOTEBOOKS_DIR}")
70
  logger.info(f"NOTEBOOKS_DIR exists: {NOTEBOOKS_DIR.exists()}")
@@ -86,131 +93,50 @@ SECURITY_TASKS = [
86
 
87
 
88
  # ============================================================================
89
- # GEMINI SERVICE (for REST API)
90
  # ============================================================================
91
 
92
- class GeminiService:
93
- """Google Gemini AI for cybersecurity chat and analysis"""
94
-
95
- SYSTEM_PROMPT = """You are CyberForge AI, an advanced cybersecurity expert. You specialize in:
96
- - Real-time threat detection and analysis
97
- - Malware and phishing identification
98
- - Network security assessment
99
- - Browser security monitoring
100
- - Risk assessment and mitigation
101
 
102
- When analyzing security queries, provide:
103
- 1. Risk Level (Critical/High/Medium/Low)
104
- 2. Threat Types identified
105
- 3. Confidence Score (0.0-1.0)
106
- 4. Detailed technical analysis
107
- 5. Specific actionable recommendations
108
 
109
- Always be precise, professional, and actionable."""
110
-
111
- def __init__(self):
112
- self.client = None
113
- self.ready = False
114
- self.custom_knowledge = {}
115
- self.training_examples = []
116
-
117
- def initialize(self):
118
- if not GEMINI_AVAILABLE:
119
- logger.warning("google-genai not installed, Gemini unavailable")
120
- return
121
- if not GEMINI_API_KEY:
122
- logger.warning("GEMINI_API_KEY not set, Gemini unavailable")
123
- return
124
- try:
125
- self.client = genai.Client(api_key=GEMINI_API_KEY)
126
- resp = self.client.models.generate_content(
127
- model=GEMINI_MODEL,
128
- contents="Test. Respond with OK."
129
- )
130
- if resp.text:
131
- self.ready = True
132
- logger.info(f"✅ Gemini initialized (model: {GEMINI_MODEL})")
133
- self._load_knowledge()
134
- except Exception as e:
135
- logger.error(f"❌ Gemini init failed: {e}")
136
- self.ready = False
137
-
138
- def _load_knowledge(self):
139
- try:
140
- for f in KNOWLEDGE_BASE_DIR.glob("*.json"):
141
- with open(f) as fh:
142
- self.custom_knowledge[f.stem] = json.load(fh)
143
- logger.info(f"Loaded {len(self.custom_knowledge)} knowledge files")
144
- except Exception as e:
145
- logger.warning(f"Knowledge load error: {e}")
146
- try:
147
- for f in TRAINING_DATA_DIR.glob("*.json"):
148
- with open(f) as fh:
149
- data = json.load(fh)
150
- if isinstance(data, list):
151
- self.training_examples.extend(data)
152
- else:
153
- self.training_examples.append(data)
154
- logger.info(f"Loaded {len(self.training_examples)} training examples")
155
- except Exception as e:
156
- logger.warning(f"Training data load error: {e}")
157
-
158
- def analyze(self, query: str, context: Dict = None, history: List = None) -> Dict:
159
- if not self.ready:
160
  return self._fallback(query)
161
- try:
162
- knowledge_str = json.dumps(self.custom_knowledge, indent=1)[:2000] if self.custom_knowledge else "None"
163
- examples_str = "\n".join(
164
- f"Q: {ex.get('input','')}\nA: {ex.get('output','')}"
165
- for ex in self.training_examples[-3:]
166
- ) if self.training_examples else "None"
167
- context_str = f"\nCONTEXT:\n{json.dumps(context, indent=1)[:3000]}\n" if context else ""
168
-
169
- prompt = f"""{self.SYSTEM_PROMPT}
170
-
171
- KNOWLEDGE BASE (summary):
172
- {knowledge_str}
173
-
174
- TRAINING EXAMPLES:
175
- {examples_str}
176
- {context_str}
177
- USER QUERY:
178
- {query}
179
-
180
- Provide a comprehensive cybersecurity analysis:"""
181
-
182
- response = self.client.models.generate_content(
183
- model=GEMINI_MODEL,
184
- contents=prompt,
185
- config={"temperature": 0.3, "max_output_tokens": 2048},
186
- )
187
- text = response.text if response.text else "No response generated"
188
- text_lower = text.lower()
189
- if "critical" in text_lower:
190
- risk_level, risk_score = "Critical", 9.0
191
- elif "high" in text_lower:
192
- risk_level, risk_score = "High", 7.0
193
- elif "medium" in text_lower:
194
- risk_level, risk_score = "Medium", 5.0
195
- else:
196
- risk_level, risk_score = "Low", 3.0
197
-
198
- return {
199
- "response": text,
200
- "confidence": 0.85,
201
- "risk_level": risk_level,
202
- "risk_score": risk_score,
203
- "insights": [f"Analysis performed by Gemini ({GEMINI_MODEL})"],
204
- "recommendations": [],
205
- "model_used": GEMINI_MODEL,
206
- "timestamp": datetime.utcnow().isoformat(),
207
- }
208
- except Exception as e:
209
- logger.error(f"Gemini analysis error: {e}")
210
  return self._fallback(query)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
  def _fallback(self, query: str) -> Dict:
213
- """ML-powered analysis when Gemini is unavailable"""
214
  import re
215
  global ml_loader
216
 
@@ -313,11 +239,11 @@ Provide a comprehensive cybersecurity analysis:"""
313
  "- URL threat analysis (phishing, malware, web attacks)\n"
314
  "- Network anomaly detection\n"
315
  "- Real-time threat event monitoring\n\n"
316
- "*AI chat (Gemini) is currently unavailable. Provide a URL for full ML-based analysis.*"
317
  )
318
  insights = [f"{n_loaded} ML models active"]
319
 
320
- response_lines.append("\n---\n*Powered by CyberForge ML models — Gemini AI offline.*")
321
 
322
  return {
323
  "response": "\n".join(response_lines),
@@ -332,7 +258,7 @@ Provide a comprehensive cybersecurity analysis:"""
332
 
333
 
334
  # Singleton
335
- gemini_service = GeminiService()
336
 
337
 
338
  # ============================================================================
@@ -567,8 +493,149 @@ def extract_url_features(url: str) -> Dict:
567
  #
568
  # Loads pretrained BERT-based classifiers from the Hugging Face Hub.
569
  # Models are loaded lazily on first request to keep cold-start fast.
570
- # A 7B Security LLM is NOT loaded locally (too big for free tier) —
571
- # it's accessed via the HF Inference API on demand.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
 
573
  class TransformerModelLoader:
574
  """Loads pretrained Transformer classifiers from the HF Hub on demand."""
@@ -585,14 +652,17 @@ class TransformerModelLoader:
585
  "desc": "BERT-based URL phishing/malware classifier",
586
  },
587
  }
588
- # Model used via HF Inference API. ZySec-AI/SecurityLLM isn't served on the
589
- # free Inference API tier (404). Mistral-7B-Instruct-v0.3 is widely available
590
- # and works well for cyber Q&A. Override via SECURITY_LLM_MODEL env var.
591
- SECURITY_LLM_REPO = os.environ.get("SECURITY_LLM_MODEL", "mistralai/Mistral-7B-Instruct-v0.3")
 
592
 
593
  def __init__(self):
594
  self.pipelines = {} # name → transformers.Pipeline (lazy-loaded)
595
  self.load_errors = {} # name → last error message
 
 
596
  self.transformers_available = False
597
  try:
598
  import transformers # noqa: F401
@@ -694,53 +764,83 @@ class TransformerModelLoader:
694
  "inference_source": "entropy-heuristic",
695
  }
696
 
697
- def security_chat(self, query: str, max_tokens: int = 512) -> Dict:
698
- """Cybersecurity Q&A via ZySec-AI/SecurityLLM hosted on HF Inference API.
699
- Falls back to Gemini when the LLM is rate-limited or HF token is missing."""
700
- if not HF_TOKEN:
701
- return {
702
- "model": "security-llm",
703
- "response": None,
704
- "error": "HF_TOKEN env var required to call ZySec-AI/SecurityLLM via Inference API",
705
- "fallback_available": gemini_service.ready,
706
- }
 
 
 
 
 
707
  try:
708
- import requests
709
- url = f"https://api-inference.huggingface.co/models/{self.SECURITY_LLM_REPO}"
710
- headers = {"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"}
711
- payload = {
712
- "inputs": query[:2000],
713
- "parameters": {"max_new_tokens": max_tokens, "temperature": 0.3, "return_full_text": False},
714
- "options": {"wait_for_model": True},
715
- }
716
- r = requests.post(url, headers=headers, json=payload, timeout=45)
 
 
 
 
 
717
  if r.status_code == 200:
718
  data = r.json()
719
- text = data[0].get("generated_text") if isinstance(data, list) and data else (data.get("generated_text") if isinstance(data, dict) else str(data))
720
  return {
721
- "model": "security-llm",
722
- "source": "huggingface_inference_api",
723
  "response": text,
724
- "model_id": self.SECURITY_LLM_REPO,
725
  }
726
- return {
727
- "model": "security-llm",
728
- "error": f"HF Inference API HTTP {r.status_code}: {r.text[:200]}",
729
- "fallback_available": gemini_service.ready,
730
- }
731
  except Exception as e:
732
- return {
733
- "model": "security-llm",
734
- "error": f"{type(e).__name__}: {str(e)[:200]}",
735
- "fallback_available": gemini_service.ready,
736
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
 
738
  def status(self) -> Dict:
739
  return {
740
  "transformers_available": self.transformers_available,
741
  "loaded": list(self.pipelines.keys()),
742
- "available": list(self.REGISTRY.keys()) + ["security_llm (via HF Inference API)"],
743
  "load_errors": self.load_errors,
 
 
 
 
 
 
 
 
744
  }
745
 
746
  @staticmethod
@@ -1027,7 +1127,9 @@ async def api_health():
1027
  "status": "healthy",
1028
  "timestamp": datetime.utcnow().isoformat(),
1029
  "services": {
1030
- "gemini": gemini_service.ready,
 
 
1031
  "ml_models": ml_loader.ready,
1032
  "models_loaded": list(ml_loader.models.keys()),
1033
  "transformers": transformer_loader.transformers_available,
@@ -1053,7 +1155,7 @@ async def api_analyze(request: Request):
1053
  query = body.get("query", "")
1054
  context = body.get("context", {})
1055
  history = body.get("conversation_history", [])
1056
- result = gemini_service.analyze(query, context, history)
1057
  return result
1058
 
1059
 
@@ -1131,7 +1233,7 @@ async def api_scan_threats(request: Request):
1131
  """Threat scanning – called by backend mlService.scanForThreats()"""
1132
  body = await request.json()
1133
  query = json.dumps(body.get("data", body), indent=1)[:3000]
1134
- result = gemini_service.analyze(f"Scan these indicators for threats:\n{query}")
1135
  return result
1136
 
1137
 
@@ -1141,7 +1243,7 @@ async def api_generate_insights(request: Request):
1141
  body = await request.json()
1142
  query = body.get("query", "")
1143
  context = body.get("context", {})
1144
- result = gemini_service.analyze(query, context)
1145
  return {
1146
  "insights": result.get("response", ""),
1147
  "confidence": result.get("confidence", 0),
@@ -1200,20 +1302,18 @@ async def api_v2_dga_detect(request: Request):
1200
 
1201
  @api.post("/api/v2/security-chat")
1202
  async def api_v2_security_chat(request: Request):
1203
- """Cybersecurity Q&A via ZySec-AI/SecurityLLM (HF Inference API).
 
1204
  Body: { "query": "...", "max_tokens": 512 }"""
1205
  body = await request.json()
1206
  query = body.get("query", "").strip()
1207
  if not query:
1208
  return JSONResponse(status_code=400, content={"detail": "query required"})
1209
  max_tokens = int(body.get("max_tokens", 512))
1210
- result = transformer_loader.security_chat(query, max_tokens=max_tokens)
1211
- # Auto-fallback to Gemini when LLM unavailable
1212
- if result.get("error") and gemini_service.ready:
1213
- gemini_result = gemini_service.analyze(query)
1214
- gemini_result["source"] = "gemini-fallback"
1215
- gemini_result["llm_error"] = result["error"]
1216
- return gemini_result
1217
  return result
1218
 
1219
 
@@ -1324,7 +1424,7 @@ async def api_v2_ioc_scan(request: Request):
1324
  "domain": "example.com", # optional
1325
  "ip": "1.2.3.4", # optional
1326
  "hash": "abc123...", # optional (future use)
1327
- "context": { ... } # optional — passed to Gemini if available
1328
  }
1329
 
1330
  Response: per-indicator results with unified risk summary.
@@ -1382,13 +1482,15 @@ async def api_v2_ioc_scan(request: Request):
1382
  else:
1383
  risk_level = "unknown"
1384
 
1385
- # Optional Gemini enrichment when available
 
1386
  ai_analysis = None
1387
- if gemini_service.ready and (url or domain):
1388
  target = url or domain
1389
- ai_analysis = gemini_service.analyze(
 
1390
  f"Scan these IOC indicators for threats: url={target}, domain={effective_domain}",
1391
- context=context,
1392
  )
1393
 
1394
  return {
@@ -1506,8 +1608,9 @@ async def api_v2_chat(request: Request):
1506
  persistent vector store, which requires paid GPU on free tier).
1507
  2. Translates raw ML signals / IOCs into plain human language when the
1508
  user includes raw data and asks for an explanation.
1509
- 3. Falls back to the Security LLM (Mistral-7B via HF Inference API)
1510
- or the ML-powered heuristic analyzer when Gemini is offline.
 
1511
 
1512
  NOTE ON REAL-TIME "LEARNING": HuggingFace Spaces cpu-basic does NOT
1513
  support fine-tuning or persistent model updates at runtime. What this
@@ -1674,22 +1777,9 @@ async def api_v2_chat(request: Request):
1674
  n_models = len(ml_loader.models)
1675
  sources.append({"type": "ml_models", "label": "ML Models", "value": f"{n_models}/4 loaded"})
1676
 
1677
- # ── LLM cascade: Gemini → Security LLM → ML heuristic ────────────
1678
- # Path 1: Gemini (premium, requires GEMINI_API_KEY env var)
1679
- if gemini_service.ready:
1680
- result = gemini_service.analyze(enriched_message, context=context, history=history)
1681
- # Merge detected risk if ML gave a stronger signal
1682
- if detected_risk_score > 0 and detected_risk_level != "unknown":
1683
- ml_score_100 = round(detected_risk_score * 100, 1)
1684
- if ml_score_100 > result.get("risk_score", 0):
1685
- result["risk_score"] = ml_score_100
1686
- result["risk_level"] = detected_risk_level
1687
- result["session_id"] = session_id
1688
- result["sources"] = sources
1689
- return result
1690
-
1691
- # Path 2: Security LLM via HF Inference API (Mistral-7B)
1692
- llm_result = transformer_loader.security_chat(enriched_message, max_tokens=600)
1693
  if llm_result.get("response") and not llm_result.get("error"):
1694
  text = llm_result["response"]
1695
  text_lower = text.lower()
@@ -1712,14 +1802,14 @@ async def api_v2_chat(request: Request):
1712
  "confidence": 0.72,
1713
  "risk_level": risk_level,
1714
  "risk_score": risk_score,
1715
- "model_used": llm_result.get("model_id", transformer_loader.SECURITY_LLM_REPO),
1716
  "session_id": session_id,
1717
  "sources": sources,
1718
  "timestamp": datetime.utcnow().isoformat(),
1719
  }
1720
 
1721
  # Path 3: ML heuristic fallback (always available)
1722
- fallback = gemini_service._fallback(enriched_message)
1723
  # Override with ML scan risk if available
1724
  if detected_risk_score > 0:
1725
  ml_score_100 = round(detected_risk_score * 100, 1)
@@ -1736,8 +1826,9 @@ async def api_analyze_network(request: Request):
1736
  """Network traffic analysis – called by backend mlService.analyzeNetworkTraffic()"""
1737
  body = await request.json()
1738
  traffic_data = body.get("traffic_data", {})
1739
- result = gemini_service.analyze(
1740
- f"Analyze this network traffic for security threats:\n{json.dumps(traffic_data, indent=1)[:3000]}"
 
1741
  )
1742
  return result
1743
 
@@ -1748,8 +1839,9 @@ async def api_execute_task(request: Request):
1748
  body = await request.json()
1749
  task_type = body.get("task_type", "")
1750
  task_data = body.get("task_data", {})
1751
- result = gemini_service.analyze(
1752
- f"Execute cybersecurity task '{task_type}':\n{json.dumps(task_data, indent=1)[:3000]}"
 
1753
  )
1754
  return result
1755
 
@@ -1759,8 +1851,9 @@ async def api_analyze_browser(request: Request):
1759
  """Browser session analysis"""
1760
  body = await request.json()
1761
  session = body.get("session_data", body)
1762
- result = gemini_service.analyze(
1763
- f"Analyze this browser session for security threats:\n{json.dumps(session, indent=1)[:3000]}"
 
1764
  )
1765
  return result
1766
 
@@ -1769,8 +1862,9 @@ async def api_analyze_browser(request: Request):
1769
  async def api_analyze_threat_feed(request: Request):
1770
  """Threat feed analysis"""
1771
  body = await request.json()
1772
- result = gemini_service.analyze(
1773
- f"Analyze this threat feed:\n{json.dumps(body, indent=1)[:3000]}"
 
1774
  )
1775
  return result
1776
 
@@ -1871,9 +1965,9 @@ def create_interface():
1871
  | Method | Endpoint | Description |
1872
  |--------|----------|-------------|
1873
  | GET | `/health` | Health check — version, models, services |
1874
- | POST | `/analyze` | AI chat / general analysis (Gemini or ML fallback) |
1875
  | POST | `/analyze-url` | URL threat analysis — 4 ML models + risk factors |
1876
- | POST | `/scan-threats` | Deep threat scan (Gemini or ML fallback) |
1877
  | POST | `/api/insights/generate` | AI-generated security insights |
1878
  | POST | `/api/models/predict` | Direct ML model prediction |
1879
  | GET | `/models` | List loaded models with source info |
@@ -1889,7 +1983,7 @@ def create_interface():
1889
  | POST | `/api/v2/ioc-scan` | Multi-IOC scanner: URL + domain + DGA + BERT |
1890
  | POST | `/api/v2/url-enrich` | Enriched analysis: ML + BERT + DGA + explainability |
1891
  | POST | `/api/v2/chat` | Security chatbot — context-grounded, RAG-lite, machine→human translation |
1892
- | POST | `/api/v2/security-chat` | Security LLM Q&A (Mistral-7B via HF Inference API) |
1893
  | GET | `/api/v2/status` | Transformer model load status |
1894
 
1895
  ### Quick Examples
@@ -1931,8 +2025,9 @@ curl -X POST https://che237-cyberforge.hf.space/api/v2/chat \\
1931
 
1932
  # Initialize services at startup
1933
  logger.info("🚀 Initializing CyberForge AI services...")
1934
- gemini_service.initialize()
1935
  ml_loader.initialize()
 
 
1936
  logger.info("✅ Services initialized")
1937
 
1938
  # Create Gradio app and mount it on FastAPI
 
12
  import os
13
  import subprocess
14
  import sys
15
+ import threading
16
+ import time
17
  from pathlib import Path
18
  from datetime import datetime
19
  import logging
 
35
  from fastapi import FastAPI, Request
36
  from fastapi.middleware.cors import CORSMiddleware
37
  from fastapi.responses import JSONResponse
38
+ from starlette.concurrency import run_in_threadpool
 
 
 
 
 
 
39
 
40
  logging.basicConfig(level=logging.INFO)
41
  logger = logging.getLogger(__name__)
 
56
  TRAINING_DATA_DIR.mkdir(exist_ok=True)
57
 
58
  # Environment
 
 
59
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
60
  HF_MODEL_REPO = os.environ.get("HF_MODEL_REPO", "Che237/cyberforge-models")
61
 
62
+ # Open-source chat model that runs inside this Space on CPU (llama.cpp, GGUF).
63
+ # It replaces Gemini and needs no API key or credit. Any GGUF chat model can be
64
+ # set here; on the free cpu-basic tier (2 vCPU) stay around 2B parameters or the
65
+ # answers will not fit inside the callers' timeouts.
66
+ LOCAL_LLM_REPO = os.environ.get("LOCAL_LLM_REPO", "unsloth/Qwen3.5-2B-GGUF")
67
+ LOCAL_LLM_FILE = os.environ.get("LOCAL_LLM_FILE", "Qwen3.5-2B-Q4_K_M.gguf")
68
+ LOCAL_LLM_CTX = int(os.environ.get("LOCAL_LLM_CTX", "4096"))
69
+ LOCAL_LLM_THREADS = int(os.environ.get("LOCAL_LLM_THREADS", "2"))
70
+ LOCAL_LLM_MAX_TOKENS = int(os.environ.get("LOCAL_LLM_MAX_TOKENS", "320"))
71
+ LOCAL_LLM_MAX_INPUT_CHARS = int(os.environ.get("LOCAL_LLM_MAX_INPUT_CHARS", "2400"))
72
+ LOCAL_LLM_TIME_BUDGET_S = float(os.environ.get("LOCAL_LLM_TIME_BUDGET_S", "22"))
73
+ LOCAL_LLM_QUEUE_TIMEOUT_S = float(os.environ.get("LOCAL_LLM_QUEUE_TIMEOUT_S", "5"))
74
+
75
  logger.info(f"APP_DIR: {APP_DIR}")
76
  logger.info(f"NOTEBOOKS_DIR: {NOTEBOOKS_DIR}")
77
  logger.info(f"NOTEBOOKS_DIR exists: {NOTEBOOKS_DIR.exists()}")
 
93
 
94
 
95
  # ============================================================================
96
+ # REASONING SERVICE (LLM-backed analysis for the REST API)
97
  # ============================================================================
98
 
99
+ class ReasoningService:
100
+ """Structured cybersecurity analysis for the REST endpoints.
 
 
 
 
 
 
 
101
 
102
+ The text comes from TransformerModelLoader.security_chat: the hosted model
103
+ when the account has Inference Providers credit, otherwise the open-source
104
+ model running inside this Space. If no LLM answers, the ML/keyword heuristic
105
+ in _fallback is used."""
 
 
106
 
107
+ def analyze(self, query: str, context: Dict = None, history: List = None,
108
+ allow_local: bool = True) -> Dict:
109
+ tl = globals().get("transformer_loader")
110
+ if tl is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  return self._fallback(query)
112
+ ctx = f"\n\nCONTEXT:\n{json.dumps(context, indent=1)[:2000]}" if context else ""
113
+ llm = tl.security_chat(f"{query}{ctx}", max_tokens=700, allow_local=allow_local)
114
+ text = llm.get("response")
115
+ if not text or llm.get("error"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  return self._fallback(query)
117
+ tlow = text.lower()
118
+ if "critical" in tlow:
119
+ risk_level, risk_score = "Critical", 9.0
120
+ elif "high" in tlow:
121
+ risk_level, risk_score = "High", 7.0
122
+ elif "medium" in tlow:
123
+ risk_level, risk_score = "Medium", 5.0
124
+ else:
125
+ risk_level, risk_score = "Low", 3.0
126
+ model_id = llm.get("model_id", "llm")
127
+ return {
128
+ "response": text,
129
+ "confidence": 0.82,
130
+ "risk_level": risk_level,
131
+ "risk_score": risk_score,
132
+ "insights": [f"Analysis by {model_id} via {llm.get('source', 'llm')}"],
133
+ "recommendations": [],
134
+ "model_used": model_id,
135
+ "timestamp": datetime.utcnow().isoformat(),
136
+ }
137
 
138
  def _fallback(self, query: str) -> Dict:
139
+ """ML-powered analysis when no LLM is available"""
140
  import re
141
  global ml_loader
142
 
 
239
  "- URL threat analysis (phishing, malware, web attacks)\n"
240
  "- Network anomaly detection\n"
241
  "- Real-time threat event monitoring\n\n"
242
+ "*The AI chat model is currently unavailable. Provide a URL for full ML-based analysis.*"
243
  )
244
  insights = [f"{n_loaded} ML models active"]
245
 
246
+ response_lines.append("\n---\n*Powered by CyberForge ML models — AI chat model offline.*")
247
 
248
  return {
249
  "response": "\n".join(response_lines),
 
258
 
259
 
260
  # Singleton
261
+ reasoning_service = ReasoningService()
262
 
263
 
264
  # ============================================================================
 
493
  #
494
  # Loads pretrained BERT-based classifiers from the Hugging Face Hub.
495
  # Models are loaded lazily on first request to keep cold-start fast.
496
+ # The chat LLM is separate: see LocalLLM below (runs in this Space) and
497
+ # TransformerModelLoader.security_chat (hosted model first, then LocalLLM).
498
+
499
+ class LocalLLM:
500
+ """Open-source chat model served from inside the Space with llama.cpp.
501
+
502
+ The GGUF file is downloaded from the Hub once and kept in memory. Generation
503
+ is serialized (a llama.cpp context is not thread-safe) and time-boxed, so a
504
+ slow CPU cannot hold a request past the callers' timeouts."""
505
+
506
+ def __init__(self):
507
+ self.llm = None
508
+ self.error: Optional[str] = None
509
+ self._chatml = False
510
+ self._prefill = ""
511
+ self._load_lock = threading.Lock()
512
+ self._gen_lock = threading.Lock()
513
+
514
+ @property
515
+ def ready(self) -> bool:
516
+ return self.llm is not None
517
+
518
+ def status(self) -> Dict:
519
+ return {
520
+ "repo": LOCAL_LLM_REPO,
521
+ "file": LOCAL_LLM_FILE,
522
+ "loaded": self.ready,
523
+ "loading": self._load_lock.locked(),
524
+ "error": self.error,
525
+ }
526
+
527
+ def load(self):
528
+ """Download and load the model. Safe to call repeatedly."""
529
+ with self._load_lock:
530
+ if self.llm is not None or self.error:
531
+ return self.llm
532
+ try:
533
+ from llama_cpp import Llama
534
+ try:
535
+ path = hf_hub_download(repo_id=LOCAL_LLM_REPO, filename=LOCAL_LLM_FILE, token=HF_TOKEN or None)
536
+ except Exception:
537
+ # A stale HF_TOKEN must not block the download of a public model
538
+ path = hf_hub_download(repo_id=LOCAL_LLM_REPO, filename=LOCAL_LLM_FILE, token=False)
539
+ self._probe(path)
540
+ llm = Llama(model_path=path, n_ctx=LOCAL_LLM_CTX, n_threads=LOCAL_LLM_THREADS, verbose=False)
541
+ template = str((llm.metadata or {}).get("tokenizer.chat_template", ""))
542
+ self._chatml = "<|im_start|>" in template
543
+ # Qwen3.5 answers directly when the assistant turn opens with an
544
+ # empty think block; this is what its template emits for
545
+ # enable_thinking=False.
546
+ self._prefill = "<think>\n\n</think>\n\n" if "<think>" in template else ""
547
+ self.llm = llm
548
+ logger.info(f"✅ Local LLM loaded: {LOCAL_LLM_REPO}/{LOCAL_LLM_FILE}")
549
+ except Exception as e:
550
+ self.error = f"{type(e).__name__}: {str(e)[:300]}"
551
+ logger.error(f"❌ Local LLM unavailable: {self.error}")
552
+ return self.llm
553
+
554
+ @staticmethod
555
+ def _probe(path: str) -> None:
556
+ """Load the model once in a child process. A native crash there (for
557
+ example a CPU instruction the prebuilt wheel needs but this machine
558
+ lacks) raises here instead of killing the API process."""
559
+ code = (
560
+ "import sys; from llama_cpp import Llama; "
561
+ "m = Llama(model_path=sys.argv[1], n_ctx=256, n_threads=1, verbose=False); "
562
+ "m.create_completion('Hi', max_tokens=1)"
563
+ )
564
+ r = subprocess.run([sys.executable, "-c", code, path], capture_output=True, text=True, timeout=300)
565
+ if r.returncode != 0:
566
+ raise RuntimeError(f"llama.cpp probe exited with {r.returncode}: {(r.stderr or '')[-200:]}")
567
+
568
+ def chat(self, system: str, user: str, max_tokens: int = 512) -> Dict:
569
+ base = {"model": "local-llm", "source": "space_local_llamacpp", "model_id": LOCAL_LLM_REPO}
570
+ if self.llm is None:
571
+ if self._load_lock.locked():
572
+ return {**base, "response": None, "error": "local LLM is still loading"}
573
+ self.load()
574
+ if self.llm is None:
575
+ return {**base, "response": None, "error": self.error or "local LLM not loaded"}
576
+ if not self._gen_lock.acquire(timeout=LOCAL_LLM_QUEUE_TIMEOUT_S):
577
+ return {**base, "response": None, "error": "local LLM is busy with another request"}
578
+ started = time.monotonic()
579
+ stream = None
580
+ try:
581
+ max_tokens = max(16, min(int(max_tokens), LOCAL_LLM_MAX_TOKENS))
582
+ # The chat markers are structural: drop them from untrusted text
583
+ system = system.replace("<|im_start|>", "").replace("<|im_end|>", "")
584
+ user = user.replace("<|im_start|>", "").replace("<|im_end|>", "")
585
+ if len(user) > LOCAL_LLM_MAX_INPUT_CHARS:
586
+ # Keep both ends: context sits at the start, the question at the end
587
+ half = LOCAL_LLM_MAX_INPUT_CHARS // 2
588
+ user = f"{user[:half]}\n[...]\n{user[-half:]}"
589
+ if self._chatml:
590
+ prompt = (
591
+ f"<|im_start|>system\n{system}<|im_end|>\n"
592
+ f"<|im_start|>user\n{user}<|im_end|>\n"
593
+ f"<|im_start|>assistant\n{self._prefill}"
594
+ )
595
+ stream = self.llm.create_completion(
596
+ prompt, max_tokens=max_tokens, temperature=0.7, top_p=0.8, top_k=20,
597
+ presence_penalty=1.5, stop=["<|im_end|>", "<|endoftext|>"], stream=True,
598
+ )
599
+ read = lambda c: c["choices"][0].get("text") or ""
600
+ else:
601
+ stream = self.llm.create_chat_completion(
602
+ messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
603
+ max_tokens=max_tokens, temperature=0.7, top_p=0.8, stream=True,
604
+ )
605
+ read = lambda c: c["choices"][0].get("delta", {}).get("content") or ""
606
+ pieces: List[str] = []
607
+ truncated = False
608
+ for chunk in stream:
609
+ pieces.append(read(chunk))
610
+ if time.monotonic() - started > LOCAL_LLM_TIME_BUDGET_S:
611
+ truncated = True
612
+ break
613
+ import re
614
+ text = re.sub(r"<think>.*?</think>", "", "".join(pieces), flags=re.S).strip()
615
+ if truncated:
616
+ # Out of time: end on the last complete sentence or line
617
+ cut = max(text.rfind(". "), text.rfind(".\n"), text.rfind("\n"))
618
+ if cut > len(text) * 0.5:
619
+ text = text[:cut + 1].strip()
620
+ if not text:
621
+ return {**base, "response": None, "error": "local LLM returned no text"}
622
+ return {
623
+ **base,
624
+ "response": text,
625
+ "tokens": len(pieces),
626
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
627
+ "truncated": truncated,
628
+ }
629
+ except Exception as e:
630
+ return {**base, "response": None, "error": f"{type(e).__name__}: {str(e)[:200]}"}
631
+ finally:
632
+ if stream is not None and hasattr(stream, "close"):
633
+ stream.close()
634
+ self._gen_lock.release()
635
+
636
+
637
+ local_llm = LocalLLM()
638
+
639
 
640
  class TransformerModelLoader:
641
  """Loads pretrained Transformer classifiers from the HF Hub on demand."""
 
652
  "desc": "BERT-based URL phishing/malware classifier",
653
  },
654
  }
655
+ # Hosted reasoning model through HF Inference Providers (OpenAI-compatible
656
+ # router at router.huggingface.co/v1). Billed per token, so it only answers
657
+ # while the account has credit; otherwise security_chat uses LocalLLM.
658
+ # Override via REASONING_LLM_MODEL.
659
+ REASONING_LLM_REPO = os.environ.get("REASONING_LLM_MODEL", "deepseek-ai/DeepSeek-V3-0324")
660
 
661
  def __init__(self):
662
  self.pipelines = {} # name → transformers.Pipeline (lazy-loaded)
663
  self.load_errors = {} # name → last error message
664
+ self.last_engine = None # "hosted" | "local": which LLM answered last
665
+ self._provider_retry_at = 0.0 # monotonic time before which the hosted model is skipped
666
  self.transformers_available = False
667
  try:
668
  import transformers # noqa: F401
 
764
  "inference_source": "entropy-heuristic",
765
  }
766
 
767
+ SYSTEM_PROMPT = (
768
+ "You are CyberForge AI, a precise, professional cybersecurity analyst. "
769
+ "Give concise, technical, actionable answers. State a Risk Level "
770
+ "(Critical/High/Medium/Low) when assessing a threat, and cite CVE / MITRE "
771
+ "ATT&CK IDs where relevant."
772
+ )
773
+
774
+ def provider_available(self) -> bool:
775
+ """True when the hosted model is worth trying: a token is set and the
776
+ last call did not fail on billing or auth."""
777
+ return bool(HF_TOKEN) and time.monotonic() >= self._provider_retry_at
778
+
779
+ def _provider_chat(self, query: str, max_tokens: int) -> Optional[Dict]:
780
+ """Ask the hosted model. Returns None when it cannot answer."""
781
+ import requests
782
  try:
783
+ r = requests.post(
784
+ "https://router.huggingface.co/v1/chat/completions",
785
+ headers={"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"},
786
+ json={
787
+ "model": self.REASONING_LLM_REPO,
788
+ "messages": [
789
+ {"role": "system", "content": self.SYSTEM_PROMPT},
790
+ {"role": "user", "content": query[:6000]},
791
+ ],
792
+ "max_tokens": max_tokens,
793
+ "temperature": 0.3,
794
+ },
795
+ timeout=60,
796
+ )
797
  if r.status_code == 200:
798
  data = r.json()
799
+ text = data["choices"][0]["message"]["content"]
800
  return {
801
+ "model": "deepseek",
802
+ "source": "hf_inference_providers",
803
  "response": text,
804
+ "model_id": self.REASONING_LLM_REPO,
805
  }
806
+ # 401/402/403 (bad token, no credit) will not fix themselves quickly
807
+ self._provider_retry_at = time.monotonic() + (900 if r.status_code in (401, 402, 403) else 60)
808
+ logger.warning(f"Hosted LLM HTTP {r.status_code}: {r.text[:160]} — using the local model")
 
 
809
  except Exception as e:
810
+ self._provider_retry_at = time.monotonic() + 60
811
+ logger.warning(f"Hosted LLM error: {type(e).__name__}: {str(e)[:160]} — using the local model")
812
+ return None
813
+
814
+ def security_chat(self, query: str, max_tokens: int = 512, allow_local: bool = True) -> Dict:
815
+ """Cybersecurity Q&A.
816
+ 1. Hosted model through HF Inference Providers, while the account has credit.
817
+ 2. The open-source model running inside this Space (free, no key)."""
818
+ if self.provider_available():
819
+ hosted = self._provider_chat(query, max_tokens)
820
+ if hosted:
821
+ self.last_engine = "hosted"
822
+ return hosted
823
+ if not allow_local:
824
+ return {"model": "security-llm", "response": None, "error": "hosted LLM unavailable"}
825
+ result = local_llm.chat(self.SYSTEM_PROMPT, query, max_tokens=max_tokens)
826
+ if result.get("response"):
827
+ self.last_engine = "local"
828
+ return result
829
 
830
  def status(self) -> Dict:
831
  return {
832
  "transformers_available": self.transformers_available,
833
  "loaded": list(self.pipelines.keys()),
834
+ "available": list(self.REGISTRY.keys()) + ["reasoning_llm"],
835
  "load_errors": self.load_errors,
836
+ "reasoning_llm": {
837
+ "last_engine": self.last_engine,
838
+ "local": local_llm.status(),
839
+ "hosted": {
840
+ "model": self.REASONING_LLM_REPO,
841
+ "usable": self.provider_available(),
842
+ },
843
+ },
844
  }
845
 
846
  @staticmethod
 
1127
  "status": "healthy",
1128
  "timestamp": datetime.utcnow().isoformat(),
1129
  "services": {
1130
+ "reasoning_llm": transformer_loader.last_engine, # "hosted" | "local" | None until first answer
1131
+ "reasoning_model": transformer_loader.REASONING_LLM_REPO if HF_TOKEN else None,
1132
+ "local_llm": local_llm.status(),
1133
  "ml_models": ml_loader.ready,
1134
  "models_loaded": list(ml_loader.models.keys()),
1135
  "transformers": transformer_loader.transformers_available,
 
1155
  query = body.get("query", "")
1156
  context = body.get("context", {})
1157
  history = body.get("conversation_history", [])
1158
+ result = await run_in_threadpool(reasoning_service.analyze, query, context, history)
1159
  return result
1160
 
1161
 
 
1233
  """Threat scanning – called by backend mlService.scanForThreats()"""
1234
  body = await request.json()
1235
  query = json.dumps(body.get("data", body), indent=1)[:3000]
1236
+ result = await run_in_threadpool(reasoning_service.analyze, f"Scan these indicators for threats:\n{query}")
1237
  return result
1238
 
1239
 
 
1243
  body = await request.json()
1244
  query = body.get("query", "")
1245
  context = body.get("context", {})
1246
+ result = await run_in_threadpool(reasoning_service.analyze, query, context)
1247
  return {
1248
  "insights": result.get("response", ""),
1249
  "confidence": result.get("confidence", 0),
 
1302
 
1303
  @api.post("/api/v2/security-chat")
1304
  async def api_v2_security_chat(request: Request):
1305
+ """Cybersecurity Q&A: hosted model when the account has credit, otherwise
1306
+ the open-source model running in this Space.
1307
  Body: { "query": "...", "max_tokens": 512 }"""
1308
  body = await request.json()
1309
  query = body.get("query", "").strip()
1310
  if not query:
1311
  return JSONResponse(status_code=400, content={"detail": "query required"})
1312
  max_tokens = int(body.get("max_tokens", 512))
1313
+ result = await run_in_threadpool(transformer_loader.security_chat, query, max_tokens)
1314
+ # Last resort: ML heuristic if no LLM answered
1315
+ if result.get("error"):
1316
+ return reasoning_service._fallback(query)
 
 
 
1317
  return result
1318
 
1319
 
 
1424
  "domain": "example.com", # optional
1425
  "ip": "1.2.3.4", # optional
1426
  "hash": "abc123...", # optional (future use)
1427
+ "context": { ... } # optional — passed to the LLM narrative
1428
  }
1429
 
1430
  Response: per-indicator results with unified risk summary.
 
1482
  else:
1483
  risk_level = "unknown"
1484
 
1485
+ # Optional LLM narrative. Hosted model only: the in-Space model takes many
1486
+ # seconds on CPU and this endpoint is called with short timeouts.
1487
  ai_analysis = None
1488
+ if transformer_loader.provider_available() and (url or domain):
1489
  target = url or domain
1490
+ ai_analysis = await run_in_threadpool(
1491
+ reasoning_service.analyze,
1492
  f"Scan these IOC indicators for threats: url={target}, domain={effective_domain}",
1493
+ context, None, False,
1494
  )
1495
 
1496
  return {
 
1608
  persistent vector store, which requires paid GPU on free tier).
1609
  2. Translates raw ML signals / IOCs into plain human language when the
1610
  user includes raw data and asks for an explanation.
1611
+ 3. Uses the hosted model when the account has credit, otherwise the
1612
+ open-source model running in this Space,
1613
+ or the ML-powered heuristic analyzer when no LLM is available.
1614
 
1615
  NOTE ON REAL-TIME "LEARNING": HuggingFace Spaces cpu-basic does NOT
1616
  support fine-tuning or persistent model updates at runtime. What this
 
1777
  n_models = len(ml_loader.models)
1778
  sources.append({"type": "ml_models", "label": "ML Models", "value": f"{n_models}/4 loaded"})
1779
 
1780
+ # LLM cascade: hosted model (when the account has credit), then the
1781
+ # open-source model in this Space, then the ML heuristic below.
1782
+ llm_result = await run_in_threadpool(transformer_loader.security_chat, enriched_message, 600)
 
 
 
 
 
 
 
 
 
 
 
 
 
1783
  if llm_result.get("response") and not llm_result.get("error"):
1784
  text = llm_result["response"]
1785
  text_lower = text.lower()
 
1802
  "confidence": 0.72,
1803
  "risk_level": risk_level,
1804
  "risk_score": risk_score,
1805
+ "model_used": llm_result.get("model_id", LOCAL_LLM_REPO),
1806
  "session_id": session_id,
1807
  "sources": sources,
1808
  "timestamp": datetime.utcnow().isoformat(),
1809
  }
1810
 
1811
  # Path 3: ML heuristic fallback (always available)
1812
+ fallback = reasoning_service._fallback(enriched_message)
1813
  # Override with ML scan risk if available
1814
  if detected_risk_score > 0:
1815
  ml_score_100 = round(detected_risk_score * 100, 1)
 
1826
  """Network traffic analysis – called by backend mlService.analyzeNetworkTraffic()"""
1827
  body = await request.json()
1828
  traffic_data = body.get("traffic_data", {})
1829
+ result = await run_in_threadpool(
1830
+ reasoning_service.analyze,
1831
+ f"Analyze this network traffic for security threats:\n{json.dumps(traffic_data, indent=1)[:3000]}",
1832
  )
1833
  return result
1834
 
 
1839
  body = await request.json()
1840
  task_type = body.get("task_type", "")
1841
  task_data = body.get("task_data", {})
1842
+ result = await run_in_threadpool(
1843
+ reasoning_service.analyze,
1844
+ f"Execute cybersecurity task '{task_type}':\n{json.dumps(task_data, indent=1)[:3000]}",
1845
  )
1846
  return result
1847
 
 
1851
  """Browser session analysis"""
1852
  body = await request.json()
1853
  session = body.get("session_data", body)
1854
+ result = await run_in_threadpool(
1855
+ reasoning_service.analyze,
1856
+ f"Analyze this browser session for security threats:\n{json.dumps(session, indent=1)[:3000]}",
1857
  )
1858
  return result
1859
 
 
1862
  async def api_analyze_threat_feed(request: Request):
1863
  """Threat feed analysis"""
1864
  body = await request.json()
1865
+ result = await run_in_threadpool(
1866
+ reasoning_service.analyze,
1867
+ f"Analyze this threat feed:\n{json.dumps(body, indent=1)[:3000]}",
1868
  )
1869
  return result
1870
 
 
1965
  | Method | Endpoint | Description |
1966
  |--------|----------|-------------|
1967
  | GET | `/health` | Health check — version, models, services |
1968
+ | POST | `/analyze` | AI chat / general analysis (LLM or ML fallback) |
1969
  | POST | `/analyze-url` | URL threat analysis — 4 ML models + risk factors |
1970
+ | POST | `/scan-threats` | Deep threat scan (LLM or ML fallback) |
1971
  | POST | `/api/insights/generate` | AI-generated security insights |
1972
  | POST | `/api/models/predict` | Direct ML model prediction |
1973
  | GET | `/models` | List loaded models with source info |
 
1983
  | POST | `/api/v2/ioc-scan` | Multi-IOC scanner: URL + domain + DGA + BERT |
1984
  | POST | `/api/v2/url-enrich` | Enriched analysis: ML + BERT + DGA + explainability |
1985
  | POST | `/api/v2/chat` | Security chatbot — context-grounded, RAG-lite, machine→human translation |
1986
+ | POST | `/api/v2/security-chat` | Security LLM Q&A (hosted model, or the open-source model in this Space) |
1987
  | GET | `/api/v2/status` | Transformer model load status |
1988
 
1989
  ### Quick Examples
 
2025
 
2026
  # Initialize services at startup
2027
  logger.info("🚀 Initializing CyberForge AI services...")
 
2028
  ml_loader.initialize()
2029
+ # Load the chat model in the background so the API answers health checks at once
2030
+ threading.Thread(target=local_llm.load, name="local-llm-loader", daemon=True).start()
2031
  logger.info("✅ Services initialized")
2032
 
2033
  # Create Gradio app and mount it on FastAPI
requirements.txt CHANGED
@@ -19,14 +19,20 @@ gradio>=5.20.0
19
  fastapi>=0.100.0
20
  uvicorn[standard]>=0.22.0
21
 
22
- # HF Hub
23
- huggingface_hub>=0.25.0
24
 
25
  # Feature Engineering
26
  tldextract>=5.1.0
27
 
28
- # Gemini AI (new SDK)
29
- google-genai>=1.0.0
 
 
 
 
 
 
30
 
31
  # Notebook execution
32
  nbformat>=5.10.0
 
19
  fastapi>=0.100.0
20
  uvicorn[standard]>=0.22.0
21
 
22
+ # HF Hub (>=0.26 for Inference Providers / OpenAI-compatible router used by DeepSeek)
23
+ huggingface_hub>=0.26.0
24
 
25
  # Feature Engineering
26
  tldextract>=5.1.0
27
 
28
+ # Reasoning LLM
29
+ # hosted: HF Inference Providers (OpenAI-compatible router), plain HTTPS via requests.
30
+ # local: open-source GGUF chat model run in-process with llama.cpp. The prebuilt
31
+ # CPU wheel is used so the image needs no compiler; --only-binary makes the
32
+ # build fail fast instead of compiling from source if that wheel is missing.
33
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
34
+ --only-binary llama-cpp-python
35
+ llama-cpp-python==0.3.36
36
 
37
  # Notebook execution
38
  nbformat>=5.10.0