diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py index e2513f43de..7f7291a56d 100644 --- a/studio/backend/core/inference/worker.py +++ b/studio/backend/core/inference/worker.py @@ -145,6 +145,8 @@ def _get_hf_download_state( blobs_dirs: list[Path] = [] if model_names: + from utils.paths import resolve_cached_repo_id_case + for name in model_names: if not name: continue @@ -154,6 +156,7 @@ def _get_hf_download_state( # relative paths, and Windows paths. if name.startswith(("/", ".", "~")) or "\\" in name: continue + name = resolve_cached_repo_id_case(name) # HF cache dir format: models--org--name (slashes -> --) cache_dir_name = "models--" + name.replace("/", "--") blobs_dir = cache / cache_dir_name / "blobs" diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 445cf0e7f4..1e31a91e26 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -49,8 +49,10 @@ try: ) from core.inference import get_inference_backend from utils.paths import ( + is_local_path, outputs_root, exports_root, + resolve_cached_repo_id_case, resolve_output_dir, resolve_export_dir, ) @@ -77,8 +79,10 @@ except ImportError: ) from core.inference import get_inference_backend from utils.paths import ( + is_local_path, outputs_root, exports_root, + resolve_cached_repo_id_case, resolve_output_dir, resolve_export_dir, ) @@ -597,10 +601,15 @@ async def get_model_config( This endpoint wraps the backend load_model_defaults function. """ try: - from utils.models.model_config import is_local_path - if not is_local_path(model_name): - model_name = model_name.lower() + resolved = resolve_cached_repo_id_case(model_name) + if resolved != model_name: + logger.info( + "Using cached repo_id casing '%s' for requested '%s'", + resolved, + model_name, + ) + model_name = resolved logger.info(f"Getting model config for: {model_name}") from utils.models.model_config import detect_audio_type diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 6cffc534aa..8c25da4f43 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -11,6 +11,8 @@ from utils.paths import ( normalize_path, is_local_path, is_model_cached, + get_cache_path, + resolve_cached_repo_id_case, outputs_root, exports_root, resolve_output_dir, @@ -711,12 +713,8 @@ def _detect_audio_from_tokenizer( # 1) Check local HF cache first (works for gated/offline models) try: - from huggingface_hub.constants import HF_HUB_CACHE - - cache_dir = Path(HF_HUB_CACHE) - repo_dir_name = f"models--{model_name.replace('/', '--')}" - repo_dir = cache_dir / repo_dir_name - if repo_dir.exists(): + repo_dir = get_cache_path(model_name) + if repo_dir is not None and repo_dir.exists(): snapshots_dir = repo_dir / "snapshots" if snapshots_dir.exists(): for snapshot in snapshots_dir.iterdir(): @@ -1627,11 +1625,18 @@ class ModelConfig: identifier = f"unsloth/{identifier}" path = identifier - # Enforce lowercase for remote Hugging Face identifiers to prevent cache duplication - # Hugging Face Hub APIs are case-insensitive remotely, but case-sensitive locally (repo_folder_name). + # Preserve requested casing, but if a case-variant already exists in local HF cache, + # reuse that exact repo_id spelling to avoid one-time re-downloads after #2592. if not is_local: - identifier = identifier.lower() - path = path.lower() + resolved_identifier = resolve_cached_repo_id_case(identifier) + if resolved_identifier != identifier: + logger.info( + "Using cached repo_id casing '%s' for requested '%s'", + resolved_identifier, + identifier, + ) + identifier = resolved_identifier + path = resolved_identifier # Auto-detect GGUF models (check before LoRA/vision detection) if is_local: @@ -1852,6 +1857,12 @@ class ModelConfig: identifier = f"unsloth/{identifier}" path = identifier + if not is_local: + resolved_identifier = resolve_cached_repo_id_case(identifier) + if resolved_identifier != identifier: + identifier = resolved_identifier + path = resolved_identifier + # --- Logic for Base Model and Vision Detection --- base_model = None is_vision = False diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 44a7c8e287..11709ae56e 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -5,7 +5,15 @@ Path utilities for model and dataset handling """ -from .path_utils import normalize_path, is_local_path, is_model_cached, get_cache_path +from .path_utils import ( + normalize_path, + is_local_path, + is_model_cached, + get_cache_path, + resolve_cached_repo_id_case, + get_cache_case_resolution_stats, + reset_cache_case_resolution_state, +) from .storage_roots import ( studio_root, assets_root, @@ -40,6 +48,9 @@ __all__ = [ "is_local_path", "is_model_cached", "get_cache_path", + "resolve_cached_repo_id_case", + "get_cache_case_resolution_stats", + "reset_cache_case_resolution_state", "studio_root", "assets_root", "datasets_root", diff --git a/studio/backend/utils/paths/path_utils.py b/studio/backend/utils/paths/path_utils.py index b38db18286..9ef9a2dd92 100644 --- a/studio/backend/utils/paths/path_utils.py +++ b/studio/backend/utils/paths/path_utils.py @@ -14,6 +14,20 @@ from loggers import get_logger logger = get_logger(__name__) +# Per-process cache to avoid repeated cache-dir scans for the same identifier. +_CACHE_CASE_RESOLUTION_MEMO: dict[str, str] = {} + +# Lightweight instrumentation counters for operational visibility. +_CACHE_CASE_RESOLUTION_STATS: dict[str, int] = { + "calls": 0, + "memo_hits": 0, + "exact_hits": 0, + "variant_hits": 0, + "tie_breaks": 0, + "fallbacks": 0, + "errors": 0, +} + def _is_wsl() -> bool: """Detect if we are running inside WSL (Windows Subsystem for Linux).""" @@ -94,8 +108,9 @@ def is_local_path(path: str) -> bool: def get_cache_path(model_name: str) -> Optional[Path]: """Get HuggingFace cache path for a model if it exists.""" - cache_dir = Path.home() / ".cache" / "huggingface" / "hub" - model_cache_name = model_name.replace("/", "--") + cache_dir = _hf_hub_cache_dir() + resolved_name = resolve_cached_repo_id_case(model_name) + model_cache_name = resolved_name.replace("/", "--") model_cache_path = cache_dir / f"models--{model_cache_name}" return model_cache_path if model_cache_path.exists() else None @@ -113,3 +128,102 @@ def is_model_cached(model_name: str) -> bool: return True return False + + +def _hf_hub_cache_dir() -> Path: + """Return HF cache root honoring HF_HUB_CACHE when available.""" + try: + from huggingface_hub.constants import HF_HUB_CACHE + + return Path(HF_HUB_CACHE) + except Exception as exc: + logger.debug( + "Could not read huggingface_hub HF_HUB_CACHE, using default hub path: %s", + exc, + ) + return Path.home() / ".cache" / "huggingface" / "hub" + + +def resolve_cached_repo_id_case(model_name: str, use_memo: bool = True) -> str: + """Resolve repo_id to the exact casing already present in local HF cache. + + Policy: prefer the requested/canonical repo_id, but if a case-variant already + exists in local HF cache, reuse that exact cached spelling. This avoids + duplicate downloads while preserving user intent whenever possible. + """ + _CACHE_CASE_RESOLUTION_STATS["calls"] += 1 + + if not model_name or "/" not in model_name: + _CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1 + return model_name + + cache_dir = _hf_hub_cache_dir() + if not cache_dir.exists(): + _CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1 + return model_name + + expected_dir = f"models--{model_name.replace('/', '--')}" + + # Always check the exact-case path first so a newly-appeared exact match + # wins over any previously memoized variant. + exact_path = cache_dir / expected_dir + if exact_path.is_dir(): + if use_memo: + _CACHE_CASE_RESOLUTION_MEMO[model_name] = model_name + _CACHE_CASE_RESOLUTION_STATS["exact_hits"] += 1 + return model_name + + # Validate memoized entries still exist on disk before returning them. + # This prevents stale results when cache dirs are deleted/recreated. + if use_memo: + cached = _CACHE_CASE_RESOLUTION_MEMO.get(model_name) + if cached is not None: + cached_path = cache_dir / f"models--{cached.replace('/', '--')}" + if cached_path.is_dir(): + _CACHE_CASE_RESOLUTION_STATS["memo_hits"] += 1 + return cached + # Stale entry -- drop it and re-scan below. + _CACHE_CASE_RESOLUTION_MEMO.pop(model_name, None) + + expected_lower = expected_dir.lower() + try: + candidates: list[str] = [] + for entry in cache_dir.iterdir(): + if not entry.is_dir(): + continue + if entry.name.lower() != expected_lower: + continue + if not entry.name.startswith("models--"): + continue + repo_part = entry.name[len("models--") :] + if not repo_part: + continue + candidates.append(repo_part.replace("--", "/")) + + if candidates: + # Deterministic tie-break if multiple case variants coexist. + resolved = sorted(candidates)[0] + if len(candidates) > 1: + _CACHE_CASE_RESOLUTION_STATS["tie_breaks"] += 1 + _CACHE_CASE_RESOLUTION_STATS["variant_hits"] += 1 + if use_memo: + _CACHE_CASE_RESOLUTION_MEMO[model_name] = resolved + return resolved + except Exception as exc: + _CACHE_CASE_RESOLUTION_STATS["errors"] += 1 + logger.debug(f"Could not resolve cached repo_id case for '{model_name}': {exc}") + + _CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1 + return model_name + + +def get_cache_case_resolution_stats() -> dict[str, int]: + """Return a copy of case-resolution instrumentation counters.""" + return dict(_CACHE_CASE_RESOLUTION_STATS) + + +def reset_cache_case_resolution_state() -> None: + """Clear resolver memo and counters (primarily for tests).""" + _CACHE_CASE_RESOLUTION_MEMO.clear() + for key in _CACHE_CASE_RESOLUTION_STATS: + _CACHE_CASE_RESOLUTION_STATS[key] = 0 diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index cfe3acb656..a6cb3eb529 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -114,6 +114,17 @@ FORCE_FLOAT32 = [ global DISABLE_COMPILE_MODEL_NAMES # Must be alphabetically sorted for each entry + + +def _strip_unsloth_bnb_4bit_suffix(model_name: str) -> str: + """Remove Unsloth 4bit suffixes without lowercasing (HF cache dirs are case-sensitive).""" + s = model_name + for suffix in ("-unsloth-bnb-4bit", "-bnb-4bit"): + if len(s) >= len(suffix) and s.lower().endswith(suffix.lower()): + s = s[: -len(suffix)] + return s + + DISABLE_COMPILE_MODEL_NAMES = [ "aya_vision", "modernbert", @@ -404,8 +415,7 @@ class FastLanguageModel(FastLlamaModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False @@ -551,8 +561,7 @@ class FastLanguageModel(FastLlamaModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False @@ -1019,8 +1028,7 @@ class FastModel(FastBaseModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False @@ -1320,8 +1328,7 @@ class FastModel(FastBaseModel): if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith( ("-unsloth-bnb-4bit", "-bnb-4bit") ): - model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit") - model_name = model_name.lower().removesuffix("-bnb-4bit") + model_name = _strip_unsloth_bnb_4bit_suffix(model_name) # Change -BF16 to all False for 4bit, 8bit etc if model_name.lower().endswith("-bf16"): load_in_4bit = False diff --git a/unsloth/models/loader_utils.py b/unsloth/models/loader_utils.py index cf5af983a6..99da5f799e 100644 --- a/unsloth/models/loader_utils.py +++ b/unsloth/models/loader_utils.py @@ -162,7 +162,7 @@ def __get_model_name( # Support returning original full -bnb-4bit name if specified specifically # since we'll map it to the dynamic version instead if lower_model_name.endswith("-bnb-4bit"): - return lower_model_name + return model_name new_model_name = FLOAT_TO_INT_MAPPER[lower_model_name] # logger.warning_once(