Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
56 changes: 56 additions & 0 deletions backend/tests/test_hub_downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 35 additions & 0 deletions backend/tests/test_hub_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 40 additions & 3 deletions backend/tinyforge/hub/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading