Skip to content

Draft: Add local development bootstrap and deterministic demo environment#1

Draft
narugo1992 wants to merge 81 commits intomainfrom
dev/narugo1992
Draft

Draft: Add local development bootstrap and deterministic demo environment#1
narugo1992 wants to merge 81 commits intomainfrom
dev/narugo1992

Conversation

@narugo1992
Copy link
Copy Markdown

Summary

  • add a hybrid local development workflow that keeps the Python backend outside Docker while using persisted Docker services for Postgres, MinIO, and LakeFS
  • add a tracked local env example, Makefile shortcuts, and developer docs for bootstrapping backend, UI, admin UI, seeding, and reset workflows
  • add deterministic first-run demo data seeded through the application/API path with realistic users, orgs, repos, and a reproducible local manifest
  • add a destructive reset flow with explicit warning text and double confirmation before local persisted data is removed
  • add KOHAKU_HUB_INTERNAL_BASE_URL support and Python 3.10 tomli fallbacks in config-related code paths
  • ignore local-only agent and environment files that should never be committed

Long-Lived Development PR

  • This PR is intentionally long-lived and will stay open for ongoing development and follow-up edits.
  • Additional commits are expected as the local DX, seeding flows, and related maintenance work continue to evolve.
  • Please treat this as the active development PR for this branch rather than a one-off short-lived integration PR.

Add open-in-new-tab support to repo titles across the UI
Add session-scoped repo sorting and browser-aware timestamps
Raise the LFS version history input limit to 9999
Add frontend unit tests and fullstack CI coverage
narugo1992 and others added 30 commits April 22, 2026 11:14
Add huggingface_hub compatibility coverage and API fixture matrix
PR #18 changed the backend create_repo exist-ok path from a 400 with
`{detail: "..."}` to a HuggingFace-compatible 409 with a top-level
`{url, repo_id, error}` body. The UI's "create" error toast kept reading
`err.response?.data?.detail`, which is now `undefined`, so users who
tried to create a repo that already existed saw a generic
"Failed to create <type>" toast instead of the actual conflict message.

Fix the two affected call sites (`pages/new.vue` and
`components/pages/RepoListPage.vue`) to read `.error` before falling
back to the legacy `.detail` shape. The fallback preserves behavior
for every other error path that still uses FastAPI's HTTPException
body.

Test coverage

Pin the new contract with two vitest cases per call site:
- a 409 response whose body matches the new HF-compatible shape must
  produce a toast carrying the server's `error` text verbatim
- a legacy `{detail: "..."}` response must keep working through the
  fallback branch

Test infrastructure fix

While adding positive ElMessage assertions it became clear that
`vi.mock("element-plus", ...)` silently no-ops across the UI test
suite: test files live under `test/kohaku-hub-ui/` which is outside
`src/kohaku-hub-ui/node_modules`, so bare imports of `element-plus`
from the test file resolve to a different specifier than the one the
real component uses. The mock registers under specifier A while the
component's import resolves to specifier B, and the mock never
intercepts the real call. Six existing test files declare
`vi.mock("element-plus", ...)` but none of them positively assert a
call, so the bug stayed hidden.

Add an explicit `element-plus` alias + dedupe entry in
`vitest.config.js` pointing at the UI's `node_modules` copy so both
sides of the mock resolve to the same module id. All 124 existing UI
tests keep passing; positive ElMessage assertions now work for
anyone who writes them in the future.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface create_repo 409 conflict in the UI toast
The local demo seed now installs https://huggingface.co as a low-priority
(priority=1000) global fallback source via the admin API, so a fresh
`make seed-demo` can resolve public HF repos out-of-the-box. Bumps the
seed version to local-dev-demo-v3 and updates verify_seed_data.py to
assert the seeded source is advertised via /api/fallback-sources/available.

The creation step is idempotent: it lists global sources first and skips
the insert when a matching URL already exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When KohakuHub forwards a HuggingFace HEAD /resolve/<rev>/<path> response,
HF replies 307 with a Location that is *relative* to huggingface.co
(e.g. /api/resolve-cache/models/...). Previously we proxied this header
verbatim, so the client followed the redirect against KohakuHub's own
origin and hit 404 — breaking hf_hub_download even though the GET path
worked. huggingface_hub probes HEAD first, so the whole download aborted
with LocalEntryNotFoundError.

urljoin(upstream_request_url, location) resolves the redirect against
the host we actually queried, producing an absolute URL back to the
upstream source. Absolute Location values pass through unchanged
(urljoin ignores the base when the second arg already has a scheme),
and responses without a Location header are untouched.

Covered by three new unit tests in test_operations.py exercising
relative, absolute and missing Location cases across model and dataset
resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KohakuHub does not natively speak the huggingface.co Xet protocol
(/api/models/<repo>/xet-read-token/<hash>/ is not implemented). When
a downstream client (huggingface_hub >= 1.x) sees X-Xet-* response
headers or a Link rel="xet-auth" relation it switches to the Xet
code path and calls endpoints we do not serve — breaking every
fallback-backed download of an LFS-stored model weight.

The fix is to strip these signals before proxying HF's response, so
hf_hub falls back to the classic LFS flow served by the standard 3xx
Location redirect that KohakuHub already forwards correctly (see
urljoin rewrite from the prior commit). The client then follows
metadata.location straight to HF's CAS-bridge / CloudFront and
downloads the file verbatim — exactly the flow that works when
talking to huggingface.co directly.

Implementation is a single pure helper, `strip_xet_response_headers`,
called from both the HEAD and GET branches of
`try_fallback_resolve`. It removes every response header whose name
starts with `x-xet-` (case-insensitive) and drops the `xet-auth`
relation from `Link` while preserving unrelated relations. Other
headers (X-Linked-Etag / X-Linked-Size / X-Repo-Commit / ETag) are
untouched — hf_hub still needs them to populate HfFileMetadata.

Unit tests cover:
- helper in isolation (mixed-case, Link partial-drop, multi-rel,
  no-op on clean headers)
- HEAD integration: xet headers + xet-auth Link relation are gone,
  the absolute upstream Location / ETag / X-Repo-Commit survive
- GET integration: xet headers scrubbed from proxied content
  response while the body itself is preserved

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #21's urljoin rewrite fixed "client follows relative Location back
into khub and gets 404" but introduced a regression for non-LFS blobs.
HF's first /resolve 307 hop carries Content-Length = <redirect body
length> (~278B), not the real file size; for LFS blobs X-Linked-Size
bypasses this, but plain files (e.g. selected_tags.csv, preprocess.json
on many tagger repos) have no X-Linked-Size. After urljoin makes
Location absolute, hf_hub's `_httpx_follow_relative_redirects_with_backoff`
deliberately refuses to follow (netloc != ""), so the client takes
278 as expected_size, downloads 308468 bytes, and trips its
post-download consistency check. `imgutils.tagging.get_wd14_tags` and
every sibling tagger go down on selected_tags.csv. Observed end-to-end
against a local khub seeded with the deepghs/SmilingWolf tagger repos.

Fix is a single HEAD on the absolute 3xx Location when X-Linked-Size
is missing — Content-Length and ETag from that hop replace the 307's.
LFS responses are unchanged (early-exit on X-Linked-Size), so we don't
pay the extra RTT for the common case and we don't pin slow cas-bridge
HEADs onto khub's request path either. `Accept-Encoding: identity`
is required here: without it HF compresses the (empty) HEAD body and
httpx's auto-decoder strips Content-Length — which was exactly the
degenerate form that made pixai-tagger-v0.9-onnx land a 0-byte
selected_tags.csv on the first attempt.

Tests
-----
test_operations.py (5 HEAD scenarios, rewritten from PR #21's set):
- relative /api/resolve-cache Location → absolute
- already-absolute Location (LFS cas-bridge style) passes through
- non-LFS 307 without X-Linked-Size → exactly one extra HEAD, real
  Content-Length + ETag; X-Repo-Commit/X-Linked-Etag preserved from
  the initial 307; Accept-Encoding: identity asserted on the wire
- extra HEAD raising httpx.HTTPError degrades to the 307 headers
  without crashing the fallback chain
- Xet signals always stripped

test_hf_hub_interop.py (new, 6 cases): feeds khub's FastAPI Response
straight into huggingface_hub 1.x's real parsing functions —
HfFileMetadata, _normalize_etag, _int_or_none,
parse_xet_file_data_from_response. Any future hf_hub change that
narrows the Content-Length / ETag / xet-hash contract will fail
these tests. Covers non-LFS-size fix, LFS early-exit,
xet→classic-LFS fallback, weak ETag (W/) normalization,
single-hop 200, and the graceful-degradation branch.

End-to-end verification
-----------------------
imgutils tagger matrix against khub with DB-stored HF token, all 6
canonical entries pass:
  wd14_swinv2_v3  57.5s, wd14_vit_v3  41.4s, deepdanbooru  64.8s,
  mldanbooru      34.2s, camie_initial 101.4s, pixai_v09   141.6s
All top-5 tag lists byte-identical to a direct-HF baseline.

animetimm README E2E (mobilenetv3_large_100.dbv4-full) re-run
unchanged: 27 tags, top-5 matches, zero requests direct to hf.co,
xet signals from khub = False.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new interop tests import huggingface_hub.utils._xet, which only
exists from hf_hub 1.0 onward. Older matrix cells (0.20.3 / 0.30.2 /
0.36.2) currently blow up at collection time. Wrap the module in a
top-level pytest.importorskip so the three pre-1.0 cells go green
while keeping the post-1.0 cells running the real interop checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… tests

huggingface_hub 0.30.2 / 0.36.2 expose parse_xet_file_data_from_response
without an `endpoint` keyword — that arg was added in 1.x. Since we
assert the function returns None after xet headers are stripped, the
endpoint value never matters for our test outcomes; pass only the
response and let both API shapes accept the call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorganizes `test_hf_hub_interop.py` around the three redirect shapes
observed in a 426-probe survey of 100 top-downloaded repos on
huggingface.co:

  A. 307 → relative /api/resolve-cache/… (72.3% — non-LFS text)
  B. 302 → absolute cas-bridge.xethub.hf.co (22.1% — LFS via xet)
  C. direct 200 with no redirect (3.5% — some README.md / YAML files)

Each pattern is now exercised through both `method="HEAD"` and
`method="GET"` on `try_fallback_resolve`, then the resulting response
is rehydrated as an `httpx.Response` and fed into huggingface_hub's
real parsing functions (`HfFileMetadata`, `_normalize_etag`,
`_int_or_none`, `parse_xet_file_data_from_response`). The assertions
match what `hf_hub_download` actually checks post-metadata:

  * `metadata.size` is the real file size (not the 307 body length)
  * `metadata.etag` is the normalized linked etag (W/ stripped)
  * `metadata.commit_hash` is preserved across any proxy rewrite
  * `xet_file_data` resolves to None so the client does not switch
    to the Xet protocol that KohakuHub does not implement
  * the rewritten Location is compatible with hf_hub's relative/absolute
    redirect follow policy

Cross-version compatibility
---------------------------
The module-level `importorskip` from the previous commit is gone.
Instead, the Xet-specific assertions are guarded by a `HAS_XET` flag
derived from a best-effort import of `huggingface_hub.utils._xet`.
`HfFileMetadata` is constructed through `inspect.signature` so the
`xet_file_data` field is only passed on the hf_hub versions where it
exists. Verified locally: `pytest test/kohakuhub/api/fallback/
test_hf_hub_interop.py` passes on both `huggingface_hub==0.20.3`
(no xet module) and `huggingface_hub==1.11.0`, so every cell in the
PR's 18-job matrix (3.10/3.11/3.12 × 0.20.3/0.30.2/0.36.2/1.0.1/
1.6.0/latest) now runs the real-hf_hub interop instead of
silently skipping.

7 tests total: 3 patterns × (HEAD + GET) + one "extra HEAD fails →
degrades gracefully" case for pattern A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second uvicorn process ("mock HF") that serves the three
/resolve redirect patterns from the 100-repo HF survey, and a live-
server test module that drives real `huggingface_hub.hf_hub_download`
against a khub live server whose fallback source points at that
mock HF. The request paths are:

  client hf_hub_download
    → HTTPS → khub live_server (real uvicorn on 127.0.0.1:port1)
    → fallback resolve → mock HF (real uvicorn on 127.0.0.1:port2)
    → response flows back, hf_hub writes to cache_dir,
      post-download consistency check runs inside hf_hub,
  then the test asserts cache file bytes == PATTERN_*_BYTES.

This is the integration layer the interop-parser tests only hinted
at: every layer of khub (the fallback decorator, try_fallback_resolve
HEAD branch with its Content-Length backfill + Xet stripping, the
GET proxy) is exercised by real-network TCP, and the source of truth
for correctness is hf_hub's own size / etag / commit-hash checks
applied to the received body.

New modules:
  * test/kohakuhub/support/mock_hf_server.py  — FastAPI app with three
    file shapes (pattern_a.txt = 307→/api/resolve-cache/, pattern_b.bin
    = 302→absolute CDN + X-Linked-Size, pattern_c.md = direct 200) plus
    deterministic byte payloads and etags.
  * test/kohakuhub/api/fallback/test_real_hf_hub_end_to_end.py
      4 tests: one per pattern + one warm-cache re-fetch that validates
      the HEAD-only revalidation path.

Portable across every hf_hub pin in the CI matrix (0.20.3 → latest)
because `hf_hub_download` is a stable public API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make fallback transparent to huggingface_hub: seed + HEAD semantics + real-client tests
hf_hub_download against hub.deepghs.org intermittently failed with
FileMetadataError -> "Force download failed due to the above error."
The 302 returned by resolve_file_get dropped response_headers computed
by _get_file_metadata, so downstream layers (Cloudflare converting HEAD
to GET on cold cache paths, or serving a cached GET 302 to a later HEAD)
surfaced a redirect with no X-Repo-Commit / X-Linked-Etag / X-Linked-Size
to huggingface_hub, reproducing #24.

Keep the GET -> S3 presigned redirect but layer the HF-relevant headers
(X-Repo-Commit / X-Linked-Etag / X-Linked-Size / ETag) onto the
RedirectResponse, and set Cache-Control: no-store so presigned, per-user
URLs can never be revived by an intermediate cache.

Pin the contract with two regression tests in test_files.py:
- header-presence assertion on the raw 302 via the ASGI TestClient
- end-to-end hf_hub_download through a 42-line HEAD->GET proxy that
  replays the Cloudflare failure mode; without this fix the test fails
  with the exact production exception
  ("ValueError: Force download failed due to the above error.")

Refs #24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
codecov/codecov-action v5 occasionally fails with
"Could not verify SHASUM" on its own CDN delivery of the CLI
binary. With fail_ci_if_error=true this made a green 499-test
matrix cell show as red solely on the upload step, blocking merge
for a reason the PR author cannot fix. Coverage reports remain
useful when they arrive, so flip fail_ci_if_error to false on all
three codecov upload steps (backend matrix, frontend, admin
frontend) — a missed upload is at worst a stale coverage badge,
not a reason to block code that passed its own tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make /resolve GET 302 carry HF metadata headers + no-store
Implements issue #27 v4: file-level HF-compatible metadata preview
computed entirely in the browser via HTTP Range reads against the
existing /resolve/ 302 → presigned S3/MinIO URL. Zero new backend
preview code, zero LRU, zero precomputation, zero new DB state.

Backend (minimal CORS plumbing only):
- main.py CORSMiddleware: add `expose_headers` so browsers can read
  Content-Range / X-Linked-* / X-Repo-Commit / ETag / Location off
  the final 206 response that follows the /resolve/ 302.
- docker-compose.example.yml + scripts/dev/up_infra.sh: wire
  `MINIO_API_CORS_ALLOW_ORIGIN` so the SPA can cross-origin Range-read
  presigned targets. Configurable via `DEV_MINIO_CORS_ALLOW_ORIGIN`.
- docs/development/local-dev.md: MinIO CORS section explaining the
  hard prerequisite + smoke-test probe + how to recreate the container.

Frontend:
- utils/safetensors.js (~190 LOC): pure-JS parser mirroring
  huggingface_hub.parse_safetensors_file_metadata byte-for-byte
  (speculative 100 KB first read, two-read fallback for fat headers,
  SAFETENSORS_MAX_HEADER_LENGTH guard). Exposes parseSafetensorsMetadata
  + summarizeSafetensors.
- utils/parquet.js: thin wrapper over hyparquet's asyncBufferFromUrl +
  parquetMetadataAsync with mode:"cors" + credentials:"omit" so cookies
  never leak onto presigned URLs. Normalizes BigInt row counts.
- components/repo/preview/FilePreviewDialog.vue: ElDialog with
  per-phase spinner text (range-head → parsing → done for safetensors,
  head → footer → parsing → done for parquet), dtype/row-group tables,
  and an explicit "CORS likely misconfigured" placeholder on failure.
- RepoViewer.vue: HF-style chart-line-data icon next to .safetensors
  and .parquet rows; click opens the modal with the resolved /resolve/
  URL for the current branch.

Tests + fixtures:
- test_files.py::test_resolve_get_302_exposes_cors_headers_for_browser_preview
  pins the `Access-Control-Expose-Headers` list against regressions.
- test/kohaku-hub-ui/utils/test_safetensors.test.js: 6 cases covering
  the real-HF-format fixture, dtype summary, progress phases, fat-header
  fallback, oversized-header guard, and non-206 error paths.
- test/kohaku-hub-ui/utils/test_parquet.test.js: footer parse +
  progress phase assertions.
- test/kohaku-hub-ui/fixtures/previews/{tiny.safetensors,tiny.parquet}:
  byte-identical-to-HF fixtures produced by the real safetensors /
  pyarrow libs via scripts/dev/generate_preview_test_fixtures.py
  (committed so tests stay offline per AGENTS.md §5.2).

Seed:
- seed_demo_data.py: add two RemoteAsset entries for real HF-hosted
  small fixtures pinned by sha256, and wire them into visible paths
  (open-media-lab/vision-language-assistant-3b/fixtures/hf-tiny-random-bert.safetensors,
  open-media-lab/multimodal-benchmark-suite/fixtures/hf-no-robots-test.parquet)
  so the preview can be exercised against files that actually came off
  huggingface.co rather than purely local pyarrow/safetensors output.
  SEED_VERSION bumped to local-dev-demo-v4.

Verified end-to-end against the dev stack: safetensors parser output
on the seeded fixtures matches huggingface_hub.parse_safetensors_file_metadata
byte-for-byte on the same file (100 tensors, 126,851 params, I64=512
/ F32=126,339, metadata `{format: pt, ...}`). Browser preview modal
renders both file kinds correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
verify_seed_data.py hardcoded EXPECTED_SEED_VERSION = "local-dev-demo-v3"
but seed_demo_data.py was bumped to v4 in the preview PR, so the
post-seed verifier would falsely fail with a version mismatch. Extract
the constant to scripts/dev/seed_shared.py and import it from both
sides so the two scripts always agree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FastAPI/Pydantic v2 emits DeprecationWarning and schedules removal for
the ``regex`` kwarg on ``Query``. Two routes still used the old name;
swap to ``pattern`` to match the current idiom elsewhere in the repo
and silence the warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the preview-icon helpers (getPreviewKind, canPreviewFile,
resolve-URL builder) from RepoViewer.vue into src/utils/file-preview.js
so they become directly unit-testable rather than buried in a Vue SFC,
then cover the remaining surface of the preview path:

- test/kohaku-hub-ui/utils/test_file_preview.test.js — 12 cases for
  the extracted helpers: extension recognition, case-insensitive
  suffix matching, directory rejection, bad-input defensiveness, URL
  encoding per path segment and per branch (so refs/convert/parquet
  survives), and guard-rail errors when required fields are missing.
- test/kohaku-hub-ui/utils/test_safetensors.test.js — 9 new cases
  covering the guard-rails that real HF fixtures do not naturally
  trigger: truncated <8-byte response, invalid UTF-8 header, null
  / non-object JSON root, truncated fat-header second read,
  second-range HTTP error, error-class instance + .status surface,
  defensive skipping of malformed tensor entries, missing shape →
  [] fallback, and unknown-dtype byte-size accounting.
- test/kohaku-hub-ui/utils/test_parquet.test.js — 5 new cases
  covering normalizeCount's null/bigint-in-range/bigint-out-of-range/
  plain-number paths and summarizeParquetSchema's empty-tree and
  shapeless-children fallbacks. normalizeCount is now exported from
  parquet.js so the tests can poke it directly (real parquet footers
  always yield BigInt, so the number-passthrough branch is not
  reachable through hyparquet without an awkward stub).
- test/kohaku-hub-ui/components/test_file_preview_dialog.test.js —
  14 cases mounting FilePreviewDialog with mocked parser modules.
  Covers the loading-phase progress copy for both kinds (including
  the fat-header copy and the "done" phase), safetensors and parquet
  ready-state renders, the error-state + Retry path, the
  CORS-likely-failure placeholder + its negative case, silent
  AbortError swallowing, AbortController plumbing on visibility
  change, re-request on URL change, the unsupported-kind error
  view, and Close emits update:visible. Includes minimal ElTable /
  ElTableColumn stubs that respect Element Plus's scoped-slot
  contract because the shared ElementPlusStubs do not cover those.

Coverage post-patch:
  file-preview.js ………… 100% / 100% / 100% / 100%
  FilePreviewDialog.vue … 100% lines / ~81% branches / 100% funcs
  parquet.js ……………… 100% lines / ~78% branches  (gaps are
                         nullish-coalesce fallbacks on fields that
                         pyarrow always populates in real output)
  safetensors.js ……… ~98% lines / ~95% branches  (one guard on
                         Number.isFinite of a u64, unreachable
                         by construction)

Total frontend test count: 132 → 172 (+40 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this change, whenever a fallback source returned a non-retryable
4xx (typically 401 for a gated HuggingFace repo), ``try_fallback_resolve``
called ``should_retry_source`` which correctly said "do not retry"
but then returned ``None`` — dropping the status code, the upstream
body, and any chance a later source might serve the same artifact.
``with_repo_fallback`` interpreted ``None`` as a fallback miss and
re-raised the original local 404 (``RepoNotFound``), so the browser
saw a misleading "repository not found" for a file whose repo it had
just successfully listed via the fallback tree endpoint.

Reproduced live against ``animetimm/mobilenetv3_large_150d.dbv4-full``
(a gated model) while developing PR#28: tree browsing works, but
download + client-side safetensors preview both died with an unhelpful
404.

### Backend

* ``try_fallback_resolve`` now records every non-success source probe
  (HEAD or GET) as a ``build_fallback_attempt`` dict and keeps iterating.
  A 401 on source 1 no longer short-circuits the chain; a downstream
  mirror that does not gate the same artifact (or that simply has a
  file the first source lacks) still gets a chance to serve the
  request. ``httpx.TimeoutException`` and other transport exceptions
  are recorded the same way instead of being swallowed in an
  ``except``.
* ``build_aggregate_failure_response`` (new, in ``fallback/utils.py``)
  combines per-source attempts into one ``JSONResponse``:
    - HTTP status priority: ``401 > 403 > 404 > 502``. The rationale
      is user-actionability — an auth failure is the most specific
      next step ("attach a token"), 403 is next, a real "not found"
      after that, and 5xx / timeout / network collapse to 502 Bad
      Gateway.
    - ``X-Error-Code`` aligns with ``huggingface_hub.utils._http.
      hf_raise_for_status``: 401 → ``GatedRepo``, all-404 →
      ``EntryNotFound``. 403 and 502 get no specific code (HF's
      generic path is correct there). This is deliberate: inventing
      new codes would downgrade ``hf_hub_download`` on a gated repo
      to ``RepositoryNotFoundError`` and lose the ``GatedRepoError``
      exception users actually catch.
    - Body keeps an HF-style ``{error, detail}`` shape plus a
      ``sources[]`` array listing every attempt with ``{name, url,
      status|null, category, message}``, so a curl / CLI user can see
      the full timeline at a glance.
* ``should_retry_source`` is no longer used for flow control (the
  loop now records every failure regardless). Kept as-is to avoid
  touching its other callers.

### Frontend

* ``SafetensorsFetchError`` carries ``errorCode`` / ``sources`` /
  ``detail`` parsed from the aggregated body + ``X-Error-*`` headers.
  ``fromResponse()`` does a defensive JSON parse so plain-text 4xx
  bodies (e.g. raw HF "Access restricted ...") still produce a
  reasonable message.
* ``FilePreviewDialog`` now renders four remediation-specific error
  states based on the classification: ``gated`` ("Attach an access
  token ..."), ``forbidden``, ``not-found``, ``upstream-unavailable``,
  and a fallback generic. The existing CORS hint still fires for the
  ``TypeError`` / "Failed to fetch" failure mode. When the error
  carries a ``sources[]`` list, it collapses into a ``<details>``
  table for diagnostic use.

### Tests

Backend (``test/kohakuhub/api/fallback/``):

* 6 new cases in ``test_operations.py`` pinning the new contract:
  single 401 → aggregate 401/``GatedRepo``; single 403 → 403; 401 +
  next-source success (continues past 401!); mixed 401/404/503/timeout
  across four sources (priority yields 401); all-404 → 404/
  ``EntryNotFound``; all-5xx/timeout → 502 with no code.
* Two pre-existing tests that pinned the old short-circuit behavior
  (``stops_on_non_retryable_status_and_handles_timeouts``,
  ``returns_none_after_generic_source_failures``) have been rewritten
  to match the new aggregating semantics.
* 3 new HF-interop tests in ``test_hf_hub_interop.py`` feed the
  aggregated response into real ``huggingface_hub.utils._http.
  hf_raise_for_status`` and assert: aggregate 401 raises
  ``GatedRepoError``, aggregate 404 raises ``EntryNotFoundError``,
  aggregate 502 raises generic ``HfHubHTTPError`` (and *not* any of
  the subclasses that would signal a wrong remediation). These pin
  the HF-alignment invariant — a future refactor that drops
  ``X-Error-Code`` will fail these tests immediately.

Frontend (``test/kohaku-hub-ui/``):

* 2 new cases in ``test_safetensors.test.js`` for
  ``SafetensorsFetchError.fromResponse``: aggregated JSON body
  populates ``errorCode`` / ``sources`` / ``detail`` from the body +
  ``X-Error-Code`` header; non-JSON bodies fall back to
  ``X-Error-Message`` header without crashing.
* 3 new cases in ``test_file_preview_dialog.test.js`` rendering the
  three new error states (``gated``, ``not-found``,
  ``upstream-unavailable``) and asserting that gated-copy specifically
  never cross-contaminates with the CORS copy.

### Verified live

Against the real gated repo through the patched local backend:

* ``HEAD /models/animetimm/mobilenetv3_large_150d.dbv4-full/resolve/
  main/model.safetensors`` now returns 401 + ``X-Error-Code:
  GatedRepo`` + aggregated JSON body (was: 404 + ``RepoNotFound``).
* ``huggingface_hub.HfApi.parse_safetensors_file_metadata`` against
  the patched endpoint raises ``GatedRepoError`` with the upstream
  message echoed into the exception text — the HF-native behavior
  users already handle.

### Not in scope

Other fallback operations (``try_fallback_tree``, ``try_fallback_info``,
``try_fallback_paths_info``, avatars, user-repos aggregation) still use
the old "return None on any failure" pattern. They rarely surface
gated-vs-missing confusion in practice (the tree endpoint is public
for HF gated repos, which is why browsing worked while downloading
broke), so this change stays focused on resolve. Extending the
aggregation contract to the other operations is a reasonable
follow-up.

Builds on top of PR#28 (pure-client safetensors / parquet preview).
Target that branch so the UI error copy + the backend classification
land together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
codecov/patch flagged 93.16% vs 94.12% target on the previous commit
— the ~1% miss was the defensive branches in `build_fallback_attempt`
and `build_aggregate_failure_response` that the integration tests in
`test_operations.py` don't naturally exercise (odd status codes, the
contract-violation fallback, the oversize-message cap, empty attempts
list).

Add 7 targeted unit tests in `test/kohakuhub/api/fallback/test_utils.py`:

- `_categorize_status` maps each of 401 / 403 / 404 / 410 / 503 to the
  right category.
- An unclassifiable status (418) lands in `CATEGORY_OTHER` so the
  aggregate shape stays consistent.
- Contract-violation call (no response / timeout / network supplied)
  returns a safe default rather than throwing.
- Oversized upstream bodies get truncated under the per-attempt cap.
- `timeout=...` and `network=...` paths record the right categories
  and surface the exception message.
- Empty-attempts aggregate is a well-formed 502 rather than a KeyError
  or a misleading 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
codecov/patch was still red at 93.16% after the previous unit-test
fill. The residual miss was a defensive four-line `return None` after
the source loop that cannot be reached by construction: the
`if not sources: return None` short-circuit at the top of the function
already handles the empty-sources case, and every branch of the loop
body either returns on success or `attempts.append(...)`s before
`continue`, so the attempts list is always non-empty once the loop
exits.

Replace the `if attempts:` guard + dead fallback with a direct call to
`build_aggregate_failure_response(attempts)` and a comment recording
the invariant, so a future change that adds an early `continue`
without recording an attempt will fail its unit tests (the aggregated
response will then claim "no fallback sources" with an empty sources
list) rather than silently returning None again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3 new HF-compat tests (pattern_D_all_401/404/5xx_raises_...)
feed `httpx.Response` objects directly into
`huggingface_hub.utils._http.hf_raise_for_status`. That function
migrated from `requests` to `httpx` in hf_hub 1.0:

- hf_hub >= 1.0: catches `httpx.HTTPStatusError`, reads X-Error-Code,
  raises the right subclass (GatedRepoError / EntryNotFoundError / ...)
- hf_hub <  1.0: catches `requests.HTTPError` only; our
  httpx.HTTPStatusError escapes the classification path entirely and
  the test sees a bare `httpx.HTTPStatusError` instead of the HF
  subclass it is pinning.

The matrix cells that failed (python_version, hf_hub):
  (3.10, 0.20.3) / (3.10, 0.30.2) / (3.10, 0.36.2) /
  (3.11, 0.20.3) / (3.11, 0.30.2) / (3.11, 0.36.2) / ...

Guard the 3 tests with a version-based `pytest.skipif` matching the
existing pattern for `HAS_XET` higher up in the file (Xet was also a
1.0+ feature). The aggregate-response contract is still upheld on
every cell — it's just that the HF-classification assertion is only
observable on cells where hf_hub speaks httpx. The other 7 tests
in this file remain matrix-wide green, including the pre-existing
pattern A/B/C that use a different assertion surface that works
across the version range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes the `skipif(not _HF_SUPPORTS_HTTPX_CLASSIFICATION)` that
the previous commit added. Skipping the three pattern-D tests on
older cells left the HF-compat invariant unverified on exactly the
matrix cells that speak a different Response type — precisely the
cells most likely to silently drift.

The classification logic in `hf_raise_for_status` (X-Error-Code →
GatedRepoError / EntryNotFoundError / generic HfHubHTTPError) is
identical across 0.20.x / 0.30.x / 0.36.x / 1.0.x / 1.6.x / latest.
Only the Response TYPE it accepts differs (requests before 1.0, httpx
after). Build the right type per installed version and assert the
classification everywhere.

Changes:

- New helper `_to_hf_response(resp, request_url=...)` which rehydrates
  the FastAPI aggregate response as either `httpx.Response` (hf_hub
  >= 1.0) or `requests.Response` (< 1.0). Only the three pattern-D
  tests use it; the existing A/B/C tests keep using `_to_httpx`
  because they only read `.status_code` / `.headers`, which exist on
  both shapes.

- New helper `_hf_error(name)` for looking up exception classes
  across the different public-module layouts:
    0.20.x        → huggingface_hub.utils.<Name>
    0.30.x+       → huggingface_hub.errors.<Name> (re-exported from utils)
  `huggingface_hub` (top-level) does NOT export the error classes in
  any version, which is why the original `from huggingface_hub.errors
  import ...` failed on 0.20.3 with `ModuleNotFoundError`.

- Swap `from huggingface_hub.utils._http import hf_raise_for_status`
  (private path, restructured in 1.0) to `from huggingface_hub.utils
  import hf_raise_for_status` (stable public re-export, present in
  every version).

Locally verified against:
  hf_hub 0.20.3 (oldest matrix cell, requests branch) — 10/10 pass
  hf_hub 1.11.0 (latest, httpx branch)                 — 10/10 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small coverage-quality cleanups that together bring `safetensors.js`
to 100% / 100% / 100% / 100% (statements / branches / functions /
lines), which in turn closes the 0.02% project-coverage regression
codecov was flagging.

- Remove the unreachable `!Number.isFinite(headerLen) || headerLen < 0`
  guard in `parseSafetensorsMetadata`. `DataView.getBigUint64` always
  returns a non-negative BigInt in `[0, 2^64 - 1]`, which `Number()`
  always maps to a finite non-negative float. No real input can hit
  that branch; the `MAX_HEADER_LENGTH` check below is the only upper
  bound that matters.

- Cover three previously untested branches in `SafetensorsFetchError.
  fromResponse` and the tensor-parse fallback:
    * tensor entry without `data_offsets` → parser defaults to [0, 0]
      instead of crashing.
    * aggregate error body with no `X-Error-Code` header → errorCode
      still populated from `body.error`.
    * aggregate error body whose `sources` is not an array → the
      parser refuses to attach a non-list and leaves `sources=null`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix: classify fallback upstream errors (gated/not-found/unavailable) instead of collapsing to 404
Restructure the safetensors preview modal around the tensor-name
hierarchy instead of a flat tensor list.

utils/safetensors.js
- New `formatHumanReadable(n)` helper: compact "1.23B" / "126.85K"
  formatting with trailing zeros stripped, used by the Total
  parameters pill and the Parameters column when in human mode.
- New `buildTensorTree(tensors, total)` helper: splits each dotted
  name into a nested tree, rolls `parameters` / `byteSize` /
  `dtypeLabel` / `leafCount` up the tree, then
    * collapses single-child chains. `a.b.c.d.weight` with no
      siblings along the way becomes one row named
      "a.b.c.d.weight" instead of five nested rows the user has to
      click through. A fork only keeps its parent split.
    * attaches `percent` relative to the IMMEDIATE parent's
      parameter count (roots use the file total). So a leaf shows
      its share of the block it lives in ("this head is 22% of
      this layer"), not its share of the whole model (always tiny).
- Also drop the dead `!isFinite(headerLen) || <0` guard: a
  `DataView.getBigUint64` return always sits in [0, 2^64-1] which
  `Number()` always maps to a finite non-negative float, so the
  branch was unreachable and the `MAX_HEADER_LENGTH` check above
  is the only upper bound that matters.

FilePreviewDialog.vue
- Tensors table is now a tree table (`row-key="path"`, `tree-props:
  { children: "children" }`), default-expanded.
- Name column shows the collapsed segment for each row plus a
  subtle `(N tensors)` hint on parents. Leaves have their `children`
  stripped so Element Plus doesn't render a useless expand chevron
  for them.
- `dtype`, `Shape`, `Parameters`, `Bytes` columns: no more
  `min-width`; they size to content.
- `% of parent` column: a thin bar behind the numeric label, with
  `rgba(100, 116, 139, <alpha>)` where alpha ramps continuously
  from 0.08 at 0% to 0.72 at 100%. Low-mass tensors stay visibly
  present; high-mass ones read as dark gray. Same RGB on both
  themes (alpha composites against the table background either
  way).
- Total parameters pill: renders human-readable by default, clicks
  to toggle to the exact `toLocaleString()` form. Icon sits
  BEFORE the label so it isn't clipped off the right edge of an
  auto-sized cell.
- Parameters column header is clickable too — same `totalParamsFormat`
  ref drives both the pill and every cell in the column, so they
  flip together. Cells themselves are not click targets per the UX
  brief (column-wide toggle, not per-cell).
- Bytes cells + Tensor bytes pill now carry a `title` tooltip with
  the exact count (`"79,691,776 bytes"`) so a user eyeballing
  "76.00 MB" can still copy the precise byte count.

Tests
- 5 new cases for `buildTensorTree`: chain-collapse of deep
  single-chains, parent-relative percent math, 0-total guard,
  malformed-entry tolerance, missing shape/data_offsets defaults.
- 3 new cases for `formatHumanReadable`: K/M/B/T buckets, trailing-
  zero trim, negative + NaN + null guards.
- 2 new dialog cases: tree rendering (parent + leaves + percent),
  Total parameters toggle flipping both the pill title and text.
- Updated the existing ElTable stub to walk `tree-props.children`
  so leaf rows also show up in text-based assertions (real
  ElTable hides collapsed subtrees, but the stub doesn't model
  chevron state so rendering everything is the right fidelity
  for text queries).
- Hardened the `summarizeSafetensors` mock to compute real totals
  from the supplied header so tests that need distinguishable
  human-vs-exact numbers aren't boxed into the default `total: 4`
  fixed value.

Frontend suite: 162 → 189 passing (27 new).

Verified live against the seeded sharded model
`open-media-lab/vision-language-assistant-3b/model-00001-of-00003.safetensors`:
tree renders "language_model (2 tensors)" parent with two
chain-collapsed leaves (`embed_tokens.weight`,
`layers.0.mlp.down_proj.weight`), percents read 78.95% / 21.05%
summing to 100% of the parent, gradient visibly differentiates
them, bytes cell tooltip shows `"79,691,776 bytes"`, and toggling
either the pill or the column header flips both to
`"39,845,888"` in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat: pure-client safetensors/parquet metadata preview (#27)
* feat: uniform classified-error UX across file-facing surfaces

Extends the aggregated-fallback contract that landed in #29 beyond
the safetensors/parquet preview dialog, so blob pages, edit pages,
RepoViewer (README + tree), and the blob-page download button all
surface the same HF-compatible classification (gated / not-found /
upstream-unavailable / cors / generic) — no more misleading
"File Not Found" when the real cause was an upstream gate.

## Shared plumbing (new)

- ``src/kohaku-hub-ui/src/utils/http-errors.js``. Central
  classification with two entry points:
    * ``classifyResponse(response)`` — reads a ``fetch`` Response's
      ``X-Error-Code`` / ``X-Error-Message`` headers plus the
      aggregate ``{error, detail, sources[]}`` body, returns
      ``{kind, status, errorCode, detail, sources}``.
    * ``classifyError(err)`` — does the same for an AxiosError /
      ``TypeError`` / SafetensorsFetchError / bare Error. Preserves
      the CORS heuristic (TypeError "Failed to fetch") previously
      inlined in ``FilePreviewDialog.isLikelyCorsError``.
    * Per-kind default copy via ``defaultCopyFor(kind)``, used by
      ErrorState below and the blob download toast.
- ``src/kohaku-hub-ui/src/components/common/ErrorState.vue``. Shared
  panel rendering the classified state — mode=``full-page`` for
  routes, ``inline-panel`` for in-place (README slot, tree slot).
  Renders the ``sources[]`` disclosure from the aggregate body when
  present. Optional ``retry`` callback + ``actions`` slot for
  caller-specific CTAs (e.g. "Open account settings" for gated).
- Axios response interceptor in ``utils/api.js`` populates
  ``err.classification`` so callers can ``catch (err) { render(err.classification) }``
  without re-parsing X-Error-Code every time.

## Consumers (re-wired)

- ``FilePreviewDialog.vue`` — error-state template replaced by
  ``<ErrorState>``; removed the inline ``errorKind`` / ``errorCorsLikely``
  / sources-table logic (300+ lines → 6). Preview-scoped title
  override keeps "File header not found" for per-file misses.
- ``pages/.../blob/[branch]/[...file].vue`` —
    * ``<ErrorState mode="full-page">`` replaces the generic
      "File Not Found" block.
    * A retrieved 404 with no ``X-Error-Code`` still retries (commit
      might be processing), but a 404 **with** a specific code
      short-circuits out of the retry loop.
    * ``downloadFile()`` now probes ``GET``-with-``Range: bytes=0-0``
      before handing the browser off via ``window.open``. On failure
      classifies the response and surfaces an ``ElMessage`` toast
      with a kind-specific CTA — no more raw JSON blob dumped into
      a new browser tab on a gated repo (the original symptom).
- ``pages/.../edit/[branch]/[...file].vue`` — same ``ErrorState``
  treatment; additionally disables the Commit dialog when the file
  never loaded, preventing accidental "overwrite with empty content".
- ``components/repo/RepoViewer.vue`` —
    * ``loadReadme`` now classifies non-2xx responses and shows
      ``<ErrorState mode="inline-panel">`` in the README slot instead
      of the misleading "No README.md found" placeholder.
    * ``loadFileTree`` no longer silently swallows axios failures
      into an empty grid; classification drives an inline ErrorState
      in the file-list slot.

## Backend: extend aggregate contract to tree / info / paths-info

Mirrors #29's ``try_fallback_resolve`` rewrite for the three
remaining fallback operations in
``src/kohakuhub/api/fallback/operations.py``. Every non-2xx source
probe becomes an attempt, the loop keeps going, and if nothing
succeeds the function returns ``build_aggregate_failure_response(attempts)``
instead of ``None``.

One nuance encoded into the helper: ``build_aggregate_failure_response``
now takes ``scope="repo"`` (for info / tree) or ``scope="file"`` (for
resolve / paths-info). Repo-scope all-404 maps to
``RepoNotFound`` → ``RepositoryNotFoundError`` on the hf_hub client;
file-scope all-404 stays ``EntryNotFound`` → ``EntryNotFoundError``.
That matches HF's own semantics — the whole repo missing vs. one
file in an existing repo missing.

``should_retry_source`` is no longer referenced from any of the
four fallback operations; it's kept intact for now to avoid
touching callers outside this diff.

## Tests

**Frontend** (``226 → 263`` total tests after this change):

- ``test/kohaku-hub-ui/utils/test_http_errors.test.js`` — 19 cases
  across ``classifyResponse`` / ``classifyError`` / ``defaultCopyFor``:
  null input, explicit ``X-Error-Code=GatedRepo``, bare 401 without
  code, 403, 404/410 plus EntryNotFound/RepoNotFound/RevisionNotFound,
  5xx, aggregated body, body.error fallback, non-array sources,
  non-JSON bodies, axios-shaped responses, AxiosError in interceptor,
  SafetensorsFetchError, TypeError/Failed-to-fetch as cors, AbortError
  swallowed, plain Error fallback, distinct copy per kind.
- ``test/kohaku-hub-ui/components/test_error_state.test.js`` — 18
  cases: per-kind copy, title/hint overrides, detail row visibility,
  default Retry button wiring, Retry absent when no callback,
  sources[] disclosure, disclosure omitted on empty/invalid,
  custom actions slot replaces default, full-page vs inline mode,
  per-kind icon class.
- Dialog test (``test_file_preview_dialog.test.js``) — updated to
  the new shared copy (e.g. "Request failed" ⇌ old "Preview failed",
  "attach a Hugging Face token" ⇌ old "Attach an access token"),
  all 18 original cases stay green.

**Backend** (``91 → 94`` fallback tests):

- ``test_operations.py`` — the pre-existing ``cached_and_failure_paths``
  test was updated to assert the new aggregated-response shape
  (``info`` + ``tree`` → repo-level codes; ``paths-info`` → per-file).
- ``test_hf_hub_interop.py`` — 3 new pattern-E tests driving the
  aggregate response through ``hf_raise_for_status``:
    * tree all-404 → ``RepositoryNotFoundError`` (not EntryNotFoundError)
    * info any-401 → ``GatedRepoError`` via ``scope="repo"``
    * paths-info all-404 → ``EntryNotFoundError``
  Runs across the full matrix (hf_hub 0.20.3 → latest) via the
  version-aware ``_to_hf_response`` helper from #29.

## Live verification

Against ``animetimm/mobilenetv3_large_150d.dbv4-full`` (gated HF
model reached via the fallback source):

- Blob page → Download button triggers
  ``"This repository is gated. Attach a Hugging Face token in
  Settings → Tokens, then retry."`` toast instead of opening a
  new tab with the raw aggregated-JSON blob.
- Preview dialog on the same file still works (unchanged UX,
  internally now uses the shared ErrorState).
- README on that repo happens to be public on HF — the card tab
  correctly renders it via fallback without triggering the error
  path. When README itself is gated (tested synthetically in unit
  tests), ErrorState displaces the "No README.md found" placeholder.

## Non-goals (not in this PR)

- No global 404 / 500 route page. Per-page inline errors carry
  more context and match HF's own UX.
- No auto-retry on 401/403; they are not transient.
- No new ``X-Error-Code`` emission on endpoints that don't already
  carry it — backend is already uniform across surfaces that matter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: extract probeUrlAndClassify + downloadToastFor helpers and cover them

codecov/patch flagged 92.51% vs 94.21% target on the initial push of
PR #30 — the gap was the new download-button probe logic and the
partial-source-dict fallbacks in ErrorState, both of which lived
inline in their consumers with no direct tests.

Extract the two cleanly-isolable bits into `utils/http-errors.js`:

- `probeUrlAndClassify(url, fetchImpl?)` — the `GET Range: bytes=0-0`
  pre-flight the blob-page Download button used inline. Now a
  testable async helper that returns `{ok, classification}` and
  accepts an injectable `fetchImpl` for the tests to drive.
- `downloadToastFor(classification)` + `DOWNLOAD_TOAST_HINTS` — the
  per-kind toast copy the blob page inlined in a dict literal. Now
  a frozen enum-keyed map so adding a new `ERROR_KIND` forces a
  matching toast entry rather than silently falling through to the
  generic hint.

Blob page `downloadFile()` reduced from ~40 lines of inline probe +
switch to 8 lines calling the two helpers — identical behavior,
fully unit-tested.

Tests (7 new cases in `test_http_errors.test.js` + 1 in
`test_error_state.test.js`):

- `probeUrlAndClassify` ok-path, 401 → gated, transport error →
  cors, and `globalThis.fetch` default when no impl is injected.
- `downloadToastFor` returns a distinct non-empty message for every
  ERROR_KIND and falls back to GENERIC on null / unknown kind.
- `classifyError` detail-falls-to-message branch (err.detail
  missing, err.message populated).
- `ErrorState.vue` renders partial source dicts (missing name /
  status / url / category / message) via the `??` / `typeof`
  fallbacks — regression guard so a future backend that adds / drops
  source fields can't render "undefined" in the disclosure table.

Coverage after this commit:
  http-errors.js   100% lines / 100% funcs (89.71% branches; the
                   only gaps are defensive pre-existing branches
                   outside the new helpers)
  ErrorState.vue   100% / 100% / 100% / 100%

Frontend suite: 226 → 235 passing (+9 new cases this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover RepoViewer tree + README error classification branches

codecov/patch still flagged 92.62% vs 94.21% after the
probeUrlAndClassify / downloadToastFor extraction — the residual
gap was the tree-error and README-error catch paths in
RepoViewer.vue that land on the aggregated 401 / 404 / 502
responses from #29 + #30's backend. Those paths had no direct
tests; the existing test_repo_viewer_paths.test.js only drove
the happy-path + an empty-tree case.

Add two cases to the existing mount harness:

- Tree root fetch returns the aggregated-gated body (401 +
  X-Error-Code=GatedRepo + sources[]). Assertions: "Authentication
  required" copy renders via the shared ErrorState, the
  "Fallback sources tried (N)" disclosure shows the source count,
  and the misleading empty-tree "No files" copy is absent.
- README resolve returns the same aggregated-gated body. Assertion:
  the card tab shows ErrorState where the README would otherwise
  sit, and the misleading "No README.md found" placeholder is
  NOT shown (that placeholder implies the repo has no README when
  the reality is just that we couldn't read it).

Both tests use the existing MSW-driven `installBaseHandlers` +
`mountViewer` scaffold from the file; no new test infrastructure.

Frontend suite: 235 → 237 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Classify bare 401 (no GatedRepo code) as RepoNotFound, not gated

HuggingFace returns 401 WITHOUT X-Error-Code for non-existent repos
as anti-enumeration; huggingface_hub's hf_raise_for_status maps that
exact shape to RepositoryNotFoundError (see the "401 is misleading"
comment in utils/_http.py). The fallback aggregator and the SPA were
both treating bare 401 as gated, so a typo'd repo URL showed the
"log in with a token" affordance instead of "not found".

This aligns with hf_hub's own heuristic in two places:

- `_categorize_status(status, error_code, ...)` now inspects the
  upstream X-Error-Code header. 401 with `GatedRepo` stays `auth`;
  bare 401 becomes `not-found`.
- `build_aggregate_failure_response` escalates bare-401 attempts to
  `X-Error-Code: RepoNotFound` even on `scope="file"` ops, so the
  client raises `RepositoryNotFoundError` instead of the wrong
  `EntryNotFoundError`. `build_fallback_attempt` persists the
  upstream error code on each attempt for that decision.

Frontend `classifyStatus` follows suit: bare 401 → NOT_FOUND.

Test updates:
- Existing tests that queued bare 401 expecting the auth path now
  pass `X-Error-Code: GatedRepo` on the mock response to stay on
  the genuine-gated path.
- New unit tests cover _categorize_status's header branch,
  build_fallback_attempt's error_code persistence, and
  build_aggregate_failure_response's repo-miss escalation.
- New Pattern F hf_hub interop tests assert that bare 401 on
  resolve and info raises `RepositoryNotFoundError`, matching
  hf_hub's native behavior against a missing repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover should_retry_source default 3xx fallthrough

Adds a 302 case to the parametrize so the final `return False`
branch in `should_retry_source` is exercised. Brings
fallback/utils.py to 100% line coverage and nudges the
codecov/project ratio back above the previous baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: narugo1992 <narugo1992@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#32)

Each row in the repo browser's file list was a plain <div> with
`@click="router.push(...)"`, so the browser never offered the native
"Open in New Tab" context menu — right-click just showed the generic
page menu, middle-click did nothing, and Cmd/Ctrl-click fell through
to the page's click handler (losing its new-tab semantics).

Converts each row to a "stretched link" layout: the outer <div> stays
for layout, and a RouterLink overlay (absolute inset-0) covers the
whole row as a real <a href>. Interactive children (the metadata
preview button, the per-row commit RouterLink) sit above the overlay
with z-20 + @click.stop so they continue to work unchanged.

With that change:
- Right-click on a file/dir row → browser offers "Open link in new tab"
- Middle-click → new tab
- Cmd/Ctrl-click → new tab
- Plain left-click → Vue Router handles SPA navigation as before

Tests:
- Flipped two existing `router.push` assertions to check the overlay
  `href` instead, because navigation is now a property of the anchor
  rather than a side effect of a click handler.
- Added a new test that renders a mixed tree (directory + file + file
  with lastCommit) and asserts every row has an <a> with the right
  href + aria-label, and that clicking the preview button does NOT
  trigger row navigation.
- Live-verified in Chromium against the dev SPA with route-mocked
  API responses: every row is an <a href>, the stretched anchor
  fills the full row box (842×48px), and dir rows link to /tree/
  while file rows link to /blob/.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant