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