"""LoRA fetch + load from Hugging Face (or any direct .safetensors path). Functions: - `_parse_hf_link(url)` → `(repo, file | None)` - `_resolve_repo_file(repo)` → pick the single .safetensors of a repo link - `_resolve_repo_revision(repo)` → Hub tip SHA (immutable cache key) - `_lora_cache_path(repo, file, revision)` → bucket path, or None if not writable - `_fetch_lora_file(repo, file, revision)` → local .safetensors path (cached) - `_load_lora_from_cache(path)` → load any local .safetensors into _CUSTOM_LoRA - `_ensure_nsfw_lora_loaded(key)` → lazy fetch + convert a catalog NSFW LoRA - `_ensure_custom_lora_loaded(repo, file)` → same for a user-supplied HF LoRA - `_ensure_custom_lora_loaded_from_path(key, path, label)` → load an already-fetched file Three layers of `H3_MAX_LORA_BYTES` enforcement: HEAD pre-check, post-download `os.path.getsize`, and a running byte counter during the bucket-cache copy (defends against a Hub lying about Content-Length). Concurrent writers use `_LORA_CACHE_LOCK` + temp file + `os.replace`. """ from __future__ import annotations import json import os import re import struct import time import gradio as gr from config import NSFW_LORA_CONFIGS from lora.cache import _evict_lora_cache_for_quota from lora.formats import ( _convert_lora, _detect_wrong_arch, ) import state as _state from state import ( _CUSTOM_LoRA, _H3_MAX_LORA_BYTES, _LORA_CACHE_DIR, _LORA_CACHE_LOCK, _NSFW_LoRA, _log, ) def _parse_hf_link(url): """Parse a Hugging Face repo or file link into (repo, file). `file` is None for a bare repo link.""" url = (url or "").strip().split("?")[0].split("#")[0] if not url: raise gr.Error("Paste a Hugging Face or CivitAI link first.") if not url.startswith("http"): parts = [p for p in url.split("/") if p] if len(parts) >= 2: return parts[0] + "/" + parts[1], "/".join(parts[2:]) or None raise gr.Error(f"Couldn't parse `{url}` as a Hugging Face repo link.") if "huggingface.co" not in url: raise gr.Error("Only Hugging Face links are supported (huggingface.co).") parts = [p for p in url.split("huggingface.co", 1)[1].strip("/").split("/") if p] if parts and parts[0] in ("spaces", "models", "datasets"): parts = parts[1:] # https://huggingface.co/spaces/Owner/Repo → Owner/Repo if len(parts) < 2: raise gr.Error(f"Couldn't parse `{url}` as a Hugging Face repo link.") repo = parts[0] + "/" + parts[1] if len(parts) == 2: return repo, None if parts[2] in ("resolve", "tree", "blob"): if len(parts) == 4 and parts[2] in ("tree", "blob"): return repo, None # tree/main or blob/main with no subpath → treat as a repo link if len(parts) < 5: raise gr.Error(f"Couldn't parse a filename from `{url}`.") if parts[3] != "main": raise gr.Error("Only `main`-branch links are supported — use a `resolve/main/...` link.") return repo, "/".join(parts[4:]) return repo, "/".join(parts[2:]) def _resolve_repo_file(repo): """Pick the single .safetensors file of a repo link; error if there isn't exactly one.""" from huggingface_hub import list_repo_files try: files = [f for f in list_repo_files(repo) if f.endswith(".safetensors")] except Exception as _e: raise gr.Error(f"Couldn't list files in `{repo}`: {_e}") if len(files) == 1: return files[0] if not files: raise gr.Error(f"No .safetensors files found in `{repo}`.") raise gr.Error(f"`{repo}` has multiple .safetensors files — paste a direct file link instead: " + ", ".join(files[:5]) + ("…" if len(files) > 5 else "")) def _resolve_repo_revision(repo: str) -> str: """Resolve the current tip of *repo* to an immutable commit SHA. Falls back to the literal string 'main' only if the Hub is unreachable so cache keys remain stable within a process; callers must not treat 'main' as immutable across restarts. """ try: from huggingface_hub import HfApi info = HfApi().repo_info(repo, repo_type="model") sha = getattr(info, "sha", None) or getattr(info, "revision", None) if sha and isinstance(sha, str) and len(sha) >= 7: return sha except Exception as _e: _log(f"revision resolve failed for {repo}: {_e}", "WARN") return "main" def _lora_cache_path(repo, file, revision: str | None = None): """Bucket path for a (repo, revision, file) LoRA, or None if the bucket isn't usable. Cache identity is (repo, revision_sha, file) so a moving `main` cannot serve stale weights under the same path. """ try: os.makedirs(_LORA_CACHE_DIR, exist_ok=True) _probe = os.path.join(_LORA_CACHE_DIR, ".probe") with open(_probe, "w") as _f: _f.write("ok") os.remove(_probe) except Exception: return None rev = (revision or "main")[:40] _key = re.sub(r"[^A-Za-z0-9._-]", "_", f"{repo.replace('/', '__')}__rev_{rev}__{file}") return os.path.join(_LORA_CACHE_DIR, _key) def _fetch_lora_file(repo, file, revision: str | None = None, max_bytes: int | None = None): """Local path to a LoRA .safetensors, preferring the bucket cache over a fresh download. Cache key includes the immutable Hub revision SHA when available. Enforces H3_MAX_LORA_BYTES (reject before/during download) and H3_MAX_LORA_CACHE_BYTES (evict unreferenced entries or reject). Concurrent writers use temp files + os.replace so consumers never see partial artifacts. ``max_bytes`` overrides the user-LoRA size cap (used for the system turbo weights, which are larger than the custom-LoRA quota by design). """ from huggingface_hub import hf_hub_download, hf_hub_url import requests limit = int(max_bytes) if max_bytes is not None else _H3_MAX_LORA_BYTES rev = revision or _resolve_repo_revision(repo) with _LORA_CACHE_LOCK: _cached = _lora_cache_path(repo, file, revision=rev) if _cached and os.path.exists(_cached): try: with open(_cached, "rb") as _f: _n = struct.unpack(" limit: try: os.remove(_cached) except OSError: pass raise gr.Error( f"`{file}` exceeds H3_MAX_LORA_BYTES " f"({size / 1e6:.0f} MB > {limit / 1e6:.0f} MB)." ) _log(f"LoRA cache hit: {repo}/{file}@{rev[:7]} ({size / 1e6:.0f} MB)") return _cached except gr.Error: raise except Exception: _log(f"LoRA cache corrupt, re-downloading: {repo}/{file}@{rev[:7]}", "WARN") try: os.remove(_cached) except OSError: pass # Pre-check size via HEAD when Content-Length is available. try: url = hf_hub_url(repo_id=repo, filename=file, revision=rev if rev != "main" else "main") head = requests.head(url, allow_redirects=True, timeout=30) cl = head.headers.get("Content-Length") if cl is not None: expected = int(cl) if expected > limit: raise gr.Error( f"`{file}` is too large ({expected / 1e6:.0f} MB > " f"{limit / 1e6:.0f} MB limit)." ) _evict_lora_cache_for_quota(expected) except gr.Error: raise except Exception as _e: _log(f"LoRA HEAD pre-check skipped ({_e})", "WARN") _t0 = time.time() # Download via hub; then enforce size on the resulting file before cache admit. _path = hf_hub_download(repo, file, revision=rev if rev != "main" else None) size = os.path.getsize(_path) _log(f"LoRA downloaded: {repo}/{file}@{rev[:7]} ({size / 1e6:.0f} MB in {time.time() - _t0:.0f}s)") if size > limit: try: os.remove(_path) except OSError: pass raise gr.Error( f"`{file}` exceeds H3_MAX_LORA_BYTES " f"({size / 1e6:.0f} MB > {limit / 1e6:.0f} MB)." ) # Architecture gate before admitting to persistent cache. try: with open(_path, "rb") as _f: _n = struct.unpack(" limit: _dst.close() try: os.remove(_tmp) except OSError: pass raise gr.Error( f"`{file}` exceeded H3_MAX_LORA_BYTES while copying " f"({written / 1e6:.0f} MB)." ) _dst.write(chunk) os.replace(_tmp, _cached) _log(f"LoRA cached to bucket: {repo}/{file}@{rev[:7]}") return _cached except gr.Error: raise except Exception as _e: _log(f"LoRA bucket cache write failed ({_e}); using HF cache", "WARN") try: if os.path.exists(_cached + ".tmp"): os.remove(_cached + ".tmp") except OSError: pass return _path def _ensure_nsfw_lora_loaded(key): """Download + convert + store a lazy NSFW LoRA's A/B factors on CPU. No-op if already loaded.""" if key in _NSFW_LoRA: return cfg = NSFW_LORA_CONFIGS[key] import torch from safetensors.torch import load_file as _load_lora _t0 = time.time() _log(f"loading NSFW LoRA {key} from {cfg['repo']} ...") # Catalog NSFW weights can exceed the user custom-LoRA quota (default 512 MB); # allow up to 1.5 GB for official catalog entries only. _path = _fetch_lora_file(cfg["repo"], cfg["file"], max_bytes=1536 * 1024 * 1024) _raw = _load_lora(_path) # The catalog's `format` is a hint, not a contract — a mislabeled entry converts under whatever # format its keys actually are, and only a file no converter recognizes becomes a user error. _sd, _fmt = _convert_lora(_raw, cfg.get("format", "")) if not _sd: raise gr.Error( f"`{cfg['file']}` converted to no usable modules — it isn't a MiniMax-H3 LoRA " f"(or its `format` is mislabeled). {_detect_wrong_arch(_raw)}" ) _log(f"NSFW LoRA {key}: {len(_raw)} keys as {_fmt}") _pairs: dict[str, dict[str, torch.Tensor]] = {} for _k, _v in _sd.items(): if ".lora_A." in _k: _pairs.setdefault(_k.split(".lora_A.")[0], {})["A"] = _v elif ".lora_B." in _k: _pairs.setdefault(_k.split(".lora_B.")[0], {})["B"] = _v _loaded = [] for _base, _pair in _pairs.items(): if "A" not in _pair or "B" not in _pair: continue _parts = _base.split(".") _obj = _state.PIPE.transformer for _p in _parts: _obj = _obj[int(_p)] if _p.isdigit() else getattr(_obj, _p) _loaded.append((_obj, _pair["A"].to(_obj.weight.dtype), _pair["B"].to(_obj.weight.dtype))) if not _loaded: raise gr.Error( f"`{cfg['file']}` has no complete LoRA module pairs — it isn't a MiniMax-H3 LoRA " f"(or its `format` is mislabeled). {_detect_wrong_arch(_raw)}" ) del _sd, _pairs, _raw import gc as _gc; _gc.collect() _NSFW_LoRA[key] = _loaded _log(f"NSFW LoRA {key} parsed ({len(_loaded)} modules in {time.time() - _t0:.1f}s)") def _ensure_custom_lora_loaded(repo, file): """Download + convert + store a user-supplied Hugging Face LoRA's A/B factors on CPU. No-op if already loaded.""" key = (repo, file) if key in _CUSTOM_LoRA: return import torch from safetensors.torch import load_file as _load_lora _t0 = time.time() _log(f"loading custom LoRA {repo}/{file} ...") try: _path = _fetch_lora_file(repo, file) _raw = _load_lora(_path) except Exception as _e: raise gr.Error(f"Couldn't fetch custom LoRA `{repo}/{file}`: {_e}") _sd, _fmt = _convert_lora(_raw) if not _sd: raise gr.Error(f"`{repo}/{file}` doesn't look like a MiniMax-H3 LoRA (no lora_A/lora_down/lokr keys found).") _log(f"custom LoRA {repo}/{file}: {len(_raw)} keys as {_fmt}") _pairs: dict[str, dict[str, torch.Tensor]] = {} for _k, _v in _sd.items(): if ".lora_A." in _k: _pairs.setdefault(_k.split(".lora_A.")[0], {})["A"] = _v elif ".lora_B." in _k: _pairs.setdefault(_k.split(".lora_B.")[0], {})["B"] = _v _loaded = [] for _base, _pair in _pairs.items(): if "A" not in _pair or "B" not in _pair: continue _parts = _base.split(".") _obj = _state.PIPE.transformer for _p in _parts: _obj = _obj[int(_p)] if _p.isdigit() else getattr(_obj, _p) _loaded.append((_obj, _pair["A"].to(_obj.weight.dtype), _pair["B"].to(_obj.weight.dtype))) if not _loaded: raise gr.Error(f"`{repo}/{file}` converted to no usable modules — is it a MiniMax-H3 LoRA? {_detect_wrong_arch(_raw)}") del _sd, _pairs, _raw import gc as _gc; _gc.collect() _CUSTOM_LoRA[key] = _loaded _log(f"custom LoRA {repo}/{file} parsed ({len(_loaded)} modules in {time.time() - _t0:.1f}s)") def _load_lora_from_cache(path): """Load a cached .safetensors LoRA directly by file path. Returns (repo, file) key and populates ``_CUSTOM_LoRA`` so it can be merged on generate.""" import torch from safetensors.torch import load_file as _load_sd _t0 = time.time() fname = os.path.basename(path) # Use the filename as a synthetic (repo, file) key. key = ("__cache__", fname) if key in _CUSTOM_LoRA: return key # Architecture gate: peek at header keys before loading full tensor data. try: with open(path, "rb") as _f: _n = struct.unpack("