fix(setup): send the HF token when installing a gated model

Installing a gated model failed the fast download path with "401
Unauthorized" even with a valid token and the licence accepted, then fell
back to snapshot_download and logged a 401 that reads like the token or
the licence grant is at fault when it is neither (#2163).

`token_resolver.resolve()` returns a ResolvedToken record, not the bearer
string. Two call sites handed that record straight to consumers typed
`token: str | None`, and both fail silently rather than loudly:

- `_segmented_snapshot` passes it to HfApi, get_hf_file_metadata and our
  own segmented_download. huggingface_hub's build_hf_headers ignores a
  non-str token and falls back to its own ambient discovery, so a token
  held only in VoiceStudio's Settings produces NO Authorization header
  and every gated file 401s. segmented_download instead interpolates it
  into `f"Bearer {token}"`, sending a malformed header that also inlines
  the raw secret into the request.
- `_step_fetch_weights` passes it to snapshot_download, so gated engine
  weights 401 the same way.

Every other resolve() caller already unwraps `.token`; these two were the
outliers. Both now unwrap once, at the seam.

The existing weights tests all stubbed resolve() to return None, so no
test ever exercised a resolved token — which is why this went unnoticed.
The new tests drive a real ResolvedToken through both seams and assert a
`str` reaches every consumer, plus an integration test that installs the
pyannote diarisation pipeline end to end: a weightless config_only repo
validates, both dependency repos are fetched, and every call carries the
bearer string.

Two catalogue invariants keep the rest of #2163 from returning by edit:
dependency repos must be revision-pinned (revision_for raises otherwise,
so an unpinned one ships an always-failing install), and a config_only
entry must declare config_required_files (without them the completeness
check can never pass and the error lists no files at all — the shape the
report hit on 0.5.2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Shivendra-Coherent
2026-09-17 16:50:16 +05:30
co-authored by Claude Opus 5
parent 4e55180f70
commit 37b498c891
8 changed files with 400 additions and 5 deletions
+1
View File
@@ -31,6 +31,7 @@ the frozen-backend fallback mirror it for their toolchains.
- Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039)
- PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041)
- MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040)
- Installing a gated model now sends your Hugging Face token on the fast download path too, so pyannote diarisation and gated engine weights stop failing with "401 Unauthorized" when the token is saved in Settings (#2173, #2163)
### CI
+10 -1
View File
@@ -230,7 +230,16 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
from services.segmented_download import segmented_download
from services.token_resolver import resolve as _resolve_token
token = _resolve_token()
# `resolve()` returns a ResolvedToken record, not the bearer string, and
# every consumer below is typed `token: str | None`. Handing over the
# record fails silently rather than loudly (#2163): huggingface_hub's
# build_hf_headers ignores a non-str token and falls back to its own
# ambient discovery, so a token held only in VoiceStudio's settings sends
# NO Authorization header at all and every gated file 401s; our own
# segmented_download interpolates it into `f"Bearer {token}"` and sends a
# malformed header carrying the raw secret. Unwrap once, here.
_resolved = _resolve_token()
token = _resolved.token if _resolved else None
api = HfApi(endpoint=endpoint, token=token)
info = api.repo_info(repo_id, repo_type="model", revision=revision)
commit = info.sha
+6 -1
View File
@@ -1543,10 +1543,15 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
# other model download in the app — see setup/download.py): the
# source checkout is unpinned upstream `main` anyway, and hf_hub
# checksum-verifies each artifact. Hence the B615 waiver below.
# Unwrap to the bearer string: snapshot_download takes `token: str |
# None`, and a ResolvedToken record is ignored in favour of ambient
# discovery, so gated engine weights 401 for a user whose token lives
# in VoiceStudio's settings rather than HF's own cache (#2163).
_resolved = resolve_token()
kwargs: dict = {
"repo_id": spec.weights_repo_id,
"local_dir": str(wdir),
"token": resolve_token(),
"token": _resolved.token if _resolved else None,
}
if spec.weights_revision:
kwargs["revision"] = spec.weights_revision
+134
View File
@@ -0,0 +1,134 @@
"""#2163: a gated model install must actually send the HF bearer token.
``token_resolver.resolve()`` returns a ``ResolvedToken`` *record*. Every
huggingface_hub entry point — and our own ``segmented_download`` — takes
``token: str | None``. The segmented accelerator handed the record straight
through, and neither consumer complains:
* ``build_hf_headers`` ignores a non-``str`` token and falls back to
huggingface_hub's own ambient discovery, so a token held only in
VoiceStudio's Settings produces **no** ``Authorization`` header at all and
every gated file 401s;
* ``segmented_download`` interpolates it into ``f"Bearer {token}"``, sending a
malformed header that also inlines the raw secret into the request.
Either way the accelerator 401s on the first file of a gated repo, is disabled
for the rest of the install, and logs a 401 that reads like the user's token or
license grant is at fault when it is neither.
"""
import importlib
import os
from types import SimpleNamespace
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
from services.token_resolver import ResolvedToken
@pytest.fixture
def download():
return importlib.import_module("api.routers.setup.download")
GATED_REPO = "pyannote/speaker-diarization-3.1"
REVISION = "c" * 40
def _drive_segmented(download, monkeypatch, tmp_path, resolved):
"""Run ``_segmented_snapshot`` with every network seam mocked.
Returns the token value each of the three consumers actually received.
"""
import huggingface_hub
from huggingface_hub import file_download as hf_file_download
from services import segmented_download as sd_mod
from services import token_resolver
monkeypatch.setattr(token_resolver, "resolve", lambda *a, **k: resolved)
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_CACHE", str(tmp_path))
seen: dict = {}
class _FakeApi:
def __init__(self, *, endpoint=None, token=None):
seen["hf_api"] = token
def repo_info(self, repo_id, repo_type=None, revision=None):
# Gated repos serve metadata unauthenticated and gate the file
# bytes — which is why the 401 in #2163 lands on the first
# resolve() call rather than here.
return SimpleNamespace(
sha=revision,
siblings=[SimpleNamespace(rfilename="config.yaml")],
)
monkeypatch.setattr(huggingface_hub, "HfApi", _FakeApi)
def _fake_metadata(url, token=None, **_kw):
seen["file_metadata"] = token
return SimpleNamespace(etag='"deadbeef"', location=url, size=10)
monkeypatch.setattr(hf_file_download, "get_hf_file_metadata", _fake_metadata)
async def _fake_segmented(url, blob_path, *, token=None, **_kw):
seen["segmented"] = token
with open(blob_path, "wb") as fh:
fh.write(b"config: ok")
return blob_path
monkeypatch.setattr(sd_mod, "segmented_download", _fake_segmented)
download._segmented_snapshot(GATED_REPO, endpoint=None, revision=REVISION)
return seen
def test_segmented_install_sends_the_bearer_string_to_every_consumer(
download, monkeypatch, tmp_path
):
seen = _drive_segmented(
download,
monkeypatch,
tmp_path,
ResolvedToken(token="hf_gatedsecret", source="app", username="tester"),
)
assert seen == {
"hf_api": "hf_gatedsecret",
"file_metadata": "hf_gatedsecret",
"segmented": "hf_gatedsecret",
}
# The record itself must never cross the seam — that is the whole bug.
for consumer, value in seen.items():
assert isinstance(value, str), f"{consumer} received {type(value).__name__}"
def test_segmented_install_sends_no_token_when_none_resolves(
download, monkeypatch, tmp_path
):
# No token anywhere: every consumer must get a real None so huggingface_hub
# treats the repo as anonymous, never the string "None".
seen = _drive_segmented(download, monkeypatch, tmp_path, None)
assert seen == {"hf_api": None, "file_metadata": None, "segmented": None}
def test_a_token_record_would_build_a_broken_authorization_header():
"""Why the unwrap matters, pinned at our own auth seam.
``_auth_headers`` is the function that turns the token into the header the
segmented downloader sends. Given the bearer string it produces a valid
header; given the record it produces a malformed one that also inlines the
raw secret. This is the failure #2163 reported as a 401.
"""
from services.segmented_download import _auth_headers
url = "https://huggingface.co/pyannote/speaker-diarization-3.1/resolve/main/config.yaml"
record = ResolvedToken(token="hf_gatedsecret", source="app", username="tester")
assert _auth_headers(url, record.token) == {"Authorization": "Bearer hf_gatedsecret"}
broken = _auth_headers(url, record)["Authorization"]
assert broken != "Bearer hf_gatedsecret"
assert "ResolvedToken" in broken
+10 -3
View File
@@ -8,10 +8,17 @@ from services import hf_revisions
def test_every_catalog_repo_has_an_immutable_revision():
catalog = yaml.safe_load(Path("backend/config/models.yaml").read_text(encoding="utf-8"))
# Dependency repos are downloaded by the installer exactly like top-level
# ones (setup/download.py resolves `revision_for(dependency["repo_id"])`),
# and `revision_for` raises on an unpinned repo — so an unpinned dependency
# ships an install that always fails. Pin them under the same rule (#2163).
curated = []
for model in catalog["models"]:
curated.append(model["repo_id"])
for dependency in model.get("dependencies") or ():
curated.append(dependency["repo_id"])
missing = {
model["repo_id"]
for model in catalog["models"]
if model["repo_id"] not in hf_revisions.CURATED_REVISIONS
repo_id for repo_id in curated if repo_id not in hf_revisions.CURATED_REVISIONS
}
assert missing == set()
assert all(len(revision) == 40 for revision in hf_revisions.CURATED_REVISIONS.values())
+30
View File
@@ -43,6 +43,36 @@ def test_every_repo_id_is_well_formed():
assert rid and _REPO_RE.match(rid), f"malformed repo_id: {rid!r}"
def test_config_only_entries_declare_the_files_that_complete_them():
"""A ``config_only`` repo carries no weights of its own, so the install
validator judges it by ``config_required_files`` instead of a weight floor.
Declare none and the entry is permanently uninstallable: the completeness
check returns False and the install fails with a message whose list of
required files is empty. That is the shape #2163 reported, so it is a
catalogue invariant rather than something a user should discover.
"""
for m in _models():
if not m.get("config_only"):
continue
required = m.get("config_required_files")
assert required, f"{m['repo_id']}: config_only needs config_required_files"
assert all(
isinstance(name, str) and name.strip() for name in required
), f"{m['repo_id']}: blank entry in config_required_files"
def test_dependency_declarations_are_installable():
"""Every declared dependency needs an id and the files that prove it landed
— the installer rejects a dependency snapshot that lacks them."""
for m in _models():
for dependency in m.get("dependencies") or ():
rid = dependency.get("repo_id")
assert rid and _REPO_RE.match(rid), f"malformed dependency repo_id: {rid!r}"
assert dependency.get("required_files"), (
f"{m['repo_id']}{rid}: dependency needs required_files"
)
def test_required_fields_present():
for m in _models():
for field in ("repo_id", "label", "role"):
+151
View File
@@ -0,0 +1,151 @@
"""#2163: installing the pyannote diarisation pipeline end to end.
The pipeline repo (`pyannote/speaker-diarization-3.1`) carries a `config.yaml`
and no weights of its own — the real checkpoints live in the two repositories
its catalogue entry declares as `dependencies`. That shape exercises three
things at once, and the report hit all three:
* the finished-snapshot validator must accept a weightless `config_only` repo
instead of rejecting it as a truncated download;
* both dependency repositories must actually be fetched, or the install
"succeeds" with nothing that can run;
* every download must carry the resolved HF bearer token **as a string**, since
the pipeline and segmentation repos are gated.
This is the integration guard for the whole scenario; the token seam itself is
unit-tested in ``test_gated_install_token_2163.py``.
"""
import asyncio
import importlib
import os
from pathlib import Path
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
from services.token_resolver import ResolvedToken
PIPELINE = "pyannote/speaker-diarization-3.1"
SEGMENTATION = "pyannote/segmentation-3.0"
EMBEDDING = "pyannote/wespeaker-voxceleb-resnet34-LM"
_WEIGHT_BYTES = 6 * 1024 * 1024 # clears the 5 MB .bin floor in setup/models
@pytest.fixture
def download():
return importlib.import_module("api.routers.setup.download")
def _install_pyannote(download, monkeypatch, tmp_path):
"""Run POST /models/install for the pipeline repo with the Hub mocked.
Returns (snapshot_download kwargs per call, emitted SSE events).
"""
import huggingface_hub
from services import hf_revisions, performance_profiles, token_resolver
from utils import hf_progress
monkeypatch.setattr(
token_resolver,
"resolve",
lambda *a, **k: ResolvedToken(
token="hf_gatedsecret", source="app", username="tester"
),
)
calls: list[dict] = []
def fake_snapshot_download(**kwargs):
calls.append(kwargs)
if kwargs.get("dry_run"):
return []
repo_id = kwargs["repo_id"]
path = tmp_path / repo_id.replace("/", "__")
path.mkdir(parents=True, exist_ok=True)
# Mirror the real repos: the pipeline ships only a config, each
# dependency ships a config plus its checkpoint.
(path / "config.yaml").write_text("pipeline: ok\n", encoding="utf-8")
if repo_id != PIPELINE:
(path / "pytorch_model.bin").write_bytes(b"\0" * _WEIGHT_BYTES)
return str(path)
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
monkeypatch.setattr(download, "compute_plan", lambda _plan: {
"total_bytes": 1, "cached_bytes": 0, "to_download_bytes": 1,
"n_files": 1, "n_cached": 0,
})
monkeypatch.setattr(download, "disk_space_error", lambda *_a, **_k: None)
# Force the snapshot_download path so this test covers the install flow;
# the segmented accelerator has its own unit tests.
monkeypatch.setattr(download, "_segmented_enabled", lambda: False)
monkeypatch.setattr(hf_revisions, "remember_revision", lambda *_a: None)
monkeypatch.setattr(performance_profiles, "reconcile_active_profile", lambda: None)
events: list[dict] = []
listener_id = hf_progress.register_listener(lambda ev: events.append(ev))
async def _run():
await download.install_model(download.InstallModelRequest(repo_id=PIPELINE))
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
if pending:
await asyncio.gather(*pending)
try:
asyncio.run(_run())
finally:
hf_progress.unregister_listener(listener_id)
download._install_cooldowns.pop(PIPELINE, None)
download._install_failures.pop(PIPELINE, None)
return calls, events
def test_pyannote_pipeline_install_completes(download, monkeypatch, tmp_path):
calls, events = _install_pyannote(download, monkeypatch, tmp_path)
phases = [e.get("phase") for e in events]
errors = [e for e in events if e.get("phase") == "install_error"]
assert not errors, f"install failed: {[e.get('error') for e in errors]}"
assert "install_done" in phases
# A weightless pipeline repo is a valid install, not a truncated download —
# the "no model weights were found in the snapshot" rejection in the report.
assert PIPELINE not in str(errors)
def test_pyannote_install_fetches_both_dependency_repositories(
download, monkeypatch, tmp_path
):
calls, _events = _install_pyannote(download, monkeypatch, tmp_path)
real = [c for c in calls if not c.get("dry_run")]
fetched = [c["repo_id"] for c in real]
assert fetched == [PIPELINE, SEGMENTATION, EMBEDDING], (
"the pipeline config alone is not a runnable install"
)
# Each dependency is filtered to the files its catalogue entry declares.
by_repo = {c["repo_id"]: c for c in real}
for dependency in (SEGMENTATION, EMBEDDING):
assert by_repo[dependency]["allow_patterns"] == [
"config.yaml",
"pytorch_model.bin",
]
# The pipeline repo itself is unfiltered — it has no allow_patterns.
assert "allow_patterns" not in by_repo[PIPELINE]
def test_every_pyannote_download_carries_the_bearer_string(
download, monkeypatch, tmp_path
):
calls, _events = _install_pyannote(download, monkeypatch, tmp_path)
assert calls, "no download was attempted"
for call in calls:
token = call.get("token")
assert token == "hf_gatedsecret", f"{call['repo_id']} sent {token!r}"
assert isinstance(token, str)
+58
View File
@@ -620,6 +620,64 @@ def test_weights_step_downloads_via_endpoint_autoselect(monkeypatch):
assert si._weights_present(spec)
def test_weights_download_sends_the_bearer_string_not_the_token_record(monkeypatch):
"""#2163: `token_resolver.resolve()` returns a ResolvedToken record, but
snapshot_download takes `token: str | None` and silently ignores a non-str
— falling back to huggingface_hub's own ambient token discovery. So gated
engine weights 401 for a user whose token lives in VoiceStudio's Settings
rather than HF's cache. Every other weights test here stubs resolve() to
None, which is exactly why this went unnoticed."""
from services.token_resolver import ResolvedToken
spec = _mk_spec(weights_repo_id="Example/Gated")
seen = {}
def fake_snapshot_download(**kwargs):
seen.update(kwargs)
Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
(Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
(Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
monkeypatch.setattr(
"services.token_resolver.resolve",
lambda: ResolvedToken(token="hf_gatedsecret", source="app", username="tester"),
)
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_weights")["state"] = "running"
si._step_fetch_weights(spec, job)
assert seen["token"] == "hf_gatedsecret"
assert isinstance(seen["token"], str)
def test_weights_download_sends_no_token_when_none_resolves(monkeypatch):
# The other half of the contract: no token anywhere must reach
# snapshot_download as a real None, never the string "None".
spec = _mk_spec(weights_repo_id="Example/Open")
seen = {}
def fake_snapshot_download(**kwargs):
seen.update(kwargs)
Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
(Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
(Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
monkeypatch.setattr("services.token_resolver.resolve", lambda: None)
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_weights")["state"] = "running"
si._step_fetch_weights(spec, job)
assert seen["token"] is None
def test_weights_revision_is_pinned_and_old_marker_forces_upgrade(monkeypatch):
spec = _mk_spec(
weights_repo_id="Example/Weights",