mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-01 15:55:32 -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:
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||||
"civitaiApiKeySet": "Einrichten",
|
"civitaiApiKeySet": "Einrichten",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "CivitAI-Host",
|
"label": "CivitAI-Host",
|
||||||
"help": "Wählen Sie aus, welche CivitAI-Seite geöffnet wird, wenn Sie „View on CivitAI“-Links verwenden.",
|
"help": "Wählen Sie aus, welche CivitAI-Seite geöffnet wird, wenn Sie „View on CivitAI“-Links verwenden.",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "Configured",
|
"civitaiApiKeyConfigured": "Configured",
|
||||||
"civitaiApiKeyNotConfigured": "Not configured",
|
"civitaiApiKeyNotConfigured": "Not configured",
|
||||||
"civitaiApiKeySet": "Set up",
|
"civitaiApiKeySet": "Set up",
|
||||||
|
"huggingfaceApiKey": "Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "Not configured",
|
||||||
|
"huggingfaceApiKeySet": "Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "CivitAI host",
|
"label": "CivitAI host",
|
||||||
"help": "Choose which CivitAI site opens when using View on CivitAI links.",
|
"help": "Choose which CivitAI site opens when using View on CivitAI links.",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "Configurado",
|
"civitaiApiKeyConfigured": "Configurado",
|
||||||
"civitaiApiKeyNotConfigured": "No configurado",
|
"civitaiApiKeyNotConfigured": "No configurado",
|
||||||
"civitaiApiKeySet": "Configurar",
|
"civitaiApiKeySet": "Configurar",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Host de CivitAI",
|
"label": "Host de CivitAI",
|
||||||
"help": "Elige qué sitio de CivitAI se abre al usar los enlaces de \"View on CivitAI\".",
|
"help": "Elige qué sitio de CivitAI se abre al usar los enlaces de \"View on CivitAI\".",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "Configuré",
|
"civitaiApiKeyConfigured": "Configuré",
|
||||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||||
"civitaiApiKeySet": "Configurer",
|
"civitaiApiKeySet": "Configurer",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Hôte CivitAI",
|
"label": "Hôte CivitAI",
|
||||||
"help": "Choisissez quel site CivitAI s'ouvre lorsque vous utilisez les liens « View on CivitAI ».",
|
"help": "Choisissez quel site CivitAI s'ouvre lorsque vous utilisez les liens « View on CivitAI ».",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "מוגדר",
|
"civitaiApiKeyConfigured": "מוגדר",
|
||||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||||
"civitaiApiKeySet": "הגדר",
|
"civitaiApiKeySet": "הגדר",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "מארח CivitAI",
|
"label": "מארח CivitAI",
|
||||||
"help": "בחר איזה אתר של CivitAI ייפתח בעת שימוש בקישורי \"View on CivitAI\".",
|
"help": "בחר איזה אתר של CivitAI ייפתח בעת שימוש בקישורי \"View on CivitAI\".",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "設定済み",
|
"civitaiApiKeyConfigured": "設定済み",
|
||||||
"civitaiApiKeyNotConfigured": "未設定",
|
"civitaiApiKeyNotConfigured": "未設定",
|
||||||
"civitaiApiKeySet": "設定",
|
"civitaiApiKeySet": "設定",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "CivitAI ホスト",
|
"label": "CivitAI ホスト",
|
||||||
"help": "「View on CivitAI」リンクを使うときに開く CivitAI サイトを選択します。",
|
"help": "「View on CivitAI」リンクを使うときに開く CivitAI サイトを選択します。",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "설정됨",
|
"civitaiApiKeyConfigured": "설정됨",
|
||||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||||
"civitaiApiKeySet": "설정",
|
"civitaiApiKeySet": "설정",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "CivitAI 호스트",
|
"label": "CivitAI 호스트",
|
||||||
"help": "\"View on CivitAI\" 링크를 사용할 때 어떤 CivitAI 사이트를 열지 선택합니다.",
|
"help": "\"View on CivitAI\" 링크를 사용할 때 어떤 CivitAI 사이트를 열지 선택합니다.",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "Настроен",
|
"civitaiApiKeyConfigured": "Настроен",
|
||||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||||
"civitaiApiKeySet": "Настроить",
|
"civitaiApiKeySet": "Настроить",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Хост CivitAI",
|
"label": "Хост CivitAI",
|
||||||
"help": "Выберите, какой сайт CivitAI будет открываться при использовании ссылок «View on CivitAI».",
|
"help": "Выберите, какой сайт CivitAI будет открываться при использовании ссылок «View on CivitAI».",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "已配置",
|
"civitaiApiKeyConfigured": "已配置",
|
||||||
"civitaiApiKeyNotConfigured": "未配置",
|
"civitaiApiKeyNotConfigured": "未配置",
|
||||||
"civitaiApiKeySet": "设置",
|
"civitaiApiKeySet": "设置",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "CivitAI 站点",
|
"label": "CivitAI 站点",
|
||||||
"help": "选择使用“在 CivitAI 中查看”时默认打开的 CivitAI 站点。",
|
"help": "选择使用“在 CivitAI 中查看”时默认打开的 CivitAI 站点。",
|
||||||
|
|||||||
@@ -325,6 +325,12 @@
|
|||||||
"civitaiApiKeyConfigured": "已設定",
|
"civitaiApiKeyConfigured": "已設定",
|
||||||
"civitaiApiKeyNotConfigured": "未設定",
|
"civitaiApiKeyNotConfigured": "未設定",
|
||||||
"civitaiApiKeySet": "設定",
|
"civitaiApiKeySet": "設定",
|
||||||
|
"huggingfaceApiKey": "[TODO: Translate] Hugging Face Access Token",
|
||||||
|
"huggingfaceApiKeyPlaceholder": "[TODO: Translate] Enter your Hugging Face access token",
|
||||||
|
"huggingfaceApiKeyHelp": "[TODO: Translate] Required to download from gated or private Hugging Face repositories. Create a read-only token at huggingface.co/settings/tokens, and accept the repository's terms on its page first.",
|
||||||
|
"huggingfaceApiKeyConfigured": "[TODO: Translate] Configured",
|
||||||
|
"huggingfaceApiKeyNotConfigured": "[TODO: Translate] Not configured",
|
||||||
|
"huggingfaceApiKeySet": "[TODO: Translate] Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "CivitAI 站點",
|
"label": "CivitAI 站點",
|
||||||
"help": "選擇使用「在 CivitAI 中查看」時預設開啟的 CivitAI 站點。",
|
"help": "選擇使用「在 CivitAI 中查看」時預設開啟的 CivitAI 站點。",
|
||||||
|
|||||||
@@ -1506,6 +1506,7 @@ class SettingsHandler:
|
|||||||
# Sensitive — never expose the actual value to the frontend;
|
# Sensitive — never expose the actual value to the frontend;
|
||||||
# frontend receives a boolean instead (*_set).
|
# frontend receives a boolean instead (*_set).
|
||||||
"civitai_api_key",
|
"civitai_api_key",
|
||||||
|
"huggingface_api_key",
|
||||||
"llm_api_key",
|
"llm_api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1564,6 +1565,8 @@ class SettingsHandler:
|
|||||||
# Sensitive fields: only expose a boolean indicating whether set
|
# Sensitive fields: only expose a boolean indicating whether set
|
||||||
raw_key = self._settings.get("civitai_api_key")
|
raw_key = self._settings.get("civitai_api_key")
|
||||||
response_data["civitai_api_key_set"] = bool(raw_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")
|
raw_llm_key = self._settings.get("llm_api_key")
|
||||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||||
# Derived capability flag (not persisted): whether the host exposes
|
# Derived capability flag (not persisted): whether the host exposes
|
||||||
|
|||||||
@@ -580,6 +580,10 @@ class ModelSourceHandler:
|
|||||||
get_settings_manager().get("download_backend", "default")
|
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":
|
if download_backend == "aria2":
|
||||||
aria2 = await Aria2Downloader.get_instance()
|
aria2 = await Aria2Downloader.get_instance()
|
||||||
aid = download_id or f"{source.platform}_{repo}_{filename}"
|
aid = download_id or f"{source.platform}_{repo}_{filename}"
|
||||||
@@ -589,6 +593,7 @@ class ModelSourceHandler:
|
|||||||
save_path=dest_path,
|
save_path=dest_path,
|
||||||
download_id=aid,
|
download_id=aid,
|
||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
|
headers=auth_headers or None,
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
await _save_source_metadata(
|
await _save_source_metadata(
|
||||||
@@ -618,6 +623,7 @@ class ModelSourceHandler:
|
|||||||
use_auth=False,
|
use_auth=False,
|
||||||
allow_resume=True,
|
allow_resume=True,
|
||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
|
custom_headers=auth_headers or None,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
await _save_source_metadata(
|
await _save_source_metadata(
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
|||||||
"https://civitai.red/api/download/",
|
"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:
|
def _is_no_uri_available_error(message: str) -> bool:
|
||||||
"""Return True for aria2's "No URI available" transfer failure.
|
"""Return True for aria2's "No URI available" transfer failure.
|
||||||
@@ -308,12 +316,12 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
resolved_url = url
|
resolved_url = url
|
||||||
request_headers = headers
|
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)
|
resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
|
||||||
if resolved_url != url:
|
if resolved_url != url:
|
||||||
request_headers = None
|
request_headers = None
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Resolved Civitai download %s to signed URL for aria2",
|
"Resolved authenticated download %s to signed URL for aria2",
|
||||||
download_id,
|
download_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,7 +349,7 @@ class Aria2Downloader:
|
|||||||
]
|
]
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)",
|
"Submitting aria2 download %s -> %s (auth=%s, signed_url=%s)",
|
||||||
download_id,
|
download_id,
|
||||||
save_path,
|
save_path,
|
||||||
bool(request_headers),
|
bool(request_headers),
|
||||||
@@ -732,7 +740,7 @@ class Aria2Downloader:
|
|||||||
if location:
|
if location:
|
||||||
return location
|
return location
|
||||||
raise Aria2Error(
|
raise Aria2Error(
|
||||||
"Authenticated Civitai redirect did not include a Location header"
|
"Authenticated redirect did not include a Location header"
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
@@ -740,12 +748,12 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
body = await response.text()
|
body = await response.text()
|
||||||
raise Aria2Error(
|
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:
|
except aiohttp.ClientError as exc:
|
||||||
if is_ssl_cert_verify_error(exc):
|
if is_ssl_cert_verify_error(exc):
|
||||||
logger.error(
|
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 "
|
"resolution for %s. This is usually caused by an outdated CA "
|
||||||
"certificate bundle. Recommended fixes:\n"
|
"certificate bundle. Recommended fixes:\n"
|
||||||
" 1. pip install --upgrade certifi\n"
|
" 1. pip install --upgrade certifi\n"
|
||||||
@@ -753,7 +761,7 @@ class Aria2Downloader:
|
|||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
raise Aria2Error(
|
raise Aria2Error(
|
||||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
f"Failed to resolve authenticated redirect: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def _ensure_process(self) -> None:
|
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.
|
"""Fetch *url* and return its body as text, or ``""`` on any failure.
|
||||||
|
|
||||||
Network problems are expected (offline installs, rate limits, dead
|
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:
|
try:
|
||||||
|
request_headers = {"User-Agent": USER_AGENT}
|
||||||
|
if headers:
|
||||||
|
request_headers.update(headers)
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
headers={"User-Agent": USER_AGENT},
|
headers=request_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
) as session:
|
) as session:
|
||||||
async with session.get(url) as resp:
|
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(
|
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]:
|
) -> tuple[int, Any]:
|
||||||
"""Fetch *url* and return ``(status, parsed_body)``.
|
"""Fetch *url* and return ``(status, parsed_body)``.
|
||||||
|
|
||||||
@@ -227,8 +232,11 @@ async def fetch_json(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
request_headers = {"User-Agent": USER_AGENT}
|
||||||
|
if headers:
|
||||||
|
request_headers.update(headers)
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
headers={"User-Agent": USER_AGENT},
|
headers=request_headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
) as session:
|
) as session:
|
||||||
async with session.get(url) as resp:
|
async with session.get(url) as resp:
|
||||||
@@ -381,6 +389,15 @@ class ModelSource:
|
|||||||
|
|
||||||
return []
|
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(
|
def file_download_url(
|
||||||
self, source_id: str, filename: str, revision: str = ""
|
self, source_id: str, filename: str, revision: str = ""
|
||||||
) -> 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):
|
class HuggingFaceSource(ModelSource):
|
||||||
"""Hugging Face Hub (``huggingface.co``)."""
|
"""Hugging Face Hub (``huggingface.co``)."""
|
||||||
|
|
||||||
@@ -45,12 +57,20 @@ class HuggingFaceSource(ModelSource):
|
|||||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||||
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
|
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:
|
async def fetch_model_card(self, source_id: str) -> str:
|
||||||
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
|
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
|
||||||
|
|
||||||
|
headers = self.auth_headers()
|
||||||
for branch in ("main", "master"):
|
for branch in ("main", "master"):
|
||||||
text = await fetch_text(
|
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:
|
if text:
|
||||||
return text
|
return text
|
||||||
@@ -67,11 +87,26 @@ class HuggingFaceSource(ModelSource):
|
|||||||
|
|
||||||
revision = self.resolve_revision(revision)
|
revision = self.resolve_revision(revision)
|
||||||
status, payload = await fetch_json(
|
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:
|
if status == 404:
|
||||||
raise ModelSourceError(f"Repository '{source_id}' not found", 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):
|
if status != 200 or not isinstance(payload, list):
|
||||||
raise ModelSourceError(
|
raise ModelSourceError(
|
||||||
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
|
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] = {
|
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"civitai_api_key": "",
|
"civitai_api_key": "",
|
||||||
|
"huggingface_api_key": "",
|
||||||
"civitai_host": "civitai.com",
|
"civitai_host": "civitai.com",
|
||||||
"download_backend": "python",
|
"download_backend": "python",
|
||||||
"aria2c_path": "",
|
"aria2c_path": "",
|
||||||
@@ -1124,6 +1125,15 @@ class SettingsManager:
|
|||||||
self.settings["civitai_api_key"] = env_api_key
|
self.settings["civitai_api_key"] = env_api_key
|
||||||
self._save_settings()
|
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 provider overrides
|
||||||
llm_env_map = {
|
llm_env_map = {
|
||||||
"LLM_API_KEY": "llm_api_key",
|
"LLM_API_KEY": "llm_api_key",
|
||||||
|
|||||||
@@ -928,6 +928,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Update API key status display (do NOT pre-fill the input)
|
// Update API key status display (do NOT pre-fill the input)
|
||||||
this.updateApiKeyStatus();
|
this.updateApiKeyStatus();
|
||||||
|
this.updateHfApiKeyStatus();
|
||||||
this.updateLlmApiKeyStatus();
|
this.updateLlmApiKeyStatus();
|
||||||
|
|
||||||
// ── AI Provider settings ──────────────────────────────────────
|
// ── AI Provider settings ──────────────────────────────────────
|
||||||
@@ -4350,6 +4351,28 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateHfApiKeyStatus() {
|
||||||
|
const hasKey = !!(state.global.settings.huggingface_api_key_set ||
|
||||||
|
state.global.settings.huggingface_api_key);
|
||||||
|
const statusText = document.getElementById('huggingfaceApiKeyStatusText');
|
||||||
|
const actionBtn = document.getElementById('huggingfaceApiKeyActionBtn');
|
||||||
|
if (!statusText || !actionBtn) return;
|
||||||
|
|
||||||
|
if (hasKey) {
|
||||||
|
statusText.classList.remove('api-key-status--unconfigured');
|
||||||
|
statusText.classList.add('api-key-status--configured');
|
||||||
|
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||||
|
+ translate('settings.huggingfaceApiKeyConfigured', {}, 'Configured');
|
||||||
|
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||||
|
} else {
|
||||||
|
statusText.classList.remove('api-key-status--configured');
|
||||||
|
statusText.classList.add('api-key-status--unconfigured');
|
||||||
|
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||||
|
+ translate('settings.huggingfaceApiKeyNotConfigured', {}, 'Not configured');
|
||||||
|
actionBtn.textContent = translate('settings.huggingfaceApiKeySet', {}, 'Set up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateLlmApiKeyStatus() {
|
updateLlmApiKeyStatus() {
|
||||||
const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key);
|
const hasKey = !!(state.global.settings.llm_api_key_set || state.global.settings.llm_api_key);
|
||||||
const statusText = document.getElementById('llmApiKeyStatusText');
|
const statusText = document.getElementById('llmApiKeyStatusText');
|
||||||
@@ -4397,9 +4420,17 @@ export class SettingsManager {
|
|||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
if (inputId === 'civitaiApiKey') {
|
this.refreshApiKeyStatus(inputId);
|
||||||
this.updateApiKeyStatus();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshApiKeyStatus(inputId) {
|
||||||
|
if (inputId === 'civitaiApiKey') {
|
||||||
|
this.updateApiKeyStatus();
|
||||||
|
} else if (inputId === 'huggingfaceApiKey') {
|
||||||
|
this.updateHfApiKeyStatus();
|
||||||
|
} else if (inputId === 'llmApiKey') {
|
||||||
|
this.updateLlmApiKeyStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4409,11 +4440,16 @@ export class SettingsManager {
|
|||||||
|
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
|
|
||||||
|
const labelNames = {
|
||||||
|
civitai_api_key: 'CivitAI API Key',
|
||||||
|
huggingface_api_key: 'Hugging Face Access Token',
|
||||||
|
llm_api_key: 'LLM API Key',
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.saveSetting(settingsKey, value);
|
await this.saveSetting(settingsKey, value);
|
||||||
const labelName = settingsKey === 'civitai_api_key' ? 'CivitAI API Key' : 'LLM API Key';
|
|
||||||
showToast('toast.settings.settingsUpdated',
|
showToast('toast.settings.settingsUpdated',
|
||||||
{ setting: labelName }, 'success');
|
{ setting: labelNames[settingsKey] || 'API Key' }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('toast.settings.settingSaveFailed',
|
showToast('toast.settings.settingSaveFailed',
|
||||||
{ message: error.message }, 'error');
|
{ message: error.message }, 'error');
|
||||||
@@ -4421,13 +4457,12 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the in-memory flag so the UI reflects the change
|
// Update the in-memory flag so the UI reflects the change
|
||||||
if (settingsKey === 'civitai_api_key') {
|
const setFlagKey = `${settingsKey}_set`;
|
||||||
state.global.settings.civitai_api_key_set = !!value;
|
if (setFlagKey in state.global.settings) {
|
||||||
|
state.global.settings[setFlagKey] = !!value;
|
||||||
}
|
}
|
||||||
this.cancelEditApiKey(true, inputId);
|
this.cancelEditApiKey(true, inputId);
|
||||||
if (inputId === 'civitaiApiKey') {
|
this.refreshApiKeyStatus(inputId);
|
||||||
this.updateApiKeyStatus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleInputVisibility(button) {
|
toggleInputVisibility(button) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TA
|
|||||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
civitai_api_key_set: false,
|
civitai_api_key_set: false,
|
||||||
|
huggingface_api_key: '',
|
||||||
|
huggingface_api_key_set: false,
|
||||||
civitai_host: 'civitai.com',
|
civitai_host: 'civitai.com',
|
||||||
download_backend: 'python',
|
download_backend: 'python',
|
||||||
aria2c_path: '',
|
aria2c_path: '',
|
||||||
|
|||||||
@@ -68,6 +68,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item api-key-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label>{{ t('settings.huggingfaceApiKey') }}</label>
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.huggingfaceApiKeyHelp') }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<!-- Status display (shown when not editing) -->
|
||||||
|
<div id="huggingfaceApiKeyStatus" class="api-key-status">
|
||||||
|
<span id="huggingfaceApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
|
||||||
|
<i class="fas fa-times-circle text-error"></i>
|
||||||
|
{{ t('settings.huggingfaceApiKeyNotConfigured') }}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="secondary-btn" id="huggingfaceApiKeyActionBtn" onclick="settingsManager.editApiKey('huggingface_api_key', 'huggingfaceApiKey')">
|
||||||
|
{{ t('settings.huggingfaceApiKeySet') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Inline edit view (shown when editing) -->
|
||||||
|
<div id="huggingfaceApiKeyEdit" class="api-key-edit is-hidden">
|
||||||
|
<div class="api-key-input">
|
||||||
|
<input type="text"
|
||||||
|
id="huggingfaceApiKey"
|
||||||
|
class="api-key-masked"
|
||||||
|
placeholder="{{ t('settings.huggingfaceApiKeyPlaceholder') }}"
|
||||||
|
autocomplete="off"
|
||||||
|
data-mask="css" />
|
||||||
|
<button type="button" class="toggle-visibility">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey('huggingface_api_key', 'huggingfaceApiKey')">{{ t('common.actions.save') }}</button>
|
||||||
|
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey(true, 'huggingfaceApiKey')">{{ t('common.actions.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ sm.setting_select('civitaiHost', 'civitai_host', 'settings.civitaiHost.label', [
|
{{ sm.setting_select('civitaiHost', 'civitai_host', 'settings.civitaiHost.label', [
|
||||||
('civitai.com', 'settings.civitaiHost.options.com'),
|
('civitai.com', 'settings.civitaiHost.options.com'),
|
||||||
('civitai.red', 'settings.civitaiHost.options.red'),
|
('civitai.red', 'settings.civitaiHost.options.red'),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
]),
|
]),
|
||||||
'settings': dict({
|
'settings': dict({
|
||||||
'civitai_api_key_set': True,
|
'civitai_api_key_set': True,
|
||||||
|
'huggingface_api_key_set': False,
|
||||||
'language': 'en',
|
'language': 'en',
|
||||||
'llm_api_key_set': False,
|
'llm_api_key_set': False,
|
||||||
'other_models_paths_available': False,
|
'other_models_paths_available': False,
|
||||||
|
|||||||
@@ -1148,3 +1148,54 @@ async def test_download_hydrates_the_card_from_the_site(tmp_path, monkeypatch):
|
|||||||
assert scanner.update_single_model_cache.await_count == 1
|
assert scanner.update_single_model_cache.await_count == 1
|
||||||
cached = scanner.update_single_model_cache.await_args.args[2]
|
cached = scanner.update_single_model_cache.await_args.args[2]
|
||||||
assert cached["model_name"] == "Krea-2-LORA"
|
assert cached["model_name"] == "Krea-2-LORA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_model_source_sends_hf_token_as_custom_headers(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""A gated/private HF repo needs the configured token on the download."""
|
||||||
|
captured = _stub_download_backend(monkeypatch)
|
||||||
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", AsyncMock())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "huggingface",
|
||||||
|
"repo": "user/repo",
|
||||||
|
"filename": "f.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert captured["custom_headers"] == {"Authorization": "Bearer hf_secret"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_model_source_sends_no_headers_without_hf_token(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
captured = _stub_download_backend(monkeypatch)
|
||||||
|
monkeypatch.setattr(model_source_handlers, "_save_source_metadata", AsyncMock())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await ModelSourceHandler().download_model_source(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"platform": "huggingface",
|
||||||
|
"repo": "user/repo",
|
||||||
|
"filename": "f.safetensors",
|
||||||
|
"model_root": str(tmp_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert captured["custom_headers"] is None
|
||||||
|
|||||||
@@ -1281,3 +1281,64 @@ async def test_download_file_does_not_refresh_url_for_other_errors(
|
|||||||
assert "Download aborted" in result
|
assert "Download aborted" in result
|
||||||
assert add_uri_count["n"] == 1
|
assert add_uri_count["n"] == 1
|
||||||
assert downloader._transfers == {}
|
assert downloader._transfers == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_file_preresolves_huggingface_redirect_and_strips_token(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""aria2 forwards custom headers to redirect targets, so the HF Bearer
|
||||||
|
token must never leave huggingface.co: the /resolve/ redirect is resolved
|
||||||
|
first and the signed CDN URL is handed to aria2 without headers."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||||
|
downloader._rpc_secret = "secret"
|
||||||
|
|
||||||
|
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||||
|
rpc_calls = []
|
||||||
|
statuses = iter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"gid": "gid-1",
|
||||||
|
"status": "complete",
|
||||||
|
"completedLength": "10",
|
||||||
|
"totalLength": "10",
|
||||||
|
"downloadSpeed": "0",
|
||||||
|
"files": [{"path": str(save_path)}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_rpc_call(method, params, **_kwargs):
|
||||||
|
rpc_calls.append((method, params))
|
||||||
|
if method == "aria2.addUri":
|
||||||
|
return "gid-1"
|
||||||
|
if method == "aria2.tellStatus":
|
||||||
|
return next(statuses)
|
||||||
|
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
downloader,
|
||||||
|
"_resolve_authenticated_redirect_url",
|
||||||
|
AsyncMock(
|
||||||
|
return_value="https://cdn-lfs.huggingface.co/signed/model.safetensors?sig=abc"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
success, result = await downloader.download_file(
|
||||||
|
"https://huggingface.co/user/repo/resolve/main/model.safetensors",
|
||||||
|
str(save_path),
|
||||||
|
download_id="download-1",
|
||||||
|
headers={"Authorization": "Bearer hf_secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert result == str(save_path)
|
||||||
|
assert rpc_calls[0][0] == "aria2.addUri"
|
||||||
|
assert rpc_calls[0][1][0] == [
|
||||||
|
"https://cdn-lfs.huggingface.co/signed/model.safetensors?sig=abc"
|
||||||
|
]
|
||||||
|
assert "header" not in rpc_calls[0][1][1]
|
||||||
|
|||||||
@@ -1139,3 +1139,100 @@ class TestHashBasedVersionMatching:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert mock_ctx.call_args.kwargs["sha256"] == "c" * 64
|
assert mock_ctx.call_args.kwargs["sha256"] == "c" * 64
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hugging Face authentication (gated / private repositories)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHuggingFaceAuth:
|
||||||
|
def test_auth_headers_empty_without_token(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert HuggingFaceSource().auth_headers() == {}
|
||||||
|
|
||||||
|
def test_auth_headers_bearer_with_token(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert HuggingFaceSource().auth_headers() == {
|
||||||
|
"Authorization": "Bearer hf_secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_files_sends_token_to_tree_api(self, monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
async def fake_fetch_json(url, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return 200, []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
await HuggingFaceSource().list_files("u/r")
|
||||||
|
|
||||||
|
assert captured["headers"] == {"Authorization": "Bearer hf_secret"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_card_sends_token(self, monkeypatch):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
async def fake_fetch_text(url, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return "# card"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface.fetch_text", fake_fetch_text
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
await HuggingFaceSource().fetch_model_card("u/r")
|
||||||
|
|
||||||
|
assert captured["headers"] == {"Authorization": "Bearer hf_secret"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthorised_without_token_explains_how_to_fix(self, monkeypatch):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 401, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ModelSourceError) as excinfo:
|
||||||
|
await HuggingFaceSource().list_files("u/r")
|
||||||
|
|
||||||
|
assert excinfo.value.status == 401
|
||||||
|
assert "access token" in str(excinfo.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_denied_with_token_points_at_repo_terms(self, monkeypatch):
|
||||||
|
async def fake_fetch_json(url, **_kwargs):
|
||||||
|
return 403, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface.fetch_json", fake_fetch_json
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.model_sources.huggingface._hf_token", lambda: "hf_secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ModelSourceError) as excinfo:
|
||||||
|
await HuggingFaceSource().list_files("u/r")
|
||||||
|
|
||||||
|
assert excinfo.value.status == 403
|
||||||
|
assert "accept its terms" in str(excinfo.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user