Skip to content

feat: pure-client safetensors/parquet metadata preview (#27)#28

Merged
narugo1992 merged 12 commits intodev/narugo1992from
feat/client-side-preview
Apr 24, 2026
Merged

feat: pure-client safetensors/parquet metadata preview (#27)#28
narugo1992 merged 12 commits intodev/narugo1992from
feat/client-side-preview

Conversation

@narugo1992
Copy link
Copy Markdown

Summary

Implements #27 v4: pure-client HF-compatible metadata preview for .safetensors and .parquet files. The SPA gets a small chart-icon next to eligible rows (HF-style); clicking opens a modal that streams phase-by-phase status ("Fetching header Range (100 KB)…" → "Parsing header JSON…" → done) while it pulls ≤ 100 KB per file over HTTP Range. No backend preview code, no LRU, no precomputation, no new DB state, no worker.

What changed

Backend — CORS plumbing only (zero preview logic):

  • src/kohakuhub/main.py:104-126 — add expose_headers list so browsers can read Content-Range, X-Linked-*, X-Repo-Commit, ETag, Location off the final 206 response. Pinned by test_resolve_get_302_exposes_cors_headers_for_browser_preview.
  • docker-compose.example.yml + scripts/dev/up_infra.sh — wire MINIO_API_CORS_ALLOW_ORIGIN (default * for dev, configurable via DEV_MINIO_CORS_ALLOW_ORIGIN). Hard prerequisite for the feature; documented in docs/development/local-dev.md.

Frontend:

  • src/kohaku-hub-ui/src/utils/safetensors.js — pure-JS parser mirroring huggingface_hub.parse_safetensors_file_metadata byte-for-byte. Two Range reads at most (speculative 100 KB first, fat-header fallback).
  • src/kohaku-hub-ui/src/utils/parquet.js — thin wrapper over hyparquet (~40 KB gzipped). CORS-safe request init so cookies never leak onto presigned URLs.
  • src/kohaku-hub-ui/src/components/repo/preview/FilePreviewDialog.vue — ElDialog with loading spinner + per-phase text, dtype breakdown + tensor table (safetensors), schema + row-group table (parquet), graceful CORS-failure placeholder.
  • src/kohaku-hub-ui/src/components/repo/RepoViewer.vuei-carbon-chart-line-data icon next to .safetensors / .parquet rows; opens the modal with the right /resolve/{rev}/{path} URL. @click.stop keeps directory-navigation clicks unaffected.

Tests + fixtures (all offline, per AGENTS.md §5.2):

  • test/kohakuhub/api/test_files.py::test_resolve_get_302_exposes_cors_headers_for_browser_preview — regression for the CORS contract.
  • test/kohaku-hub-ui/utils/test_safetensors.test.js (6 cases) — real-fixture parse, dtype summary, phase ordering, fat-header two-read fallback, oversized-header guard, non-206 error.
  • test/kohaku-hub-ui/utils/test_parquet.test.js (2 cases) — footer parse + phases.
  • test/kohaku-hub-ui/fixtures/previews/{tiny.safetensors, tiny.parquet} — 2 KB + 2.7 KB fixtures produced via the real safetensors.numpy.save + pyarrow.parquet.write_table (so wire format is byte-identical to what HF emits). Regeneratable via scripts/dev/generate_preview_test_fixtures.py.

Seed (real HF-sourced fixtures):

  • scripts/dev/seed_demo_data.py — adds two RemoteAsset entries (hf-tiny-random-bert.safetensors ≈ 520 KB, hf-no-robots-test.parquet ≈ 570 KB), pinned by sha256, wired into visible paths under open-media-lab/vision-language-assistant-3b/fixtures/ and open-media-lab/multimodal-benchmark-suite/fixtures/. SEED_VERSIONlocal-dev-demo-v4 so reseed triggers.

Verification

  • All 8 new frontend unit tests + 2 new backend tests green.

  • Full frontend suite: 132 / 132 passing. Full backend test_files.py: 7 / 7 passing.

  • End-to-end against dev stack: parser output on the seeded real-HF file matches huggingface_hub.HfApi(endpoint=<kohakuhub>).parse_safetensors_file_metadata(...) byte-for-byte:

    JS parser (in-browser) huggingface_hub (reference)
    tensors 100 100
    total params 126,851 126,851
    dtypes {I64: 512, F32: 126,339} {'I64': 512, 'F32': 126339}
    metadata {format: pt, cls.predictions.decoder.bias: cls.predictions.bias} identical
  • Real-browser Playwright drive: file list renders the preview icon; safetensors modal shows the right dtype/param/metadata breakdown; parquet modal shows schema + row-group data for the no_robots test split. Screenshots reviewed — HF-style affordance placement confirmed.

  • CORS chain smoke test (backend OPTIONS + MinIO OPTIONS + final 206 with Access-Control-Expose-Headers: … Content-Range, …) all green against the Vite origin (http://127.0.0.1:5173).

Explicitly NOT in this PR (per #27 v4 scope)

  • Repo-level aggregate badges (?expand[]=safetensors, ?expand[]=gguf, "total model size" pill) — if the UI ever wants this, the SPA should concurrently fetch shard headers itself, no backend work.
  • datasets-server/* endpoints.
  • Any backend-side Range reader, parser, or cache for preview.
  • Any new DB column / worker / precomputation / Redis / MongoDB.

Test plan

  • make test-backend restricted to test_files.py passes (7 / 7).
  • Frontend npm test full suite passes (132 / 132) with coverage.
  • Manual: ./scripts/dev/up_infra.sh + ./scripts/dev/run_backend.sh + npm run dev; open a repo with a seeded safetensors or parquet file; click the preview icon; confirm modal renders real data without download of the full file (verifiable via MinIO access logs).
  • Compat: huggingface_hub.HfApi(endpoint=<kohakuhub>).parse_safetensors_file_metadata(repo_id=<seeded>, filename=fixtures/hf-tiny-random-bert.safetensors) output equals the in-browser parser output on the same file.
  • CI green on upstream runner.

Closes — partial scope for #27 (file-level preview only; repo-level aggregate explicitly deferred per v4 rewrite).

🤖 Generated with Claude Code

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>
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

❌ Patch coverage is 95.08891% with 58 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.21%. Comparing base (2e62bcb) to head (9764a0e).
⚠️ Report is 13 commits behind head on dev/narugo1992.

Files with missing lines Patch % Lines
.../src/components/repo/preview/FilePreviewDialog.vue 94.36% 25 Missing and 4 partials ⚠️
...c/kohaku-hub-ui/src/components/repo/RepoViewer.vue 61.36% 17 Missing ⚠️
src/kohaku-hub-ui/src/utils/parquet.js 93.39% 5 Missing and 2 partials ⚠️
src/kohaku-hub-ui/src/utils/safetensors.js 98.74% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@                Coverage Diff                 @@
##           dev/narugo1992      #28      +/-   ##
==================================================
+ Coverage           94.14%   94.21%   +0.06%     
==================================================
  Files                 115      119       +4     
  Lines               14820    15994    +1174     
  Branches              761      995     +234     
==================================================
+ Hits                13953    15069    +1116     
- Misses                865      914      +49     
- Partials                2       11       +9     
Flag Coverage Δ
backend 95.48% <100.00%> (+0.03%) ⬆️
frontend 91.70% <94.79%> (+0.64%) ⬆️
frontend-admin 99.30% <ø> (ø)
hf0.20.3 95.48% <100.00%> (+0.03%) ⬆️
hf0.30.2 95.48% <100.00%> (+0.03%) ⬆️
hf0.36.2 95.48% <100.00%> (+0.03%) ⬆️
hf1.0.1 95.48% <100.00%> (+0.03%) ⬆️
hf1.6.0 95.48% <100.00%> (+0.03%) ⬆️
hflatest 95.48% <100.00%> (+0.03%) ⬆️
py3.10 95.48% <100.00%> (+0.03%) ⬆️
py3.11 95.46% <100.00%> (+0.03%) ⬆️
py3.12 95.46% <100.00%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

narugo1992 and others added 3 commits April 24, 2026 14:02
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>
@narugo1992
Copy link
Copy Markdown
Author

Follow-up: exhaustive coverage for the new preview code

Pushed b8f7a73 — pulls the preview-icon glue out of RepoViewer.vue into src/utils/file-preview.js so it can be unit-tested directly, then fills in the surface of the new feature with +40 tests (frontend: 132 → 172):

New file / helper Coverage
src/utils/file-preview.js (new) 100 % lines / 100 % branches / 100 % funcs
src/components/repo/preview/FilePreviewDialog.vue 100 % lines / ≈ 81 % branches / 100 % funcs
src/utils/safetensors.js ≈ 98 % lines / ≈ 95 % branches / 100 % funcs
src/utils/parquet.js 100 % lines / ≈ 78 % branches / 100 % funcs

What the new tests hit, concretely:

  • file-preview.js (12 cases): extension recognition, case-insensitive matching, directory rejection, defensive bad-input, URL percent-encoding per path segment (so weird file name (1).parquet survives), branch-name encoding (so refs/convert/parquet survives), and guard-rail errors when fields are missing.
  • safetensors.js (9 new cases): truncated < 8-byte response, invalid UTF-8 header, null / non-object JSON root, truncated fat-header second read, second-range HTTP error, error-class surface (instance + .status), defensive skipping of malformed tensor entries, shape-less tensors, unknown-dtype byte-size accounting.
  • parquet.js (5 new cases): normalizeCount exhaustively (null / undefined / bigint-in-range / bigint > MAX_SAFE_INTEGER / plain number) and summarizeParquetSchema fallbacks for empty trees + shapeless children. normalizeCount is now exported so it can be exercised directly — real parquet footers always yield BigInt so the plain-number branch is unreachable via the happy-path fixture.
  • FilePreviewDialog.vue (14 cases): loading-phase progress copy for both kinds (including fat-header and done), safetensors and parquet ready-state render, error + Retry, likely-CORS placeholder + its negative case, silent AbortError swallowing, AbortController plumbing on visibility change, re-request on URL change, unsupported-kind error, Close emits update:visible.

The last unreachable branches are genuinely dead by construction: Number.isFinite on a u64 result (always finite), and optional-chain fallbacks on parquet fields that pyarrow always populates. I experimented with mocking hyparquet to exercise those synthetic branches but vi.mock + MSW collide in jsdom and make the tests fragile — happy to revisit if CI exposes a different preference.

Also carries two small housekeeping commits from earlier rounds:

  • 6b42694fix(seed): share SEED_VERSION between seed + verify scripts. The verify script was hardcoding local-dev-demo-v3 and would false-alarm against the v4 bump. Extracted to scripts/dev/seed_shared.py.
  • a3bcf70chore: pydantic v2 regex→pattern on two Query params. Silences the Pydantic 2.x DeprecationWarning noise on two routers (/api/models list + admin top-repos).

Full branch now has 4 commits on top of main. All 172 / 172 frontend tests pass locally; full backend test_files.py 7 / 7 pass. Waiting on CI.

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>
narugo1992 and others added 6 commits April 24, 2026 15:27
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
@narugo1992 narugo1992 changed the base branch from main to dev/narugo1992 April 24, 2026 08:24
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>
@narugo1992 narugo1992 merged commit b4032cc into dev/narugo1992 Apr 24, 2026
22 checks passed
@narugo1992 narugo1992 deleted the feat/client-side-preview branch April 24, 2026 09:24
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