Files
Azornes-Comfyui-Model-Resolver/core/sources/huggingface.py
T
Dariusz L dca6bc93fb Improve filename confidence and HuggingFace partial matching
Use order-insensitive token comparison in calculate_filename_confidence
so variant names with reordered tokens (e.g., int8_convrot vs convrot_int8)
score higher. Replace first-match partial matching in HuggingFace source with
best-match selection based on filename confidence, and surface confidence in
search results.
2026-09-16 19:55:09 +02:00

1600 lines
53 KiB
Python

"""
HuggingFace Source Module
Search and download models from HuggingFace Hub.
"""
import os
import posixpath
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import quote, unquote, urlparse
from ..contracts import HuggingFaceFileReference, SearchResult
from ..log_system import create_module_logger
from ..progress import get_progress_reporter
log = create_module_logger(__name__)
from ..matcher import (
build_filename_search_queries,
calculate_filename_confidence,
)
from ..network_utils import execute_provider_json_request, host_matches_domain, request_source_response
from ..path_utils import METADATA_DIR, get_filename_from_path, read_json_safe, write_json_atomic
from ..type_utils import (
build_model_result,
check_credential_http,
clear_remote_size_cache,
extract_file_size,
looks_like_model_file,
normalize_sha256,
)
from .common import probe_remote_file_size, resolve_file_size
HF_API_URL = "https://huggingface.co/api"
HF_AUTHOR_FALLBACKS = ["Comfy-Org", "Kijai"]
BRAVE_SEARCH_API_URL = "https://api.search.brave.com/res/v1/web/search"
HF_AUTHOR_INDEX_CACHE_TTL_SECONDS = 24 * 60 * 60
HF_AUTHOR_INDEX_CACHE_VERSION = 2
HF_AUTHOR_INDEX_CACHE_PATH = os.path.join(
METADATA_DIR, "huggingface-author-index.json"
)
# Cache for search results
_search_cache: Dict[str, Any] = {}
_author_index_cache: Dict[str, Dict[str, Any]] = {}
_author_index_lock = threading.RLock()
def clear_search_cache():
"""Clear cached HuggingFace search results and in-memory indexes."""
global _search_cache, _author_index_cache
_search_cache.clear()
clear_remote_size_cache()
with _author_index_lock:
_author_index_cache.clear()
_report_progress = get_progress_reporter("HuggingFace progress callback")
def check_huggingface_token(token: Optional[str]) -> Dict[str, Any]:
"""Check whether a HuggingFace access token is accepted."""
from ..type_utils import check_credential_preconditions
precheck = check_credential_preconditions(token, "HuggingFace token")
if precheck:
return precheck
value = (token or "").strip()
headers = {"Authorization": f"Bearer {value}"}
def get_user(data):
return data.get("name") or data.get("user", {}).get("name") or ""
result = check_credential_http(
f"{HF_API_URL}/whoami-v2",
headers=headers,
success_message="HuggingFace token is valid.",
get_username=get_user,
error_msg_401_403="HuggingFace token is not accepted.",
)
if result.get("status_code") == 404:
result = check_credential_http(
f"{HF_API_URL}/whoami",
headers=headers,
success_message="HuggingFace token is valid.",
get_username=get_user,
error_msg_401_403="HuggingFace token is not accepted.",
)
return result
def check_brave_search_api_key(api_key: Optional[str]) -> Dict[str, Any]:
"""Check whether a Brave Search API key is accepted."""
from ..type_utils import check_credential_preconditions
precheck = check_credential_preconditions(api_key, "Brave Search API key")
if precheck:
return precheck
key = (api_key or "").strip()
headers = {"X-Subscription-Token": key}
params = {"q": "test", "count": 1}
def custom_429(response):
return {
"success": False,
"valid": False,
"status": "limited",
"message": "Brave Search rate limit was reached.",
"status_code": 429,
}
return check_credential_http(
BRAVE_SEARCH_API_URL,
headers=headers,
params=params,
success_message="Brave Search API key is valid.",
error_msg_401_403="Brave Search API key is not accepted.",
custom_429_handler=custom_429,
)
def _author_index_cache_key(author: str, headers: Dict[str, str]) -> str:
auth_key = "token" if headers.get("Authorization") else "public"
return f"{author}::{auth_key}"
def _is_author_index_fresh(index: Any) -> bool:
if not isinstance(index, dict) or not index:
return False
updated_at = index.get("updated_at")
if not isinstance(updated_at, (int, float)):
return False
return (time.time() - updated_at) < HF_AUTHOR_INDEX_CACHE_TTL_SECONDS
def _read_persistent_author_indexes() -> Dict[str, Any]:
default_val = {"version": HF_AUTHOR_INDEX_CACHE_VERSION, "authors": {}}
data = read_json_safe(HF_AUTHOR_INDEX_CACHE_PATH, default_val)
if not isinstance(data, dict) or data.get("version") != HF_AUTHOR_INDEX_CACHE_VERSION:
return {"version": HF_AUTHOR_INDEX_CACHE_VERSION, "authors": {}}
if not isinstance(data.get("authors"), dict):
data["authors"] = {}
else:
# A short-lived regression stored the raw HuggingFace response list in
# place of the built author index. Ignore malformed entries so they are
# fetched and rebuilt normally.
data["authors"] = {
author: index
for author, index in data["authors"].items()
if isinstance(index, dict)
}
return data
def _write_persistent_author_index(author: str, index: Dict[str, Any]) -> None:
try:
data = _read_persistent_author_indexes()
data["authors"][author] = index
write_json_atomic(
HF_AUTHOR_INDEX_CACHE_PATH,
data,
indent=2,
)
except Exception as e:
log.debug(f"Error writing HuggingFace author index cache: {e}")
def _build_author_index_from_models(
author: str, repos: List[Dict[str, Any]]
) -> Dict[str, Any]:
files = []
repo_ids = []
hash_count = 0
for repo in repos:
repo_id = repo.get("id", "")
if not repo_id:
continue
repo_ids.append(repo_id)
for sibling in repo.get("siblings") or []:
file_path = sibling.get("rfilename") or sibling.get("path") or ""
if not file_path:
continue
sha256 = _extract_huggingface_file_sha256(sibling)
if sha256:
hash_count += 1
files.append(
{
"repo_id": repo_id,
"path": file_path,
"filename": get_filename_from_path(file_path),
"size": sibling.get("size"),
"sha256": sha256,
}
)
return {
"author": author,
"updated_at": time.time(),
"repo_count": len(repo_ids),
"file_count": len(files),
"hash_count": hash_count,
"repos": repo_ids,
"files": files,
}
def _is_huggingface_hash_candidate_path(file_path: str) -> bool:
return str(file_path or "").lower().endswith(
(
".safetensors",
".ckpt",
".pt",
".pth",
".bin",
".gguf",
".onnx",
)
)
def _enrich_author_index_with_repo_trees(
index: Dict[str, Any], headers: Dict[str, str]
) -> Dict[str, Any]:
"""Add SHA-256 values from repository tree responses to an author index."""
files_by_repo_path = {}
for file_info in index.get("files") or []:
repo_id = file_info.get("repo_id", "")
file_path = file_info.get("path", "")
if (
repo_id
and file_path
and _is_huggingface_hash_candidate_path(file_path)
and not _extract_huggingface_file_sha256(file_info)
):
files_by_repo_path[(repo_id, file_path)] = file_info
repo_ids = sorted({repo_id for repo_id, _ in files_by_repo_path})
if not repo_ids:
index["hash_count"] = sum(
1
for file_info in index.get("files") or []
if _extract_huggingface_file_sha256(file_info)
)
return index
def fetch_repo_tree(repo_id: str):
return repo_id, _get_repo_tree(repo_id, headers=headers)
max_workers = min(8, len(repo_ids))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(fetch_repo_tree, repo_id): repo_id
for repo_id in repo_ids
}
for future in as_completed(futures):
repo_id = futures[future]
try:
_, tree = future.result()
except Exception as e:
log.debug(
f"HuggingFace author index tree failed repo={repo_id}: {e}"
)
continue
for tree_file in tree or []:
file_path = tree_file.get("path", "")
file_info = files_by_repo_path.get((repo_id, file_path))
if not file_info:
continue
sha256 = _extract_huggingface_file_sha256(tree_file)
if sha256:
file_info["sha256"] = sha256
if file_info.get("size") is None:
file_info["size"] = extract_file_size(tree_file)
index["hash_count"] = sum(
1
for file_info in index.get("files") or []
if _extract_huggingface_file_sha256(file_info)
)
return index
def _fetch_author_index(author: str, headers: Dict[str, str], limit: int = 200):
try:
repos = execute_provider_json_request(
"HuggingFace author index",
f"{HF_API_URL}/models",
params={"author": author, "limit": limit, "full": "true"},
headers=headers,
timeout=15,
)
if not isinstance(repos, list):
return None
index = _build_author_index_from_models(author, repos)
index = _enrich_author_index_with_repo_trees(index, headers=headers)
log.info(
f"HuggingFace author index refreshed for author={author}: "
f"repos={index['repo_count']}, files={index['file_count']}, "
f"hashes={index['hash_count']}"
)
return index
except Exception as e:
log.debug(f"Error refreshing HuggingFace author index for author={author}: {e}")
return None
def _get_author_index(
author: str,
headers: Dict[str, str],
force_refresh: bool = False,
persist: bool = True,
allow_stale: bool = False,
) -> Optional[Dict[str, Any]]:
cache_key = _author_index_cache_key(author, headers)
can_persist = not headers.get("Authorization")
with _author_index_lock:
memory_index = _author_index_cache.get(cache_key)
if not force_refresh and _is_author_index_fresh(memory_index):
return memory_index
persistent_index = None
if can_persist:
persistent_index = _read_persistent_author_indexes().get("authors", {}).get(
author
)
if not force_refresh and _is_author_index_fresh(persistent_index):
_author_index_cache[cache_key] = persistent_index
log.debug(f"HuggingFace author index cache hit for author={author}")
return persistent_index
if allow_stale and not force_refresh:
stale_index = persistent_index or memory_index
if isinstance(stale_index, dict):
_author_index_cache[cache_key] = stale_index
log.debug(f"HuggingFace stale author index cache hit for author={author}")
return stale_index
fresh_index = _fetch_author_index(author, headers=headers)
if fresh_index:
_author_index_cache[cache_key] = fresh_index
if can_persist and persist:
_write_persistent_author_index(author, fresh_index)
return fresh_index
if persistent_index:
log.warning(
f"Using stale HuggingFace author index for author={author}; refresh failed"
)
_author_index_cache[cache_key] = persistent_index
return persistent_index
return None
def get_author_fallback_index_status(author: str = "Comfy-Org") -> Dict[str, Any]:
"""Return persisted public author fallback index status for the options UI."""
with _author_index_lock:
index = _read_persistent_author_indexes().get("authors", {}).get(author)
updated_at = index.get("updated_at") if isinstance(index, dict) else None
age_seconds = (time.time() - updated_at) if isinstance(updated_at, (int, float)) else None
return {
"author": author,
"exists": bool(index),
"updated_at": updated_at,
"age_seconds": age_seconds,
"ttl_seconds": HF_AUTHOR_INDEX_CACHE_TTL_SECONDS,
"stale": bool(index) and not _is_author_index_fresh(index),
"repo_count": index.get("repo_count", 0) if isinstance(index, dict) else 0,
"file_count": index.get("file_count", 0) if isinstance(index, dict) else 0,
"hash_count": index.get("hash_count", 0) if isinstance(index, dict) else 0,
}
def get_known_author_fallback_indexes_status() -> Dict[str, Any]:
"""Return aggregate status for all known HuggingFace author indexes."""
author_statuses = [
get_author_fallback_index_status(author) for author in HF_AUTHOR_FALLBACKS
]
updated_times = [
status["updated_at"]
for status in author_statuses
if isinstance(status.get("updated_at"), (int, float))
]
cached_author_count = sum(bool(status["exists"]) for status in author_statuses)
fully_cached = cached_author_count == len(author_statuses)
return {
"authors": author_statuses,
"author_count": len(author_statuses),
"cached_author_count": cached_author_count,
"exists": cached_author_count > 0,
"fully_cached": fully_cached,
"updated_at": max(updated_times) if updated_times else None,
"stale": not fully_cached
or any(bool(status["stale"]) for status in author_statuses),
"repo_count": sum(int(status["repo_count"]) for status in author_statuses),
"file_count": sum(int(status["file_count"]) for status in author_statuses),
"hash_count": sum(int(status.get("hash_count") or 0) for status in author_statuses),
}
def refresh_author_fallback_index(
token: Optional[str] = None, author: str = "Comfy-Org"
) -> Dict[str, Any]:
"""Force refresh the public HuggingFace author fallback index."""
headers = {}
index = _get_author_index(author, headers=headers, force_refresh=True)
status = get_author_fallback_index_status(author)
status["success"] = bool(index)
status["persisted"] = bool(index)
return status
def refresh_known_author_fallback_indexes(
token: Optional[str] = None,
) -> Dict[str, Any]:
"""Force refresh all known public HuggingFace author indexes."""
refresh_results = [
refresh_author_fallback_index(token, author) for author in HF_AUTHOR_FALLBACKS
]
status = get_known_author_fallback_indexes_status()
failed_authors = [
result["author"] for result in refresh_results if not result.get("success")
]
status["success"] = not failed_authors
status["persisted"] = all(
bool(result.get("persisted")) for result in refresh_results
)
status["failed_authors"] = failed_authors
return status
def parse_huggingface_url(url: str) -> Optional[HuggingFaceFileReference]:
"""
Parse a HuggingFace URL to extract repo and filename.
Handles formats:
- https://huggingface.co/user/repo/resolve/main/file.safetensors
- https://huggingface.co/user/repo/blob/main/file.safetensors
- hf://user/repo/file.safetensors
Returns:
Typed repository-file identity, or None if not an HF URL
"""
if url.startswith("hf://"):
parts = url[5:].split("/", 2)
if len(parts) >= 3:
return HuggingFaceFileReference(
repo=f"{parts[0]}/{parts[1]}",
filename=parts[2],
)
return None
parsed = urlparse(url)
if not host_matches_domain(parsed.hostname, "huggingface.co"):
return None
match = re.match(r"^/([^/]+/[^/]+)/(resolve|blob)/([^/]+)/(.+)$", parsed.path)
if match:
return HuggingFaceFileReference(
repo=match.group(1),
branch=match.group(3),
filename=match.group(4),
)
return None
def get_huggingface_download_url(repo: str, filename: str, branch: str = "main") -> str:
"""Generate a direct download URL for a HuggingFace file."""
return (
f"https://huggingface.co/{repo}/resolve/"
f"{quote(branch, safe='')}/{quote(filename)}"
)
def _normalize_huggingface_path(value: Any) -> str:
return str(value or "").replace("\\", "/").strip("/")
def _get_huggingface_variant_label(filename: str) -> str:
"""Return a concise precision/quantization label inferred from a filename."""
stem = os.path.splitext(get_filename_from_path(filename))[0].lower()
patterns = (
("nvfp4_mixed", "NVFP4 mixed"),
("fp8_scaled", "FP8 scaled"),
("int8_convrot", "INT8 convrot"),
("nvfp4", "NVFP4"),
("fp8", "FP8"),
("bf16", "BF16"),
("fp16", "FP16"),
("int8", "INT8"),
("int4", "INT4"),
)
for token, label in patterns:
if re.search(rf"(?:^|[_.-]){re.escape(token)}(?:$|[_.-])", stem):
return label
return ""
def _normalize_huggingface_details_file(
repo_id: str,
branch: str,
file_info: Dict[str, Any],
target_path: str,
) -> Optional[Dict[str, Any]]:
file_path = _normalize_huggingface_path(file_info.get("path"))
if not file_path:
return None
download_url = get_huggingface_download_url(repo_id, file_path, branch)
if not looks_like_model_file(download_url):
return None
filename = get_filename_from_path(file_path)
sha256 = _extract_huggingface_file_sha256(file_info)
extension = os.path.splitext(filename)[1].lstrip(".")
metadata = {
"format": extension.upper() if extension else "",
"fp": _get_huggingface_variant_label(filename),
}
metadata = {key: value for key, value in metadata.items() if value}
page_url = (
f"https://huggingface.co/{repo_id}/blob/"
f"{quote(branch, safe='')}/{quote(file_path, safe='/')}"
)
return {
"name": filename,
"filename": filename,
"path": file_path,
"size": extract_file_size(file_info),
"sha256": sha256,
"hash": sha256,
"hashes": {"SHA256": sha256} if sha256 else {},
"download_url": download_url,
"url": page_url,
"primary": bool(target_path and file_path == target_path),
"metadata": metadata,
}
def get_huggingface_model_details(
repo_id: str,
file_path: str = "",
branch: str = "main",
token: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Fetch model-file variants from the folder containing a matched HF file."""
normalized_repo_id = str(repo_id or "").strip().strip("/")
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", normalized_repo_id):
return None
normalized_branch = str(branch or "main").strip() or "main"
if len(normalized_branch) > 200 or any(
character in normalized_branch for character in "\r\n?#"
):
return None
target_path = _normalize_huggingface_path(file_path)
target_directory = posixpath.dirname(target_path)
headers = {"Authorization": f"Bearer {token.strip()}"} if token and token.strip() else {}
repo_tree = _get_repo_tree(
normalized_repo_id,
headers=headers,
branch=normalized_branch,
)
if not repo_tree:
return None
normalized_files = []
for file_info in repo_tree:
candidate_path = _normalize_huggingface_path(file_info.get("path"))
if not candidate_path:
continue
if target_path and posixpath.dirname(candidate_path) != target_directory:
continue
normalized_file = _normalize_huggingface_details_file(
normalized_repo_id,
normalized_branch,
file_info,
target_path,
)
if normalized_file:
normalized_files.append(normalized_file)
if not normalized_files:
return None
normalized_files.sort(
key=lambda item: (
not item.get("primary", False),
str(item.get("name") or "").lower(),
)
)
model_data: Dict[str, Any] = {}
try:
response_data = execute_provider_json_request(
"HuggingFace model details",
f"{HF_API_URL}/models/{normalized_repo_id}",
headers=headers,
timeout=15,
)
if isinstance(response_data, dict):
model_data = response_data
except Exception as e:
log.debug(
f"Error getting HuggingFace model metadata for {normalized_repo_id}: {e}"
)
card_data = (
model_data.get("cardData")
if isinstance(model_data.get("cardData"), dict)
else {}
)
tags = model_data.get("tags") if isinstance(model_data.get("tags"), list) else []
repo_url = f"https://huggingface.co/{normalized_repo_id}"
tree_url = (
f"{repo_url}/tree/{quote(normalized_branch, safe='')}"
+ (
f"/{quote(target_directory, safe='/')}"
if target_directory
else ""
)
)
version = {
"id": normalized_branch,
"name": normalized_branch,
"published_at": model_data.get("createdAt") or "",
"updated_at": model_data.get("lastModified") or "",
"description": "",
"trained_words": [],
"stats": {
"downloads": model_data.get("downloads", 0),
"thumbsUpCount": model_data.get("likes", 0),
},
"files": normalized_files,
"images": [],
"url": tree_url,
}
owner = normalized_repo_id.split("/", 1)[0]
description = (
model_data.get("description")
or card_data.get("description")
or (
f"Select a model file from `{target_directory}` in this Hugging Face "
"repository."
if target_directory
else "Select a model file from this Hugging Face repository."
)
)
return {
"source": "huggingface",
"details_source": "huggingface",
"model_id": normalized_repo_id,
"version_id": normalized_branch,
"name": model_data.get("modelId") or normalized_repo_id,
"type": model_data.get("pipeline_tag")
or model_data.get("library_name")
or "Model repository",
"description": description,
"creator": {"username": model_data.get("author") or owner},
"stats": {
"downloads": model_data.get("downloads", 0),
"thumbsUpCount": model_data.get("likes", 0),
},
"tags": tags,
"url": repo_url,
"version_url": tree_url,
"folder": target_directory,
"branch": normalized_branch,
"versions": [version],
"selected_version": version,
"images": [],
}
# _extract_file_info_size removed in favor of extract_file_size from type_utils
def _build_huggingface_result(
repo_id: str,
file_path: str,
file_info: Dict[str, Any],
match_type: str,
headers: Optional[Dict[str, str]] = None,
confidence: float = 0.0,
) -> SearchResult:
sha256 = _extract_huggingface_file_sha256(file_info)
download_url = get_huggingface_download_url(repo_id, file_path)
size = resolve_file_size(
file_info,
[download_url],
probe=lambda url: probe_remote_file_size(url, headers=headers),
)
return build_model_result(
"huggingface",
model_id=repo_id,
version_id=None,
filename=get_filename_from_path(file_path),
url=download_url,
download_url=download_url,
size=size,
match_type=match_type,
confidence=confidence,
sha256=sha256,
normalize_hashes=True,
repo_id=repo_id,
path=file_path,
branch="main",
)
def _extract_huggingface_file_sha256(file_info: Dict[str, Any]) -> str:
lfs_info = file_info.get("lfs") if isinstance(file_info.get("lfs"), dict) else {}
return normalize_sha256(
file_info.get("sha256")
or file_info.get("hash")
or lfs_info.get("sha256")
or lfs_info.get("oid")
)
def _get_repo_tree(
repo_id: str, headers: Dict[str, str], branch: str = "main"
) -> Optional[List[Dict[str, Any]]]:
files_url = f"{HF_API_URL}/models/{repo_id}/tree/{quote(branch, safe='')}"
try:
data = execute_provider_json_request(
"HuggingFace tree",
files_url,
params={"recursive": "1"},
headers=headers,
timeout=15,
)
if isinstance(data, list):
return data
return None
except Exception as e:
log.debug(f"Error getting HuggingFace tree for {repo_id}@{branch}: {e}")
return None
def get_huggingface_file_sha256(
url: str,
headers: Optional[Dict[str, str]] = None,
) -> str:
"""Return the regular file SHA256 for a Hugging Face file URL."""
parsed = parse_huggingface_url(url)
if not parsed:
return ""
repo_id = parsed.repo
branch = parsed.branch
target_path = _normalize_huggingface_path(
unquote(parsed.filename)
)
if not repo_id or not target_path:
return ""
repo_tree = _get_repo_tree(repo_id, headers=headers or {}, branch=branch)
for file_info in repo_tree or []:
candidate_path = _normalize_huggingface_path(file_info.get("path"))
if candidate_path == target_path:
return _extract_huggingface_file_sha256(file_info)
return ""
def _find_matching_file_in_repo(
repo_id: str,
files: List[Dict[str, Any]],
filename: str,
exact_only: bool = False,
headers: Optional[Dict[str, str]] = None,
) -> Optional[SearchResult]:
filename_lower = filename.lower()
filename_base = os.path.splitext(filename_lower)[0]
for file_info in files:
file_path = file_info.get("path", "")
if not file_path:
continue
file_name = get_filename_from_path(file_path)
file_name_lower = file_name.lower()
if file_name_lower == filename_lower or file_path.lower().endswith(filename_lower):
return _build_huggingface_result(
repo_id,
file_path,
file_info,
"exact",
headers=headers,
confidence=100.0,
)
if exact_only:
return None
best_match: Optional[SearchResult] = None
for file_info in files:
file_path = file_info.get("path", "")
if not file_path:
continue
file_name = get_filename_from_path(file_path)
file_name_lower = file_name.lower()
file_base = os.path.splitext(file_name_lower)[0]
if not file_path.lower().endswith((".safetensors", ".ckpt")):
continue
confidence = calculate_filename_confidence(filename, file_name)
is_partial_filename = (
filename_base in file_base or file_base in filename_base
)
if not is_partial_filename and confidence < 70.0:
continue
candidate = _build_huggingface_result(
repo_id,
file_path,
file_info,
"partial",
headers=headers,
confidence=confidence,
)
if best_match is None or candidate.confidence > best_match.confidence:
best_match = candidate
return best_match
def _find_matching_file_in_author_index(
index: Dict[str, Any],
filename: str,
exact_only: bool = False,
headers: Optional[Dict[str, str]] = None,
sha256: Optional[str] = None,
) -> Optional[SearchResult]:
files = index.get("files") or []
requested_sha256 = normalize_sha256(sha256)
if requested_sha256:
for file_info in files:
repo_id = file_info.get("repo_id", "")
file_path = file_info.get("path", "")
if (
not repo_id
or not file_path
or _extract_huggingface_file_sha256(file_info) != requested_sha256
):
continue
return _build_huggingface_result(
repo_id,
file_path,
file_info,
"hash",
headers=headers,
confidence=100.0,
)
return None
filename_lower = filename.lower()
filename_base = os.path.splitext(filename_lower)[0]
for file_info in files:
repo_id = file_info.get("repo_id", "")
file_path = file_info.get("path", "")
if not repo_id or not file_path:
continue
file_name = get_filename_from_path(file_path)
file_name_lower = file_name.lower()
if file_name_lower == filename_lower or file_path.lower().endswith(
filename_lower
):
return _build_huggingface_result(
repo_id,
file_path,
file_info,
"exact",
headers=headers,
confidence=100.0,
)
if exact_only:
return None
best_match: Optional[SearchResult] = None
for file_info in files:
repo_id = file_info.get("repo_id", "")
file_path = file_info.get("path", "")
if not repo_id or not file_path:
continue
file_name = get_filename_from_path(file_path)
file_name_lower = file_name.lower()
file_base = os.path.splitext(file_name_lower)[0]
file_path_lower = file_path.lower()
if not file_path_lower.endswith((".safetensors", ".ckpt")):
continue
confidence = calculate_filename_confidence(filename, file_name)
is_partial_filename = (
filename_base in file_base or file_base in filename_base
)
if not is_partial_filename and confidence < 70.0:
continue
candidate = _build_huggingface_result(
repo_id,
file_path,
file_info,
"partial",
headers=headers,
confidence=confidence,
)
if best_match is None or candidate.confidence > best_match.confidence:
best_match = candidate
return best_match
def _get_repos_by_author(
author: str, headers: Dict[str, str], limit: int = 200
) -> List[str]:
try:
response = request_source_response(
f"{HF_API_URL}/models",
params={"author": author, "limit": limit},
headers=headers,
timeout=15,
log_name="HuggingFace repos by author"
)
if response.status_code != 200:
log.debug(
f"HuggingFace author search returned {response.status_code} for author={author}"
)
return []
repos = response.json()
return [repo.get("id", "") for repo in repos if repo.get("id")]
except Exception as e:
log.debug(f"Error getting HuggingFace repos for author={author}: {e}")
return []
def _search_brave_for_huggingface_candidates(
filename: str, brave_api_key: str
) -> List[Dict[str, str]]:
if not brave_api_key:
return []
query = f'"{filename}" site:huggingface.co'
headers = {
"Accept": "application/json",
"Accept-Encoding": "gzip",
"X-Subscription-Token": brave_api_key,
}
params = {
"q": query,
"count": 20,
"safesearch": "off",
"spellcheck": "false",
}
candidates = []
seen = set()
try:
response = request_source_response(
BRAVE_SEARCH_API_URL, headers=headers, params=params, timeout=15, log_name="Brave search"
)
if response.status_code != 200:
log.warning(
f"Brave search returned {response.status_code} for query={query}"
)
return []
payload = response.json()
results = ((payload or {}).get("web") or {}).get("results") or []
for result in results:
candidate_url = result.get("url", "")
if not candidate_url:
continue
parsed = parse_huggingface_url(candidate_url)
if parsed:
repo_id = parsed.repo
file_path = parsed.filename
branch = parsed.branch
else:
parsed_url = urlparse(candidate_url)
if "huggingface.co" not in parsed_url.netloc:
continue
path_parts = [part for part in parsed_url.path.split("/") if part]
if len(path_parts) < 2:
continue
repo_id = f"{path_parts[0]}/{path_parts[1]}"
file_path = filename
branch = "main"
key = (repo_id, branch, file_path)
if not repo_id or key in seen:
continue
seen.add(key)
candidates.append(
{
"repo": repo_id,
"branch": branch,
"filename": file_path,
"url": candidate_url,
}
)
except Exception as e:
log.debug(f"Brave search error for filename={filename}: {e}")
return candidates
def search_huggingface_for_file(
filename: str,
token: Optional[str] = None,
exact_only: bool = False,
brave_api_key: Optional[str] = None,
use_api_search: bool = True,
use_comfy_org_fallback: bool = True,
use_brave_fallback: bool = True,
force_refresh: bool = False,
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
sha256: Optional[str] = None,
) -> Optional[SearchResult]:
"""
Search HuggingFace for a specific model file.
Returns the first repo that contains a matching file.
Args:
filename: Filename to search for
token: Optional HF token
exact_only: If True, only return exact filename matches (for downloads).
If False, also try partial matching (for local file resolution).
Returns:
A typed SearchResult if a matching file is found, otherwise None.
"""
global _search_cache
requested_sha256 = normalize_sha256(sha256)
brave_key = "brave" if brave_api_key and use_brave_fallback else "nobrave"
token_key = "token" if token else "notoken"
methods_key = (
f"api{int(bool(use_api_search))}_"
f"comfy{int(bool(use_comfy_org_fallback))}_"
f"brave{int(bool(use_brave_fallback))}_"
f"force{int(bool(force_refresh))}"
)
cache_key = f"hf_{filename}_sha{requested_sha256}_exact{exact_only}_{token_key}_{brave_key}_{methods_key}"
if cache_key in _search_cache:
log.debug(f"HuggingFace cache hit file={filename} exact={exact_only}")
_report_progress(
progress_callback,
"cache",
"Using HuggingFace cache",
86,
)
return _search_cache[cache_key]
try:
def result_matches_requested_hash(result: Optional[SearchResult]) -> bool:
if not requested_sha256:
return True
return normalize_sha256(
result.sha256
or result.extra_value("hash")
or (result.hashes or {}).get("SHA256")
or (result.hashes or {}).get("sha256")
) == requested_sha256
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
log.info(f"HuggingFace start file={filename} exact={exact_only}")
def find_author_index_result(
author: str,
) -> tuple[Optional[Dict[str, Any]], Optional[SearchResult]]:
index = _get_author_index(
author,
headers={},
allow_stale=force_refresh,
)
result = (
_find_matching_file_in_author_index(
index,
filename,
exact_only=exact_only,
headers={},
sha256=requested_sha256,
)
if index
else None
)
if (
force_refresh
and index
and not _is_author_index_fresh(index)
and result is None
):
index = _get_author_index(
author,
headers={},
force_refresh=True,
)
result = (
_find_matching_file_in_author_index(
index,
filename,
exact_only=exact_only,
headers={},
sha256=requested_sha256,
)
if index
else None
)
return index, result
cached_author_indexes: Dict[str, Optional[Dict[str, Any]]] = {}
if requested_sha256 and use_comfy_org_fallback:
total_authors = max(1, len(HF_AUTHOR_FALLBACKS))
for author_index, author in enumerate(HF_AUTHOR_FALLBACKS, start=1):
_report_progress(
progress_callback,
"author_hash_index",
f"Checking HF hash index {author_index}/{total_authors}",
28 + ((author_index - 1) / total_authors) * 6,
author=author,
author_index=author_index,
author_count=total_authors,
)
index, result = find_author_index_result(author)
cached_author_indexes[author] = index
if not index:
continue
if result:
_search_cache[cache_key] = result
_report_progress(
progress_callback,
"found",
"Found HuggingFace SHA-256 match",
92,
repo=result.extra_value("repo_id"),
match_type=result.match_type,
)
log.info(
f"HuggingFace found source=author_index "
f"match=hash file={result.filename} "
f"repo={result.extra_value('repo_id')} path={result.extra_value('path')}"
)
return result
repos_to_check = []
seen_repos = set()
search_queries = build_filename_search_queries(filename)
if use_api_search:
total_queries = max(1, len(search_queries))
for query_index, search_query in enumerate(search_queries, start=1):
_report_progress(
progress_callback,
"api_search",
f"HuggingFace API query {query_index}/{total_queries}",
32 + ((query_index - 1) / total_queries) * 10,
query=search_query,
query_index=query_index,
query_count=total_queries,
)
search_url = f"{HF_API_URL}/models?search={quote(search_query)}&limit=20"
repos = execute_provider_json_request(
"HuggingFace models search",
search_url,
api_key=token,
timeout=10,
)
if repos is None:
log.warning(
f"HuggingFace API query={search_query} failed"
)
continue
if not repos:
continue
log.info(f"HuggingFace API query={search_query} repos={len(repos)}")
for repo in repos:
repo_id = repo.get("id", "")
if repo_id and repo_id not in seen_repos:
seen_repos.add(repo_id)
repos_to_check.append(repo_id)
_report_progress(
progress_callback,
"api_candidates",
f"HuggingFace API candidates: {len(repos_to_check)} repos",
44,
candidate_count=len(repos_to_check),
)
else:
_report_progress(
progress_callback,
"api_skip",
"Skipping HuggingFace API search",
44,
)
repo_count = len(repos_to_check)
for repo_index, repo_id in enumerate(repos_to_check, start=1):
_report_progress(
progress_callback,
"repo_files",
f"Checking HF repo {repo_index}/{repo_count}",
48 + (repo_index / max(1, repo_count)) * 14,
repo=repo_id,
candidate_index=repo_index,
candidate_count=repo_count,
)
log.debug(f"HuggingFace check repo={repo_id} file={filename}")
files = _get_repo_tree(repo_id, headers=headers)
if not files:
continue
log.debug(f"HuggingFace tree repo={repo_id} files={len(files)}")
result = _find_matching_file_in_repo(
repo_id,
files,
filename,
exact_only=exact_only,
headers=headers,
)
if result and result_matches_requested_hash(result):
_search_cache[cache_key] = result
_report_progress(
progress_callback,
"found",
"Found HuggingFace match",
92,
repo=repo_id,
match_type=result.match_type,
)
log.info(
f"HuggingFace found match={result.match_type} file={filename} repo={repo_id} path={result.extra_value('path')}"
)
return result
author_fallback_repos = []
author_fallback_repo_count = 0
if use_comfy_org_fallback:
total_authors = max(1, len(HF_AUTHOR_FALLBACKS))
for author_index, author in enumerate(HF_AUTHOR_FALLBACKS, start=1):
_report_progress(
progress_callback,
"author_index",
f"Checking HF fallback index {author_index}/{total_authors}",
64 + ((author_index - 1) / total_authors) * 6,
author=author,
author_index=author_index,
author_count=total_authors,
)
if author in cached_author_indexes:
index = cached_author_indexes[author]
result = (
_find_matching_file_in_author_index(
index,
filename,
exact_only=exact_only,
headers={},
sha256=requested_sha256,
)
if index
else None
)
else:
index, result = find_author_index_result(author)
if index:
author_fallback_repo_count += int(index.get("repo_count") or 0)
if result and result_matches_requested_hash(result):
_search_cache[cache_key] = result
_report_progress(
progress_callback,
"found",
"Found HuggingFace fallback match",
92,
repo=result.extra_value("repo_id"),
match_type=result.match_type,
)
log.info(
f"HuggingFace found source=author_index match={result.match_type} file={filename} repo={result.extra_value('repo_id')} path={result.extra_value('path')}"
)
return result
log.debug(
f"HuggingFace author_index miss author={author} file={filename} repos={index.get('repo_count', 0)} files={index.get('file_count', 0)}"
)
continue
repos = _get_repos_by_author(author, headers=headers)
log.info(f"HuggingFace author_fallback author={author} repos={len(repos)}")
for repo_id in repos:
if repo_id not in seen_repos:
seen_repos.add(repo_id)
author_fallback_repos.append(repo_id)
author_fallback_repo_count += 1
else:
_report_progress(
progress_callback,
"author_skip",
"Skipping HuggingFace fallback index",
70,
)
fallback_repo_count = len(author_fallback_repos)
for repo_index, repo_id in enumerate(author_fallback_repos, start=1):
_report_progress(
progress_callback,
"author_repo",
f"Checking HF fallback repo {repo_index}/{fallback_repo_count}",
70 + (repo_index / max(1, fallback_repo_count)) * 8,
repo=repo_id,
candidate_index=repo_index,
candidate_count=fallback_repo_count,
)
log.debug(
f"HuggingFace author_fallback check repo={repo_id} file={filename}"
)
files = _get_repo_tree(repo_id, headers=headers)
if not files:
continue
result = _find_matching_file_in_repo(
repo_id,
files,
filename,
exact_only=exact_only,
headers=headers,
)
if result and result_matches_requested_hash(result):
_search_cache[cache_key] = result
_report_progress(
progress_callback,
"found",
"Found HuggingFace fallback match",
92,
repo=repo_id,
match_type=result.match_type,
)
log.info(
f"HuggingFace found source=author_fallback match={result.match_type} file={filename} repo={repo_id} path={result.extra_value('path')}"
)
return result
if use_brave_fallback:
_report_progress(
progress_callback,
"brave_search",
"Searching Brave fallback",
80,
)
brave_candidates = (
_search_brave_for_huggingface_candidates(filename, brave_api_key or "")
if use_brave_fallback
else []
)
log.info(f"HuggingFace brave_fallback file={filename} candidates={len(brave_candidates)}")
_report_progress(
progress_callback,
"brave_candidates",
f"Brave candidates: {len(brave_candidates)}",
84,
candidate_count=len(brave_candidates),
)
brave_count = len(brave_candidates)
for candidate_index, candidate in enumerate(brave_candidates, start=1):
repo_id = candidate.get("repo", "")
branch = candidate.get("branch", "main")
expected_file = candidate.get("filename", filename)
_report_progress(
progress_callback,
"brave_repo",
f"Checking Brave candidate {candidate_index}/{brave_count}",
84 + (candidate_index / max(1, brave_count)) * 6,
repo=repo_id,
candidate_index=candidate_index,
candidate_count=brave_count,
)
files = _get_repo_tree(repo_id, headers=headers, branch=branch)
if not files:
continue
result = _find_matching_file_in_repo(
repo_id,
files,
expected_file,
exact_only=True,
headers=headers,
)
if (
result
and result.filename.lower() == filename.lower()
and result_matches_requested_hash(result)
):
_search_cache[cache_key] = result
_report_progress(
progress_callback,
"found",
"Found HuggingFace Brave match",
92,
repo=repo_id,
match_type=result.match_type,
)
log.info(
f"HuggingFace found source=brave_fallback match=exact file={filename} repo={repo_id} path={result.extra_value('path')}"
)
return result
# Not found
_search_cache[cache_key] = None
_report_progress(
progress_callback,
"done",
"HuggingFace checked",
92,
candidate_count=len(repos_to_check),
author_repo_count=author_fallback_repo_count,
brave_candidate_count=len(brave_candidates),
)
log.info(
f"HuggingFace miss file={filename} repos={len(repos_to_check)} author_repos={author_fallback_repo_count} brave={len(brave_candidates)}"
)
return None
except Exception as e:
log.exception(f"HuggingFace search error for {filename}: {e}")
_report_progress(
progress_callback,
"error",
f"HuggingFace search error: {e}",
100,
status="error",
)
return None
def search_huggingface(
query: str,
model_type: Optional[str] = None,
limit: int = 10,
token: Optional[str] = None,
) -> List[SearchResult]:
"""
Search HuggingFace Hub for models (general search).
Returns repos that might be relevant, not guaranteed to have exact file.
"""
results = []
try:
params = {"search": query, "limit": limit, "full": "true"}
data = execute_provider_json_request(
"HuggingFace general search",
f"{HF_API_URL}/models",
params=params,
api_key=token,
timeout=15,
)
if data:
models = data
for model in models:
repo_id = model.get("id", "")
results.append(
build_model_result(
"huggingface",
name=model.get("modelId", repo_id),
url=f"https://huggingface.co/{repo_id}",
repo=repo_id,
downloads=model.get("downloads", 0),
likes=model.get("likes", 0),
)
)
except Exception as e:
log.error(f"HuggingFace search error: {e}")
return results
def build_huggingface_custom_result(
url: str,
expected_filename: str = "",
token: Optional[str] = None,
) -> Optional[SearchResult]:
from urllib.parse import quote, unquote
parsed = parse_huggingface_url(url)
if not parsed:
return None
repo_id = parsed.repo
branch = parsed.branch
file_path = unquote(parsed.filename)
if not repo_id or not file_path:
return None
filename = get_filename_from_path(file_path)
download_url = get_huggingface_download_url(repo_id, file_path, branch)
if not looks_like_model_file(download_url, expected_filename or filename):
return None
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
file_info: Dict[str, Any] = {}
repo_tree = _get_repo_tree(repo_id, headers, branch)
normalized_file_path = file_path.replace("\\", "/").strip("/")
for candidate in repo_tree or []:
candidate_path = str(candidate.get("path") or "").replace("\\", "/").strip("/")
if candidate_path == normalized_file_path:
file_info = candidate
break
size = resolve_file_size(
file_info,
[download_url],
probe=lambda url: probe_remote_file_size(url, headers=headers, timeout=10),
)
sha256 = _extract_huggingface_file_sha256(file_info)
def quote_url_path(val):
return quote(str(val or "").replace("\\", "/"), safe="/")
page_url = (
f"https://huggingface.co/{repo_id}/blob/{branch}/{quote_url_path(file_path)}"
)
return build_model_result(
"huggingface",
model_id=repo_id,
version_id=None,
name=repo_id,
filename=filename,
url=download_url,
download_url=download_url,
size=size,
sha256=sha256,
hashes={"SHA256": sha256} if sha256 else {},
match_type="custom_url",
details_source="huggingface",
repo_id=repo_id,
repo=repo_id,
path=file_path,
branch=branch,
page_url=page_url,
version_url=page_url,
custom_url=True,
)