mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 05:24:09 -03:00
feat: support gated/private Hugging Face repos via access token
Add a huggingface_api_key setting (Settings UI, HF_TOKEN / HUGGING_FACE_HUB_TOKEN env override) and attach it as a Bearer token to Hugging Face file listing, model card fetching and downloads, so gated and private repositories can be downloaded once the user has accepted the repo terms. - fetch_json/fetch_text accept custom headers; ModelSource gains an auth_headers() hook so handlers stay platform-agnostic - 401/403 from the tree API now explain how to fix (configure token / accept gated terms) - aria2 pre-resolves huggingface.co redirects and strips credentials before handing the signed CDN URL to aria2, mirroring the CivitAI handling so the token never leaks to the CDN - settings API exposes huggingface_api_key_set only; the raw key joins _NO_SYNC_KEYS
This commit is contained in:
@@ -1506,6 +1506,7 @@ class SettingsHandler:
|
||||
# Sensitive — never expose the actual value to the frontend;
|
||||
# frontend receives a boolean instead (*_set).
|
||||
"civitai_api_key",
|
||||
"huggingface_api_key",
|
||||
"llm_api_key",
|
||||
}
|
||||
)
|
||||
@@ -1564,6 +1565,8 @@ class SettingsHandler:
|
||||
# Sensitive fields: only expose a boolean indicating whether set
|
||||
raw_key = self._settings.get("civitai_api_key")
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
raw_hf_key = self._settings.get("huggingface_api_key")
|
||||
response_data["huggingface_api_key_set"] = bool(raw_hf_key)
|
||||
raw_llm_key = self._settings.get("llm_api_key")
|
||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||
# Derived capability flag (not persisted): whether the host exposes
|
||||
|
||||
@@ -580,6 +580,10 @@ class ModelSourceHandler:
|
||||
get_settings_manager().get("download_backend", "default")
|
||||
)
|
||||
|
||||
# Site-specific credentials (e.g. a Hugging Face access token for
|
||||
# gated/private repositories); empty for anonymous downloads.
|
||||
auth_headers = source.auth_headers()
|
||||
|
||||
if download_backend == "aria2":
|
||||
aria2 = await Aria2Downloader.get_instance()
|
||||
aid = download_id or f"{source.platform}_{repo}_{filename}"
|
||||
@@ -589,6 +593,7 @@ class ModelSourceHandler:
|
||||
save_path=dest_path,
|
||||
download_id=aid,
|
||||
progress_callback=progress_callback,
|
||||
headers=auth_headers or None,
|
||||
)
|
||||
if ok:
|
||||
await _save_source_metadata(
|
||||
@@ -618,6 +623,7 @@ class ModelSourceHandler:
|
||||
use_auth=False,
|
||||
allow_resume=True,
|
||||
progress_callback=progress_callback,
|
||||
custom_headers=auth_headers or None,
|
||||
)
|
||||
if success:
|
||||
await _save_source_metadata(
|
||||
|
||||
@@ -81,6 +81,14 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
#: Hosts whose authenticated downloads redirect to a signed CDN URL. aria2
|
||||
#: forwards custom headers to redirect targets, so for these hosts the
|
||||
#: redirect is resolved first and the signed URL is handed to aria2 without
|
||||
#: the credentials.
|
||||
AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES = CIVITAI_DOWNLOAD_URL_PREFIXES + (
|
||||
"https://huggingface.co/",
|
||||
)
|
||||
|
||||
|
||||
def _is_no_uri_available_error(message: str) -> bool:
|
||||
"""Return True for aria2's "No URI available" transfer failure.
|
||||
@@ -308,12 +316,12 @@ class Aria2Downloader:
|
||||
|
||||
resolved_url = url
|
||||
request_headers = headers
|
||||
if headers and url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES):
|
||||
if headers and url.startswith(AUTH_REDIRECT_DOWNLOAD_URL_PREFIXES):
|
||||
resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
|
||||
if resolved_url != url:
|
||||
request_headers = None
|
||||
logger.debug(
|
||||
"Resolved Civitai download %s to signed URL for aria2",
|
||||
"Resolved authenticated download %s to signed URL for aria2",
|
||||
download_id,
|
||||
)
|
||||
|
||||
@@ -341,7 +349,7 @@ class Aria2Downloader:
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)",
|
||||
"Submitting aria2 download %s -> %s (auth=%s, signed_url=%s)",
|
||||
download_id,
|
||||
save_path,
|
||||
bool(request_headers),
|
||||
@@ -732,7 +740,7 @@ class Aria2Downloader:
|
||||
if location:
|
||||
return location
|
||||
raise Aria2Error(
|
||||
"Authenticated Civitai redirect did not include a Location header"
|
||||
"Authenticated redirect did not include a Location header"
|
||||
)
|
||||
|
||||
if response.status == 200:
|
||||
@@ -740,12 +748,12 @@ class Aria2Downloader:
|
||||
|
||||
body = await response.text()
|
||||
raise Aria2Error(
|
||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||
f"Failed to resolve authenticated redirect: status={response.status} body={body[:300]}"
|
||||
)
|
||||
except aiohttp.ClientError as exc:
|
||||
if is_ssl_cert_verify_error(exc):
|
||||
logger.error(
|
||||
"SSL certificate verification failed during Civitai redirect "
|
||||
"SSL certificate verification failed during authenticated redirect "
|
||||
"resolution for %s. This is usually caused by an outdated CA "
|
||||
"certificate bundle. Recommended fixes:\n"
|
||||
" 1. pip install --upgrade certifi\n"
|
||||
@@ -753,7 +761,7 @@ class Aria2Downloader:
|
||||
url,
|
||||
)
|
||||
raise Aria2Error(
|
||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||
f"Failed to resolve authenticated redirect: {exc}"
|
||||
) from exc
|
||||
|
||||
async def _ensure_process(self) -> None:
|
||||
|
||||
@@ -193,7 +193,9 @@ def is_valid_source_id(source_id: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
async def fetch_text(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
"""Fetch *url* and return its body as text, or ``""`` on any failure.
|
||||
|
||||
Network problems are expected (offline installs, rate limits, dead
|
||||
@@ -202,8 +204,11 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
"""
|
||||
|
||||
try:
|
||||
request_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=request_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -216,7 +221,7 @@ async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
|
||||
|
||||
async def fetch_json(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT, headers: Optional[Dict[str, str]] = None
|
||||
) -> tuple[int, Any]:
|
||||
"""Fetch *url* and return ``(status, parsed_body)``.
|
||||
|
||||
@@ -227,8 +232,11 @@ async def fetch_json(
|
||||
"""
|
||||
|
||||
try:
|
||||
request_headers = {"User-Agent": USER_AGENT}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
headers=request_headers,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
@@ -381,6 +389,15 @@ class ModelSource:
|
||||
|
||||
return []
|
||||
|
||||
def auth_headers(self) -> Dict[str, str]:
|
||||
"""Extra request headers this site needs for API and file downloads.
|
||||
|
||||
Empty by default; sites with gated/private content (Hugging Face)
|
||||
override it to attach the user's access token when one is configured.
|
||||
"""
|
||||
|
||||
return {}
|
||||
|
||||
def file_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
|
||||
@@ -27,6 +27,18 @@ _STRICT_URL_PATTERN = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _hf_token() -> str:
|
||||
"""Return the configured Hugging Face access token, or ``""``."""
|
||||
|
||||
try:
|
||||
from ..settings_manager import get_settings_manager
|
||||
|
||||
token = get_settings_manager().get("huggingface_api_key", "")
|
||||
except Exception: # pragma: no cover - settings must never break downloads
|
||||
return ""
|
||||
return token.strip() if isinstance(token, str) else ""
|
||||
|
||||
|
||||
class HuggingFaceSource(ModelSource):
|
||||
"""Hugging Face Hub (``huggingface.co``)."""
|
||||
|
||||
@@ -45,12 +57,20 @@ class HuggingFaceSource(ModelSource):
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
|
||||
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
"""Bearer header for gated/private repositories, when a token is set."""
|
||||
|
||||
token = _hf_token()
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
|
||||
|
||||
headers = self.auth_headers()
|
||||
for branch in ("main", "master"):
|
||||
text = await fetch_text(
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md",
|
||||
headers=headers,
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
@@ -67,11 +87,26 @@ class HuggingFaceSource(ModelSource):
|
||||
|
||||
revision = self.resolve_revision(revision)
|
||||
status, payload = await fetch_json(
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}"
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}",
|
||||
headers=self.auth_headers(),
|
||||
)
|
||||
|
||||
if status == 404:
|
||||
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
|
||||
if status in (401, 403):
|
||||
if _hf_token():
|
||||
raise ModelSourceError(
|
||||
f"Access to '{source_id}' was denied (HTTP {status}). For a gated "
|
||||
"repository you must accept its terms on the Hugging Face page, "
|
||||
"and the configured token needs read permission for it.",
|
||||
status=403,
|
||||
)
|
||||
raise ModelSourceError(
|
||||
f"'{source_id}' requires a Hugging Face access token (gated or "
|
||||
"private repository). Configure one in Settings → Hugging Face "
|
||||
"Access Token, and accept the repository's terms on its page.",
|
||||
status=401,
|
||||
)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
raise ModelSourceError(
|
||||
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
|
||||
|
||||
@@ -67,6 +67,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"civitai_api_key": "",
|
||||
"huggingface_api_key": "",
|
||||
"civitai_host": "civitai.com",
|
||||
"download_backend": "python",
|
||||
"aria2c_path": "",
|
||||
@@ -1124,6 +1125,15 @@ class SettingsManager:
|
||||
self.settings["civitai_api_key"] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
# Hugging Face accepts either of its conventional variable names
|
||||
env_hf_token = os.environ.get("HF_TOKEN") or os.environ.get(
|
||||
"HUGGING_FACE_HUB_TOKEN"
|
||||
)
|
||||
if env_hf_token:
|
||||
logger.info("Found HF_TOKEN environment variable")
|
||||
self.settings["huggingface_api_key"] = env_hf_token
|
||||
self._save_settings()
|
||||
|
||||
# LLM provider overrides
|
||||
llm_env_map = {
|
||||
"LLM_API_KEY": "llm_api_key",
|
||||
|
||||
Reference in New Issue
Block a user