"""Cache management and single-model runtimes for the ZeroGPU playground.""" from __future__ import annotations import gc import logging import os import re import threading from pathlib import Path from typing import Any import requests # ZeroGPU must be imported before torch. The lazy llama.cpp import below is # also intentionally kept inside the GPU-side loader. import spaces import torch from huggingface_hub import hf_hub_download, scan_cache_dir, snapshot_download from transformers import AutoModelForCausalLM, AutoTokenizer from backend_router import ( BACKEND_AUTO, BACKEND_LLAMACPP, BACKEND_TRANSFORMERS, BackendRouter, BackendRouterError, ModelInspection, ResolvedBackend, inspection_from_files, ) LOGGER = logging.getLogger(__name__) MODEL_ID_PATTERN = re.compile(r"^[^/\\s]+/[^/\\s]+$") # Some very new architectures ship their Transformers implementation inside # the model repository. Never enable remote code globally in this playground: # the model ID is user-controlled, so doing so would allow an arbitrary Hub # repository to execute Python in the Space. TRUST_REMOTE_CODE_ALLOWLIST = { "XHToken/Spark-X2.5-4B", } def _trust_remote_code(model_id: str) -> bool: return model_id in TRUST_REMOTE_CODE_ALLOWLIST def validate_model_id(model_id: str) -> str: normalized = (model_id or "").strip() if not MODEL_ID_PATTERN.fullmatch(normalized): raise ValueError("Model ID must look like namespace/model-name.") return normalized class UnsupportedModelError(RuntimeError): """Compatibility alias for callers that want a user-facing load error.""" class HubAccessError(RuntimeError): """Raised when a gated/private Hub artifact needs explicit Space access.""" def _hub_token() -> str: return (os.getenv("HF_TOKEN") or "").strip() def _hub_access_diagnosis(model_id: str) -> str: """Explain a Hub auth failure without exposing the secret or account data.""" token = _hub_token() if not token: return ( f"`{model_id}` is gated, but this Space process cannot see an `HF_TOKEN` secret. " "Add it under Space Settings → Repository secrets and restart the Space." ) try: response = requests.get( "https://huggingface.co/api/whoami-v2", headers={"Authorization": f"Bearer {token}"}, timeout=15, ) if response.status_code in {401, 403}: return ( "The Space sees `HF_TOKEN`, but the token is invalid, expired, or revoked. " "Create a new read-scoped token and replace the Space secret." ) if response.ok: return ( f"The Space sees `HF_TOKEN`, but its token account has not been granted access to `{model_id}`. " "Accept the gated model access with that same Hugging Face account, " "then restart the Space." ) except Exception: pass return ( f"Hugging Face denied access to gated repo `{model_id}`. " "Check that the token is read-scoped, belongs to the account that accepted access, " "and restart the Space after changing the secret." ) class ModelCache: """Keep standard snapshots and individually selected GGUF files in one cache.""" def __init__(self, cache_dir: str | None = None) -> None: default_dir = Path.home() / ".cache" / "huggingface" / "llm-playground" self.root = Path(cache_dir or os.getenv("PLAYGROUND_CACHE_DIR", default_dir)) self.root.mkdir(parents=True, exist_ok=True) self.router = BackendRouter() def inspect_remote(self, model_id: str) -> ModelInspection: return self.router.inspect_remote(validate_model_id(model_id), cache_dir=self.root) def download(self, model_id: str) -> Path: """Download a standard Transformers snapshot on CPU.""" return Path( snapshot_download( repo_id=validate_model_id(model_id), repo_type="model", cache_dir=str(self.root), token=_hub_token() or None, ) ) def download_gguf(self, model_id: str, filename: str) -> Path: """Download exactly one GGUF file, never the whole repository.""" model_id = validate_model_id(model_id) filename = (filename or "").strip() if not filename or not filename.lower().endswith(".gguf"): raise ValueError("Choose one `.gguf` file before downloading.") inspection = self.inspect_remote(model_id) if filename not in inspection.gguf_files: raise ValueError(f"`{filename}` is not a GGUF file in `{model_id}`.") try: return Path( hf_hub_download( repo_id=model_id, filename=filename, repo_type="model", cache_dir=str(self.root), token=_hub_token() or None, ) ) except Exception as exc: error_text = f"{exc.__class__.__name__} {exc}".lower() if any( marker in error_text for marker in ("401", "403", "gatedrepoerror", "unauthorized", "forbidden") ): raise HubAccessError(_hub_access_diagnosis(model_id)) from exc raise def cached_snapshot(self, model_id: str) -> Path: model_id = validate_model_id(model_id) try: return Path( snapshot_download( repo_id=model_id, repo_type="model", cache_dir=str(self.root), local_files_only=True, token=_hub_token() or None, ) ) except Exception as exc: raise FileNotFoundError( f"{model_id} is not downloaded yet. Click Download first." ) from exc def cached_gguf(self, model_id: str, filename: str) -> Path: model_id = validate_model_id(model_id) filename = (filename or "").strip() if not filename.lower().endswith(".gguf"): raise ValueError("Choose one `.gguf` file before loading.") try: return Path( hf_hub_download( repo_id=model_id, filename=filename, repo_type="model", cache_dir=str(self.root), local_files_only=True, token=_hub_token() or None, ) ) except Exception as exc: raise FileNotFoundError( f"`{filename}` is not downloaded yet. Click Download for this GGUF file first." ) from exc def cached_gguf_default(self, model_id: str) -> str | None: """Return the preferred already-downloaded GGUF, if one exists.""" model_id = validate_model_id(model_id) info = scan_cache_dir(cache_dir=str(self.root)) cached_names: set[str] = set() for repo in info.repos: if repo.repo_id != model_id: continue for revision in repo.revisions: cached_names.update( file_info.file_name for file_info in revision.files if file_info.file_name.lower().endswith(".gguf") ) if not cached_names: return None return inspection_from_files(model_id, cached_names, source="cache").default_gguf def resolve_cached( self, model_id: str, requested_backend: str | None, selected_file: str | None, ) -> ResolvedBackend: """Resolve a local artifact without doing network I/O on a GPU call.""" model_id = validate_model_id(model_id) selected = (selected_file or "").strip() if selected: path = self.cached_gguf(model_id, selected) inspection = self.router.synthetic_gguf(model_id, selected) backend = self.router.resolve_backend( inspection, requested_backend=requested_backend, selected_file=selected, ) else: try: path = self.cached_snapshot(model_id) inspection = self.router.inspect_snapshot(model_id, path) backend = self.router.resolve_backend( inspection, requested_backend=requested_backend, selected_file=None, ) except (FileNotFoundError, BackendRouterError): # API callers and a freshly opened browser may not yet have # the dynamic Dropdown value. If a GGUF was already selected # and downloaded, Auto can still pick that cached quant. if (requested_backend or BACKEND_AUTO).strip().lower() not in { "auto", "automatic", "llama.cpp", "llama-cpp", "llamacpp", "llama", }: raise selected = self.cached_gguf_default(model_id) or "" if not selected: raise path = self.cached_gguf(model_id, selected) inspection = self.router.synthetic_gguf(model_id, selected) backend = self.router.resolve_backend( inspection, requested_backend=requested_backend, selected_file=selected, ) if backend == BACKEND_LLAMACPP and not selected: raise BackendRouterError( "Choose the GGUF file you downloaded before loading it with llama.cpp." ) return ResolvedBackend( backend=backend, path=path, inspection=inspection, selected_file=selected or None, ) def describe(self, model_id: str, selected_file: str | None = None) -> str: model_id = (model_id or "").strip() if not model_id: return "Disk cache: no model selected." try: if selected_file: path = self.cached_gguf(model_id, selected_file) return f"Disk cache: GGUF ready (`{path.name}`)." path = self.cached_snapshot(model_id) return f"Disk cache: snapshot ready (`{path.name}`)." except (ValueError, FileNotFoundError): return f"Disk cache: `{model_id}` is not downloaded." def delete(self, model_id: str) -> bool: """Remove all cached revisions for one model, including selected GGUFs.""" model_id = validate_model_id(model_id) cache_info = scan_cache_dir(cache_dir=str(self.root)) revisions = [] for repo in cache_info.repos: if repo.repo_id == model_id: revisions.extend(revision.commit_hash for revision in repo.revisions) if not revisions: return False cache_info.delete_revisions(*revisions).execute() return True class ModelRuntime: """Route one active model to Transformers or llama.cpp.""" def __init__(self, cache: ModelCache) -> None: self.cache = cache self._model: Any | None = None self._tokenizer: Any | None = None self._llama: Any | None = None self._model_id: str | None = None self._backend: str | None = None self._selected_file: str | None = None self._inspection: ModelInspection | None = None self._lock = threading.RLock() @property def active_model_id(self) -> str | None: return self._model_id @property def active_backend(self) -> str | None: return self._backend @property def active_selected_file(self) -> str | None: return self._selected_file @property def active_inspection(self) -> ModelInspection | None: return self._inspection def active_label(self) -> str: if not self._model_id: return "No model loaded" suffix = f" · {self._backend}" if self._selected_file: suffix += f" · {self._selected_file}" return f"{self._model_id}{suffix}" def unload(self) -> None: """Release both possible runtimes and clear CUDA allocator state.""" with self._lock: old_model = self._model old_llama = self._llama self._model = None self._tokenizer = None self._llama = None self._model_id = None self._backend = None self._selected_file = None self._inspection = None if old_llama is not None: close = getattr(old_llama, "close", None) if callable(close): try: close() except Exception: LOGGER.debug("llama.cpp close failed during cleanup", exc_info=True) del old_llama if old_model is not None: del old_model gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() def ensure_loaded( self, model_id: str, requested_backend: str | None = BACKEND_AUTO, selected_file: str | None = None, ) -> ResolvedBackend: """Load one local artifact on GPU, replacing any active model.""" model_id = validate_model_id(model_id) target = self.cache.resolve_cached(model_id, requested_backend, selected_file) identity = (model_id, target.backend, target.selected_file) with self._lock: current = (self._model_id, self._backend, self._selected_file) if current == identity and (self._model is not None or self._llama is not None): return target # The one-model invariant is enforced before constructing either # a new Transformers model or a new llama.cpp context. self.unload() if target.backend == BACKEND_LLAMACPP: self._load_llama(target) else: self._load_transformers(target) self._model_id = model_id self._backend = target.backend self._selected_file = target.selected_file self._inspection = target.inspection return target @staticmethod def _preferred_dtype(inspection: ModelInspection) -> Any: value = str( inspection.config.get("torch_dtype") or inspection.config.get("dtype") or "" ).lower() if "float16" in value or value in {"fp16", "half"}: return torch.float16 if "float32" in value or value == "fp32": return torch.float32 if "float8" in value or "fp8" in value: return "auto" return torch.bfloat16 @staticmethod def _bnb_config(inspection: ModelInspection) -> Any | None: """Create a BitsAndBytesConfig only for filename-only quant repos.""" quant_config = inspection.config.get("quantization_config") if isinstance(quant_config, dict) and any( key in quant_config for key in ("load_in_4bit", "load_in_8bit", "_load_in_4bit", "_load_in_8bit") ): return None # Transformers will consume the repository config itself. if inspection.quantization_kind != "bitsandbytes": return None try: from transformers import BitsAndBytesConfig text = inspection.format_label.lower() load_in_8bit = "8-bit" in text return BitsAndBytesConfig( load_in_4bit=not load_in_8bit, load_in_8bit=load_in_8bit, bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, ) except Exception: LOGGER.debug("Could not construct a BitsAndBytesConfig", exc_info=True) return None def _load_transformers(self, target: ResolvedBackend) -> None: inspection = target.inspection trust_remote_code = _trust_remote_code(inspection.model_id) tokenizer = AutoTokenizer.from_pretrained( str(target.path), local_files_only=True, use_fast=True, trust_remote_code=trust_remote_code, ) quantized = inspection.is_transformers_quantized load_kwargs: dict[str, Any] = { "local_files_only": True, "low_cpu_mem_usage": True, "trust_remote_code": trust_remote_code, "dtype": "auto" if quantized else self._preferred_dtype(inspection), } if quantized: # Quantized modules must be placed by Accelerate/Transformers and # must not receive a later blanket `.to("cuda")` call. load_kwargs["device_map"] = "cuda" bnb_config = self._bnb_config(inspection) if bnb_config is not None: load_kwargs["quantization_config"] = bnb_config try: model = AutoModelForCausalLM.from_pretrained(str(target.path), **load_kwargs) # Spark-X2.5 ships generation_config.top_k = -1 to mean # "disable top-k". Recent Transformers validates GenerationConfig # before generation and rejects negative top_k values, even when # generate() receives a positive top_k override. if inspection.model_id == "XHToken/Spark-X2.5-4B": if getattr(model, "generation_config", None) is not None: model.generation_config.top_k = 20 if not quantized: model = model.to("cuda") model = model.eval() except Exception as exc: label = inspection.format_label raise RuntimeError( f"Could not load `{label}` with Transformers. " "The repository's quantization runtime may need a compatible loader package. " f"Details: {str(exc).splitlines()[0][:260]}" ) from exc if tokenizer.pad_token_id is None and tokenizer.eos_token_id is not None: tokenizer.pad_token = tokenizer.eos_token if getattr(model.config, "pad_token_id", None) is None: model.config.pad_token_id = tokenizer.pad_token_id self._tokenizer = tokenizer self._model = model def _load_llama(self, target: ResolvedBackend) -> None: try: from llama_cpp import Llama except Exception as exc: raise RuntimeError( "llama.cpp is not available. The Space needs the CUDA-enabled llama-cpp-python wheel." ) from exc kwargs: dict[str, Any] = { "model_path": str(target.path), "n_gpu_layers": -1, "n_ctx": 8192, "n_batch": 512, "n_threads": max(2, min(8, os.cpu_count() or 4)), "verbose": False, } try: llama = Llama(**kwargs, flash_attn=True) except TypeError: # Keep compatibility with older wheels that predate flash_attn in # the Python constructor; the GPU offload remains explicit. llama = Llama(**kwargs) except Exception as exc: raise RuntimeError( f"Could not load `{target.selected_file}` with llama.cpp GPU offload. " f"Details: {str(exc).splitlines()[0][:260]}" ) from exc self._llama = llama @staticmethod def _history_to_messages(history: list[Any] | None) -> list[dict[str, str]]: messages: list[dict[str, str]] = [] for item in history or []: if isinstance(item, dict): role = str(item.get("role", "")) content = item.get("content", "") if role in {"user", "assistant"} and isinstance(content, str): messages.append({"role": role, "content": content}) elif isinstance(item, (list, tuple)) and len(item) == 2: user_text, assistant_text = item if isinstance(user_text, str) and user_text: messages.append({"role": "user", "content": user_text}) if isinstance(assistant_text, str) and assistant_text: messages.append({"role": "assistant", "content": assistant_text}) return messages @staticmethod def _plain_prompt(messages: list[dict[str, str]]) -> str: lines = [f"{m['role'].capitalize()}: {m['content']}" for m in messages] return "\n".join(lines) + "\nAssistant:" def _tokenize(self, messages: list[dict[str, str]]) -> Any: assert self._tokenizer is not None tokenizer = self._tokenizer if hasattr(tokenizer, "apply_chat_template"): try: return tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_tensors="pt", return_dict=True, ) except TypeError: try: return tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_tensors="pt", ) except Exception: LOGGER.debug("Chat template without return_dict failed", exc_info=True) except Exception: LOGGER.debug("Chat template failed; using plain prompt", exc_info=True) return tokenizer(self._plain_prompt(messages), return_tensors="pt") def generate( self, model_id: str, requested_backend: str | None, selected_file: str | None, message: str, history: list[Any] | None, system_prompt: str, max_new_tokens: int, temperature: float, top_p: float, ) -> str: target = self.ensure_loaded(model_id, requested_backend, selected_file) messages: list[dict[str, str]] = [] if (system_prompt or "").strip(): messages.append({"role": "system", "content": system_prompt.strip()}) messages.extend(self._history_to_messages(history)) messages.append({"role": "user", "content": (message or "").strip()}) with self._lock: if target.backend == BACKEND_LLAMACPP: if self._llama is None: raise RuntimeError("llama.cpp runtime is not loaded.") result = self._llama.create_chat_completion( messages=messages, max_tokens=max_new_tokens, temperature=temperature, top_p=top_p, ) answer = "" if isinstance(result, dict) and result.get("choices"): answer = str( result["choices"][0].get("message", {}).get("content", "") ) return answer.strip() or "The model returned an empty response." if self._model is None or self._tokenizer is None: raise RuntimeError("Transformers runtime is not loaded.") encoded = self._tokenize(messages) encoded = { key: value.to("cuda") for key, value in encoded.items() if torch.is_tensor(value) } input_length = int(encoded["input_ids"].shape[-1]) generation_kwargs: dict[str, Any] = { "max_new_tokens": max_new_tokens, "do_sample": temperature > 0, "top_k": 20, } if temperature > 0: generation_kwargs.update({ "temperature": temperature, "top_p": top_p, "top_k": 20, }) with torch.inference_mode(): generated = self._model.generate(**encoded, **generation_kwargs) new_tokens = generated[0, input_length:] answer = self._tokenizer.decode(new_tokens, skip_special_tokens=True).strip() return answer or "The model returned an empty response."