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,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()