alpiecode 0.6.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,460 @@
1
+ """
2
+ Local GGUF model engine for AlpieCode.
3
+
4
+ Downloads and caches GGUF models from HuggingFace (`169Pi/Alpie_learn_prototype_GGUF_NEW`).
5
+ Runs local inference via `llama-cpp-python` with automatic GPU acceleration / CPU fallback.
6
+ Provides an OpenAI-compatible `create_chat_completion` interface.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ CACHE_DIR = Path.home() / ".alpiecode" / "models"
15
+ DEFAULT_REPO = "169Pi/Alpie_learn_prototype_GGUF_NEW"
16
+ DEFAULT_CTX_SIZE = 32768 # 32k tokens default for fast loading (supports up to 256k)
17
+
18
+ # Preload WSL NVIDIA CUDA driver if running under WSL
19
+ if sys.platform.startswith("linux"):
20
+ try:
21
+ import ctypes
22
+ wsl_cuda = Path("/usr/lib/wsl/lib/libcuda.so.1")
23
+ if wsl_cuda.exists():
24
+ ctypes.CDLL(str(wsl_cuda), mode=ctypes.RTLD_GLOBAL)
25
+ except Exception:
26
+ pass
27
+
28
+
29
+ # ── GPU Auto-Detection ────────────────────────────────────────────────
30
+
31
+ def detect_gpu() -> int:
32
+ """
33
+ Detect GPU hardware and return optimal layer offload count.
34
+ Returns: -1 (all layers to GPU), 26 (6GB VRAM), 0 (CPU only).
35
+
36
+ Priority: NVIDIA (nvidia-smi) > macOS Metal > Vulkan discrete GPU > CPU.
37
+ Skips Intel/AMD integrated GPUs (UHD, Iris, Vega) — too weak for LLM inference.
38
+ """
39
+ if os.environ.get("CUDA_VISIBLE_DEVICES") == "-1":
40
+ return 0
41
+
42
+ try:
43
+ import llama_cpp
44
+ if not getattr(llama_cpp, "llama_supports_gpu_offload", lambda: False)():
45
+ return 0 # Build doesn't support GPU
46
+ except Exception:
47
+ return 0
48
+
49
+ # Build supports GPU — now check if actual GPU hardware exists
50
+ import subprocess
51
+ import platform
52
+
53
+ # ── 1. NVIDIA GPU (highest priority — most common dedicated GPU) ──
54
+ nvidia_smi_candidates = [
55
+ "nvidia-smi",
56
+ "nvidia-smi.exe",
57
+ "/usr/lib/wsl/lib/nvidia-smi",
58
+ r"C:\Windows\System32\nvidia-smi.exe",
59
+ r"C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
60
+ ]
61
+ for smi_bin in nvidia_smi_candidates:
62
+ try:
63
+ res = subprocess.run(
64
+ [smi_bin, "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
65
+ capture_output=True, text=True, timeout=3
66
+ )
67
+ if res.returncode == 0 and res.stdout.strip():
68
+ vram_mb = int(res.stdout.strip().split("\n")[0].strip())
69
+ if vram_mb <= 6500: # 6GB VRAM (e.g. RTX 3050 Laptop)
70
+ return 26 # Partial offload — keep 2GB VRAM free
71
+ return -1 # 8GB+ VRAM — offload all layers
72
+ except Exception:
73
+ pass
74
+
75
+ # Windows fallback: check via wmic or powershell for NVIDIA controllers
76
+ if platform.system() == "Windows":
77
+ try:
78
+ res = subprocess.run(
79
+ ["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name"],
80
+ capture_output=True, text=True, timeout=3
81
+ )
82
+ if res.returncode == 0 and "NVIDIA" in res.stdout.upper():
83
+ return -1 # NVIDIA GPU found on Windows
84
+ except Exception:
85
+ pass
86
+
87
+ # ── 2. macOS Metal (Apple Silicon — unified memory, always fast) ──
88
+ if platform.system() == "Darwin":
89
+ try:
90
+ res = subprocess.run(["sysctl", "-n", "hw.memsize"],
91
+ capture_output=True, text=True, timeout=2)
92
+ if res.returncode == 0 and res.stdout.strip():
93
+ return -1 # Apple Silicon — offload all layers
94
+ except Exception:
95
+ pass
96
+
97
+ # ── 3. No dedicated GPU found — use CPU ──
98
+ # NOTE: We intentionally skip Vulkan-only GPUs (Intel UHD, Intel Iris,
99
+ # AMD Vega integrated) because they are too slow for LLM inference.
100
+ # Users with AMD discrete GPUs can set ALPIECODE_GPU_LAYERS=-1 to force.
101
+ return 0
102
+
103
+
104
+ # ── Model Downloader ─────────────────────────────────────────────────
105
+
106
+ def download_model(repo_id: str = DEFAULT_REPO, token: Optional[str] = None) -> Path:
107
+ """
108
+ Download single GGUF model from HuggingFace using huggingface_hub.
109
+ Saves to ~/.alpiecode/models/ and returns the local Path.
110
+
111
+ OFFLINE-FIRST: Checks local cache before any network call.
112
+ """
113
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
114
+ hf_token = token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
115
+
116
+ # ── STEP 1: Check local cache FIRST (zero network) ───────────────
117
+ local_gguf = list(CACHE_DIR.glob("*.gguf"))
118
+ # Filter out mmproj files for main model
119
+ local_main = [f for f in local_gguf if "mmproj" not in f.name.lower()]
120
+ if local_main:
121
+ return local_main[0]
122
+ if local_gguf:
123
+ return local_gguf[0]
124
+
125
+ # ── STEP 2: Model not cached — must download from HuggingFace ────
126
+ try:
127
+ from huggingface_hub import hf_hub_download, list_repo_files
128
+ except ImportError:
129
+ raise RuntimeError(
130
+ "huggingface_hub package is missing. Install it with: pip install huggingface_hub"
131
+ )
132
+
133
+ # List files in repo to find the GGUF file
134
+ try:
135
+ files = list_repo_files(repo_id=repo_id, token=hf_token)
136
+ gguf_files = [f for f in files if f.endswith(".gguf")]
137
+ except Exception as e:
138
+ err_str = str(e).lower()
139
+ # Differentiate auth/access errors vs network errors
140
+ if "401" in err_str or "unauthorized" in err_str:
141
+ raise RuntimeError(
142
+ f"\n❌ HuggingFace Authentication Failed for '{repo_id}'.\n"
143
+ " Your HF token is invalid or expired.\n"
144
+ " → Go to https://huggingface.co/settings/tokens to generate a new token.\n"
145
+ " → Then run: alpiecode init"
146
+ ) from e
147
+ elif "403" in err_str or "forbidden" in err_str or "storage limit" in err_str:
148
+ raise RuntimeError(
149
+ f"\n❌ HuggingFace Access Denied (403) for '{repo_id}'.\n"
150
+ " Your token does not have access to this private repo.\n"
151
+ " → Ask the 169Pi team to grant you access, or check your token permissions."
152
+ ) from e
153
+ elif "404" in err_str or "not found" in err_str:
154
+ raise RuntimeError(
155
+ f"\n❌ HuggingFace repo '{repo_id}' not found (404).\n"
156
+ " The model repository may not exist or is private.\n"
157
+ " → Verify the repo name and ensure your HF token has read access."
158
+ ) from e
159
+ else:
160
+ raise RuntimeError(
161
+ "\n╭──────────────────────────────────────────────────────────────╮\n"
162
+ "│ GGUF model not found locally and could not download. │\n"
163
+ "│ │\n"
164
+ f"│ Error: {str(e)[:52]:<53}│\n"
165
+ "│ │\n"
166
+ "│ Connect to internet and run: │\n"
167
+ "│ alpiecode init │\n"
168
+ "│ │\n"
169
+ "│ After the one-time download, offline mode works forever. │\n"
170
+ "╰──────────────────────────────────────────────────────────────╯"
171
+ ) from e
172
+
173
+ # Separate main model file from mmproj (vision projector) file
174
+ main_gguf_files = [f for f in gguf_files if "mmproj" not in f.lower()]
175
+ mmproj_files = [f for f in gguf_files if "mmproj" in f.lower()]
176
+
177
+ if not main_gguf_files:
178
+ main_gguf_files = gguf_files
179
+
180
+ target_file = main_gguf_files[0]
181
+ cached_file = CACHE_DIR / target_file
182
+
183
+ if not cached_file.exists():
184
+ print(f"📥 Downloading local model from HuggingFace ({repo_id}/{target_file})...")
185
+ print(" This is a one-time download. Subsequent runs will work 100% offline.")
186
+ try:
187
+ hf_hub_download(
188
+ repo_id=repo_id,
189
+ filename=target_file,
190
+ local_dir=CACHE_DIR,
191
+ token=hf_token,
192
+ )
193
+ except Exception as e:
194
+ err_str = str(e)
195
+ if "403" in err_str or "storage limit" in err_str.lower() or "forbidden" in err_str.lower():
196
+ raise RuntimeError(
197
+ f"HuggingFace 403 Forbidden Error for repo '{repo_id}':\n"
198
+ " 'Private repository storage limit reached for 169Pi account.'\n"
199
+ " 💡 Please ask your 169Pi organization admin to upgrade the HF storage plan or free up space on HuggingFace."
200
+ ) from e
201
+ raise RuntimeError(f"HuggingFace Download Failed for '{repo_id}/{target_file}': {e}") from e
202
+
203
+ # Download mmproj file if available (for vision features)
204
+ if mmproj_files:
205
+ mmproj_target = mmproj_files[0]
206
+ mmproj_cached = CACHE_DIR / mmproj_target
207
+ if not mmproj_cached.exists():
208
+ try:
209
+ print(f"📥 Downloading vision projector ({repo_id}/{mmproj_target})...")
210
+ hf_hub_download(
211
+ repo_id=repo_id,
212
+ filename=mmproj_target,
213
+ local_dir=CACHE_DIR,
214
+ token=hf_token,
215
+ )
216
+ except Exception as e:
217
+ print(f"⚠️ Could not download vision projector ({mmproj_target}): {e}")
218
+
219
+ return cached_file
220
+
221
+
222
+ # ── Local Model Engine Class ──────────────────────────────────────────
223
+
224
+ def _ensure_llama_cpp():
225
+ """Auto-install pre-compiled binary wheel for llama-cpp-python if missing."""
226
+ try:
227
+ from llama_cpp import Llama
228
+ return Llama
229
+ except ImportError:
230
+ pass
231
+
232
+ print("⚙️ Auto-installing pre-compiled local GGUF engine (one-time setup)...")
233
+ import sys
234
+ import subprocess
235
+
236
+ is_win = sys.platform == "win32"
237
+ is_mac = sys.platform == "darwin"
238
+ if is_win:
239
+ wheel_url = "https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.34-vulkan/llama_cpp_python-0.3.34-py3-none-win_amd64.whl"
240
+ elif is_mac:
241
+ wheel_url = "https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.34-metal/llama_cpp_python-0.3.34-py3-none-macosx_11_0_arm64.whl"
242
+ else:
243
+ wheel_url = "https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.34-vulkan/llama_cpp_python-0.3.34-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl"
244
+
245
+ installed = False
246
+ last_error = ""
247
+ for cmd_base in [["uv", "pip", "install", wheel_url], [sys.executable, "-m", "pip", "install", wheel_url]]:
248
+ try:
249
+ res = subprocess.run(cmd_base, capture_output=True, text=True, timeout=120)
250
+ if res.returncode == 0:
251
+ installed = True
252
+ break
253
+ else:
254
+ last_error = res.stderr.strip() or res.stdout.strip()
255
+ except Exception as e:
256
+ last_error = str(e)
257
+ continue
258
+
259
+ try:
260
+ from llama_cpp import Llama
261
+ print("✅ Pre-compiled local GGUF engine installed successfully!")
262
+ return Llama
263
+ except ImportError:
264
+ err_detail = f"\n Last error: {last_error[:200]}" if last_error else ""
265
+ raise RuntimeError(
266
+ "\n╭────────────────────────────────────────────────────────────╮\n"
267
+ "│ Failed to auto-install local GGUF engine. │\n"
268
+ "│ Internet connection required for first-time setup. │\n"
269
+ "╰────────────────────────────────────────────────────────────╯"
270
+ f"{err_detail}"
271
+ )
272
+
273
+
274
+ class LocalModel:
275
+ def __init__(self, repo_id: str = DEFAULT_REPO, n_ctx: int = DEFAULT_CTX_SIZE,
276
+ n_gpu_layers: Optional[int] = None, token: Optional[str] = None):
277
+ self.repo_id = repo_id
278
+ self.n_ctx = n_ctx
279
+ self.n_gpu_layers = n_gpu_layers if n_gpu_layers is not None else detect_gpu()
280
+ self.token = token
281
+ self.model_path: Optional[Path] = None
282
+ self._llm = None
283
+
284
+ def ensure_model(self) -> Path:
285
+ """Download model if not present."""
286
+ if not self.model_path or not self.model_path.exists():
287
+ self.model_path = download_model(self.repo_id, token=self.token)
288
+ return self.model_path
289
+
290
+ def load(self):
291
+ """Load the model into memory via llama-cpp-python."""
292
+ if self._llm is not None:
293
+ return self._llm
294
+
295
+ model_path = self.ensure_model()
296
+ Llama = _ensure_llama_cpp()
297
+
298
+ accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
299
+ print(f"🧠 Loading local GGUF model: {model_path.name}")
300
+ print(f" Context: {self.n_ctx} tokens | Mode: {accel} | Threads: {max(1, (os.cpu_count() or 4) - 1)}")
301
+ if accel == "CPU":
302
+ print(f" ⏳ CPU loading ~4GB model — this takes 30-90 seconds on first run...")
303
+ else:
304
+ print(f" ⚡ GPU-accelerated loading...")
305
+
306
+ n_threads = max(1, (os.cpu_count() or 4) - 1)
307
+
308
+ # Suppress noisy ggml_vulkan/ggml_cuda stderr messages from C library
309
+ # These confuse users ("ggml_vulkan: Found 1 Vulkan devices: Intel UHD...")
310
+ import time
311
+ t0 = time.monotonic()
312
+ _stderr_fd = None
313
+ _devnull_fd = None
314
+ try:
315
+ _stderr_fd = os.dup(2)
316
+ _devnull_fd = os.open(os.devnull, os.O_WRONLY)
317
+ os.dup2(_devnull_fd, 2)
318
+ except Exception:
319
+ _stderr_fd = None # Fallback: don't suppress if dup2 fails
320
+
321
+ try:
322
+ self._llm = Llama(
323
+ model_path=str(model_path),
324
+ n_ctx=self.n_ctx,
325
+ n_batch=2048 if self.n_gpu_layers == 0 else 4096,
326
+ n_threads=n_threads,
327
+ n_gpu_layers=self.n_gpu_layers,
328
+ use_mmap=True,
329
+ flash_attn=(self.n_gpu_layers != 0), # Flash Attention only for GPU
330
+ verbose=False,
331
+ )
332
+ finally:
333
+ # Restore stderr
334
+ if _stderr_fd is not None:
335
+ try:
336
+ os.dup2(_stderr_fd, 2)
337
+ os.close(_stderr_fd)
338
+ except Exception:
339
+ pass
340
+ if _devnull_fd is not None:
341
+ try:
342
+ os.close(_devnull_fd)
343
+ except Exception:
344
+ pass
345
+
346
+ elapsed = time.monotonic() - t0
347
+ print(f" ✅ Model loaded in {elapsed:.1f}s — ready!")
348
+ return self._llm
349
+
350
+ def create_chat_completion(self, messages: List[Dict[str, Any]], tools: Optional[List[Dict[str, Any]]] = None,
351
+ temperature: float = 0.2, max_tokens: int = 4096, **kwargs) -> Any:
352
+ """
353
+ Run inference matching OpenAI chat completions interface.
354
+ Returns a dot-accessible structure (resp.choices[0].message).
355
+ """
356
+ llm = self.load()
357
+
358
+ enable_thinking = kwargs.get("enable_thinking", True)
359
+
360
+ # When thinking is disabled, inject assistant prefill to skip reasoning tokens
361
+ # This eliminates ~6s of thinking token generation per turn
362
+ if not enable_thinking:
363
+ msgs = list(messages) + [{"role": "assistant", "content": "<think>\n\n</think>\n\n"}]
364
+ else:
365
+ msgs = messages
366
+
367
+ params = {
368
+ "messages": msgs,
369
+ "temperature": 0.1 if not enable_thinking else temperature,
370
+ "max_tokens": max_tokens,
371
+ }
372
+ if tools:
373
+ params["tools"] = tools
374
+ params["tool_choice"] = kwargs.get("tool_choice", "auto")
375
+
376
+ result_dict = llm.create_chat_completion(**params)
377
+ return _DictWrapper(result_dict)
378
+
379
+
380
+ # ── OpenAI-style Object Wrapper ───────────────────────────────────────
381
+
382
+ class _DictWrapper:
383
+ """Wraps dictionary output from llama-cpp to allow dot-notation (resp.choices[0].message)."""
384
+ def __init__(self, data: dict):
385
+ self._data = data
386
+
387
+ @property
388
+ def choices(self):
389
+ return [_ChoiceWrapper(c) for c in self._data.get("choices", [])]
390
+
391
+
392
+ class _ChoiceWrapper:
393
+ def __init__(self, data: dict):
394
+ self._data = data
395
+
396
+ @property
397
+ def message(self):
398
+ return _MessageWrapper(self._data.get("message", {}))
399
+
400
+
401
+ class _MessageWrapper:
402
+ def __init__(self, data: dict):
403
+ self._data = data
404
+
405
+ @property
406
+ def content(self):
407
+ return self._data.get("content")
408
+
409
+ @property
410
+ def role(self):
411
+ return self._data.get("role", "assistant")
412
+
413
+ @property
414
+ def reasoning(self):
415
+ return self._data.get("reasoning") or self._data.get("reasoning_content")
416
+
417
+ @property
418
+ def reasoning_content(self):
419
+ return self.reasoning
420
+
421
+ @property
422
+ def tool_calls(self):
423
+ raw_tc = self._data.get("tool_calls")
424
+ if not raw_tc:
425
+ return None
426
+ return [_ToolCallWrapper(tc) for tc in raw_tc]
427
+
428
+
429
+ class _ToolCallWrapper:
430
+ def __init__(self, data: dict):
431
+ self._data = data
432
+
433
+ @property
434
+ def id(self):
435
+ return self._data.get("id", "call_local")
436
+
437
+ @property
438
+ def type(self):
439
+ return self._data.get("type", "function")
440
+
441
+ @property
442
+ def function(self):
443
+ return _FunctionWrapper(self._data.get("function", {}))
444
+
445
+
446
+ class _FunctionWrapper:
447
+ def __init__(self, data: dict):
448
+ self._data = data
449
+
450
+ @property
451
+ def name(self):
452
+ return self._data.get("name")
453
+
454
+ @property
455
+ def arguments(self):
456
+ args = self._data.get("arguments")
457
+ if isinstance(args, dict):
458
+ import json
459
+ return json.dumps(args)
460
+ return args or "{}"