dragon-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dragon_cli/__init__.py +5 -0
- dragon_cli/__main__.py +4 -0
- dragon_cli/_cuda.py +42 -0
- dragon_cli/app.py +123 -0
- dragon_cli/banner.py +96 -0
- dragon_cli/chat/__init__.py +4 -0
- dragon_cli/chat/commands.py +175 -0
- dragon_cli/chat/session.py +97 -0
- dragon_cli/chat/streaming.py +18 -0
- dragon_cli/cli.py +147 -0
- dragon_cli/config.py +28 -0
- dragon_cli/hardware.py +361 -0
- dragon_cli/mcp/__init__.py +10 -0
- dragon_cli/mcp/manager.py +148 -0
- dragon_cli/mcp/tool_call.py +17 -0
- dragon_cli/menu.py +1140 -0
- dragon_cli/model_hub.py +252 -0
- dragon_cli/models/__init__.py +11 -0
- dragon_cli/models/catalog.py +205 -0
- dragon_cli/models/loader.py +187 -0
- dragon_cli/recommendation.py +295 -0
- dragon_cli/runtime.py +252 -0
- dragon_cli/server.py +144 -0
- dragon_cli/setup_llm.py +197 -0
- dragon_cli/storage/__init__.py +3 -0
- dragon_cli/storage/conversations.py +134 -0
- dragon_cli/storage/database.py +32 -0
- dragon_cli/web.py +112 -0
- dragon_cli-0.2.0.dist-info/METADATA +147 -0
- dragon_cli-0.2.0.dist-info/RECORD +34 -0
- dragon_cli-0.2.0.dist-info/WHEEL +5 -0
- dragon_cli-0.2.0.dist-info/entry_points.txt +3 -0
- dragon_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- dragon_cli-0.2.0.dist-info/top_level.txt +1 -0
dragon_cli/model_hub.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Model Hub integration for Dragon CLI.
|
|
2
|
+
|
|
3
|
+
Integrates with Hugging Face, Ollama, and other model hubs to:
|
|
4
|
+
- Query model catalogs
|
|
5
|
+
- Filter by hardware compatibility
|
|
6
|
+
- Download models
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .config import MODELS_DIR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelHub:
|
|
17
|
+
"""Integration layer for model hubs (Hugging Face, Ollama, etc.)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.catalog_cache_file = MODELS_DIR / "model_catalog_cache.json"
|
|
21
|
+
|
|
22
|
+
def fetch_huggingface_models(
|
|
23
|
+
self,
|
|
24
|
+
search_query: str = "GGUF",
|
|
25
|
+
limit: int = 50
|
|
26
|
+
) -> List[Dict[str, Any]]:
|
|
27
|
+
"""Fetch models from Hugging Face.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
search_query: Search term (default: "GGUF" for quantized models)
|
|
31
|
+
limit: Maximum number of results
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of model metadata dictionaries
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
from huggingface_hub import HfApi
|
|
38
|
+
api = HfApi()
|
|
39
|
+
|
|
40
|
+
# Search for GGUF models. ``direction`` is not supported in some
|
|
41
|
+
# huggingface_hub versions, so fall back to a signature-compatible call.
|
|
42
|
+
try:
|
|
43
|
+
models = api.list_models(
|
|
44
|
+
search=search_query,
|
|
45
|
+
limit=limit,
|
|
46
|
+
sort="downloads",
|
|
47
|
+
direction=-1,
|
|
48
|
+
)
|
|
49
|
+
except TypeError:
|
|
50
|
+
models = api.list_models(
|
|
51
|
+
search=search_query,
|
|
52
|
+
limit=limit,
|
|
53
|
+
sort="downloads",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
results = []
|
|
57
|
+
for model in models:
|
|
58
|
+
# Only include if it has GGUF files
|
|
59
|
+
model_info = {
|
|
60
|
+
"name": model.id,
|
|
61
|
+
"repo_id": model.id,
|
|
62
|
+
"downloads": getattr(model, "downloads", 0),
|
|
63
|
+
"likes": getattr(model, "likes", 0),
|
|
64
|
+
"source": "huggingface",
|
|
65
|
+
}
|
|
66
|
+
results.append(model_info)
|
|
67
|
+
|
|
68
|
+
return results
|
|
69
|
+
|
|
70
|
+
except ImportError:
|
|
71
|
+
print("huggingface_hub not installed. Install with: pip install huggingface_hub")
|
|
72
|
+
return []
|
|
73
|
+
except Exception as e:
|
|
74
|
+
print(f"Error fetching from Hugging Face: {e}")
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
def get_model_files(self, repo_id: str) -> List[str]:
|
|
78
|
+
"""Get list of GGUF files in a repository.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
repo_id: Hugging Face repository ID (e.g., "Qwen/Qwen2.5-7B-Instruct-GGUF")
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of filenames ending with .gguf
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
from huggingface_hub import HfApi
|
|
88
|
+
api = HfApi()
|
|
89
|
+
|
|
90
|
+
files = api.list_repo_files(repo_id=repo_id)
|
|
91
|
+
gguf_files = [f for f in files if f.endswith(".gguf")]
|
|
92
|
+
|
|
93
|
+
return gguf_files
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"Error listing files for {repo_id}: {e}")
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
def search_models(
|
|
100
|
+
self,
|
|
101
|
+
query: str = "",
|
|
102
|
+
min_ram_gb: float = 0,
|
|
103
|
+
max_ram_gb: Optional[float] = None,
|
|
104
|
+
requires_gpu: bool = False,
|
|
105
|
+
source: str = "huggingface",
|
|
106
|
+
) -> List[Dict[str, Any]]:
|
|
107
|
+
"""Search models with filters.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
query: Search query string
|
|
111
|
+
min_ram_gb: Minimum RAM requirement in GB
|
|
112
|
+
max_ram_gb: Maximum RAM requirement in GB
|
|
113
|
+
requires_gpu: Whether GPU is required
|
|
114
|
+
source: Model source ("huggingface", "ollama", "all")
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Filtered list of models
|
|
118
|
+
"""
|
|
119
|
+
from .models.catalog import MODEL_CATALOG
|
|
120
|
+
|
|
121
|
+
results = []
|
|
122
|
+
|
|
123
|
+
# Search local catalog first
|
|
124
|
+
for model in MODEL_CATALOG:
|
|
125
|
+
ram_req = model.get("ram_min_gb", 0)
|
|
126
|
+
|
|
127
|
+
# Apply filters
|
|
128
|
+
if min_ram_gb > 0 and ram_req < min_ram_gb:
|
|
129
|
+
continue
|
|
130
|
+
if max_ram_gb and ram_req > max_ram_gb:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Check if matches query
|
|
134
|
+
if query and query.lower() not in model["name"].lower():
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
model_copy = model.copy()
|
|
138
|
+
model_copy["source"] = "local_catalog"
|
|
139
|
+
results.append(model_copy)
|
|
140
|
+
|
|
141
|
+
# Optionally fetch from Hugging Face
|
|
142
|
+
if source in ("huggingface", "all") and query:
|
|
143
|
+
hf_models = self.fetch_huggingface_models(search_query=query, limit=20)
|
|
144
|
+
for model in hf_models:
|
|
145
|
+
# Check if already in results
|
|
146
|
+
if not any(m.get("repo_id") == model["repo_id"] for m in results):
|
|
147
|
+
results.append(model)
|
|
148
|
+
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
def get_recommended_quantization(self, vram_gb: float, ram_gb: float) -> str:
|
|
152
|
+
"""Recommend quantization level based on available memory.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
vram_gb: Available GPU VRAM in GB
|
|
156
|
+
ram_gb: Available system RAM in GB
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Recommended quantization string (e.g., "Q4_K_M", "Q8_0")
|
|
160
|
+
"""
|
|
161
|
+
# If good VRAM, can use higher quantization on GPU
|
|
162
|
+
if vram_gb >= 24:
|
|
163
|
+
return "Q8_0" # Near FP16 quality
|
|
164
|
+
elif vram_gb >= 16:
|
|
165
|
+
return "Q6_K" # High quality
|
|
166
|
+
elif vram_gb >= 8:
|
|
167
|
+
return "Q5_K_M" # Good balance
|
|
168
|
+
elif vram_gb >= 6:
|
|
169
|
+
return "Q4_K_M" # Standard recommendation
|
|
170
|
+
elif ram_gb >= 16:
|
|
171
|
+
return "Q5_K_M" # Can use more RAM
|
|
172
|
+
else:
|
|
173
|
+
return "Q4_K_M" # Safe default
|
|
174
|
+
|
|
175
|
+
def cache_catalog(self, models: List[Dict[str, Any]]) -> None:
|
|
176
|
+
"""Cache model catalog to disk."""
|
|
177
|
+
MODELS_DIR.mkdir(exist_ok=True)
|
|
178
|
+
try:
|
|
179
|
+
with open(self.catalog_cache_file, "w") as f:
|
|
180
|
+
json.dump(models, f, indent=2)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
print(f"Error caching catalog: {e}")
|
|
183
|
+
|
|
184
|
+
def load_cached_catalog(self) -> List[Dict[str, Any]]:
|
|
185
|
+
"""Load cached model catalog from disk."""
|
|
186
|
+
if not self.catalog_cache_file.exists():
|
|
187
|
+
return []
|
|
188
|
+
try:
|
|
189
|
+
with open(self.catalog_cache_file, "r") as f:
|
|
190
|
+
return json.load(f)
|
|
191
|
+
except Exception:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_model_info_from_hf(repo_id: str, filename: str) -> Optional[Dict[str, Any]]:
|
|
196
|
+
"""Get detailed information about a specific model file.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
repo_id: Hugging Face repository ID
|
|
200
|
+
filename: GGUF filename
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Model information dictionary or None
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
from huggingface_hub import HfApi, hf_hub_download
|
|
207
|
+
api = HfApi()
|
|
208
|
+
|
|
209
|
+
# Get file info
|
|
210
|
+
files = api.list_repo_files(repo_id=repo_id)
|
|
211
|
+
if filename not in files:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
# Try to get file size
|
|
215
|
+
try:
|
|
216
|
+
file_info = api.hf_hub_download(
|
|
217
|
+
repo_id=repo_id,
|
|
218
|
+
filename=filename,
|
|
219
|
+
local_dir=str(MODELS_DIR),
|
|
220
|
+
force_download=False,
|
|
221
|
+
)
|
|
222
|
+
# This would need additional API calls for size
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
"repo_id": repo_id,
|
|
228
|
+
"filename": filename,
|
|
229
|
+
"exists": (MODELS_DIR / filename).exists(),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
except ImportError:
|
|
233
|
+
return None
|
|
234
|
+
except Exception as e:
|
|
235
|
+
print(f"Error getting model info: {e}")
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
# Test the hub
|
|
241
|
+
hub = ModelHub()
|
|
242
|
+
|
|
243
|
+
print("Searching for Qwen GGUF models...")
|
|
244
|
+
models = hub.search_models(query="Qwen GGUF", max_ram_gb=16)
|
|
245
|
+
|
|
246
|
+
print(f"\nFound {len(models)} models:\n")
|
|
247
|
+
for i, model in enumerate(models[:10], 1):
|
|
248
|
+
name = model.get("name", model.get("repo_id", "Unknown"))
|
|
249
|
+
source = model.get("source", "unknown")
|
|
250
|
+
ram = model.get("ram_min_gb", "?")
|
|
251
|
+
print(f" {i}. {name}")
|
|
252
|
+
print(f" Source: {source}, Min RAM: {ram} GB\n")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .catalog import MODEL_CATALOG, add_model_to_catalog, available_ram_gb, list_catalog
|
|
2
|
+
from .loader import download_model, load_llm
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"MODEL_CATALOG",
|
|
6
|
+
"add_model_to_catalog",
|
|
7
|
+
"available_ram_gb",
|
|
8
|
+
"list_catalog",
|
|
9
|
+
"download_model",
|
|
10
|
+
"load_llm",
|
|
11
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Catalog of verified GGUF models.
|
|
2
|
+
|
|
3
|
+
Each entry carries the metadata the runtime needs to choose a sensible GPU
|
|
4
|
+
offload without crashing on small VRAM:
|
|
5
|
+
|
|
6
|
+
ram_min_gb — system RAM (GB) required to run the model
|
|
7
|
+
vram_min_gb — GPU VRAM (GB) required to offload ALL layers (full GPU mode)
|
|
8
|
+
n_layers — transformer block count, used to compute partial offload and
|
|
9
|
+
to back off gracefully when full offload runs out of VRAM
|
|
10
|
+
ctx — default context window
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Tuple
|
|
16
|
+
|
|
17
|
+
from ..config import C_DIM, C_GREEN, C_RED, C_RESET, MODELS_DIR
|
|
18
|
+
|
|
19
|
+
MODEL_CATALOG = [
|
|
20
|
+
{
|
|
21
|
+
"name": "Qwen2.5-1.5B-Instruct (Q4_K_M) — tiny, GPU-friendly",
|
|
22
|
+
"repo_id": "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
|
|
23
|
+
"filename": "qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
|
24
|
+
"ram_min_gb": 3, "vram_min_gb": 1.5, "n_layers": 28, "ctx": 8192,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "DeepSeek-R1-Distill-Qwen-1.5B (Q4_K_M) — light, for testing",
|
|
28
|
+
"repo_id": "bartowski/DeepSeek-R1-Distill-Qwen-1.5B-GGUF",
|
|
29
|
+
"filename": "DeepSeek-R1-Distill-Qwen-1.5B-Q4_K_M.gguf",
|
|
30
|
+
"ram_min_gb": 3, "vram_min_gb": 1.5, "n_layers": 28, "ctx": 4096,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "Llama-3.2-3B-Instruct (Q4_K_M) — small, GPU-friendly",
|
|
34
|
+
"repo_id": "bartowski/Llama-3.2-3B-Instruct-GGUF",
|
|
35
|
+
"filename": "Llama-3.2-3B-Instruct-Q4_K_M.gguf",
|
|
36
|
+
"ram_min_gb": 4, "vram_min_gb": 2.5, "n_layers": 28, "ctx": 8192,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "Qwen2.5-3B-Instruct (Q4_K_M) — fast, good multilingual quality",
|
|
40
|
+
"repo_id": "Qwen/Qwen2.5-3B-Instruct-GGUF",
|
|
41
|
+
"filename": "qwen2.5-3b-instruct-q4_k_m.gguf",
|
|
42
|
+
"ram_min_gb": 4, "vram_min_gb": 2.5, "n_layers": 36, "ctx": 8192,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "Qwen2.5-7B-Instruct (Q4_K_M) — general use, great for tools",
|
|
46
|
+
"repo_id": "Qwen/Qwen2.5-7B-Instruct-GGUF",
|
|
47
|
+
"filename": "qwen2.5-7b-instruct-q4_k_m.gguf",
|
|
48
|
+
"ram_min_gb": 8, "vram_min_gb": 5.0, "n_layers": 28, "ctx": 8192,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "DeepSeek-R1-Distill-Qwen-7B (Q4_K_M) — reasoning/code",
|
|
52
|
+
"repo_id": "bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF",
|
|
53
|
+
"filename": "DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf",
|
|
54
|
+
"ram_min_gb": 8, "vram_min_gb": 5.0, "n_layers": 28, "ctx": 8192,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "Meta-Llama-3.1-8B-Instruct (Q4_K_M) — general use",
|
|
58
|
+
"repo_id": "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
|
|
59
|
+
"filename": "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
|
|
60
|
+
"ram_min_gb": 9, "vram_min_gb": 5.5, "n_layers": 32, "ctx": 8192,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "Qwen2.5-14B-Instruct (Q4_K_M) — heavy, best quality",
|
|
64
|
+
"repo_id": "Qwen/Qwen2.5-14B-Instruct-GGUF",
|
|
65
|
+
"filename": "qwen2.5-14b-instruct-q4_k_m.gguf",
|
|
66
|
+
"ram_min_gb": 14, "vram_min_gb": 9.0, "n_layers": 48, "ctx": 8192,
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
CUSTOM_CATALOG_FILE = MODELS_DIR / "custom_model_catalog.json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _model_key(model: Dict[str, Any]) -> Tuple[str, str]:
|
|
74
|
+
repo_id = str(model.get("repo_id", "")).strip().lower()
|
|
75
|
+
filename = str(model.get("filename", "")).strip().lower()
|
|
76
|
+
return repo_id, filename
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _merge_model(target: Dict[str, Any], source: Dict[str, Any]) -> Dict[str, Any]:
|
|
80
|
+
merged = dict(target)
|
|
81
|
+
for key in ("name", "repo_id", "filename", "ram_min_gb", "vram_min_gb", "n_layers", "ctx"):
|
|
82
|
+
if key in source:
|
|
83
|
+
merged[key] = source[key]
|
|
84
|
+
return merged
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_custom_catalog() -> List[Dict[str, Any]]:
|
|
88
|
+
"""Load custom model entries from disk, returning validated dicts."""
|
|
89
|
+
if not CUSTOM_CATALOG_FILE.exists():
|
|
90
|
+
return []
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(CUSTOM_CATALOG_FILE.read_text(encoding="utf-8"))
|
|
93
|
+
except Exception:
|
|
94
|
+
return []
|
|
95
|
+
if not isinstance(data, list):
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
items: List[Dict[str, Any]] = []
|
|
99
|
+
for raw in data:
|
|
100
|
+
if not isinstance(raw, dict):
|
|
101
|
+
continue
|
|
102
|
+
repo_id = str(raw.get("repo_id", "")).strip()
|
|
103
|
+
filename = str(raw.get("filename", "")).strip()
|
|
104
|
+
if not repo_id or not filename:
|
|
105
|
+
continue
|
|
106
|
+
items.append(
|
|
107
|
+
{
|
|
108
|
+
"name": str(raw.get("name", filename)).strip() or filename,
|
|
109
|
+
"repo_id": repo_id,
|
|
110
|
+
"filename": filename,
|
|
111
|
+
"ram_min_gb": int(raw.get("ram_min_gb", 4) or 4),
|
|
112
|
+
"vram_min_gb": float(raw.get("vram_min_gb", 0) or 0),
|
|
113
|
+
"n_layers": int(raw.get("n_layers", 32) or 32),
|
|
114
|
+
"ctx": int(raw.get("ctx", 8192) or 8192),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
return items
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def save_custom_catalog(models: List[Dict[str, Any]]) -> None:
|
|
121
|
+
"""Persist custom model entries under models/custom_model_catalog.json."""
|
|
122
|
+
MODELS_DIR.mkdir(exist_ok=True)
|
|
123
|
+
payload: List[Dict[str, Any]] = []
|
|
124
|
+
seen = set()
|
|
125
|
+
for model in models:
|
|
126
|
+
key = _model_key(model)
|
|
127
|
+
if key in seen:
|
|
128
|
+
continue
|
|
129
|
+
seen.add(key)
|
|
130
|
+
payload.append(
|
|
131
|
+
{
|
|
132
|
+
"name": str(model.get("name", "")).strip(),
|
|
133
|
+
"repo_id": str(model.get("repo_id", "")).strip(),
|
|
134
|
+
"filename": str(model.get("filename", "")).strip(),
|
|
135
|
+
"ram_min_gb": int(model.get("ram_min_gb", 4) or 4),
|
|
136
|
+
"vram_min_gb": float(model.get("vram_min_gb", 0) or 0),
|
|
137
|
+
"n_layers": int(model.get("n_layers", 32) or 32),
|
|
138
|
+
"ctx": int(model.get("ctx", 8192) or 8192),
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
CUSTOM_CATALOG_FILE.write_text(
|
|
142
|
+
json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def add_model_to_catalog(model: Dict[str, Any], persist: bool = False) -> Tuple[int, bool]:
|
|
147
|
+
"""Add a model to runtime catalog and optionally persist it.
|
|
148
|
+
|
|
149
|
+
Returns ``(index, created)`` where index is 1-based in MODEL_CATALOG.
|
|
150
|
+
"""
|
|
151
|
+
incoming = _merge_model({}, model)
|
|
152
|
+
incoming_key = _model_key(incoming)
|
|
153
|
+
|
|
154
|
+
for i, existing in enumerate(MODEL_CATALOG, 1):
|
|
155
|
+
if _model_key(existing) == incoming_key:
|
|
156
|
+
MODEL_CATALOG[i - 1] = _merge_model(existing, incoming)
|
|
157
|
+
if persist:
|
|
158
|
+
_persist_model_entry(MODEL_CATALOG[i - 1])
|
|
159
|
+
return i, False
|
|
160
|
+
|
|
161
|
+
MODEL_CATALOG.append(incoming)
|
|
162
|
+
if persist:
|
|
163
|
+
_persist_model_entry(incoming)
|
|
164
|
+
return len(MODEL_CATALOG), True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _persist_model_entry(model: Dict[str, Any]) -> None:
|
|
168
|
+
items = load_custom_catalog()
|
|
169
|
+
model_key = _model_key(model)
|
|
170
|
+
for i, existing in enumerate(items):
|
|
171
|
+
if _model_key(existing) == model_key:
|
|
172
|
+
items[i] = _merge_model(existing, model)
|
|
173
|
+
save_custom_catalog(items)
|
|
174
|
+
return
|
|
175
|
+
items.append(model)
|
|
176
|
+
save_custom_catalog(items)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _load_custom_into_runtime_catalog() -> None:
|
|
180
|
+
for model in load_custom_catalog():
|
|
181
|
+
add_model_to_catalog(model, persist=False)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_load_custom_into_runtime_catalog()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def available_ram_gb() -> float:
|
|
188
|
+
try:
|
|
189
|
+
import psutil
|
|
190
|
+
return psutil.virtual_memory().available / (1024 ** 3)
|
|
191
|
+
except ImportError:
|
|
192
|
+
return -1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def list_catalog() -> None:
|
|
196
|
+
ram = available_ram_gb()
|
|
197
|
+
if ram > 0:
|
|
198
|
+
print(f"{C_DIM}Available RAM: {ram:.1f} GB{C_RESET}")
|
|
199
|
+
for i, m in enumerate(MODEL_CATALOG, 1):
|
|
200
|
+
downloaded = (MODELS_DIR / m["filename"]).exists()
|
|
201
|
+
tag = f"{C_GREEN}[downloaded]{C_RESET}" if downloaded else f"{C_DIM}[cloud]{C_RESET}"
|
|
202
|
+
warning = f" {C_RED}[!] RAM may be too low right now{C_RESET}" if 0 < ram < m["ram_min_gb"] else ""
|
|
203
|
+
gpu = m.get("vram_min_gb")
|
|
204
|
+
gpu_tag = f" {C_DIM}(GPU full ~{gpu:.0f}GB){C_RESET}" if gpu else ""
|
|
205
|
+
print(f" {i}. {m['name']} {tag}{gpu_tag}{warning}")
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Model download and loading (huggingface_hub + llama-cpp-python)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from .._cuda import preload_cuda_runtime as _preload_cuda_runtime
|
|
8
|
+
from ..config import C_GREEN, C_RED, C_RESET, C_YELLOW, MODELS_DIR
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def download_model(model: dict) -> Path:
|
|
13
|
+
from huggingface_hub import HfApi, hf_hub_download
|
|
14
|
+
|
|
15
|
+
repo_id = model["repo_id"]
|
|
16
|
+
filename = model["filename"]
|
|
17
|
+
|
|
18
|
+
# HF file paths are case-sensitive. If catalog data was saved in lowercase,
|
|
19
|
+
# resolve the exact-cased filename from the repository before downloading.
|
|
20
|
+
try:
|
|
21
|
+
api = HfApi()
|
|
22
|
+
files = api.list_repo_files(repo_id=repo_id)
|
|
23
|
+
if filename not in files:
|
|
24
|
+
by_lower = {f.lower(): f for f in files}
|
|
25
|
+
resolved = by_lower.get(filename.lower())
|
|
26
|
+
if resolved:
|
|
27
|
+
print(f"{C_YELLOW}[download]{C_RESET} Using canonical HF filename: {resolved}")
|
|
28
|
+
filename = resolved
|
|
29
|
+
model["filename"] = resolved
|
|
30
|
+
except Exception:
|
|
31
|
+
# If listing fails (network/rate limit), keep original filename and let
|
|
32
|
+
# hf_hub_download surface a clear error.
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
MODELS_DIR.mkdir(exist_ok=True)
|
|
36
|
+
destination = MODELS_DIR / filename
|
|
37
|
+
if destination.exists():
|
|
38
|
+
print(f"{C_GREEN}[ok]{C_RESET} Model already saved at {destination}")
|
|
39
|
+
return destination
|
|
40
|
+
print(f"{C_YELLOW}[download]{C_RESET} Downloading {filename} "
|
|
41
|
+
f"(a few GB, only happens once)...")
|
|
42
|
+
path = hf_hub_download(repo_id=repo_id, filename=filename, local_dir=str(MODELS_DIR))
|
|
43
|
+
print(f"{C_GREEN}[ok]{C_RESET} Saved to {path}")
|
|
44
|
+
return Path(path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_llm(
|
|
48
|
+
path: Path,
|
|
49
|
+
ctx: int,
|
|
50
|
+
model_info: Optional[Dict[str, Any]] = None,
|
|
51
|
+
hardware_profile: Optional[Dict[str, Any]] = None,
|
|
52
|
+
gpu_layers: Optional[int] = None,
|
|
53
|
+
force_cpu: bool = False,
|
|
54
|
+
force_gpu: bool = False,
|
|
55
|
+
):
|
|
56
|
+
"""Load LLM with optimized runtime parameters.
|
|
57
|
+
|
|
58
|
+
GPU offload precedence (first match wins):
|
|
59
|
+
1. ``gpu_layers`` — explicit layer count (0 = CPU, -1/<0 = all layers);
|
|
60
|
+
2. ``force_cpu`` — disables the GPU entirely;
|
|
61
|
+
3. ``force_gpu`` — offloads all layers when a GPU is present;
|
|
62
|
+
4. otherwise — auto, from the hardware-aware ``RuntimeConfig``.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Path to the GGUF model file
|
|
66
|
+
ctx: Context window size (fallback if not auto-detected)
|
|
67
|
+
model_info: Optional model metadata for optimization
|
|
68
|
+
hardware_profile: Optional hardware profile for optimization
|
|
69
|
+
gpu_layers: Explicit number of layers to offload to GPU (None = auto)
|
|
70
|
+
force_cpu: Force CPU-only execution (ignore any GPU)
|
|
71
|
+
force_gpu: Force GPU offload of all layers
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Loaded Llama instance
|
|
75
|
+
"""
|
|
76
|
+
_preload_cuda_runtime()
|
|
77
|
+
from llama_cpp import Llama
|
|
78
|
+
|
|
79
|
+
# Try to use runtime configuration for optimal params
|
|
80
|
+
try:
|
|
81
|
+
from ..runtime import RuntimeConfig
|
|
82
|
+
config = RuntimeConfig(hardware_profile=hardware_profile, model_info=model_info)
|
|
83
|
+
params = config.get_runtime_params()
|
|
84
|
+
|
|
85
|
+
# Override context if explicitly provided
|
|
86
|
+
if ctx:
|
|
87
|
+
params["n_ctx"] = ctx
|
|
88
|
+
|
|
89
|
+
threads = params["n_threads"]
|
|
90
|
+
batch_size = params["n_batch"]
|
|
91
|
+
gpu_available = config.hardware.get("gpu_available", False)
|
|
92
|
+
|
|
93
|
+
# Resolve the number of GPU layers according to the precedence above.
|
|
94
|
+
if force_cpu:
|
|
95
|
+
n_gpu_layers = 0
|
|
96
|
+
exec_mode = "CPU"
|
|
97
|
+
elif gpu_layers is not None:
|
|
98
|
+
n_gpu_layers = gpu_layers
|
|
99
|
+
exec_mode = "CPU" if gpu_layers == 0 else "GPU"
|
|
100
|
+
elif force_gpu:
|
|
101
|
+
if gpu_available:
|
|
102
|
+
n_gpu_layers = -1 # llama.cpp convention: -1 == all layers
|
|
103
|
+
exec_mode = "GPU"
|
|
104
|
+
else:
|
|
105
|
+
print(f"{C_YELLOW}[gpu]{C_RESET} --gpu requested but no GPU was "
|
|
106
|
+
f"detected; falling back to CPU.")
|
|
107
|
+
n_gpu_layers = 0
|
|
108
|
+
exec_mode = "CPU"
|
|
109
|
+
else:
|
|
110
|
+
n_gpu_layers = params["n_gpu_layers"]
|
|
111
|
+
exec_mode = config.decide_execution_mode().upper()
|
|
112
|
+
|
|
113
|
+
mode_msg = f"mode={exec_mode} n_gpu_layers={n_gpu_layers}"
|
|
114
|
+
|
|
115
|
+
except ImportError:
|
|
116
|
+
# Fallback to simple configuration
|
|
117
|
+
threads = max(1, (os.cpu_count() or 4) - 2)
|
|
118
|
+
n_gpu_layers = 0
|
|
119
|
+
batch_size = 512
|
|
120
|
+
exec_mode = "CPU"
|
|
121
|
+
mode_msg = "mode=CPU (fallback)"
|
|
122
|
+
|
|
123
|
+
print(f"{C_YELLOW}[load]{C_RESET} Loading model (threads={threads}, "
|
|
124
|
+
f"ctx={params.get('n_ctx', ctx)}, {mode_msg})...")
|
|
125
|
+
|
|
126
|
+
# Full GPU offload can run out of VRAM on small cards (the whole model +
|
|
127
|
+
# the display's memory). Rather than crash, try the requested layer count,
|
|
128
|
+
# then progressively fewer GPU layers, finally falling back to CPU.
|
|
129
|
+
total_layers = int((model_info or {}).get("n_layers", 32))
|
|
130
|
+
if n_gpu_layers == 0:
|
|
131
|
+
candidates = [0]
|
|
132
|
+
elif n_gpu_layers > 0:
|
|
133
|
+
candidates = []
|
|
134
|
+
for n in (n_gpu_layers, n_gpu_layers // 2, 0):
|
|
135
|
+
if n not in candidates:
|
|
136
|
+
candidates.append(n)
|
|
137
|
+
else: # -1 / "all layers"
|
|
138
|
+
candidates = []
|
|
139
|
+
for frac in (1.0, 0.66, 0.5, 0.33, 0.0):
|
|
140
|
+
n = int(round(total_layers * frac))
|
|
141
|
+
if n not in candidates:
|
|
142
|
+
candidates.append(n)
|
|
143
|
+
|
|
144
|
+
n_ctx = params.get("n_ctx", ctx)
|
|
145
|
+
llm = None
|
|
146
|
+
last_err = None
|
|
147
|
+
for ng in candidates:
|
|
148
|
+
try:
|
|
149
|
+
llm = Llama(
|
|
150
|
+
model_path=str(path),
|
|
151
|
+
n_ctx=n_ctx,
|
|
152
|
+
n_threads=threads,
|
|
153
|
+
n_gpu_layers=ng,
|
|
154
|
+
n_batch=batch_size,
|
|
155
|
+
verbose=False,
|
|
156
|
+
)
|
|
157
|
+
used_layers = ng
|
|
158
|
+
break
|
|
159
|
+
except Exception as exc: # noqa: BLE001 - ggml surfaces OOM as ValueError
|
|
160
|
+
last_err = exc
|
|
161
|
+
llm = None
|
|
162
|
+
if ng == 0:
|
|
163
|
+
break
|
|
164
|
+
print(f"{C_RED}[gpu]{C_RESET} Offloading {ng} layers failed (likely "
|
|
165
|
+
f"out of VRAM). Retrying with fewer layers...")
|
|
166
|
+
# A failed constructor can strand VRAM until GC runs; reclaim it
|
|
167
|
+
# before the next attempt so retries don't cascade into failure.
|
|
168
|
+
import gc
|
|
169
|
+
gc.collect()
|
|
170
|
+
|
|
171
|
+
if llm is None:
|
|
172
|
+
# Even CPU (0 layers) failed — this is a real error (corrupt file, etc.).
|
|
173
|
+
raise last_err
|
|
174
|
+
|
|
175
|
+
if used_layers > 0 and exec_mode != "CPU":
|
|
176
|
+
exec_mode = "GPU"
|
|
177
|
+
if used_layers < total_layers:
|
|
178
|
+
print(f"{C_YELLOW}[gpu]{C_RESET} Running with {used_layers}/{total_layers} "
|
|
179
|
+
f"layers on GPU (partial offload).")
|
|
180
|
+
else:
|
|
181
|
+
exec_mode = "CPU"
|
|
182
|
+
print(f"{C_YELLOW}[gpu]{C_RESET} GPU offload unavailable; running on CPU.")
|
|
183
|
+
|
|
184
|
+
# Expose the chosen backend so the UI (chat banner, server) can display it.
|
|
185
|
+
llm.exec_mode = exec_mode
|
|
186
|
+
llm.n_gpu_layers = used_layers
|
|
187
|
+
return llm
|