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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Model recommendation engine for Dragon CLI.
|
|
2
|
+
|
|
3
|
+
Analyzes hardware profile and recommends optimal models based on:
|
|
4
|
+
- Available RAM and VRAM
|
|
5
|
+
- CPU capabilities
|
|
6
|
+
- User preferences (speed vs quality)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .models.catalog import MODEL_CATALOG
|
|
12
|
+
from .hardware import detect_hardware
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelRecommender:
|
|
16
|
+
"""Recommends LLM models based on hardware capabilities."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, hardware_profile: Optional[Dict[str, Any]] = None):
|
|
19
|
+
"""Initialize recommender with optional hardware profile.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
hardware_profile: Hardware info dict from detect_hardware(),
|
|
23
|
+
or None to auto-detect
|
|
24
|
+
"""
|
|
25
|
+
if hardware_profile is None:
|
|
26
|
+
self.hardware = detect_hardware()
|
|
27
|
+
else:
|
|
28
|
+
self.hardware = hardware_profile
|
|
29
|
+
|
|
30
|
+
def get_compatible_models(
|
|
31
|
+
self,
|
|
32
|
+
priority: str = "balanced",
|
|
33
|
+
max_context: Optional[int] = None,
|
|
34
|
+
) -> List[Dict[str, Any]]:
|
|
35
|
+
"""Get list of models compatible with current hardware.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
priority: One of "speed", "quality", "balanced", "memory"
|
|
39
|
+
max_context: Maximum context window size desired
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of compatible models with scores
|
|
43
|
+
"""
|
|
44
|
+
ram_available = self.hardware.get("ram_available_gb", 8)
|
|
45
|
+
vram_available = self.hardware.get("gpu_vram_gb", 0)
|
|
46
|
+
gpu_available = self.hardware.get("gpu_available", False)
|
|
47
|
+
cpu_count = self.hardware.get("cpu_count", 4)
|
|
48
|
+
|
|
49
|
+
compatible = []
|
|
50
|
+
|
|
51
|
+
for model in MODEL_CATALOG:
|
|
52
|
+
ram_required = model.get("ram_min_gb", 0)
|
|
53
|
+
|
|
54
|
+
# Basic compatibility check
|
|
55
|
+
if ram_required > ram_available * 1.2: # Allow 20% overhead
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Calculate compatibility score
|
|
59
|
+
score = self._calculate_score(
|
|
60
|
+
model=model,
|
|
61
|
+
ram_available=ram_available,
|
|
62
|
+
vram_available=vram_available,
|
|
63
|
+
gpu_available=gpu_available,
|
|
64
|
+
cpu_count=cpu_count,
|
|
65
|
+
priority=priority,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
model_copy = model.copy()
|
|
69
|
+
model_copy["compatibility_score"] = score
|
|
70
|
+
model_copy["can_use_gpu"] = gpu_available and vram_available >= 4
|
|
71
|
+
model_copy["recommended"] = score >= 0.7
|
|
72
|
+
|
|
73
|
+
compatible.append(model_copy)
|
|
74
|
+
|
|
75
|
+
# Sort by score descending
|
|
76
|
+
compatible.sort(key=lambda x: x["compatibility_score"], reverse=True)
|
|
77
|
+
|
|
78
|
+
return compatible
|
|
79
|
+
|
|
80
|
+
def _calculate_score(
|
|
81
|
+
self,
|
|
82
|
+
model: Dict[str, Any],
|
|
83
|
+
ram_available: float,
|
|
84
|
+
vram_available: float,
|
|
85
|
+
gpu_available: bool,
|
|
86
|
+
cpu_count: int,
|
|
87
|
+
priority: str,
|
|
88
|
+
) -> float:
|
|
89
|
+
"""Calculate compatibility score for a model (0.0 to 1.0)."""
|
|
90
|
+
ram_required = model.get("ram_min_gb", 0)
|
|
91
|
+
|
|
92
|
+
# Base score: how well RAM fits
|
|
93
|
+
ram_ratio = ram_available / max(ram_required, 1)
|
|
94
|
+
ram_score = min(1.0, ram_ratio / 1.5) # Cap at 1.0
|
|
95
|
+
|
|
96
|
+
# GPU bonus
|
|
97
|
+
gpu_score = 0.0
|
|
98
|
+
if gpu_available:
|
|
99
|
+
# Estimate VRAM needed (rough: 0.7 GB per billion params at Q4)
|
|
100
|
+
params_estimate = self._estimate_params_from_name(model.get("name", ""))
|
|
101
|
+
vram_needed = params_estimate * 0.7
|
|
102
|
+
if vram_available >= vram_needed:
|
|
103
|
+
gpu_score = 0.3 # Bonus for full GPU offload capability
|
|
104
|
+
elif vram_available >= vram_needed * 0.5:
|
|
105
|
+
gpu_score = 0.15 # Partial offload
|
|
106
|
+
|
|
107
|
+
# CPU score (more cores = better for CPU inference)
|
|
108
|
+
cpu_score = min(1.0, cpu_count / 8) * 0.1
|
|
109
|
+
|
|
110
|
+
# Priority adjustments
|
|
111
|
+
if priority == "speed":
|
|
112
|
+
# Favor smaller models
|
|
113
|
+
size_penalty = (ram_required / 16) * 0.2
|
|
114
|
+
total_score = ram_score + gpu_score + cpu_score - size_penalty
|
|
115
|
+
elif priority == "quality":
|
|
116
|
+
# Favor larger models
|
|
117
|
+
size_bonus = min(0.2, ram_required / 20)
|
|
118
|
+
total_score = ram_score + gpu_score + cpu_score + size_bonus
|
|
119
|
+
elif priority == "memory":
|
|
120
|
+
# Strongly favor smaller models
|
|
121
|
+
memory_efficiency = 1.0 - (ram_required / 20)
|
|
122
|
+
total_score = ram_score * 0.5 + memory_efficiency * 0.5 + gpu_score
|
|
123
|
+
else: # balanced
|
|
124
|
+
total_score = ram_score + gpu_score + cpu_score
|
|
125
|
+
|
|
126
|
+
return min(1.0, max(0.0, total_score))
|
|
127
|
+
|
|
128
|
+
def _estimate_params_from_name(self, name: str) -> float:
|
|
129
|
+
"""Estimate parameter count from model name (in billions)."""
|
|
130
|
+
import re
|
|
131
|
+
|
|
132
|
+
# Look for patterns like "7B", "14B", "1.5B", etc.
|
|
133
|
+
matches = re.findall(r'(\d+\.?\d*)[Bb]', name)
|
|
134
|
+
if matches:
|
|
135
|
+
try:
|
|
136
|
+
return float(matches[0])
|
|
137
|
+
except ValueError:
|
|
138
|
+
pass
|
|
139
|
+
return 7.0 # Default assumption
|
|
140
|
+
|
|
141
|
+
def recommend_best(
|
|
142
|
+
self,
|
|
143
|
+
priority: str = "balanced",
|
|
144
|
+
) -> Optional[Dict[str, Any]]:
|
|
145
|
+
"""Get the single best recommended model.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
priority: One of "speed", "quality", "balanced", "memory"
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Best model dictionary or None if no compatible models
|
|
152
|
+
"""
|
|
153
|
+
compatible = self.get_compatible_models(priority=priority)
|
|
154
|
+
if compatible:
|
|
155
|
+
return compatible[0]
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def get_recommendation_reason(self, model: Dict[str, Any]) -> str:
|
|
159
|
+
"""Get human-readable explanation for why a model was recommended."""
|
|
160
|
+
ram_available = self.hardware.get("ram_available_gb", 0)
|
|
161
|
+
ram_required = model.get("ram_min_gb", 0)
|
|
162
|
+
gpu_available = self.hardware.get("gpu_available", False)
|
|
163
|
+
vram = self.hardware.get("gpu_vram_gb", 0)
|
|
164
|
+
|
|
165
|
+
reasons = []
|
|
166
|
+
|
|
167
|
+
# RAM assessment
|
|
168
|
+
if ram_available >= ram_required * 1.5:
|
|
169
|
+
reasons.append(f"✓ Plenty of RAM ({ram_available:.1f} GB available, {ram_required} GB needed)")
|
|
170
|
+
elif ram_available >= ram_required:
|
|
171
|
+
reasons.append(f"✓ Sufficient RAM ({ram_available:.1f} GB available)")
|
|
172
|
+
else:
|
|
173
|
+
reasons.append(f"⚠ Limited RAM ({ram_available:.1f} GB available, {ram_required} GB recommended)")
|
|
174
|
+
|
|
175
|
+
# GPU assessment
|
|
176
|
+
if gpu_available and vram >= 6:
|
|
177
|
+
reasons.append(f"✓ GPU acceleration available ({vram:.1f} GB VRAM)")
|
|
178
|
+
elif gpu_available:
|
|
179
|
+
reasons.append(f"○ GPU available but limited VRAM ({vram:.1f} GB)")
|
|
180
|
+
else:
|
|
181
|
+
reasons.append("○ CPU-only mode (no GPU detected)")
|
|
182
|
+
|
|
183
|
+
# Model-specific notes
|
|
184
|
+
params = self._estimate_params_from_name(model.get("name", ""))
|
|
185
|
+
if params <= 3:
|
|
186
|
+
reasons.append("→ Fast inference, good for quick tasks")
|
|
187
|
+
elif params <= 8:
|
|
188
|
+
reasons.append("→ Good balance of speed and quality")
|
|
189
|
+
else:
|
|
190
|
+
reasons.append("→ Higher quality, slower inference")
|
|
191
|
+
|
|
192
|
+
return "; ".join(reasons)
|
|
193
|
+
|
|
194
|
+
def print_recommendations(
|
|
195
|
+
self,
|
|
196
|
+
top_n: int = 5,
|
|
197
|
+
priority: str = "balanced",
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Print formatted recommendations to console."""
|
|
200
|
+
from .config import C_CYAN, C_RESET, C_GREEN, C_YELLOW, C_DIM, C_RED
|
|
201
|
+
|
|
202
|
+
compatible = self.get_compatible_models(priority=priority)
|
|
203
|
+
|
|
204
|
+
print(f"\n{C_CYAN}{'='*60}{C_RESET}")
|
|
205
|
+
print(f"{C_CYAN}MODEL RECOMMENDATIONS{C_RESET}")
|
|
206
|
+
print(f"{C_CYAN}{'='*60}{C_RESET}\n")
|
|
207
|
+
|
|
208
|
+
# Show hardware summary
|
|
209
|
+
print(f"{C_DIM}Your Hardware:{C_RESET}")
|
|
210
|
+
print(f" RAM: {self.hardware.get('ram_available_gb', 0):.1f} GB available")
|
|
211
|
+
if self.hardware.get('gpu_available'):
|
|
212
|
+
print(f" GPU: {self.hardware.get('gpu_name', 'Unknown')} ({self.hardware.get('gpu_vram_gb', 0):.1f} GB VRAM)")
|
|
213
|
+
else:
|
|
214
|
+
print(f" GPU: None detected (CPU-only)")
|
|
215
|
+
print()
|
|
216
|
+
|
|
217
|
+
if not compatible:
|
|
218
|
+
print(f"{C_RED}No compatible models found for your hardware.{C_RESET}\n")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
print(f"{C_DIM}Priority: {priority.capitalize()}{C_RESET}\n")
|
|
222
|
+
print(f"{C_GREEN}Top Recommendations:{C_RESET}\n")
|
|
223
|
+
|
|
224
|
+
for i, model in enumerate(compatible[:top_n], 1):
|
|
225
|
+
score = model.get("compatibility_score", 0)
|
|
226
|
+
recommended = model.get("recommended", False)
|
|
227
|
+
|
|
228
|
+
badge = f"{C_GREEN}[RECOMMENDED]{C_RESET}" if recommended else f"{C_DIM}[Compatible]{C_RESET}"
|
|
229
|
+
|
|
230
|
+
# Score indicator
|
|
231
|
+
if score >= 0.8:
|
|
232
|
+
score_str = f"{C_GREEN}██████████{C_RESET} {score:.0%}"
|
|
233
|
+
elif score >= 0.6:
|
|
234
|
+
score_str = f"{C_YELLOW}██████░░░░{C_RESET} {score:.0%}"
|
|
235
|
+
else:
|
|
236
|
+
score_str = f"{C_RED}████░░░░░░{C_RESET} {score:.0%}"
|
|
237
|
+
|
|
238
|
+
print(f" {i}. {model['name']}")
|
|
239
|
+
print(f" {badge}")
|
|
240
|
+
print(f" Match: {score_str}")
|
|
241
|
+
print(f" Min RAM: {model.get('ram_min_gb', '?')} GB | Context: {model.get('ctx', '?')}")
|
|
242
|
+
|
|
243
|
+
if i == 1:
|
|
244
|
+
reason = self.get_recommendation_reason(model)
|
|
245
|
+
print(f" {C_DIM}Why: {reason}{C_RESET}")
|
|
246
|
+
print()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def recommend_model_for_hardware(
|
|
250
|
+
priority: str = "balanced",
|
|
251
|
+
show_all: bool = False,
|
|
252
|
+
) -> Optional[Dict[str, Any]]:
|
|
253
|
+
"""Convenience function to get model recommendation.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
priority: One of "speed", "quality", "balanced", "memory"
|
|
257
|
+
show_all: If True, print all recommendations
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Best recommended model or None
|
|
261
|
+
"""
|
|
262
|
+
recommender = ModelRecommender()
|
|
263
|
+
|
|
264
|
+
if show_all:
|
|
265
|
+
recommender.print_recommendations(priority=priority)
|
|
266
|
+
|
|
267
|
+
return recommender.recommend_best(priority=priority)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
import argparse
|
|
272
|
+
|
|
273
|
+
parser = argparse.ArgumentParser(description="Get model recommendations for your hardware")
|
|
274
|
+
parser.add_argument(
|
|
275
|
+
"--priority",
|
|
276
|
+
choices=["speed", "quality", "balanced", "memory"],
|
|
277
|
+
default="balanced",
|
|
278
|
+
help="Optimization priority (default: balanced)"
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument(
|
|
281
|
+
"--all",
|
|
282
|
+
action="store_true",
|
|
283
|
+
help="Show all compatible models"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
args = parser.parse_args()
|
|
287
|
+
|
|
288
|
+
result = recommend_model_for_hardware(
|
|
289
|
+
priority=args.priority,
|
|
290
|
+
show_all=args.all
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if result and not args.all:
|
|
294
|
+
print(f"\n{result['name']}")
|
|
295
|
+
print(f"Min RAM: {result.get('ram_min_gb', '?')} GB")
|
dragon_cli/runtime.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Runtime configuration module for Dragon CLI.
|
|
2
|
+
|
|
3
|
+
Decides execution parameters based on hardware and model:
|
|
4
|
+
- CPU vs GPU vs hybrid execution
|
|
5
|
+
- Context window size
|
|
6
|
+
- Thread count
|
|
7
|
+
- Batch size
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuntimeConfig:
|
|
14
|
+
"""Configure runtime parameters for LLM inference."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
hardware_profile: Optional[Dict[str, Any]] = None,
|
|
19
|
+
model_info: Optional[Dict[str, Any]] = None,
|
|
20
|
+
):
|
|
21
|
+
"""Initialize runtime configuration.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
hardware_profile: Hardware info from detect_hardware()
|
|
25
|
+
model_info: Model metadata (ram_min_gb, ctx, etc.)
|
|
26
|
+
"""
|
|
27
|
+
from .hardware import detect_hardware
|
|
28
|
+
|
|
29
|
+
self.hardware = hardware_profile or detect_hardware()
|
|
30
|
+
self.model = model_info or {}
|
|
31
|
+
|
|
32
|
+
def decide_execution_mode(self) -> str:
|
|
33
|
+
"""Decide whether to use CPU, GPU, or hybrid execution.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
One of: "gpu", "cpu", "hybrid"
|
|
37
|
+
"""
|
|
38
|
+
gpu_available = self.hardware.get("gpu_available", False)
|
|
39
|
+
vram = self.hardware.get("gpu_vram_gb", 0)
|
|
40
|
+
ram = self.hardware.get("ram_available_gb", 8)
|
|
41
|
+
|
|
42
|
+
# Estimate model VRAM requirement
|
|
43
|
+
params_billions = self._estimate_params()
|
|
44
|
+
vram_needed = params_billions * 0.7 # Rough estimate for Q4 quantization
|
|
45
|
+
|
|
46
|
+
if gpu_available and vram >= vram_needed:
|
|
47
|
+
return "gpu" # Full GPU offload possible
|
|
48
|
+
elif gpu_available and vram >= 4:
|
|
49
|
+
return "hybrid" # Partial GPU offload
|
|
50
|
+
else:
|
|
51
|
+
return "cpu" # CPU only
|
|
52
|
+
|
|
53
|
+
def get_gpu_layers(self) -> int:
|
|
54
|
+
"""Calculate optimal number of layers to offload to GPU.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Number of layers for n_gpu_layers parameter
|
|
58
|
+
"""
|
|
59
|
+
execution_mode = self.decide_execution_mode()
|
|
60
|
+
|
|
61
|
+
if execution_mode == "gpu":
|
|
62
|
+
# Offload all layers
|
|
63
|
+
return 999 # llama-cpp-python convention for "all layers"
|
|
64
|
+
elif execution_mode == "hybrid":
|
|
65
|
+
# Calculate partial offload
|
|
66
|
+
vram = self.hardware.get("gpu_vram_gb", 0)
|
|
67
|
+
params_billions = self._estimate_params()
|
|
68
|
+
|
|
69
|
+
# Assume ~1GB per 1B params at Q4, adjust for layers
|
|
70
|
+
layers_per_gb = 2.5 # Approximate
|
|
71
|
+
max_layers = int(vram * layers_per_gb)
|
|
72
|
+
|
|
73
|
+
# Typical models have 32-40 layers
|
|
74
|
+
total_layers = self._estimate_layer_count()
|
|
75
|
+
return min(max_layers, total_layers)
|
|
76
|
+
else:
|
|
77
|
+
return 0 # CPU only
|
|
78
|
+
|
|
79
|
+
def get_thread_count(self) -> int:
|
|
80
|
+
"""Calculate optimal thread count for CPU inference.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Number of threads for n_threads parameter
|
|
84
|
+
"""
|
|
85
|
+
cpu_count = self.hardware.get("cpu_count", 4)
|
|
86
|
+
|
|
87
|
+
# Leave some cores for system
|
|
88
|
+
# Physical cores usually better than logical for inference
|
|
89
|
+
physical_count = self.hardware.get("cpu_count_physical", cpu_count // 2)
|
|
90
|
+
|
|
91
|
+
if physical_count > 0:
|
|
92
|
+
threads = max(1, physical_count - 1)
|
|
93
|
+
else:
|
|
94
|
+
threads = max(1, cpu_count - 2)
|
|
95
|
+
|
|
96
|
+
# Cap at reasonable maximum
|
|
97
|
+
return min(threads, 16)
|
|
98
|
+
|
|
99
|
+
def get_context_size(self) -> int:
|
|
100
|
+
"""Determine optimal context window size.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Context size for n_ctx parameter
|
|
104
|
+
"""
|
|
105
|
+
model_ctx = self.model.get("ctx", 4096)
|
|
106
|
+
ram = self.hardware.get("ram_available_gb", 8)
|
|
107
|
+
|
|
108
|
+
# Context memory: roughly 2GB per 8K context for 7B model
|
|
109
|
+
# Scale by model size
|
|
110
|
+
params_billions = self._estimate_params()
|
|
111
|
+
memory_per_8k = params_billions / 7.0 * 2.0 # GB
|
|
112
|
+
|
|
113
|
+
# Calculate max context we can afford
|
|
114
|
+
# Reserve 2GB for model weights and system
|
|
115
|
+
available_for_ctx = max(0, ram - 2 - (params_billions * 0.7))
|
|
116
|
+
|
|
117
|
+
if available_for_ctx <= 0:
|
|
118
|
+
max_ctx = 2048
|
|
119
|
+
else:
|
|
120
|
+
max_ctx = int(available_for_ctx / memory_per_8k * 8192)
|
|
121
|
+
max_ctx = max(2048, min(max_ctx, 32768)) # Reasonable bounds
|
|
122
|
+
|
|
123
|
+
# Use model's recommended context or calculated max, whichever is smaller
|
|
124
|
+
return min(model_ctx, max_ctx)
|
|
125
|
+
|
|
126
|
+
def get_batch_size(self) -> int:
|
|
127
|
+
"""Determine optimal batch size for inference.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Batch size for n_batch parameter
|
|
131
|
+
"""
|
|
132
|
+
execution_mode = self.decide_execution_mode()
|
|
133
|
+
ram = self.hardware.get("ram_available_gb", 8)
|
|
134
|
+
|
|
135
|
+
if execution_mode == "gpu":
|
|
136
|
+
# GPU can handle larger batches
|
|
137
|
+
return 512
|
|
138
|
+
elif ram >= 16:
|
|
139
|
+
return 256
|
|
140
|
+
elif ram >= 8:
|
|
141
|
+
return 128
|
|
142
|
+
else:
|
|
143
|
+
return 64
|
|
144
|
+
|
|
145
|
+
def get_runtime_params(self) -> Dict[str, Any]:
|
|
146
|
+
"""Get complete runtime parameters for llama-cpp-python.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dictionary with all parameters for Llama() constructor
|
|
150
|
+
"""
|
|
151
|
+
execution_mode = self.decide_execution_mode()
|
|
152
|
+
|
|
153
|
+
params = {
|
|
154
|
+
"n_threads": self.get_thread_count(),
|
|
155
|
+
"n_ctx": self.get_context_size(),
|
|
156
|
+
"n_batch": self.get_batch_size(),
|
|
157
|
+
"n_gpu_layers": self.get_gpu_layers(),
|
|
158
|
+
"verbose": False,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Additional optimizations
|
|
162
|
+
if execution_mode == "gpu":
|
|
163
|
+
params["use_mmap"] = True
|
|
164
|
+
params["use_mlock"] = False
|
|
165
|
+
else:
|
|
166
|
+
params["use_mmap"] = True
|
|
167
|
+
params["use_mlock"] = False
|
|
168
|
+
|
|
169
|
+
return params
|
|
170
|
+
|
|
171
|
+
def _estimate_params(self) -> float:
|
|
172
|
+
"""Estimate model parameters in billions from name."""
|
|
173
|
+
import re
|
|
174
|
+
|
|
175
|
+
name = self.model.get("name", "")
|
|
176
|
+
matches = re.findall(r'(\d+\.?\d*)[Bb]', name)
|
|
177
|
+
if matches:
|
|
178
|
+
try:
|
|
179
|
+
return float(matches[0])
|
|
180
|
+
except ValueError:
|
|
181
|
+
pass
|
|
182
|
+
return 7.0 # Default
|
|
183
|
+
|
|
184
|
+
def _estimate_layer_count(self) -> int:
|
|
185
|
+
"""Estimate layer count from model name."""
|
|
186
|
+
name = self.model.get("name", "").lower()
|
|
187
|
+
|
|
188
|
+
# Common patterns
|
|
189
|
+
if "llama" in name or "qwen" in name:
|
|
190
|
+
params = self._estimate_params()
|
|
191
|
+
if params <= 3:
|
|
192
|
+
return 28
|
|
193
|
+
elif params <= 8:
|
|
194
|
+
return 32
|
|
195
|
+
elif params <= 14:
|
|
196
|
+
return 40
|
|
197
|
+
else:
|
|
198
|
+
return 48
|
|
199
|
+
|
|
200
|
+
return 32 # Default
|
|
201
|
+
|
|
202
|
+
def print_config(self) -> None:
|
|
203
|
+
"""Print runtime configuration summary."""
|
|
204
|
+
from .config import C_CYAN, C_RESET, C_GREEN, C_DIM
|
|
205
|
+
|
|
206
|
+
params = self.get_runtime_params()
|
|
207
|
+
execution_mode = self.decide_execution_mode()
|
|
208
|
+
|
|
209
|
+
print(f"\n{C_CYAN}{'='*50}{C_RESET}")
|
|
210
|
+
print(f"{C_CYAN}RUNTIME CONFIGURATION{C_RESET}")
|
|
211
|
+
print(f"{C_CYAN}{'='*50}{C_RESET}\n")
|
|
212
|
+
|
|
213
|
+
print(f"{C_GREEN}Execution Mode:{C_RESET} {execution_mode.upper()}")
|
|
214
|
+
print(f"{C_GREEN}Threads:{C_RESET} {params['n_threads']}")
|
|
215
|
+
print(f"{C_GREEN}Context:{C_RESET} {params['n_ctx']} tokens")
|
|
216
|
+
print(f"{C_GREEN}Batch Size:{C_RESET} {params['n_batch']}")
|
|
217
|
+
print(f"{C_GREEN}GPU Layers:{C_RESET} {params['n_gpu_layers']}")
|
|
218
|
+
|
|
219
|
+
if execution_mode != "cpu":
|
|
220
|
+
print(f"\n{C_DIM}→ GPU acceleration enabled{C_RESET}")
|
|
221
|
+
else:
|
|
222
|
+
print(f"\n{C_DIM}→ Running on CPU only{C_RESET}")
|
|
223
|
+
|
|
224
|
+
print()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def configure_runtime(
|
|
228
|
+
model_info: Optional[Dict[str, Any]] = None,
|
|
229
|
+
hardware_profile: Optional[Dict[str, Any]] = None,
|
|
230
|
+
) -> Dict[str, Any]:
|
|
231
|
+
"""Convenience function to get runtime configuration.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
model_info: Model metadata
|
|
235
|
+
hardware_profile: Hardware profile (auto-detected if None)
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Runtime parameters dictionary for llama-cpp-python
|
|
239
|
+
"""
|
|
240
|
+
config = RuntimeConfig(hardware_profile=hardware_profile, model_info=model_info)
|
|
241
|
+
return config.get_runtime_params()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
# Test runtime configuration
|
|
246
|
+
config = RuntimeConfig()
|
|
247
|
+
config.print_config()
|
|
248
|
+
|
|
249
|
+
print("\nFull parameters:")
|
|
250
|
+
params = config.get_runtime_params()
|
|
251
|
+
for key, value in params.items():
|
|
252
|
+
print(f" {key}: {value}")
|
dragon_cli/server.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Minimal OpenAI-compatible HTTP server exposing the loaded local model.
|
|
2
|
+
|
|
3
|
+
No MCP tool-calling here by design — this is a small subset of the OpenAI
|
|
4
|
+
chat completions API (/v1/models, /v1/chat/completions, streaming included),
|
|
5
|
+
enough for any tool or SDK that already speaks that protocol to talk to the
|
|
6
|
+
local model, e.g. by pointing an OpenAI client at http://127.0.0.1:8000/v1.
|
|
7
|
+
Binds to localhost only unless --host is overridden explicitly.
|
|
8
|
+
|
|
9
|
+
Built on FastAPI so interactive API docs come for free: Swagger UI at /docs,
|
|
10
|
+
ReDoc at /redoc, raw schema at /openapi.json.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import uvicorn
|
|
19
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
20
|
+
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
|
21
|
+
from fastapi.staticfiles import StaticFiles
|
|
22
|
+
from fastapi.templating import Jinja2Templates
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
from starlette.concurrency import run_in_threadpool
|
|
25
|
+
|
|
26
|
+
from .config import C_CYAN, C_GREEN, C_RESET, C_YELLOW
|
|
27
|
+
from .web import FAVICON_SVG
|
|
28
|
+
|
|
29
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
30
|
+
TEMPLATES = Jinja2Templates(directory=BASE_DIR / "templates")
|
|
31
|
+
IMAGE_DIR = BASE_DIR.parents[2] / "image"
|
|
32
|
+
|
|
33
|
+
# llama.cpp inference is not safe to run concurrently from multiple threads,
|
|
34
|
+
# so every request (streaming or not) serializes on this lock.
|
|
35
|
+
_llm_lock = asyncio.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChatMessage(BaseModel):
|
|
39
|
+
role: str = Field(examples=["user"])
|
|
40
|
+
content: str = Field(examples=["Hello!"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ChatCompletionRequest(BaseModel):
|
|
44
|
+
messages: list[ChatMessage] = Field(min_length=1)
|
|
45
|
+
model: str | None = Field(default=None, description="Ignored — only one model is loaded per server.")
|
|
46
|
+
temperature: float = 0.7
|
|
47
|
+
max_tokens: int = 1024
|
|
48
|
+
stream: bool = Field(default=False, description="If true, respond with server-sent events.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _stream_chat_completion(llm, kwargs):
|
|
52
|
+
async with _llm_lock:
|
|
53
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
54
|
+
loop = asyncio.get_running_loop()
|
|
55
|
+
|
|
56
|
+
def worker():
|
|
57
|
+
try:
|
|
58
|
+
for chunk in llm.create_chat_completion(stream=True, **kwargs):
|
|
59
|
+
loop.call_soon_threadsafe(queue.put_nowait, chunk)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
loop.call_soon_threadsafe(queue.put_nowait, {"error": {"message": str(exc)}})
|
|
62
|
+
finally:
|
|
63
|
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
64
|
+
|
|
65
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
66
|
+
|
|
67
|
+
while True:
|
|
68
|
+
chunk = await queue.get()
|
|
69
|
+
if chunk is None:
|
|
70
|
+
break
|
|
71
|
+
yield f"data: {json.dumps(chunk)}\n\n".encode()
|
|
72
|
+
yield b"data: [DONE]\n\n"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_app(llm, model_name: str) -> FastAPI:
|
|
76
|
+
app = FastAPI(
|
|
77
|
+
title="Dragon CLI — local model server",
|
|
78
|
+
description="Minimal OpenAI-compatible API for the locally loaded GGUF model. "
|
|
79
|
+
"No MCP tool-calling here — model only.",
|
|
80
|
+
version="1.0.0",
|
|
81
|
+
)
|
|
82
|
+
app.state.llm = llm
|
|
83
|
+
app.state.model_name = model_name
|
|
84
|
+
|
|
85
|
+
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
|
86
|
+
|
|
87
|
+
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|
88
|
+
async def index(request: Request) -> HTMLResponse:
|
|
89
|
+
return TEMPLATES.TemplateResponse(request, "index.html", {
|
|
90
|
+
"model_name": model_name,
|
|
91
|
+
"base_url": str(request.base_url).rstrip("/"),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
@app.get("/chat", response_class=HTMLResponse, include_in_schema=False)
|
|
95
|
+
async def chat(request: Request) -> HTMLResponse:
|
|
96
|
+
return TEMPLATES.TemplateResponse(request, "chat.html", {
|
|
97
|
+
"model_name": model_name,
|
|
98
|
+
"base_url": str(request.base_url).rstrip("/"),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
@app.get("/favicon.ico", include_in_schema=False)
|
|
102
|
+
async def favicon() -> Response:
|
|
103
|
+
return Response(FAVICON_SVG, media_type="image/svg+xml")
|
|
104
|
+
|
|
105
|
+
@app.get("/v1/models", tags=["models"], summary="List the loaded model")
|
|
106
|
+
async def list_models() -> dict:
|
|
107
|
+
return {
|
|
108
|
+
"object": "list",
|
|
109
|
+
"data": [{"id": model_name, "object": "model", "owned_by": "dragon-cli"}],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@app.post("/v1/chat/completions", tags=["chat"], summary="Create a chat completion")
|
|
113
|
+
async def chat_completions(body: ChatCompletionRequest):
|
|
114
|
+
kwargs = {
|
|
115
|
+
"messages": [m.model_dump() for m in body.messages],
|
|
116
|
+
"temperature": body.temperature,
|
|
117
|
+
"max_tokens": body.max_tokens,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if not body.stream:
|
|
121
|
+
async with _llm_lock:
|
|
122
|
+
try:
|
|
123
|
+
return await run_in_threadpool(llm.create_chat_completion, stream=False, **kwargs)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise HTTPException(status_code=500, detail=str(exc))
|
|
126
|
+
|
|
127
|
+
return StreamingResponse(_stream_chat_completion(llm, kwargs), media_type="text/event-stream")
|
|
128
|
+
|
|
129
|
+
return app
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def run_server(llm, model_name: str, host: str, port: int) -> None:
|
|
133
|
+
app = create_app(llm, model_name)
|
|
134
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="warning")
|
|
135
|
+
server = uvicorn.Server(config)
|
|
136
|
+
print(f"{C_GREEN}[serve]{C_RESET} OpenAI-compatible endpoint at "
|
|
137
|
+
f"{C_CYAN}http://{host}:{port}/v1{C_RESET}")
|
|
138
|
+
print(f"{C_GREEN}[serve]{C_RESET} Interactive docs (Swagger UI): "
|
|
139
|
+
f"{C_CYAN}http://{host}:{port}/docs{C_RESET}")
|
|
140
|
+
print(f"{C_GREEN}[serve]{C_RESET} Chat UI: "
|
|
141
|
+
f"{C_CYAN}http://{host}:{port}/chat{C_RESET}")
|
|
142
|
+
backend = getattr(llm, "exec_mode", "CPU")
|
|
143
|
+
print(f"{C_YELLOW}[serve]{C_RESET} Model: {model_name} on {backend}. Press Ctrl+C to stop.")
|
|
144
|
+
await server.serve()
|