- Introduced support for Hugging Face API integration, allowing fallback model resolution when Civitai fails. - Updated configuration to include `HF_TOKEN` and `HF_TOKEN_PATH` for authentication. - Enhanced model capture logic to differentiate between Civitai and Hugging Face sources. - Improved error handling for model downloads, providing clearer messages for authentication issues. - Updated documentation to reflect new environment variables and usage instructions for Hugging Face integration. - Added tests to validate the new fallback mechanism and ensure robust model resolution.
204 lines
6.7 KiB
Python
204 lines
6.7 KiB
Python
"""Hugging Face Hub: auth probe, metadata lookup (capture fallback), URL helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from urllib.parse import quote, urlparse
|
|
|
|
import httpx
|
|
|
|
from gpu_rent.errors import CloudError
|
|
|
|
HF_API = "https://huggingface.co/api"
|
|
HF_HOST = "huggingface.co"
|
|
|
|
|
|
@dataclass
|
|
class HfProbe:
|
|
ok: bool
|
|
name: str = ""
|
|
detail: str = ""
|
|
|
|
|
|
@dataclass
|
|
class HfFileHit:
|
|
repo_id: str
|
|
filename: str
|
|
url: str
|
|
sha256: str = ""
|
|
title: str = ""
|
|
|
|
|
|
def is_huggingface_url(url: str) -> bool:
|
|
host = (urlparse(url).hostname or "").lower()
|
|
return host == HF_HOST or host.endswith(".huggingface.co")
|
|
|
|
|
|
def hf_resolve_url(repo_id: str, filename: str, *, revision: str = "main") -> str:
|
|
repo = repo_id.strip().strip("/")
|
|
name = filename.lstrip("/")
|
|
rev = revision.strip() or "main"
|
|
return f"https://{HF_HOST}/{repo}/resolve/{quote(rev, safe='')}/{quote(name, safe='/')}"
|
|
|
|
|
|
def probe_whoami(token: str, timeout: float = 15.0) -> HfProbe:
|
|
token = (token or "").strip()
|
|
if not token:
|
|
return HfProbe(ok=False, detail="токена нет")
|
|
url = f"{HF_API}/whoami-v2"
|
|
try:
|
|
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
response = client.get(url, headers={"Authorization": f"Bearer {token}"})
|
|
except httpx.HTTPError as exc:
|
|
return HfProbe(ok=False, detail=str(exc)[:160])
|
|
if response.status_code == 200:
|
|
data = response.json() if response.content else {}
|
|
name = ""
|
|
if isinstance(data, dict):
|
|
name = str(data.get("name") or data.get("fullname") or "")
|
|
return HfProbe(ok=True, name=name, detail="токен принят")
|
|
if response.status_code in {401, 403}:
|
|
return HfProbe(
|
|
ok=False,
|
|
detail="токен отвергнут — https://huggingface.co/settings/tokens",
|
|
)
|
|
return HfProbe(
|
|
ok=False,
|
|
detail=f"HTTP {response.status_code}: {(response.text or '')[:120]}",
|
|
)
|
|
|
|
|
|
def _headers(token: str | None) -> dict[str, str]:
|
|
h = {"User-Agent": "gpu-rent/1"}
|
|
if token:
|
|
h["Authorization"] = f"Bearer {token}"
|
|
return h
|
|
|
|
|
|
def _sha_from_entry(entry: dict) -> str:
|
|
lfs = entry.get("lfs")
|
|
if isinstance(lfs, dict):
|
|
for key in ("sha256", "oid", "sha"):
|
|
val = lfs.get(key)
|
|
if val:
|
|
return str(val).strip().lower()
|
|
for key in ("sha256", "oid", "blob_id"):
|
|
val = entry.get(key)
|
|
if val and len(str(val)) >= 40:
|
|
return str(val).strip().lower()
|
|
return ""
|
|
|
|
|
|
def _tree_match(
|
|
client: httpx.Client,
|
|
repo_id: str,
|
|
want_sha: str,
|
|
*,
|
|
filename_hint: str | None,
|
|
token: str | None,
|
|
) -> HfFileHit | None:
|
|
url = f"{HF_API}/models/{repo_id}/tree/main"
|
|
response = client.get(
|
|
url,
|
|
params={"recursive": "true"},
|
|
headers=_headers(token),
|
|
)
|
|
if response.status_code != 200:
|
|
return None
|
|
data = response.json()
|
|
if not isinstance(data, list):
|
|
return None
|
|
hint = (filename_hint or "").replace("\\", "/").split("/")[-1].lower()
|
|
for entry in data:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("type") and entry.get("type") != "file":
|
|
continue
|
|
path = str(entry.get("path") or entry.get("rfilename") or "")
|
|
if not path:
|
|
continue
|
|
sha = _sha_from_entry(entry)
|
|
if sha and sha == want_sha:
|
|
return HfFileHit(
|
|
repo_id=repo_id,
|
|
filename=path,
|
|
url=hf_resolve_url(repo_id, path),
|
|
sha256=sha,
|
|
title=repo_id,
|
|
)
|
|
# Filename fallback when tree has no sha (rare) — only exact name match
|
|
if hint and path.replace("\\", "/").split("/")[-1].lower() == hint and not sha:
|
|
return HfFileHit(
|
|
repo_id=repo_id,
|
|
filename=path,
|
|
url=hf_resolve_url(repo_id, path),
|
|
sha256="",
|
|
title=repo_id,
|
|
)
|
|
return None
|
|
|
|
|
|
def lookup_by_sha256(
|
|
token: str | None,
|
|
sha256: str,
|
|
*,
|
|
filename: str | None = None,
|
|
limit_repos: int = 12,
|
|
timeout: float = 45.0,
|
|
) -> HfFileHit | None:
|
|
"""Best-effort: search Hub by filename, match LFS sha256 in repo tree.
|
|
|
|
Hugging Face has no public global by-hash index; this is the practical fallback
|
|
after Civitai miss. Token optional for public repos; gated need HF_TOKEN.
|
|
"""
|
|
digest = sha256.strip().lower()
|
|
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
|
raise CloudError(f"HF by-hash: неверный SHA256 ({sha256[:16]}…)")
|
|
|
|
hint = (filename or "").replace("\\", "/").split("/")[-1]
|
|
stem = hint.rsplit(".", 1)[0] if hint else ""
|
|
queries: list[str] = []
|
|
if hint:
|
|
queries.append(hint)
|
|
if stem and stem.lower() != hint.lower():
|
|
queries.append(stem)
|
|
if not queries:
|
|
return None
|
|
|
|
try:
|
|
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
seen: set[str] = set()
|
|
for q in queries:
|
|
response = client.get(
|
|
f"{HF_API}/models",
|
|
params={"search": q, "limit": limit_repos},
|
|
headers=_headers(token),
|
|
)
|
|
if response.status_code in {401, 403}:
|
|
raise CloudError(
|
|
"HF search: 401/403 — нужен валидный HF_TOKEN "
|
|
"(https://huggingface.co/settings/tokens)"
|
|
)
|
|
if response.status_code != 200:
|
|
continue
|
|
models = response.json()
|
|
if not isinstance(models, list):
|
|
continue
|
|
for model in models:
|
|
if not isinstance(model, dict):
|
|
continue
|
|
repo_id = str(model.get("modelId") or model.get("id") or "").strip()
|
|
if not repo_id or repo_id in seen:
|
|
continue
|
|
seen.add(repo_id)
|
|
hit = _tree_match(
|
|
client, repo_id, digest, filename_hint=hint, token=token
|
|
)
|
|
if hit:
|
|
return hit
|
|
if len(seen) >= limit_repos:
|
|
return None
|
|
except httpx.HTTPError as exc:
|
|
raise CloudError(f"HF lookup: {exc}") from exc
|
|
return None
|