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:
Will Miao
2026-09-25 18:44:06 +08:00
parent 067e605e75
commit 8b7ba59263
23 changed files with 446 additions and 23 deletions
@@ -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
cached = scanner.update_single_model_cache.await_args.args[2]
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