"""Model catalog, platform detection, and cache introspection. Extracted from the monolithic ``setup.py`` to keep concerns separate: - ``KNOWN_MODELS`` loaded from ``config/models.yaml`` - ``GET /models`` endpoint (with 10 s response cache) - ``GET /setup/recommendations`` device-aware preset endpoint - ``ModelCatalog`` dependency for use with ``Depends()`` """ from __future__ import annotations import logging import os import platform as _platform import sys import time from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends logger = logging.getLogger("omnivoice.setup.models") router = APIRouter() # ── Model Catalog (loaded from YAML) ────────────────────────────────────── _YAML_PATH = Path(__file__).resolve().parents[3] / "config" / "models.yaml" def _load_models_from_yaml() -> list[dict]: """Load model catalog from config/models.yaml. Falls back to an empty list if the file is missing or unreadable. The YAML file is read once at import time — restart to pick up edits. """ try: import yaml # PyYAML is already a transitive dep of huggingface_hub with open(_YAML_PATH, "r", encoding="utf-8") as f: data = yaml.safe_load(f) models = data.get("models", []) logger.info("Loaded %d models from %s", len(models), _YAML_PATH) return models except FileNotFoundError: logger.warning("models.yaml not found at %s — using empty catalog", _YAML_PATH) return [] except Exception as e: logger.error("Failed to load models.yaml: %s — using empty catalog", e) return [] KNOWN_MODELS = _load_models_from_yaml() # Back-compat tuple view for code that expects (repo_id, label) pairs. REQUIRED_MODELS = [(m["repo_id"], m["label"]) for m in KNOWN_MODELS if m.get("required")] # ── Dependency Injection ─────────────────────────────────────────────────── # Use `catalog: ModelCatalog = Depends(get_model_catalog)` in endpoint params # for testable, mockable access to the model registry. class ModelCatalog: """Injectable service wrapping the model catalog + cache scanner.""" def __init__(self, models: list[dict] | None = None): self.models = models if models is not None else KNOWN_MODELS self._by_id = {m["repo_id"]: m for m in self.models} self._required = [(m["repo_id"], m["label"]) for m in self.models if m.get("required")] def get(self, repo_id: str) -> dict | None: return self._by_id.get(repo_id) @property def required(self) -> list[tuple[str, str]]: return self._required @property def all(self) -> list[dict]: return self.models def supported_on_host(self, model: dict) -> bool: return _model_supported(model) # Singleton — shared across all requests. _catalog = ModelCatalog() def get_model_catalog() -> ModelCatalog: """FastAPI dependency — inject with ``Depends(get_model_catalog)``.""" return _catalog # ── Platform Detection ───────────────────────────────────────────────────── def _current_platform_tags() -> list[str]: """Return platform tags that the current host supports.""" tags = [sys.platform] arch = _platform.machine() tags.append(f"{sys.platform}-{arch}") try: import torch if torch.cuda.is_available(): tags.append("cuda") except Exception: pass return tags def _model_supported(model: dict) -> bool: """Check if a model is supported on the current platform.""" plats = model.get("platforms") if not plats: return True return bool(set(plats) & set(_current_platform_tags())) # ── HF Cache Helpers ─────────────────────────────────────────────────────── def hf_cache_dir() -> str: return ( os.environ.get("HF_HUB_CACHE") or os.environ.get("HUGGINGFACE_HUB_CACHE") or os.environ.get("HF_HOME") or os.path.expanduser("~/.cache/huggingface") ) def is_cached(repo_id: str) -> bool: """Best-effort check: does HF have this repo in its cache on disk?""" try: from huggingface_hub import scan_cache_dir info = scan_cache_dir() for entry in info.repos: if entry.repo_id == repo_id and entry.size_on_disk > 0: return True return False except Exception as e: logger.debug("scan_cache_dir failed: %s", e) return False # ── Response Cache ───────────────────────────────────────────────────────── # Simple TTL dict cache to avoid re-scanning the HF cache directory on every # frontend poll. Entries expire after ``_CACHE_TTL`` seconds. _CACHE_TTL = 10.0 # seconds _cache: dict[str, tuple[float, object]] = {} def _cached(key: str, ttl: float = _CACHE_TTL): """Return cached value if still valid, else None.""" entry = _cache.get(key) if entry and (time.monotonic() - entry[0]) < ttl: return entry[1] return None def _set_cache(key: str, value: object) -> None: _cache[key] = (time.monotonic(), value) def invalidate_cache() -> None: """Called after install/delete to bust the models cache.""" _cache.clear() # ── Endpoints ────────────────────────────────────────────────────────────── @router.get("/models") def list_models(): """Catalogue every known model + its on-disk install state. Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk walks when the frontend polls. """ cached_response = _cached("models") if cached_response is not None: return cached_response cached_by_repo: dict[str, dict] = {} try: from huggingface_hub import scan_cache_dir info = scan_cache_dir() for entry in info.repos: cached_by_repo[entry.repo_id] = { "size_on_disk": entry.size_on_disk, "last_accessed": entry.last_accessed, "nb_files": entry.nb_files, } except Exception as e: logger.warning("scan_cache_dir failed: %s", e) out = [] for m in KNOWN_MODELS: cached = cached_by_repo.get(m["repo_id"]) out.append({ **m, "installed": cached is not None and cached["size_on_disk"] > 0, "size_on_disk_bytes": cached["size_on_disk"] if cached else 0, "nb_files": cached["nb_files"] if cached else 0, "supported": _model_supported(m), }) response = { "models": out, "total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out), "hf_cache_dir": hf_cache_dir(), "platform_tags": _current_platform_tags(), } _set_cache("models", response) return response @router.get("/setup/recommendations") def recommendations(): """Return a curated model preset for the caller's device + architecture.""" is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64" is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64" is_linux = sys.platform.startswith("linux") is_windows = sys.platform == "win32" has_cuda = False try: import torch has_cuda = bool(torch.cuda.is_available()) except Exception: pass # Device label — used as the card title. if is_mac_arm: device_label = f"Apple Silicon ({_platform.machine()})" elif is_mac_intel: device_label = "macOS Intel (x86_64)" elif is_windows: device_label = "Windows x64" + (" + CUDA" if has_cuda else "") elif is_linux: device_label = "Linux x64" + (" + CUDA" if has_cuda else "") else: device_label = f"{sys.platform} / {_platform.machine()}" # Pick the preset for this device. if is_mac_arm: recommended_ids = [ "k2-fsa/OmniVoice", "Systran/faster-whisper-large-v3", "mlx-community/whisper-large-v3-mlx", "mlx-community/whisper-large-v3-turbo", "mlx-community/Kokoro-82M-bf16", "KittenML/kitten-tts-mini-0.8", ] rationale = ( "Apple Silicon gets the full stack: OmniVoice for multilingual clone + " "WhisperX (faster-whisper weights) for cross-platform ASR + MLX-Whisper " "for the Apple-optimised speedup + Whisper Turbo (5× faster) for live " "dictation + Kokoro (mlx-audio) for fast local English + KittenTTS as " "a CPU-realtime backup." ) else: recommended_ids = [ "k2-fsa/OmniVoice", "Systran/faster-whisper-large-v3", "KittenML/kitten-tts-mini-0.8", ] if has_cuda: recommended_ids.append("openai/whisper-large-v3") rationale = ( "Cross-platform stack + pytorch-whisper as a CUDA-accelerated " "ASR fallback. MLX / mlx-audio are Apple-Silicon-only and don't " "apply here." ) else: rationale = ( "Cross-platform stack: OmniVoice (multilingual clone) + WhisperX " "(faster-whisper ASR) + KittenTTS (English turbo, CPU-realtime). " "Clean install, every model runs on CPU." ) known_by_id = {m["repo_id"]: m for m in KNOWN_MODELS} cached_ids: set[str] = set() try: from huggingface_hub import scan_cache_dir info = scan_cache_dir() cached_ids = { entry.repo_id for entry in info.repos if entry.size_on_disk > 0 } except Exception: pass entries = [] for rid in recommended_ids: meta = known_by_id.get(rid, {}) entries.append({ "repo_id": rid, "label": meta.get("label", rid), "role": meta.get("role", ""), "size_gb": meta.get("size_gb", 0), "required": bool(meta.get("required", False)), "note": meta.get("note"), "installed": rid in cached_ids, }) to_download_gb = sum(e["size_gb"] for e in entries if not e["installed"]) all_installed = all(e["installed"] for e in entries) return { "device": { "os": sys.platform, "arch": _platform.machine(), "is_mac_arm": is_mac_arm, "is_mac_intel": is_mac_intel, "is_linux": is_linux, "is_windows": is_windows, "has_cuda": has_cuda, "label": device_label, }, "rationale": rationale, "models": entries, "download_gb_remaining": round(to_download_gb, 2), "total_gb": round(sum(e["size_gb"] for e in entries), 2), "all_installed": all_installed, }