diff --git a/App/project.yml b/App/project.yml index 2e5fb9a..4f2e2d5 100644 --- a/App/project.yml +++ b/App/project.yml @@ -50,7 +50,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: io.gnosis.tinyforge.app PRODUCT_NAME: TinyForge ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: "0.1.1" + MARKETING_VERSION: "0.1.2" CURRENT_PROJECT_VERSION: "1" INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.developer-tools" INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Sandipan Kundu" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0973180..ca0d9b4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tinyforge" -version = "0.1.1" +version = "0.1.2" description = "TinyForge ML backend — FastAPI orchestrator for training/finetuning tiny models on Apple Silicon" readme = "../README.md" requires-python = ">=3.12" diff --git a/backend/tests/test_hub_downloads.py b/backend/tests/test_hub_downloads.py index ad51698..1ed78a3 100644 --- a/backend/tests/test_hub_downloads.py +++ b/backend/tests/test_hub_downloads.py @@ -84,3 +84,59 @@ def test_progress_unknown_job_raises() -> None: mgr = DownloadManager(plan_fn=lambda r, t: []) with pytest.raises(KeyError): mgr.progress("nope") + + +class _FakeResponse: + """Minimal stand-in for the requests.Response carried by HfHubHTTPError.""" + + def __init__(self, status_code: int) -> None: + self.status_code = status_code + self.headers: dict = {} + self.request = None + + +def test_start_surfaces_friendly_message_when_model_is_gated() -> None: + from huggingface_hub.errors import GatedRepoError + + def plan_fn(repo_id, repo_type): + raise GatedRepoError( + "401 Client Error. Cannot access gated repo", response=_FakeResponse(401) + ) + + mgr = DownloadManager(plan_fn=plan_fn, id_factory=lambda: "job1") + # A gated repo must not crash the request handler with a bare 500: start() + # records the failure as an error job instead of raising. + job_id = mgr.start("meta-llama/Llama-3.2-1B-Instruct") + + progress = mgr.progress(job_id) + assert progress.state == "error" + assert progress.error is not None + assert "gated" in progress.error.lower() + assert "token" in progress.error.lower() + + +def test_start_surfaces_friendly_message_when_repo_missing() -> None: + from huggingface_hub.errors import RepositoryNotFoundError + + def plan_fn(repo_id, repo_type): + raise RepositoryNotFoundError("404 Client Error", response=_FakeResponse(404)) + + mgr = DownloadManager(plan_fn=plan_fn, id_factory=lambda: "job1") + job_id = mgr.start("nope/does-not-exist") + + progress = mgr.progress(job_id) + assert progress.state == "error" + assert progress.error is not None + assert "not found" in progress.error.lower() + + +def test_start_preserves_message_for_unknown_planning_failure() -> None: + def plan_fn(repo_id, repo_type): + raise RuntimeError("disk full") + + mgr = DownloadManager(plan_fn=plan_fn, id_factory=lambda: "job1") + job_id = mgr.start("meta/x") + + progress = mgr.progress(job_id) + assert progress.state == "error" + assert progress.error is not None and "disk full" in progress.error diff --git a/backend/tests/test_hub_routes.py b/backend/tests/test_hub_routes.py index 57a9371..b46363f 100644 --- a/backend/tests/test_hub_routes.py +++ b/backend/tests/test_hub_routes.py @@ -122,6 +122,41 @@ def test_start_then_get_download_progress(client_and_services) -> None: assert progress.json()["fraction"] == 1.0 +def test_start_download_returns_friendly_error_for_gated_repo() -> None: + # Regression: a gated repo used to escape planning as a bare HTTP 500 + # (surfaced in the app as "unexpectedStatus(500)"). The route must now + # respond 200 with an error-state job carrying an actionable message. + from huggingface_hub.errors import GatedRepoError + + from tinyforge.hub.downloads import DownloadManager + + class _Resp: + status_code = 401 + headers: dict = {} + request = None + + def plan_fn(repo_id, repo_type): + raise GatedRepoError("401 Client Error. Cannot access gated repo", response=_Resp()) + + services = Services( + auth=FakeAuth(), hub=FakeHub(), + downloads=DownloadManager(plan_fn=plan_fn, id_factory=lambda: "job1"), + cache=FakeCache(), datasets=None, training=None, inference=None, exports=None, + ) + client = TestClient(create_app(token=TOKEN, services=services)) + + resp = client.post( + "/v1/hub/downloads", + json={"repo_id": "meta-llama/Llama-3.2-1B-Instruct"}, + headers=headers(), + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["state"] == "error" + assert "gated" in body["error"].lower() + + def test_cache_info_and_delete(client_and_services) -> None: client, _ = client_and_services assert client.get("/v1/hub/cache", headers=headers()).json()["size_on_disk"] == 2000 diff --git a/backend/tinyforge/hub/downloads.py b/backend/tinyforge/hub/downloads.py index 43dc2a7..6db50ad 100644 --- a/backend/tinyforge/hub/downloads.py +++ b/backend/tinyforge/hub/downloads.py @@ -24,6 +24,30 @@ DownloadFn = Callable[[str, str, Callable[[int], None]], str] +def _friendly_download_error(exc: Exception) -> str: + """Translate a planning/transfer failure into an actionable message. + + Planning runs synchronously in the request handler, so an uncaught failure + surfaces to the client as an opaque HTTP 500. The two most common causes — + a gated/private repo with no token, and a mistyped repo id — are worth + spelling out; everything else passes through verbatim. + """ + class_names = {cls.__name__ for cls in type(exc).__mro__} + status = getattr(getattr(exc, "response", None), "status_code", None) + # Check gated before not-found: GatedRepoError subclasses RepositoryNotFoundError. + if "GatedRepoError" in class_names or status in (401, 403): + return ( + "This model is gated. Request access on its Hugging Face page, then " + "sign in with a Hugging Face token in Settings to download it." + ) + if "RepositoryNotFoundError" in class_names or status == 404: + return ( + "Model not found on Hugging Face. Check the repository id — if it is " + "private, sign in with a Hugging Face token in Settings." + ) + return str(exc) + + @dataclass class _Job: id: str @@ -92,9 +116,22 @@ def plan(self, repo_id: str, repo_type: str = "model") -> DownloadPlan: ) def start(self, repo_id: str, repo_type: str = "model") -> str: - plan = self.plan(repo_id, repo_type) + job_id = self._id_factory() + try: + plan = self.plan(repo_id, repo_type) + except Exception as exc: # noqa: BLE001 - recorded as an error job, not raised + # Planning happens in the request handler; surfacing the failure as an + # error job (rather than letting it escape as a bare HTTP 500) lets the + # client show why the download could not start. + job = _Job( + id=job_id, repo_id=repo_id, repo_type=repo_type, + total_bytes=0, state="error", error=_friendly_download_error(exc), + ) + with self._lock: + self._jobs[job_id] = job + return job_id job = _Job( - id=self._id_factory(), repo_id=repo_id, repo_type=repo_type, + id=job_id, repo_id=repo_id, repo_type=repo_type, total_bytes=plan.total_bytes, ) with self._lock: @@ -116,7 +153,7 @@ def on_progress(delta: int) -> None: except Exception as exc: # noqa: BLE001 - surfaced to the client with self._lock: job.state = "error" - job.error = str(exc) + job.error = _friendly_download_error(exc) return with self._lock: diff --git a/backend/uv.lock b/backend/uv.lock index c384651..db36812 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1850,7 +1850,7 @@ wheels = [ [[package]] name = "tinyforge" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "accelerate" },