nspasojevic commited on
Commit
a0d61cb
·
verified ·
1 Parent(s): c6c9533

Indexing main 955a553a: usdsearch fd61a332 packages, package downloads, usdsearch validator

Browse files

Files deploy-space(-gpu) stages from NVIDIA-dev/simready-central-indexing-internal main 955a553a (PRs #39, #41, #43); same run 20261005-ee37df6; Space secrets unchanged.

Files changed (48) hide show
  1. Dockerfile +14 -6
  2. THIRD_PARTY_NOTICE.md +5 -6
  3. docker/hf-space/deeptag-validate/app.py +0 -836
  4. docker/hf-space/hf-storage-api/SOURCE.txt +2 -2
  5. docker/hf-space/hf-storage-api/constraints-hf-storage-api.txt +3 -3
  6. docker/hf-space/hf-storage-api/hf_storage_api/addressing.py +15 -1
  7. docker/hf-space/hf-storage-api/hf_storage_api/catalog/loader.py +137 -43
  8. docker/hf-space/hf-storage-api/hf_storage_api/catalog/model.py +13 -1
  9. docker/hf-space/hf-storage-api/hf_storage_api/catalog/urllist.py +3 -12
  10. docker/hf-space/hf-storage-api/hf_storage_api/catalog_sync/publish.py +3 -2
  11. docker/hf-space/hf-storage-api/hf_storage_api/hf/client.py +15 -2
  12. docker/hf-space/hf-storage-api/hf_storage_api/services/capabilities.py +6 -5
  13. docker/hf-space/hf-storage-api/hf_storage_api/services/filefolder.py +7 -6
  14. docker/hf-space/hf-storage-api/hf_storage_api/services/fileobject.py +33 -15
  15. docker/hf-space/hf-storage-api/hf_storage_api/services/metadata_service.py +7 -1
  16. docker/hf-space/hf-storage-api/hf_storage_api/settings.py +9 -0
  17. docker/hf-space/hf-storage-api/hf_storage_api/store.py +83 -32
  18. docker/hf-space/nginx.conf.template +13 -10
  19. docker/hf-space/siglip2-model/SOURCE.txt +3 -3
  20. docker/hf-space/siglip2-model/pyproject.toml +1 -0
  21. docker/hf-space/siglip2-model/uv.lock +5 -3
  22. docker/hf-space/supervisord-gpu.conf +16 -20
  23. docker/hf-space/supervisord.conf +34 -24
  24. docker/hf-space/wheels/SOURCE.txt +2 -2
  25. docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl +0 -0
  26. docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl +0 -0
  27. docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl +0 -0
  28. docker/hf-space/wheels/constraints.txt +2 -2
  29. docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl +2 -2
  30. docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl +0 -0
  31. docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl +2 -2
  32. docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl +2 -2
  33. docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl +0 -0
  34. licenses/fsspec-2026.4.0.txt +0 -29
  35. licenses/{fsspec-2026.3.0.txt → fsspec-2026.9.0.txt} +0 -0
  36. licenses/{langgraph-sdk-0.4.2.txt → langgraph-sdk-0.4.6.txt} +0 -0
  37. licenses/{os-redis-server-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt} +0 -0
  38. licenses/{os-redis-tools-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt} +0 -0
  39. pipeline/bake.py +19 -8
  40. pipeline/config.py +17 -0
  41. pipeline/package_download/__init__.py +19 -0
  42. pipeline/package_download/__main__.py +35 -0
  43. pipeline/package_download/app.py +417 -0
  44. pipeline/package_download/bundle.py +468 -0
  45. pipeline/package_download/catalog.py +131 -0
  46. pipeline/package_download/settings.py +102 -0
  47. pipeline/package_download/source.py +115 -0
  48. pipeline/package_download/telemetry.py +68 -0
Dockerfile CHANGED
@@ -284,15 +284,14 @@ COPY docker/hf-space/supervisord-gpu.conf /etc/supervisor/conf.d/hf-space.conf
284
 
285
  COPY docker/hf-space/nginx.conf.template /etc/nginx/hf-space.conf.template
286
 
287
- # SimReady Central's validator (supervisord's [program:deeptag-validate], nginx's
288
- # /vlm_validate/search_result): one vendored file, run by the app venv, which already
289
- # has its fastapi, uvicorn, httpx, pydantic and pillow (deepsearch-api's own).
290
- COPY docker/hf-space/deeptag-validate/app.py /app/deeptag-validate/app.py
291
-
292
  COPY services/explorer/dist /usr/share/nginx/explorer
293
 
294
 
295
  COPY --chown=1000:1000 pipeline /app/hf-space-pipeline/pipeline
 
 
 
 
296
 
297
  ARG ARTIFACT_S3_URI
298
  ARG ARTIFACT_S3_REGION=us-east-2
@@ -314,7 +313,8 @@ ARG ES_NAME
314
  ARG S3_STORAGE_BUCKET_NAME
315
  ARG S3_STORAGE_REGION_NAME=us-west-2
316
  ARG STORAGE_BACKEND_TYPE=s3
317
- # DOWNLOAD_ENABLED / HF_STORAGE_API_TOKEN / SUBMIT_CONTENT_URL / STORAGE_API_* / DOWNLOAD_* — see
 
318
  # docker/Dockerfile.hf-space's own copies of these comments for the full
319
  # explanation.
320
  # USDSEARCH_URL_MAPPING_MODE/_RULES — hf_space_build_args() derives them from
@@ -342,6 +342,10 @@ ENV HF_STORAGE_API_TOKEN= \
342
  ASSERT_ADMIN_USER=false \
343
  USDSEARCH_VISION_VALIDATION_ENABLED=false \
344
  USDSEARCH_LLM_PARSING_ENABLED=false \
 
 
 
 
345
  USDSEARCH_LLM_PARSING_FIELDS_FILEPATH=/app/search_fields.yaml \
346
  # Business telemetry — same as docker/Dockerfile.hf-space, see its comment.
347
  OTEL_SDK_DISABLED=false \
@@ -362,6 +366,10 @@ ENV HF_STORAGE_API_TOKEN= \
362
  DOWNLOAD_MAX_CONCURRENT=2 \
363
  DOWNLOAD_QUEUE_TIMEOUT=120 \
364
  DOWNLOAD_MAX_BUNDLE_BYTES=2000000000 \
 
 
 
 
365
  STORAGE_API_THUMBNAIL_METADATA_FIELDS="[\"hf:thumbnail_url\"]" \
366
  SIGLIP2_TRITON_SERVER_URL=localhost:8001 \
367
  TRITON_SERVER_URL=localhost:8001 \
 
284
 
285
  COPY docker/hf-space/nginx.conf.template /etc/nginx/hf-space.conf.template
286
 
 
 
 
 
 
287
  COPY services/explorer/dist /usr/share/nginx/explorer
288
 
289
 
290
  COPY --chown=1000:1000 pipeline /app/hf-space-pipeline/pipeline
291
+ # package-download (supervisord's program of that name) runs from this copy on the
292
+ # venv deepsearch-api already brought (fastapi, uvicorn, grpcio, the storage_api
293
+ # stubs, search_utils): fail the build, not the Space, if that ever stops importing.
294
+ RUN cd /app/hf-space-pipeline && env -u PYTHONPATH /app/.venv/bin/python -c "import pipeline.package_download.app, pipeline.package_download.source, search_utils.url_mapping"
295
 
296
  ARG ARTIFACT_S3_URI
297
  ARG ARTIFACT_S3_REGION=us-east-2
 
313
  ARG S3_STORAGE_BUCKET_NAME
314
  ARG S3_STORAGE_REGION_NAME=us-west-2
315
  ARG STORAGE_BACKEND_TYPE=s3
316
+ # DOWNLOAD_ENABLED / HF_STORAGE_API_TOKEN / SUBMIT_CONTENT_URL / STORAGE_API_* / DOWNLOAD_* /
317
+ # PACKAGE_DOWNLOAD_* — see
318
  # docker/Dockerfile.hf-space's own copies of these comments for the full
319
  # explanation.
320
  # USDSEARCH_URL_MAPPING_MODE/_RULES — hf_space_build_args() derives them from
 
342
  ASSERT_ADMIN_USER=false \
343
  USDSEARCH_VISION_VALIDATION_ENABLED=false \
344
  USDSEARCH_LLM_PARSING_ENABLED=false \
345
+ # Validation limits are per deepsearch-api worker: 8 checks in flight and a 125k-verdict cache in
346
+ # each of the 4 workers (DEEPSEARCH_API_WORKERS below), 32 and 500k in all as on the cpu image.
347
+ USDSEARCH_VISION_VALIDATION_MAX_CONCURRENT=8 \
348
+ USDSEARCH_VISION_VALIDATION_CACHE_SIZE=125000 \
349
  USDSEARCH_LLM_PARSING_FIELDS_FILEPATH=/app/search_fields.yaml \
350
  # Business telemetry — same as docker/Dockerfile.hf-space, see its comment.
351
  OTEL_SDK_DISABLED=false \
 
366
  DOWNLOAD_MAX_CONCURRENT=2 \
367
  DOWNLOAD_QUEUE_TIMEOUT=120 \
368
  DOWNLOAD_MAX_BUNDLE_BYTES=2000000000 \
369
+ PACKAGE_DOWNLOAD_PORT=8020 \
370
+ PACKAGE_DOWNLOAD_MAX_CONCURRENT=4 \
371
+ PACKAGE_DOWNLOAD_QUEUE_TIMEOUT=120 \
372
+ PACKAGE_DOWNLOAD_MAX_BUNDLE_BYTES=2000000000 \
373
  STORAGE_API_THUMBNAIL_METADATA_FIELDS="[\"hf:thumbnail_url\"]" \
374
  SIGLIP2_TRITON_SERVER_URL=localhost:8001 \
375
  TRITON_SERVER_URL=localhost:8001 \
THIRD_PARTY_NOTICE.md CHANGED
@@ -31,7 +31,7 @@ the Hugging Face datasets the assets are downloaded from).
31
  Generated by `scripts/generate_third_party_notice.sh` in the internal
32
  source repository (simready-central-indexing-internal); do not hand-edit.
33
 
34
- **153** unique `(package, version)` entries.
35
 
36
  | Package | Version | License | Homepage | Sources | License text |
37
  | --- | --- | --- | --- | --- | --- |
@@ -71,8 +71,7 @@ source repository (simready-central-indexing-internal); do not hand-edit.
71
  | `filelock` | 3.29.0 | MIT | <https://github.com/tox-dev/py-filelock> | app-venv | [`licenses/filelock-3.29.0.txt`](licenses/filelock-3.29.0.txt) |
72
  | `fire` | 0.7.1 | Apache-2.0 | <https://github.com/google/python-fire> | app-venv | [`licenses/fire-0.7.1.txt`](licenses/fire-0.7.1.txt) |
73
  | `frozenlist` | 1.8.0 | Apache-2.0 | <https://github.com/aio-libs/frozenlist> | app-venv | [`licenses/frozenlist-1.8.0.txt`](licenses/frozenlist-1.8.0.txt) |
74
- | `fsspec` | 2026.3.0 | BSD-3-Clause | <https://github.com/fsspec/filesystem_spec> | app-venv | [`licenses/fsspec-2026.3.0.txt`](licenses/fsspec-2026.3.0.txt) |
75
- | `fsspec` | 2026.4.0 | BSD-3-Clause | <https://github.com/fsspec/filesystem_spec> | siglip2-triton | [`licenses/fsspec-2026.4.0.txt`](licenses/fsspec-2026.4.0.txt) |
76
  | `googleapis-common-protos` | 1.74.0 | Apache 2.0 | <https://github.com/googleapis/google-cloud-python/tree/main/packages/googleapis-common-protos> | app-venv | [`licenses/googleapis-common-protos-1.74.0.txt`](licenses/googleapis-common-protos-1.74.0.txt) |
77
  | `greenlet` | 3.4.0 | MIT AND PSF-2.0 | <https://greenlet.readthedocs.io> | app-venv | [`licenses/greenlet-3.4.0.txt`](licenses/greenlet-3.4.0.txt) |
78
  | `grpcio` | 1.80.0 | Apache-2.0 | <https://grpc.io> | app-venv | [`licenses/grpcio-1.80.0.txt`](licenses/grpcio-1.80.0.txt) |
@@ -103,7 +102,7 @@ source repository (simready-central-indexing-internal); do not hand-edit.
103
  | `langgraph` | 1.2.11 | MIT | <https://docs.langchain.com/oss/python/langgraph/overview> | app-venv | [`licenses/langgraph-1.2.11.txt`](licenses/langgraph-1.2.11.txt) |
104
  | `langgraph-checkpoint` | 4.1.1 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint> | app-venv | [`licenses/langgraph-checkpoint-4.1.1.txt`](licenses/langgraph-checkpoint-4.1.1.txt) |
105
  | `langgraph-prebuilt` | 1.1.0 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt> | app-venv | [`licenses/langgraph-prebuilt-1.1.0.txt`](licenses/langgraph-prebuilt-1.1.0.txt) |
106
- | `langgraph-sdk` | 0.4.2 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py> | app-venv | [`licenses/langgraph-sdk-0.4.2.txt`](licenses/langgraph-sdk-0.4.2.txt) |
107
  | `langsmith` | 0.8.18 | MIT | <https://smith.langchain.com/> | app-venv | [`licenses/langsmith-0.8.18.txt`](licenses/langsmith-0.8.18.txt) |
108
  | `markdown-it-py` | 4.0.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | app-venv | [`licenses/markdown-it-py-4.0.0.txt`](licenses/markdown-it-py-4.0.0.txt) |
109
  | `markdown-it-py` | 4.2.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | siglip2-triton | [`licenses/markdown-it-py-4.2.0.txt`](licenses/markdown-it-py-4.2.0.txt) |
@@ -233,6 +232,6 @@ inferred from each package's `/usr/share/doc/<pkg>/copyright`.
233
  | `python3.12-venv` | 3.12.3-1ubuntu0.17 | Python-2.0 | <https://packages.ubuntu.com/search?keywords=python3.12-venv> | hf-space-gpu | [`licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt`](licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt) |
234
  | `python3-pip-whl` | 24.0+dfsg-1ubuntu1.3 | Expat, Apache-2.0, MPL-2, LGPL-2.1+, BSD-3, Python, Apache-2.0 OR BSD-2, ISC, BSD-2 | <https://packages.ubuntu.com/search?keywords=python3-pip-whl> | hf-space-gpu | [`licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt`](licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt) |
235
  | `python3-setuptools-whl` | 68.1.2-2ubuntu1.2 | Apache-2.0 | <https://packages.ubuntu.com/search?keywords=python3-setuptools-whl> | hf-space-gpu | [`licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt`](licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt) |
236
- | `redis-server` | 5:7.0.15-1ubuntu0.24.04.4 | BSD-3-clause, BSD-2-clause, MIT | <https://packages.ubuntu.com/search?keywords=redis-server> | hf-space-gpu | [`licenses/os-redis-server-5-7.0.15-1ubuntu0.24.04.4.txt`](licenses/os-redis-server-5-7.0.15-1ubuntu0.24.04.4.txt) |
237
- | `redis-tools` | 5:7.0.15-1ubuntu0.24.04.4 | BSD-3-clause, BSD-2-clause, MIT | <https://packages.ubuntu.com/search?keywords=redis-tools> | hf-space-gpu | [`licenses/os-redis-tools-5-7.0.15-1ubuntu0.24.04.4.txt`](licenses/os-redis-tools-5-7.0.15-1ubuntu0.24.04.4.txt) |
238
  | `supervisor` | 4.2.5-1ubuntu0.1 | BSD-derived, RDFLIB, GPL-2+, Medusa, BSD-Derived | <https://packages.ubuntu.com/search?keywords=supervisor> | hf-space-gpu | [`licenses/os-supervisor-4.2.5-1ubuntu0.1.txt`](licenses/os-supervisor-4.2.5-1ubuntu0.1.txt) |
 
31
  Generated by `scripts/generate_third_party_notice.sh` in the internal
32
  source repository (simready-central-indexing-internal); do not hand-edit.
33
 
34
+ **152** unique `(package, version)` entries.
35
 
36
  | Package | Version | License | Homepage | Sources | License text |
37
  | --- | --- | --- | --- | --- | --- |
 
71
  | `filelock` | 3.29.0 | MIT | <https://github.com/tox-dev/py-filelock> | app-venv | [`licenses/filelock-3.29.0.txt`](licenses/filelock-3.29.0.txt) |
72
  | `fire` | 0.7.1 | Apache-2.0 | <https://github.com/google/python-fire> | app-venv | [`licenses/fire-0.7.1.txt`](licenses/fire-0.7.1.txt) |
73
  | `frozenlist` | 1.8.0 | Apache-2.0 | <https://github.com/aio-libs/frozenlist> | app-venv | [`licenses/frozenlist-1.8.0.txt`](licenses/frozenlist-1.8.0.txt) |
74
+ | `fsspec` | 2026.9.0 | BSD-3-Clause | <https://github.com/fsspec/filesystem_spec> | app-venv, siglip2-triton | [`licenses/fsspec-2026.9.0.txt`](licenses/fsspec-2026.9.0.txt) |
 
75
  | `googleapis-common-protos` | 1.74.0 | Apache 2.0 | <https://github.com/googleapis/google-cloud-python/tree/main/packages/googleapis-common-protos> | app-venv | [`licenses/googleapis-common-protos-1.74.0.txt`](licenses/googleapis-common-protos-1.74.0.txt) |
76
  | `greenlet` | 3.4.0 | MIT AND PSF-2.0 | <https://greenlet.readthedocs.io> | app-venv | [`licenses/greenlet-3.4.0.txt`](licenses/greenlet-3.4.0.txt) |
77
  | `grpcio` | 1.80.0 | Apache-2.0 | <https://grpc.io> | app-venv | [`licenses/grpcio-1.80.0.txt`](licenses/grpcio-1.80.0.txt) |
 
102
  | `langgraph` | 1.2.11 | MIT | <https://docs.langchain.com/oss/python/langgraph/overview> | app-venv | [`licenses/langgraph-1.2.11.txt`](licenses/langgraph-1.2.11.txt) |
103
  | `langgraph-checkpoint` | 4.1.1 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint> | app-venv | [`licenses/langgraph-checkpoint-4.1.1.txt`](licenses/langgraph-checkpoint-4.1.1.txt) |
104
  | `langgraph-prebuilt` | 1.1.0 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/prebuilt> | app-venv | [`licenses/langgraph-prebuilt-1.1.0.txt`](licenses/langgraph-prebuilt-1.1.0.txt) |
105
+ | `langgraph-sdk` | 0.4.6 | MIT | <https://github.com/langchain-ai/langgraph/tree/main/libs/sdk-py> | app-venv | [`licenses/langgraph-sdk-0.4.6.txt`](licenses/langgraph-sdk-0.4.6.txt) |
106
  | `langsmith` | 0.8.18 | MIT | <https://smith.langchain.com/> | app-venv | [`licenses/langsmith-0.8.18.txt`](licenses/langsmith-0.8.18.txt) |
107
  | `markdown-it-py` | 4.0.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | app-venv | [`licenses/markdown-it-py-4.0.0.txt`](licenses/markdown-it-py-4.0.0.txt) |
108
  | `markdown-it-py` | 4.2.0 | MIT License | <https://github.com/executablebooks/markdown-it-py> | siglip2-triton | [`licenses/markdown-it-py-4.2.0.txt`](licenses/markdown-it-py-4.2.0.txt) |
 
232
  | `python3.12-venv` | 3.12.3-1ubuntu0.17 | Python-2.0 | <https://packages.ubuntu.com/search?keywords=python3.12-venv> | hf-space-gpu | [`licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt`](licenses/os-python3.12-venv-3.12.3-1ubuntu0.17.txt) |
233
  | `python3-pip-whl` | 24.0+dfsg-1ubuntu1.3 | Expat, Apache-2.0, MPL-2, LGPL-2.1+, BSD-3, Python, Apache-2.0 OR BSD-2, ISC, BSD-2 | <https://packages.ubuntu.com/search?keywords=python3-pip-whl> | hf-space-gpu | [`licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt`](licenses/os-python3-pip-whl-24.0-dfsg-1ubuntu1.3.txt) |
234
  | `python3-setuptools-whl` | 68.1.2-2ubuntu1.2 | Apache-2.0 | <https://packages.ubuntu.com/search?keywords=python3-setuptools-whl> | hf-space-gpu | [`licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt`](licenses/os-python3-setuptools-whl-68.1.2-2ubuntu1.2.txt) |
235
+ | `redis-server` | 5:7.0.15-1ubuntu0.24.04.5 | BSD-3-clause, BSD-2-clause, MIT | <https://packages.ubuntu.com/search?keywords=redis-server> | hf-space-gpu | [`licenses/os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt`](licenses/os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt) |
236
+ | `redis-tools` | 5:7.0.15-1ubuntu0.24.04.5 | BSD-3-clause, BSD-2-clause, MIT | <https://packages.ubuntu.com/search?keywords=redis-tools> | hf-space-gpu | [`licenses/os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt`](licenses/os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt) |
237
  | `supervisor` | 4.2.5-1ubuntu0.1 | BSD-derived, RDFLIB, GPL-2+, Medusa, BSD-Derived | <https://packages.ubuntu.com/search?keywords=supervisor> | hf-space-gpu | [`licenses/os-supervisor-4.2.5-1ubuntu0.1.txt`](licenses/os-supervisor-4.2.5-1ubuntu0.1.txt) |
docker/hf-space/deeptag-validate/app.py DELETED
@@ -1,836 +0,0 @@
1
- # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
- # SPDX-License-Identifier: Apache-2.0
3
- """
4
- deeptag-validate — VLM relevance-validation shim.
5
-
6
- Two routes, one scorer:
7
-
8
- - `POST /vlm_validate/search_result` — the original per-image contract: the
9
- browser uploads one base64 thumbnail per call (~100 requests per search).
10
- - `POST /vlm_validate/batch_stream` — the streamed batch contract (task #27):
11
- ONE request per result page carrying only ids + image references; the shim
12
- fetches each thumbnail ITSELF through the gateway's `/images` (LAN hop,
13
- shared 7-day nginx cache the browsers already warmed, index-scoped by the
14
- request's `index` slug) and streams verdicts back as NDJSON lines the
15
- moment each lands — shim-cache hits first, then misses dispatched in
16
- REQUEST (retrieval) order. Client disconnect cancels every queued engine
17
- call (the whole point of the per-request task set — an abandoned query
18
- must not strand work on the shared engine).
19
-
20
- Bridges the Explorer's `/vlm_validate/*` contract to the self-hosted
21
- unified vlm-parser-reranker engine (Qwen3.6-35B-A3B-FP8 on one RTX PRO 6000, served alias
22
- "reranker" — the same
23
- engine also serves query parsing under the alias "parser"). The engine is used
24
- as a relevance scorer via the first-token logprob trick: it reads the verdict
25
- distribution from one forward pass (enable_thinking=false; Qwen thinks by
26
- default, so a missed flag silently corrupts verdicts), ~20x faster than
27
- generating a verdict. We return the SAME shape and scale as usdsearch's own
28
- validator (llm_client's log-prob method), minus the `reasoning` the UI no
29
- longer shows:
30
-
31
- {"is_match": bool, "confidence": float, "similarity_score": float, "model": str}
32
-
33
- ONE verdict method on every route, the Qwen Yes/No log-prob verdict: a Yes/No
34
- prompt (JUDGE_PROMPT for text queries, JUDGE_IMAGE_PROMPT for an image alone,
35
- JUDGE_IMAGE_TEXT_PROMPT for an image with words), P(Yes) read from the
36
- first-token log-probs (position scan, Yes/No mass renormalized), is_match =
37
- P(Yes) > 0.5, confidence = P(Yes) in [0,1] and similarity_score = P(Yes) x 100
38
- in [0,100] — usdsearch's scale, so the Explorer reads every validator's score
39
- the same way (2026-10-06: the shim sent P(Yes) in [0,1] until then, and the
40
- Explorer's per-value guess showed a usdsearch reject at P(Yes) 0.0076 as 76 %).
41
- With JUDGE_ASSET_CONTEXT on (2026-10-07), a words-only check also reads the
42
- candidate's file path and the index's caption, between the query and the
43
- question (_select_prompt); image checks never do.
44
- is_match coincides with the 50 % badge boundary the Explorer hardcodes (a
45
- false is_match renders the orange "VLM rejected, likely false positive" chip,
46
- and searchOutcome.js's WEAK state means "no hit at or above 50%"). This is the
47
- verdict the 2026-09-14/15 evaluations measured
48
- (docs/decisions/2026-09-search-quality.md). The graded 0/1/2 method and the
49
- Gemma-4 engine it targeted were evaluated on 2026-09-15 and rejected (user
50
- decision).
51
-
52
- Validator-contract semantics (the authoritative engine + client contracts
53
- live in INTEGRATION.md of https://gitlab-master.nvidia.com/omniverse/deeptag/parser-reranker):
54
- - ABSTAIN (Yes/No probability mass below threshold at every position) → retry
55
- the call once; a persistent abstain is returned as is_match=true with
56
- abstain=true and confidence 0.0 — the candidate KEEPS its retrieval-order
57
- place, unscored. An abstain must never be treated as "No": rejecting on an
58
- unreliable read silently deletes good results.
59
- - Transient failure (connect error / engine 5xx) → back off and retry once,
60
- then fail-soft: 503/502, which the Explorer reads as "VLM unavailable": it
61
- shows the VLM-unavailable banner and stops validating (verdicts already
62
- collected keep their badges), leaving search intact. Scores are never
63
- fabricated.
64
-
65
- Throughput: the serving owner's operational contract caps validation fan-out
66
- at 24 in-flight requests (engine-side 42.9 verdicts/s at that fan-out,
67
- measured on the earlier L40S engine; end-to-end the shim sustains less because it also fetches the
68
- thumbnails; more in-flight degrades p95 and adds nothing — the engine also
69
- serves query parsing), so this shim is tuned
70
- client-side:
71
- - ONE pooled keep-alive HTTP client (lifespan-managed) — no per-call TCP
72
- handshake tax (~an RTT each on ~100 calls per search);
73
- - images reach the engine at most VALIDATE_MAX_EDGE (448) px on the longest
74
- edge (2026-10-07): Isaac 6.2 stores every SimReady thumbnail at 1024x1024,
75
- 1,078 prompt tokens per verdict at that size against 250 at 448 (robots stay
76
- 256/512 px; anything at or under the cap passes through untouched). A JPEG
77
- decodes at a reduced DCT scale first (draft), so the resize costs ~3 ms of
78
- CPU per 1024-px image. Measured in docs/decisions/2026-09-search-quality.md:
79
- 65 vs 38 verdicts/s at saturation, 97 % of Yes/No verdicts unchanged;
80
- - an asyncio.Semaphore caps in-flight reranker calls at the contract's
81
- fan-out ceiling, converting bursts into queueing at the sweet spot;
82
- - max_tokens 2: Qwen3.6 emits the verdict at position 0 (no wrapper tokens —
83
- verified by probe); the position SCAN below is kept anyway, per the
84
- integration contract, so a future wrapper-emitting model still extracts.
85
-
86
- Environment:
87
- RERANKER_BASE_URL OpenAI-compatible engine base URL
88
- (default http://simready-central-prod-cluster.nvidia.com/vlm-parser-reranker/v1)
89
- RERANKER_MODEL served model name (default "reranker")
90
- RERANKER_API_KEY bearer key for an engine that wants one (default
91
- none: the cluster engine is open). Sent on the
92
- engine's chat calls only, never on an image fetch.
93
- RERANKER_NAME display name in responses (default "Qwen3.6-35B-A3B-FP8";
94
- the `model` field in every verdict)
95
- RERANKER_COVER_MIN Yes/No verdict-token mass below this → abstain
96
- (default 0.2).
97
- RERANKER_TIMEOUT_S per-call timeout (default 30, per the serving
98
- contract; a 50-hit page drained in ≈1.2 s, 1.46 s p95, on the earlier L40S)
99
- RERANKER_MAX_TOKENS decode steps per call (default 2; raise if a
100
- future model buries the verdict deeper)
101
- RERANKER_MAX_CONCURRENCY in-flight cap toward the engine (default 48 for
102
- the RTX PRO 6000 engine with async scheduling and
103
- two API servers: 3 clients x c48 = 120.9 verdicts/s
104
- at p95 1.41 s; 24 was the L40-class ceiling)
105
- VALIDATE_MAX_EDGE downscale images whose longest edge exceeds this
106
- many pixels before sending (default 448 since
107
- 2026-10-07; 4096 = no resize from 2026-09-21,
108
- 512 before 2026-09-15). The verdict cache key
109
- includes it: changing it starts the shim's cache
110
- cold.
111
- RERANKER_BATCH_CONCURRENCY per-batch fan-out cap INSIDE the global
112
- semaphore (default 24) — two users' concurrent
113
- batches interleave at the engine instead of one
114
- queueing whole behind the other.
115
- SHIM_GATEWAY_BASE_URL the gateway's public listener, for shim-side
116
- thumbnail fetches (default http://gateway:8080).
117
- IMAGES_TIMEOUT_S per-thumbnail fetch timeout on the dedicated
118
- image client (default 5; slow thumbnails must
119
- not starve the engine connection pool).
120
- BATCH_MAX_ITEMS batch size ceiling (default 200; larger → 413).
121
- BATCH_HEARTBEAT_S blank-line heartbeat cadence on an otherwise
122
- quiet stream. COUPLED CONSTANT — the gateway's
123
- batch_stream proxy_read_timeout (60 s, hard-
124
- coded) and the Explorer's stream stall guard
125
- (45 s) are both sized off the default 10:
126
- raising it past ~40 trips the client guard,
127
- past 60 the gateway severs healthy streams.
128
- VALIDATE_CACHE_SIZE verdict-cache entries (default 4000000, ~0.9 GB
129
- at 240 bytes an entry; two entries per verdict;
130
- 0 disables; 20000 until 2026-10-07).
131
- Verdicts are deterministic (temperature 0), so one
132
- (model, max-edge, query, image-bytes) tuple always
133
- scores the same — caching makes repeat waves
134
- (re-searches, pagination bounces, other USERS
135
- asking the same query) free. Complements the
136
- Explorer's per-browser verdict cache: this one is
137
- shared across every client of this shim. Keyed on
138
- the prompt too (a different query never
139
- coalesces).
140
-
141
- Tests (tests/test_scoring.py: the scorer, the payload builder, the verdict
142
- contract and the cache keys as pure functions; tests/test_routes.py: both
143
- routes through fastapi.testclient with the engine call and the image fetch
144
- monkeypatched, i.e. the prompt selection, the two cache paths, the NDJSON line
145
- protocol and the per-item vs fatal error split; no engine, no network), from
146
- the repo root:
147
-
148
- cd deeptag-validate && pip install -r requirements-dev.txt && python -m pytest -q
149
- # or, with no install step:
150
- uv run --with pytest --with fastapi --with httpx --with pillow pytest deeptag-validate/tests
151
-
152
- CI runs the first form as the `test-deeptag-validate` job, which gates
153
- `build-deeptag-validate` exactly like the simready-api suite gates its image.
154
- """
155
- import asyncio
156
- import base64
157
- import hashlib
158
- import io
159
- import json
160
- import logging
161
- import math
162
- import os
163
- import re
164
- from collections import OrderedDict
165
- from contextlib import asynccontextmanager
166
- from typing import List, Optional
167
-
168
- import httpx
169
- from fastapi import FastAPI, HTTPException
170
- from fastapi.responses import StreamingResponse
171
- from PIL import Image
172
- from pydantic import BaseModel
173
-
174
- logger = logging.getLogger("deeptag-validate")
175
-
176
- # The unified vlm-parser-reranker engine's "reranker" alias, OpenAI-compatible. Defaults to
177
- # the cluster engine's ingress; override per deployment. It does not gate on an API key.
178
- RERANKER_BASE_URL = os.environ.get("RERANKER_BASE_URL", "http://simready-central-prod-cluster.nvidia.com/vlm-parser-reranker/v1").rstrip("/")
179
- RERANKER_MODEL = os.environ.get("RERANKER_MODEL", "reranker")
180
- # A hosted engine (the HF Spaces' NVCF function / Inference Endpoint) wants a bearer key. It rides the
181
- # engine's POSTs alone: the same pooled client also fetches http image URLs a request names, and a
182
- # default header there would hand the key to any origin.
183
- RERANKER_API_KEY = os.environ.get("RERANKER_API_KEY", "").strip()
184
- _ENGINE_HEADERS = {"Authorization": f"Bearer {RERANKER_API_KEY}"} if RERANKER_API_KEY else None
185
- RERANKER_NAME = os.environ.get("RERANKER_NAME", "Qwen3.6-35B-A3B-FP8")
186
-
187
- # Yes/No verdict-token probability mass below this is unreliable → abstain
188
- # (treat as unscored, never as "No").
189
- COVER_MIN = float(os.environ.get("RERANKER_COVER_MIN", "0.2"))
190
- TIMEOUT_S = float(os.environ.get("RERANKER_TIMEOUT_S", "30"))
191
- # Qwen3.6 emits the verdict at position 0; one spare decode step is plenty and
192
- # more only burns shared engine compute (it also serves query parsing).
193
- RERANKER_MAX_TOKENS = int(os.environ.get("RERANKER_MAX_TOKENS", "2"))
194
- # The serving contract's fan-out ceiling: past 24 in-flight, p95 degrades and
195
- # throughput gains nothing.
196
- RERANKER_MAX_CONCURRENCY = int(os.environ.get("RERANKER_MAX_CONCURRENCY", "48"))
197
- # Vision-token cost scales with pixels; 448 px (longest edge) is the one view
198
- # the production Yes/No verdict was measured at — the same edge the server-side
199
- # verdict stage judges (docs/operations.md "Measured quality and the ordering
200
- # rule"). The cache key carries the edge, so changing it never re-serves a
201
- # verdict judged at another size.
202
- VALIDATE_MAX_EDGE = int(os.environ.get("VALIDATE_MAX_EDGE", "448"))
203
-
204
- # The asset's own facts in the words-only judge prompt (2026-10-07): "off" (the
205
- # 2026-09 prompt), "path" (the candidate's file path) or "path+caption" (+ the
206
- # index's one-line description). The client sends each hit's `path` and
207
- # `caption`; a hit without a path is judged without facts, and image queries
208
- # never use them (only words-only judging was evaluated). Measured in
209
- # docs/decisions/2026-09-search-quality.md.
210
- ASSET_CONTEXT_MODES = ("off", "path", "path+caption")
211
- JUDGE_ASSET_CONTEXT = os.environ.get("JUDGE_ASSET_CONTEXT", "off").strip().lower()
212
- if JUDGE_ASSET_CONTEXT not in ASSET_CONTEXT_MODES:
213
- logger.warning(
214
- "JUDGE_ASSET_CONTEXT=%r is not one of %s: judging without asset facts",
215
- JUDGE_ASSET_CONTEXT, ", ".join(ASSET_CONTEXT_MODES),
216
- )
217
- JUDGE_ASSET_CONTEXT = "off"
218
- # Prompt-length bounds for the facts: a path keeps its tail (the file and its
219
- # folders name the asset), a caption its head.
220
- ASSET_PATH_MAX = 300
221
- ASSET_CAPTION_MAX = 200
222
-
223
- # Streamed batch validation (task #27). The per-batch cap nests INSIDE the
224
- # global engine semaphore: a 50-item batch occupies at most this many of the
225
- # 24 engine slots at once, so concurrent users' batches interleave fairly.
226
- RERANKER_BATCH_CONCURRENCY = int(os.environ.get("RERANKER_BATCH_CONCURRENCY", "24"))
227
- # Shim-side thumbnail fetches go through the gateway's public listener — the
228
- # same /images route (and shared nginx cache) the browsers use, index-scoped
229
- # by the x-usdsearch-storage-backend header. NEVER a client-supplied URL: the
230
- # base is pinned here and references travel as urlencoded query params only.
231
- SHIM_GATEWAY_BASE_URL = os.environ.get("SHIM_GATEWAY_BASE_URL", "http://gateway:8080").rstrip("/")
232
- IMAGES_TIMEOUT_S = float(os.environ.get("IMAGES_TIMEOUT_S", "5"))
233
- BATCH_MAX_ITEMS = int(os.environ.get("BATCH_MAX_ITEMS", "200"))
234
- BATCH_HEARTBEAT_S = float(os.environ.get("BATCH_HEARTBEAT_S", "10"))
235
-
236
- # Cross-user verdict cache: temperature-0 verdicts are pure functions of
237
- # (model, resize edge, query, image bytes), so identical pairs — from ANY
238
- # client — re-serve without an engine call. Insertion-order eviction. An entry
239
- # costs ~240 bytes (measured on python:3.11), and each fresh verdict writes two
240
- # (its reference key and its content key), so 4,000,000 entries hold 2,000,000
241
- # verdicts in ~0.9 GB: about half a day at the capacity plan's Phase 1 load
242
- # (2026-10-07; 20000 before was ~30 s of traffic at 600 checks/s). In-process
243
- # only: every shim restart starts cold.
244
- VALIDATE_CACHE_SIZE = int(os.environ.get("VALIDATE_CACHE_SIZE", "4000000"))
245
- _verdict_cache: "OrderedDict[str, object]" = OrderedDict()
246
-
247
-
248
- def _cache_key(query: str, image_url: str, query_image_digest: str = "") -> str:
249
- """Content-keyed verdict-cache entry: the prompt, the PREPARED candidate
250
- image and, for an image query, the query image's digest (every image query
251
- shares one prompt, so without it two different query images would
252
- coalesce)."""
253
- h = hashlib.sha256()
254
- for part in (RERANKER_MODEL, str(VALIDATE_MAX_EDGE), query, image_url, query_image_digest or ""):
255
- h.update(part.encode())
256
- h.update(b"\x00")
257
- return h.hexdigest()
258
-
259
-
260
- def _ref_key(prompt: str, query_image_digest: str, index: str, ref: str) -> str:
261
- """Reference-keyed verdict-cache entry for the batch path: keyed on the
262
- IMAGE REFERENCE (image_key/asset_url + index slug) instead of prepared
263
- bytes, so a cache hit costs no thumbnail fetch at all. The content key
264
- (`_cache_key`) is still written alongside on every fresh verdict, so the
265
- two paths coalesce. The query image participates as a PRE-COMPUTED digest
266
- (image queries share one prompt and must not collide across different
267
- reference images; hashing the multi-MB data URL per item would stall the
268
- event loop; the route hashes it once)."""
269
- h = hashlib.sha256()
270
- for part in (RERANKER_MODEL, str(VALIDATE_MAX_EDGE), "ref", prompt, query_image_digest or "", index or "", ref):
271
- h.update(part.encode())
272
- h.update(b"\x00")
273
- return h.hexdigest()
274
-
275
-
276
- def _cache_get(key: str):
277
- p = _verdict_cache.get(key)
278
- if p is not None:
279
- _verdict_cache.move_to_end(key)
280
- return p
281
-
282
-
283
- def _cache_put(key: str, score) -> None:
284
- if VALIDATE_CACHE_SIZE <= 0:
285
- return
286
- _verdict_cache[key] = score
287
- _verdict_cache.move_to_end(key)
288
- while len(_verdict_cache) > VALIDATE_CACHE_SIZE:
289
- _verdict_cache.popitem(last=False)
290
-
291
- # Firm, one-word judge prompt (a weak prompt makes GLM describe the image → the
292
- # logprob trick breaks). Text goes BEFORE the image in the message content.
293
- JUDGE_PROMPT_HEAD = 'You are a visual relevance judge for a 3D-asset search. Query: "{query}". '
294
- JUDGE_QUESTION = "Does the rendered asset match the query? Answer with exactly one word: Yes or No."
295
- JUDGE_PROMPT = JUDGE_PROMPT_HEAD + JUDGE_QUESTION
296
- # The asset facts (JUDGE_ASSET_CONTEXT), between the query and the question, as
297
- # evaluated on 2026-10-07.
298
- JUDGE_PATH_FACT = 'The candidate asset\'s file is "{path}". '
299
- JUDGE_CAPTION_FACT = 'Catalog description: "{caption}". '
300
- # An image alone (find-similar): usdsearch's own image-query question
301
- # (llm_client LOGPROB_JUDGE_PROMPT_IMAGE_QUERY without its caption clause),
302
- # word for word; the query image goes first. Without a dedicated prompt an
303
- # image search would be scored against 'Query: ""' — pure noise. On 40 known
304
- # pairs (2026-10-06) it matched the shim's earlier wording: every same-object
305
- # pair Yes, every unrelated pair No.
306
- JUDGE_IMAGE_PROMPT = (
307
- "You are a visual relevance judge for a 3D-asset search. The first image is "
308
- "the query image; the remaining images are rendered views of one asset. Does "
309
- "the rendered asset match the query? Answer with exactly one word: Yes or No."
310
- )
311
- # An image WITH words: both count, the query image first. Not usdsearch's
312
- # caption clause ('… the query image and the text query is "…"; the remaining
313
- # images are rendered views of one asset'), which let the model judge the query
314
- # image itself against the words: on the same 40 pairs it passed 3-4 unrelated
315
- # candidates, this wording none (the words alone, the shim's earlier rule: 1).
316
- JUDGE_IMAGE_TEXT_PROMPT = (
317
- 'You are a visual relevance judge for a 3D-asset search. Query: "{query}", '
318
- "together with the first image, which shows what the user is looking for. "
319
- "The other image shows a candidate asset. Does the candidate asset match the "
320
- "query? Answer with exactly one word: Yes or No."
321
- )
322
- YES_TOKENS = {"yes", "y", "true"}
323
- NO_TOKENS = {"no", "n", "false"}
324
-
325
- # One pooled keep-alive client for the process (created in lifespan): validation
326
- # is ~100 calls per search against a remote engine, and a per-request client
327
- # pays a fresh TCP handshake (an RTT) on every call.
328
- _reranker_client: Optional[httpx.AsyncClient] = None
329
- # Separate, short-timeout client for shim-side thumbnail fetches (batch path):
330
- # 50 image GETs must never compete with engine calls for the reranker pool,
331
- # and one slow origin must cost 5 s, not the engine's 30.
332
- _images_client: Optional[httpx.AsyncClient] = None
333
- # In-flight cap at the engine's measured saturation point — a burst beyond it
334
- # queues here instead of doubling everyone's latency engine-side.
335
- _reranker_sem = asyncio.Semaphore(RERANKER_MAX_CONCURRENCY)
336
-
337
-
338
- @asynccontextmanager
339
- async def _lifespan(app: FastAPI):
340
- global _reranker_client, _images_client
341
- _reranker_client = httpx.AsyncClient(
342
- timeout=TIMEOUT_S,
343
- limits=httpx.Limits(max_connections=64, max_keepalive_connections=64),
344
- )
345
- _images_client = httpx.AsyncClient(
346
- # pool=None: waiting for a connection SLOT must queue, not error — a
347
- # scalar timeout also bounds pool acquisition, and N concurrent
348
- # batches × 12 workers over a finite pool would otherwise fabricate
349
- # PoolTimeout "image fetch failed" verdicts for perfectly good
350
- # thumbnails under load.
351
- timeout=httpx.Timeout(connect=IMAGES_TIMEOUT_S, read=IMAGES_TIMEOUT_S, write=IMAGES_TIMEOUT_S, pool=None),
352
- limits=httpx.Limits(max_connections=64, max_keepalive_connections=64),
353
- )
354
- try:
355
- yield
356
- finally:
357
- await _reranker_client.aclose()
358
- _reranker_client = None
359
- await _images_client.aclose()
360
- _images_client = None
361
-
362
-
363
- app = FastAPI(title="deeptag-validate", version="1.1.0", lifespan=_lifespan)
364
-
365
-
366
- class ValidateRequest(BaseModel):
367
- query_text: Optional[str] = None
368
- query_image: Optional[str] = None
369
- images: List[str] = []
370
- # The asset's facts for words-only judging (JUDGE_ASSET_CONTEXT).
371
- path: Optional[str] = None
372
- caption: Optional[str] = None
373
-
374
-
375
- def p_yes_from_logprobs(content):
376
- """Scan token positions for the one carrying Yes/No mass (scan-mode is the
377
- contract: robust to wrapper tokens some models emit before the verdict);
378
- return (p_yes, cover) or (None, 0) if uncertain."""
379
- for pos in content or []:
380
- yes = no = 0.0
381
- for entry in pos.get("top_logprobs", []) or []:
382
- token = (entry.get("token") or "").strip().lower()
383
- prob = math.exp(entry["logprob"])
384
- if token in YES_TOKENS:
385
- yes += prob
386
- elif token in NO_TOKENS:
387
- no += prob
388
- if yes + no > COVER_MIN:
389
- return yes / (yes + no), yes + no
390
- return None, 0.0
391
-
392
-
393
- def _shrink_bytes(raw: bytes, src: str, was_data_url: bool) -> str:
394
- """Sync (thread-pooled) half of image prep: decode, downscale if the longest
395
- edge exceeds VALIDATE_MAX_EDGE (LANCZOS, JPEG q90), return a data URL. An
396
- already-small data URL passes through UNTOUCHED; an already-small fetched
397
- image is re-encoded as a data URL so the engine doesn't re-fetch it."""
398
- with Image.open(io.BytesIO(raw)) as img:
399
- w, h = img.size
400
- if max(w, h) <= VALIDATE_MAX_EDGE:
401
- logger.debug("image %dx%d <= %dpx: pass-through", w, h, VALIDATE_MAX_EDGE)
402
- if was_data_url:
403
- return src
404
- fmt = (img.format or "png").lower()
405
- mime = "image/jpeg" if fmt in ("jpg", "jpeg") else f"image/{fmt}"
406
- return f"data:{mime};base64," + base64.b64encode(raw).decode()
407
- # A JPEG decodes straight at a reduced DCT scale (1/2 for a 1024-px
408
- # thumbnail; never below the cap): about half the shim CPU of a full
409
- # decode before the resize. Other formats ignore it.
410
- img.draft(None, (VALIDATE_MAX_EDGE, VALIDATE_MAX_EDGE))
411
- rgb = img.convert("RGB")
412
- rgb.thumbnail((VALIDATE_MAX_EDGE, VALIDATE_MAX_EDGE), Image.LANCZOS)
413
- buf = io.BytesIO()
414
- rgb.save(buf, format="JPEG", quality=90)
415
- logger.debug(
416
- "image resized %dx%d -> %dx%d (%d -> %d bytes)",
417
- w, h, rgb.width, rgb.height, len(raw), buf.tell(),
418
- )
419
- return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
420
-
421
-
422
- async def _prepare_image(src: str) -> str:
423
- """Downscale-and-inline an image reference before it reaches the engine.
424
- Defensive: anything unparseable is sent through untouched — a weird image
425
- must degrade to the engine's own handling, never break validation."""
426
- try:
427
- if src.startswith("data:"):
428
- _, _, b64 = src.partition(",")
429
- raw = base64.b64decode(b64)
430
- was_data_url = True
431
- elif src.startswith(("http://", "https://")):
432
- resp = await _reranker_client.get(src)
433
- resp.raise_for_status()
434
- raw = resp.content
435
- was_data_url = False
436
- else:
437
- # Bare base64: the Explorer strips the data: prefix off its query
438
- # image. Sent as is, the engine reads it as an image URL and 400s
439
- # every image-only verdict; inline it as a data URL instead.
440
- # Non-base64 strings still pass through (strict decode raises).
441
- raw = base64.b64decode(src, validate=True)
442
- was_data_url = False
443
- return await asyncio.to_thread(_shrink_bytes, raw, src, was_data_url)
444
- except Exception as exc: # noqa: BLE001 — image prep must never break validation
445
- logger.warning("image prep failed (%s: %s); sending original", type(exc).__name__, exc)
446
- return src
447
-
448
-
449
- def _judge_payload(prompt: str, image_url: str, query_image_url: str = None) -> dict:
450
- # Text before image(s) per the judge contract. For image queries the query
451
- # image precedes the candidate (the image prompts' "first image" wording
452
- # depends on this order). Plain sampling parameters only:
453
- # no structured-outputs grammar (that belonged to the retired graded
454
- # method), so an engine without a grammar backend serves this unchanged.
455
- content = [{"type": "text", "text": prompt}]
456
- if query_image_url:
457
- content.append({"type": "image_url", "image_url": {"url": query_image_url}})
458
- content.append({"type": "image_url", "image_url": {"url": image_url}})
459
- return {
460
- "model": RERANKER_MODEL,
461
- "temperature": 0,
462
- "max_tokens": RERANKER_MAX_TOKENS,
463
- "logprobs": True,
464
- "top_logprobs": 20,
465
- "chat_template_kwargs": {"enable_thinking": False},
466
- "messages": [{"role": "user", "content": content}],
467
- }
468
-
469
-
470
- async def _call_reranker(payload: dict) -> dict:
471
- # Transient-failure semantics from the validator contract: back off and
472
- # retry ONCE on connect errors / engine 5xx before failing soft. The
473
- # semaphore holds in-flight calls at the engine's saturation point.
474
- for attempt in (1, 2):
475
- try:
476
- async with _reranker_sem:
477
- resp = await _reranker_client.post(
478
- f"{RERANKER_BASE_URL}/chat/completions", json=payload, headers=_ENGINE_HEADERS
479
- )
480
- resp.raise_for_status()
481
- return resp.json()
482
- except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout):
483
- if attempt == 1:
484
- await asyncio.sleep(0.25)
485
- continue
486
- # Engine down / slow → fail-soft. The Explorer reads 503 as
487
- # "VLM unavailable": banner, validation stops; search is unaffected.
488
- raise HTTPException(status_code=503, detail="reranker unavailable")
489
- except httpx.HTTPStatusError as exc:
490
- if attempt == 1 and exc.response.status_code >= 500:
491
- await asyncio.sleep(0.25)
492
- continue
493
- raise EngineRejected(exc.response.status_code, f"reranker error: {exc}")
494
- except httpx.HTTPError as exc:
495
- raise HTTPException(status_code=502, detail=f"reranker error: {exc}")
496
-
497
-
498
- # The configuration refusal's message: fixed, never the engine's own error (it names the engine's URL).
499
- IMAGE_QUERY_REFUSED = "the validation engine refused an image query (it may take one image per prompt)"
500
-
501
-
502
- class EngineRejected(HTTPException):
503
- """The engine ANSWERED with an error status (a 502 to the client, as before; the batch route keeps
504
- treating it per item). `engine_status` lets the per-image route tell a refused request shape apart."""
505
-
506
- def __init__(self, engine_status: int, detail: str):
507
- super().__init__(status_code=502, detail=detail)
508
- self.engine_status = engine_status
509
-
510
-
511
- def _abstain_verdict() -> dict:
512
- # Persistent abstain → keep the candidate in retrieval order, unscored
513
- # (is_match=true so the Explorer does not reject it; confidence 0.0 and
514
- # abstain=true make the non-verdict explicit rather than a fabricated
515
- # score).
516
- return {"is_match": True, "confidence": 0.0, "similarity_score": 0.0, "abstain": True, "model": RERANKER_NAME}
517
-
518
-
519
- def _verdict_from_score(score: float) -> dict:
520
- """The ONE place a verdict dict is built from a cached or fresh SCORE
521
- RECORD (the P(Yes) float), for both routes and both cache paths — a
522
- boundary that lived in three copies would silently regress on cache hits.
523
- similarity_score is P(Yes) x 100 (usdsearch's scale); confidence stays
524
- P(Yes)."""
525
- return {
526
- "is_match": score > 0.5,
527
- "confidence": round(score, 4),
528
- "similarity_score": round(score * 100, 2),
529
- "model": RERANKER_NAME,
530
- }
531
-
532
-
533
- def _extract_score(data: dict):
534
- """Pull the P(Yes) score record out of one engine response, or None
535
- (abstain)."""
536
- content = ((data.get("choices") or [{}])[0].get("logprobs") or {}).get("content")
537
- p_yes, _cover = p_yes_from_logprobs(content)
538
- return p_yes
539
-
540
-
541
- def _fact_text(value, limit: int, keep_tail: bool = False) -> str:
542
- """One asset fact as prompt text: whitespace collapsed, double quotes made
543
- single (the fact sits inside a quoted span), at most `limit` characters."""
544
- text = " ".join(str(value or "").split()).replace('"', "'")
545
- if len(text) > limit:
546
- text = text[-limit:] if keep_tail else text[:limit]
547
- return text
548
-
549
-
550
- _ISAAC_ROOT = re.compile(r"/Isaac/[^/]+/Isaac/(.+)$")
551
- # A Hugging Face dataset file (the hf-* indexes, the HF Spaces): below the dataset, and below
552
- # `resolve|blob/<revision>/` where the key pins one, e.g.
553
- # hf://huggingface.co/datasets/nvidia/simready-assets/resolve/2464ca32…/fixture_island_a01/simready_usd/x.usd
554
- # → fixture_island_a01/simready_usd/x.usd.
555
- _HF_DATASET_ROOT = re.compile(
556
- r"^(?:[a-z][a-z0-9+.-]*://)?huggingface\.co/datasets/[^/]+/[^/]+/(?:(?:resolve|blob)/[^/]+/)?(.+)$", re.I
557
- )
558
-
559
-
560
- def _asset_path(path) -> str:
561
- """The path as the judge reads it: below the release-agnostic Isaac content
562
- root (`Robots/Unitree/H2/H2.usda`, as evaluated), or below a Hugging Face
563
- dataset's root, else without its leading slash."""
564
- p = str(path or "").strip()
565
- m = _ISAAC_ROOT.search(p) or _HF_DATASET_ROOT.match(p)
566
- return _fact_text(m.group(1) if m else p.lstrip("/"), ASSET_PATH_MAX, keep_tail=True)
567
-
568
-
569
- def _asset_facts(path, caption) -> str:
570
- """The words-only prompt's asset facts under JUDGE_ASSET_CONTEXT, or "":
571
- the file path, then (path+caption) the caption. No path → no facts, so
572
- only the evaluated combinations are ever sent."""
573
- if JUDGE_ASSET_CONTEXT == "off":
574
- return ""
575
- p = _asset_path(path)
576
- if not p:
577
- return ""
578
- facts = JUDGE_PATH_FACT.format(path=p)
579
- caption_text = _fact_text(caption, ASSET_CAPTION_MAX) if JUDGE_ASSET_CONTEXT == "path+caption" else ""
580
- if caption_text:
581
- facts += JUDGE_CAPTION_FACT.format(caption=caption_text)
582
- return facts
583
-
584
-
585
- def _select_prompt(query_text: str, has_query_image: bool, path=None, caption=None) -> str:
586
- """The judge prompt for one candidate, over the stripped query text: an
587
- image with words → JUDGE_IMAGE_TEXT_PROMPT, an image alone →
588
- JUDGE_IMAGE_PROMPT, words alone → JUDGE_PROMPT, with the candidate's facts
589
- before the question when JUDGE_ASSET_CONTEXT is on (a request with neither
590
- words nor image keeps the historical 'Query: ""' prompt)."""
591
- text = (query_text or "").strip()
592
- if has_query_image:
593
- return JUDGE_IMAGE_TEXT_PROMPT.format(query=text) if text else JUDGE_IMAGE_PROMPT
594
- facts = _asset_facts(path, caption)
595
- if not facts:
596
- return JUDGE_PROMPT.format(query=text)
597
- return JUDGE_PROMPT_HEAD.format(query=text) + facts + JUDGE_QUESTION
598
-
599
-
600
- def _query_image_digest(query_image: Optional[str]) -> str:
601
- """Hash the (possibly multi-MB) query image ONCE per request — the cache
602
- keys run per item, and per-item sha256 over a full data URL would stall
603
- the loop."""
604
- return hashlib.sha256(query_image.encode()).hexdigest() if query_image else ""
605
-
606
-
607
- async def _score_prepared(prompt: str, image_url: str, query_image_url: str = None):
608
- """One verdict for a PREPARED (downscaled, inlined) image. Returns
609
- (score | None, verdict_dict); score None means persistent abstain (the
610
- dict already carries abstain=true; the CALLER decides caching, and
611
- abstains are never cached). The score is the cache record (see
612
- _verdict_from_score). Raises HTTPException 502/503 when the engine is
613
- unreachable (fail-soft to the Explorer's "VLM unavailable")."""
614
- data = await _call_reranker(_judge_payload(prompt, image_url, query_image_url))
615
- score = _extract_score(data)
616
- if score is None:
617
- # Abstain semantics: retry once — never treat an unreliable read as "No".
618
- data = await _call_reranker(_judge_payload(prompt, image_url, query_image_url))
619
- score = _extract_score(data)
620
- if score is None:
621
- return None, _abstain_verdict()
622
- return score, _verdict_from_score(score)
623
-
624
-
625
- @app.get("/health")
626
- def health():
627
- return {"status": "ok", "reranker": RERANKER_BASE_URL, "model": RERANKER_MODEL, "asset_context": JUDGE_ASSET_CONTEXT}
628
-
629
-
630
- @app.post("/vlm_validate/search_result")
631
- async def validate(req: ValidateRequest):
632
- if not req.images:
633
- raise HTTPException(status_code=400, detail="no image provided")
634
- # The same judge as the batch route: a query image (the Explorer sends it
635
- # bare base64 when it finishes a cut batch stream here) goes to the engine
636
- # first, under the image prompts.
637
- prompt = _select_prompt(req.query_text, bool(req.query_image), req.path, req.caption)
638
- query_image_url = await _prepare_image(req.query_image) if req.query_image else None
639
- image_url = await _prepare_image(req.images[0])
640
- # Serve deterministic repeats from the verdict cache — no engine call. The
641
- # key hashes the PREPARED image (post-resize), so the same asset arriving
642
- # as a data URL or a fetched http URL coalesces once prepared.
643
- ckey = _cache_key(prompt, image_url, _query_image_digest(req.query_image))
644
- cached = _cache_get(ckey) if VALIDATE_CACHE_SIZE > 0 else None
645
- if cached is not None:
646
- return {**_verdict_from_score(cached), "cached": True}
647
- try:
648
- score, verdict = await _score_prepared(prompt, image_url, query_image_url)
649
- except EngineRejected as exc:
650
- # An engine that takes one image per prompt (the dev HF Space's NVCF function) answers an image
651
- # query (query image + candidate) with a 4xx. Nothing is down: answer usdsearch's configuration
652
- # refusal, which the Space's Explorer reads as "this search goes unvalidated" instead of the
653
- # VLM-unavailable banner. Central's Explorer reads any 503 as before.
654
- if query_image_url and 400 <= exc.engine_status < 500 and exc.engine_status not in (408, 429):
655
- raise HTTPException(
656
- status_code=503,
657
- detail={"error_type": "configuration", "retryable": False, "message": IMAGE_QUERY_REFUSED},
658
- ) from exc
659
- raise
660
- if score is not None:
661
- _cache_put(ckey, score)
662
- return verdict
663
-
664
-
665
- class BatchItem(BaseModel):
666
- id: str
667
- image_key: Optional[str] = None
668
- asset_url: Optional[str] = None
669
- # The hit's source.path and the index's caption for it: facts for
670
- # words-only judging (JUDGE_ASSET_CONTEXT).
671
- path: Optional[str] = None
672
- caption: Optional[str] = None
673
-
674
-
675
- class BatchValidateRequest(BaseModel):
676
- query_text: Optional[str] = None
677
- query_image: Optional[str] = None
678
- # The index SLUG (x-usdsearch-storage-backend value) the shim forwards on
679
- # its /images fetches — routing, api-key injection, and the shared image
680
- # cache all key off it upstream. Empty → the gateway's default index.
681
- index: Optional[str] = None
682
- # In the client's DISPLAY (retrieval) order — dispatch preserves it, so
683
- # the most-likely matches score first and the ranked flow fills top-down.
684
- items: List[BatchItem] = []
685
-
686
-
687
- def _ndjson(obj) -> str:
688
- return json.dumps(obj, separators=(",", ":")) + "\n"
689
-
690
-
691
- @app.post("/vlm_validate/batch_stream")
692
- async def validate_batch(req: BatchValidateRequest):
693
- """Streamed batch validation (NDJSON). Line protocol:
694
-
695
- {"id", "cached", "verdict": {is_match, confidence, similarity_score,
696
- model[, abstain]}} — one per scored hit
697
- {"id", "error": "..."} — per-hit failure (bad thumbnail etc.);
698
- NEVER poisons the rest of the batch
699
- "" — blank heartbeat every BATCH_HEARTBEAT_S
700
- {"fatal": "..."} — engine unreachable; treat as 502/503
701
- {"done": {completed, errored, cached}} — ALWAYS last on a clean finish;
702
- its absence means the stream was cut
703
- (the client re-POSTs the missing ids)
704
-
705
- Shim-cache hits flush first (no engine, no image fetch — the reference-
706
- keyed entries); misses dispatch in request order under the per-batch cap.
707
- Client disconnect cancels every pending worker; `async with _reranker_sem`
708
- releases on cancellation, so an abandoned query frees its engine slots."""
709
- if not req.items:
710
- raise HTTPException(status_code=400, detail="no items")
711
- if len(req.items) > BATCH_MAX_ITEMS:
712
- raise HTTPException(status_code=413, detail=f"batch exceeds {BATCH_MAX_ITEMS} items")
713
- if not (req.query_text or req.query_image):
714
- raise HTTPException(status_code=400, detail="no query")
715
- index = (req.index or "").strip()
716
- qimg_digest = _query_image_digest(req.query_image)
717
- # An image query (alone or with words): the judge sees the query image
718
- # first, then each candidate. Prepared (downscaled) once. With asset facts
719
- # on, the prompt (and so every cache key) is per item.
720
- def prompt_for(it: BatchItem) -> str:
721
- return _select_prompt(req.query_text, bool(req.query_image), it.path, it.caption)
722
-
723
- query_image_url = await _prepare_image(req.query_image) if req.query_image else None
724
-
725
- # Phase 0 — reference-keyed cache pass: free verdicts, emitted first.
726
- cached_lines = [] # (kind, line) — kind: "verdict" | "error"
727
- misses = []
728
- for it in req.items:
729
- ref = it.image_key or it.asset_url
730
- if not ref:
731
- cached_lines.append(("error", _ndjson({"id": it.id, "error": "no image reference"})))
732
- continue
733
- cached = _cache_get(_ref_key(prompt_for(it), qimg_digest, index, ref)) if VALIDATE_CACHE_SIZE > 0 else None
734
- if cached is not None:
735
- cached_lines.append(
736
- ("verdict", _ndjson({
737
- "id": it.id,
738
- "cached": True,
739
- "verdict": _verdict_from_score(cached),
740
- }))
741
- )
742
- else:
743
- misses.append(it)
744
-
745
- queue: asyncio.Queue = asyncio.Queue()
746
- batch_sem = asyncio.Semaphore(RERANKER_BATCH_CONCURRENCY)
747
-
748
- async def worker(it: BatchItem):
749
- async with batch_sem:
750
- ref = it.image_key or it.asset_url
751
- prompt = prompt_for(it)
752
- # Pinned base + urlencoded params ONLY — a reference can never
753
- # change the request target (no SSRF surface); image_key (a
754
- # specific matched view) wins over asset_url, mirroring the
755
- # Explorer's own /images precedence.
756
- params = {"image_key": it.image_key} if it.image_key else {"asset_url": it.asset_url}
757
- headers = {"x-usdsearch-storage-backend": index} if index else {}
758
- try:
759
- resp = await _images_client.get(f"{SHIM_GATEWAY_BASE_URL}/images", params=params, headers=headers)
760
- resp.raise_for_status()
761
- raw = resp.content
762
- except Exception as exc: # noqa: BLE001 — one bad thumbnail must not poison the batch
763
- await queue.put(("error", _ndjson({"id": it.id, "error": f"image fetch failed: {type(exc).__name__}"})))
764
- return
765
- try:
766
- # Raw bytes straight into the thread-pooled shrink: no base64
767
- # round-trip on the loop, and the data URL's mime comes from
768
- # PIL's own sniff (img.format), never the upstream's
769
- # content-type header (S3-ish origins say octet-stream, which
770
- # OpenAI-compatible engines 400 on).
771
- image_url = await asyncio.to_thread(_shrink_bytes, raw, "", False)
772
- except Exception as exc: # noqa: BLE001 — undecodable bytes must not reach the engine
773
- await queue.put(("error", _ndjson({"id": it.id, "error": f"image decode failed: {type(exc).__name__}"})))
774
- return
775
- try:
776
- score, verdict = await _score_prepared(prompt, image_url, query_image_url)
777
- except HTTPException as exc:
778
- if exc.status_code == 503:
779
- # Engine UNREACHABLE — the whole batch is over.
780
- await queue.put(("fatal", _ndjson({"fatal": exc.detail})))
781
- else:
782
- # Engine rejected THIS item (4xx→502: e.g. an image it
783
- # can't decode) — per-hit, never batch-fatal.
784
- await queue.put(("error", _ndjson({"id": it.id, "error": f"engine rejected item: {exc.detail}"})))
785
- return
786
- except Exception as exc: # noqa: BLE001 — a dead worker with no line would
787
- # strand `remaining` and heartbeat the stream FOREVER (the
788
- # blank lines reset every downstream timeout). Anything
789
- # unexpected (non-JSON 200 from a proxy, logprob shape drift)
790
- # settles the hit as a per-hit error instead.
791
- logger.warning("batch worker failed (%s: %s)", type(exc).__name__, exc)
792
- await queue.put(("error", _ndjson({"id": it.id, "error": f"scoring failed: {type(exc).__name__}"})))
793
- return
794
- if score is not None:
795
- _cache_put(_ref_key(prompt, qimg_digest, index, ref), score)
796
- _cache_put(_cache_key(prompt, image_url, qimg_digest), score)
797
- await queue.put(("verdict", _ndjson({"id": it.id, "cached": False, "verdict": verdict})))
798
-
799
- async def stream():
800
- completed = sum(1 for kind, _ in cached_lines if kind == "verdict")
801
- errored = len(cached_lines) - completed
802
- cached_count = completed
803
- # Workers are created INSIDE the generator: a client that aborts
804
- # before the body is ever iterated (POST → immediate abort) must not
805
- # leak a batch onto the shared engine — a never-started generator
806
- # never runs its finally, so task lifetime has to live in this frame.
807
- # Dispatch in REQUEST order — the per-batch semaphore wakes FIFO, so
808
- # completion approximates retrieval order and the flow's top fills
809
- # first.
810
- tasks = [asyncio.create_task(worker(it)) for it in misses]
811
- try:
812
- for _, ln in cached_lines:
813
- yield ln
814
- remaining = len(tasks)
815
- while remaining > 0:
816
- try:
817
- kind, ln = await asyncio.wait_for(queue.get(), timeout=BATCH_HEARTBEAT_S)
818
- except asyncio.TimeoutError:
819
- yield "\n" # heartbeat — keeps proxies from severing a quiet stream
820
- continue
821
- yield ln
822
- if kind == "fatal":
823
- return # finally cancels the rest; no `done` line on a fatal
824
- remaining -= 1
825
- if kind == "error":
826
- errored += 1
827
- else:
828
- completed += 1
829
- yield _ndjson({"done": {"completed": completed, "errored": errored, "cached": cached_count}})
830
- finally:
831
- # Client gone, fatal, or clean finish — nothing may keep burning
832
- # shared engine slots. Cancellation releases the semaphores.
833
- for t in tasks:
834
- t.cancel()
835
-
836
- return StreamingResponse(stream(), media_type="application/x-ndjson")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker/hf-space/hf-storage-api/SOURCE.txt CHANGED
@@ -1,3 +1,3 @@
1
  # Generated by scripts/extract_hf_storage_api.sh — do not hand-edit.
2
- source_image=nvcr.io/m3sujtetvf5w/usdsearch/hf-storage-api:71b7537
3
- extracted_at=2026-09-30T07:11:27Z
 
1
  # Generated by scripts/extract_hf_storage_api.sh — do not hand-edit.
2
+ source_image=nvcr.io/m3sujtetvf5w/usdsearch/hf-storage-api:417d9af
3
+ extracted_at=2026-10-08T22:52:12Z
docker/hf-space/hf-storage-api/constraints-hf-storage-api.txt CHANGED
@@ -10,10 +10,10 @@ httpx==0.28.1
10
  idna==3.20
11
  packaging==26.3
12
  protobuf==7.36.2
13
- pydantic==2.13.5
14
- pydantic_core==2.46.5
15
  pydantic-settings==2.15.0
16
- python-dotenv==1.2.3
17
  PyYAML==6.0.3
18
  typing_extensions==4.16.0
19
  typing-inspection==0.4.4
 
10
  idna==3.20
11
  packaging==26.3
12
  protobuf==7.36.2
13
+ pydantic==2.14.0
14
+ pydantic_core==2.50.0
15
  pydantic-settings==2.15.0
16
+ python-dotenv==1.2.4
17
  PyYAML==6.0.3
18
  typing_extensions==4.16.0
19
  typing-inspection==0.4.4
docker/hf-space/hf-storage-api/hf_storage_api/addressing.py CHANGED
@@ -32,6 +32,13 @@ INBOUND_SCHEMES = frozenset({"hf", "https", "http"})
32
 
33
  DEFAULT_SCHEME = "https"
34
 
 
 
 
 
 
 
 
35
 
36
  @dataclass(frozen=True)
37
  class Address:
@@ -136,10 +143,17 @@ def build(
136
  a fetchable HF URL — HF requires a ``/resolve/<rev>/`` or ``/blob/<rev>/`` segment.
137
  It still round-trips through :func:`parse`, which is what this service needs of it;
138
  see the plan's §6.1 on pairing ``https`` with ``URI_REVISION_MODE``.
 
 
 
 
139
  """
140
  prefix = REPO_TYPE_PREFIX.get(repo_type, "")
141
  ref = f"resolve/{revision}" if revision else ""
142
- parts = [p for p in (prefix, repo_id, ref, path.strip("/")) if p]
 
 
 
143
  out = f"{scheme}://{host}/" + "/".join(parts)
144
  if is_folder and not out.endswith("/"):
145
  out += "/"
 
32
 
33
  DEFAULT_SCHEME = "https"
34
 
35
+ # The only characters `build()` escapes: the ones that do not survive a round
36
+ # trip through `parse()` raw — `%` because `parse()` unquotes, `#` and `?`
37
+ # because `urlsplit` cuts the path at them. Escaping anything more (`@`, space,
38
+ # non-ASCII) would change addresses USD Search has already indexed, and those
39
+ # round-trip fine as they are. `%` goes first so the others are not re-escaped.
40
+ _ADDRESS_ESCAPES = (("%", "%25"), ("#", "%23"), ("?", "%3F"))
41
+
42
 
43
  @dataclass(frozen=True)
44
  class Address:
 
143
  a fetchable HF URL — HF requires a ``/resolve/<rev>/`` or ``/blob/<rev>/`` segment.
144
  It still round-trips through :func:`parse`, which is what this service needs of it;
145
  see the plan's §6.1 on pairing ``https`` with ``URI_REVISION_MODE``.
146
+
147
+ ``path`` is escaped just enough to round-trip (see ``_ADDRESS_ESCAPES``): a file
148
+ named ``Package%401.0.0.json`` is emitted as ``Package%25401.0.0.json``, which
149
+ is also the form the Hub itself needs.
150
  """
151
  prefix = REPO_TYPE_PREFIX.get(repo_type, "")
152
  ref = f"resolve/{revision}" if revision else ""
153
+ path = path.strip("/")
154
+ for char, escaped in _ADDRESS_ESCAPES:
155
+ path = path.replace(char, escaped)
156
+ parts = [p for p in (prefix, repo_id, ref, path) if p]
157
  out = f"{scheme}://{host}/" + "/".join(parts)
158
  if is_folder and not out.endswith("/"):
159
  out += "/"
docker/hf-space/hf-storage-api/hf_storage_api/catalog/loader.py CHANGED
@@ -10,6 +10,14 @@ blipped.
10
  Every transport is polled, not just ``url``: a catalog file is mounted from a
11
  ConfigMap or written by the catalog-sync sidecar, and both change under a running
12
  process (sidecar plan §3).
 
 
 
 
 
 
 
 
13
  """
14
 
15
  from __future__ import annotations
@@ -17,6 +25,7 @@ from __future__ import annotations
17
  import asyncio
18
  import hashlib
19
  import logging
 
20
  from pathlib import Path
21
 
22
  import httpx
@@ -24,7 +33,7 @@ import yaml
24
 
25
  from ..hf.client import HFClient
26
  from ..settings import Settings
27
- from .model import Catalog, LayoutConfig, RepoConfig
28
  from .urllist import UrlListError, looks_like_url_list, normalize_asset_urls, parse_url_list
29
 
30
  logger = logging.getLogger(__name__)
@@ -65,41 +74,71 @@ def parse_text(text: str, *, fmt: str = "auto", layout: LayoutConfig | None = No
65
  return parse_catalog(text)
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  class CatalogLoader:
69
  """Loads the catalog and keeps it fresh, holding the last good copy."""
70
 
71
  def __init__(self, settings: Settings, hf: HFClient) -> None:
72
  self._settings = settings
73
  self._hf = hf
74
- self._etag: str | None = None
75
  self._last_good: Catalog | None = None
76
  self._task: asyncio.Task | None = None
77
- # What the catalog file looked like last time we read it: a cheap
78
- # (mtime, size) gate in front of an authoritative content digest.
79
- self._stat: tuple[int, int] | None = None
80
- self._digest: str | None = None
81
 
82
  @property
83
  def current(self) -> Catalog | None:
84
  return self._last_good
85
 
86
  async def load(self) -> Catalog:
87
- """Load once. Raises on the first load; later failures keep the last good."""
88
- try:
89
- text = await self._fetch_text()
90
- if text is None: # 304 Not Modified
91
- return self._last_good # type: ignore[return-value]
92
- catalog = self._parse(text)
93
- except Exception as exc:
94
- if self._last_good is None:
95
- raise
96
- logger.warning("catalog refresh failed (%s); keeping the last good copy", exc)
97
- return self._last_good
98
 
99
- if self._settings.catalog_source == "discover" or any(r.discover is not None for r in catalog.repos):
100
- catalog = await self._discover(catalog)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
 
 
 
 
 
 
102
  validate_catalog(catalog)
 
103
  self._last_good = catalog
104
  logger.info(
105
  "catalog loaded: %d repo(s), %d asset(s)",
@@ -108,24 +147,50 @@ class CatalogLoader:
108
  )
109
  return catalog
110
 
111
- async def _fetch_text(self) -> str | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  """Fetch the raw catalog. ``None`` means "unchanged since last time"."""
113
- source = self._settings.catalog_source
114
- if source in ("file", "discover"):
115
- return self._read_file()
116
 
117
- headers = {"If-None-Match": self._etag} if self._etag else {}
118
  async with httpx.AsyncClient(timeout=self._settings.http_timeout) as client:
119
  response = await client.get(self._settings.catalog_url or "", headers=headers)
120
- if response.status_code == 304 and self._last_good is not None:
121
  logger.debug("catalog unchanged (304)")
122
  return None
123
  response.raise_for_status()
124
- self._etag = response.headers.get("ETag")
125
  return response.text
126
 
127
- def _read_file(self) -> str | None:
128
- """Read the catalog file, or ``None`` when its contents have not changed.
129
 
130
  Two gates, because they answer different questions. ``stat()`` is what
131
  makes polling free — it is the only syscall in the steady state. The
@@ -137,23 +202,20 @@ class CatalogLoader:
137
  bytes that failed to parse must not be retried every interval, or one
138
  bad file becomes one log line per tick forever.
139
  """
140
- path = self._settings.catalog_path
141
- if path is None: # pragma: no cover - Settings validates this
142
- raise CatalogError("catalog_path is unset")
143
- path = Path(path)
144
 
145
  info = path.stat()
146
  stamp = (info.st_mtime_ns, info.st_size)
147
- if self._last_good is not None and stamp == self._stat:
148
  return None
149
- self._stat = stamp
150
 
151
  text = path.read_text(encoding="utf-8")
152
  digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
153
- if self._last_good is not None and digest == self._digest:
154
  logger.debug("catalog file touched but unchanged (%s)", path)
155
  return None
156
- self._digest = digest
157
  return text
158
 
159
  def _parse(self, text: str) -> Catalog:
@@ -219,9 +281,10 @@ class CatalogLoader:
219
  was silently ignored until the pod restarted — the asset set stayed at
220
  whatever it was at boot while everything upstream reported success.
221
 
222
- ``discover`` re-walks the repo only when the file that names the repos
223
- changed. A repo-wide tree read on a timer is a much bigger promise than
224
- this loop should make on its own (plan §5.2).
 
225
 
226
  Polling rather than watching: ``stat()`` behaves the same on an
227
  ``emptyDir`` file replaced by ``os.replace``, on a ConfigMap's symlink
@@ -259,6 +322,26 @@ class CatalogLoader:
259
  self._task = None
260
 
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  def validate_catalog(catalog: Catalog) -> None:
263
  """Reject a catalog that would serve nothing, or serve it ambiguously.
264
 
@@ -269,14 +352,25 @@ def validate_catalog(catalog: Catalog) -> None:
269
  """
270
  if not catalog.repos:
271
  raise CatalogError("catalog lists no repos")
272
- seen: set[str] = set()
273
  for repo in catalog.repos:
274
  if "/" not in repo.repo_id:
275
  raise CatalogError(f"repo_id must be <owner>/<name>: {repo.repo_id!r}")
276
- if repo.key in seen:
277
- raise CatalogError(f"duplicate repo in catalog: {repo.repo_id!r}")
278
- seen.add(repo.key)
279
  if not repo.assets and repo.discover is None:
280
  raise CatalogError(
281
  f"repo {repo.repo_id!r} lists no assets and no discover block — it would serve nothing"
282
  )
 
 
 
 
 
 
 
 
 
 
 
 
10
  Every transport is polled, not just ``url``: a catalog file is mounted from a
11
  ConfigMap or written by the catalog-sync sidecar, and both change under a running
12
  process (sidecar plan §3).
13
+
14
+ The primary catalog may be joined by *extra* catalog files
15
+ (``HF_ADAPTER_CATALOG_EXTRA_PATHS``) — in practice a YAML catalog that walks some
16
+ repos, plus a URL list the sidecar keeps in step with a GitHub submission file.
17
+ Each source is read, discovered and guarded on its own and keeps its own last
18
+ good copy, so a broken sync leaves the YAML repos serving and a changed URL list
19
+ does not re-walk them. The results are merged into one catalog: still one access
20
+ boundary, still one repo per key.
21
  """
22
 
23
  from __future__ import annotations
 
25
  import asyncio
26
  import hashlib
27
  import logging
28
+ from dataclasses import dataclass
29
  from pathlib import Path
30
 
31
  import httpx
 
33
 
34
  from ..hf.client import HFClient
35
  from ..settings import Settings
36
+ from .model import Catalog, LayoutConfig, RepoConfig, looks_like_sha
37
  from .urllist import UrlListError, looks_like_url_list, normalize_asset_urls, parse_url_list
38
 
39
  logger = logging.getLogger(__name__)
 
74
  return parse_catalog(text)
75
 
76
 
77
+ @dataclass
78
+ class _Source:
79
+ """One catalog input and what it looked like last time it was read."""
80
+
81
+ path: Path | None # None: the primary catalog, fetched from `catalog_url`
82
+ primary: bool
83
+ etag: str | None = None
84
+ # A cheap (mtime, size) gate in front of an authoritative content digest.
85
+ stat: tuple[int, int] | None = None
86
+ digest: str | None = None
87
+ # Parsed, discovered and guarded — what this source contributes right now.
88
+ last_good: Catalog | None = None
89
+
90
+ @property
91
+ def name(self) -> str:
92
+ return str(self.path) if self.path is not None else "catalog URL"
93
+
94
+
95
  class CatalogLoader:
96
  """Loads the catalog and keeps it fresh, holding the last good copy."""
97
 
98
  def __init__(self, settings: Settings, hf: HFClient) -> None:
99
  self._settings = settings
100
  self._hf = hf
 
101
  self._last_good: Catalog | None = None
102
  self._task: asyncio.Task | None = None
103
+ primary_path = None if settings.catalog_source == "url" else settings.catalog_path
104
+ self._sources = [_Source(primary_path, primary=True)] + [
105
+ _Source(path, primary=False) for path in settings.extra_catalog_paths
106
+ ]
107
 
108
  @property
109
  def current(self) -> Catalog | None:
110
  return self._last_good
111
 
112
  async def load(self) -> Catalog:
113
+ """Load once. Raises on the first load; later failures keep the last good.
 
 
 
 
 
 
 
 
 
 
114
 
115
+ "Last good" is kept per source: one broken input holds its own previous
116
+ contribution while the others still update.
117
+ """
118
+ parts: list[Catalog] = []
119
+ changed = False
120
+ for source in self._sources:
121
+ try:
122
+ part = await self._load_source(source)
123
+ except Exception as exc:
124
+ if source.last_good is None:
125
+ raise
126
+ logger.warning(
127
+ "catalog refresh failed for %s (%s); keeping its last good copy", source.name, exc
128
+ )
129
+ part = None
130
+ if part is not None:
131
+ changed = True
132
+ parts.append(source.last_good) # type: ignore[arg-type] - set by now
133
 
134
+ if not changed and self._last_good is not None:
135
+ return self._last_good
136
+
137
+ catalog = merge_catalogs(parts)
138
+ # Per-source checks cannot see across sources: the same repo named by the
139
+ # YAML catalog and by the synced list is only visible here.
140
  validate_catalog(catalog)
141
+ self._check_revisions_are_addressable(catalog)
142
  self._last_good = catalog
143
  logger.info(
144
  "catalog loaded: %d repo(s), %d asset(s)",
 
147
  )
148
  return catalog
149
 
150
+ def _check_revisions_are_addressable(self, catalog: Catalog) -> None:
151
+ """Several revisions of one repo are told apart only by the
152
+ ``/resolve/<sha>/`` segment in their addresses — which ``never`` drops."""
153
+ if self._settings.uri_revision_mode != "never":
154
+ return
155
+ keys = [repo.key for repo in catalog.repos]
156
+ if len(keys) != len(set(keys)):
157
+ raise CatalogError(
158
+ "the catalog serves a repo at several revisions, which needs the revision in every "
159
+ "address — HF_ADAPTER_URI_REVISION_MODE=never removes it"
160
+ )
161
+
162
+ async def _load_source(self, source: _Source) -> Catalog | None:
163
+ """Refresh one source. ``None`` means "unchanged since last time"."""
164
+ text = await self._fetch_text(source)
165
+ if text is None:
166
+ return None
167
+ catalog = self._parse(text)
168
+
169
+ discover = source.primary and self._settings.catalog_source == "discover"
170
+ if discover or any(r.discover is not None for r in catalog.repos):
171
+ catalog = await self._discover(catalog)
172
+
173
+ validate_catalog(catalog)
174
+ source.last_good = catalog
175
+ return catalog
176
+
177
+ async def _fetch_text(self, source: _Source) -> str | None:
178
  """Fetch the raw catalog. ``None`` means "unchanged since last time"."""
179
+ if source.path is not None:
180
+ return self._read_file(source)
 
181
 
182
+ headers = {"If-None-Match": source.etag} if source.etag else {}
183
  async with httpx.AsyncClient(timeout=self._settings.http_timeout) as client:
184
  response = await client.get(self._settings.catalog_url or "", headers=headers)
185
+ if response.status_code == 304 and source.last_good is not None:
186
  logger.debug("catalog unchanged (304)")
187
  return None
188
  response.raise_for_status()
189
+ source.etag = response.headers.get("ETag")
190
  return response.text
191
 
192
+ def _read_file(self, source: _Source) -> str | None:
193
+ """Read a catalog file, or ``None`` when its contents have not changed.
194
 
195
  Two gates, because they answer different questions. ``stat()`` is what
196
  makes polling free — it is the only syscall in the steady state. The
 
202
  bytes that failed to parse must not be retried every interval, or one
203
  bad file becomes one log line per tick forever.
204
  """
205
+ path = Path(source.path) # type: ignore[arg-type] - only called for files
 
 
 
206
 
207
  info = path.stat()
208
  stamp = (info.st_mtime_ns, info.st_size)
209
+ if source.last_good is not None and stamp == source.stat:
210
  return None
211
+ source.stat = stamp
212
 
213
  text = path.read_text(encoding="utf-8")
214
  digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
215
+ if source.last_good is not None and digest == source.digest:
216
  logger.debug("catalog file touched but unchanged (%s)", path)
217
  return None
218
+ source.digest = digest
219
  return text
220
 
221
  def _parse(self, text: str) -> Catalog:
 
281
  was silently ignored until the pod restarted — the asset set stayed at
282
  whatever it was at boot while everything upstream reported success.
283
 
284
+ ``discover`` re-walks a repo only when the file that names it changed —
285
+ an extra catalog file changing does not re-walk the primary's repos. A
286
+ repo-wide tree read on a timer is a much bigger promise than this loop
287
+ should make on its own (plan §5.2).
288
 
289
  Polling rather than watching: ``stat()`` behaves the same on an
290
  ``emptyDir`` file replaced by ``os.replace``, on a ConfigMap's symlink
 
322
  self._task = None
323
 
324
 
325
+ def merge_catalogs(parts: list[Catalog]) -> Catalog:
326
+ """Join several catalogs into one, each repo keeping its own endpoint and layout.
327
+
328
+ ``endpoint`` and ``layout`` are catalog-level defaults, so a repo relying on
329
+ its own catalog's defaults would silently pick up the first catalog's instead
330
+ — a URL list inferring a different manifest, say. Pinning them per repo
331
+ first makes the merge lossless. Duplicates are left for
332
+ :func:`validate_catalog` to reject.
333
+ """
334
+ if len(parts) == 1:
335
+ return parts[0]
336
+ repos = [
337
+ repo.model_copy(update={"endpoint": repo.endpoint or part.endpoint, "layout": part.layout_for(repo)})
338
+ for part in parts
339
+ for repo in part.repos
340
+ ]
341
+ first = parts[0]
342
+ return Catalog(endpoint=first.endpoint, layout=first.layout, repos=repos)
343
+
344
+
345
  def validate_catalog(catalog: Catalog) -> None:
346
  """Reject a catalog that would serve nothing, or serve it ambiguously.
347
 
 
352
  """
353
  if not catalog.repos:
354
  raise CatalogError("catalog lists no repos")
355
+ revisions: dict[str, list[str]] = {}
356
  for repo in catalog.repos:
357
  if "/" not in repo.repo_id:
358
  raise CatalogError(f"repo_id must be <owner>/<name>: {repo.repo_id!r}")
359
+ if repo.revision in revisions.setdefault(repo.key, []):
360
+ raise CatalogError(f"duplicate repo in catalog: {repo.repo_id!r} at revision {repo.revision!r}")
361
+ revisions[repo.key].append(repo.revision)
362
  if not repo.assets and repo.discover is None:
363
  raise CatalogError(
364
  f"repo {repo.repo_id!r} lists no assets and no discover block — it would serve nothing"
365
  )
366
+ # One repo at several revisions is served side by side, each address carrying
367
+ # its own `/resolve/<sha>/` — which is what tells them apart. A moving ref has
368
+ # no fixed sha to put there, so it can only be served alone.
369
+ for key, revs in revisions.items():
370
+ moving = [r for r in revs if not looks_like_sha(r)]
371
+ if len(revs) > 1 and moving:
372
+ raise CatalogError(
373
+ f"{key.split(':', 1)[1]} appears at {len(revs)} revisions ({', '.join(sorted(revs))}); "
374
+ f"serving several revisions of one repo needs each pinned to a commit sha, "
375
+ f"but {', '.join(repr(r) for r in moving)} is a moving ref"
376
+ )
docker/hf-space/hf-storage-api/hf_storage_api/catalog/model.py CHANGED
@@ -16,6 +16,12 @@ from pydantic import BaseModel, Field, model_validator
16
 
17
  DEFAULT_HOST = "huggingface.co"
18
 
 
 
 
 
 
 
19
  # Defaults below are written for `catalog_kind="manifest"` (nvidia/simready-assets).
20
  # `catalog_kind="wrapp"` (nvidia/srp-staging-controlled) and `catalog_kind=
21
  # "package_entry"` (nvidia/SimReady-Internal) each swap in their own defaults for
@@ -166,8 +172,14 @@ class Catalog(BaseModel):
166
  return repo.layout or self.layout
167
 
168
  def repo(self, repo_type: str, repo_id: str) -> RepoConfig | None:
 
 
 
 
 
 
169
  key = f"{repo_type}:{repo_id}"
170
- return next((r for r in self.repos if r.key == key), None)
171
 
172
  def host(self, repo: RepoConfig) -> str:
173
  endpoint = repo.endpoint or self.endpoint
 
16
 
17
  DEFAULT_HOST = "huggingface.co"
18
 
19
+
20
+ def looks_like_sha(revision: str) -> bool:
21
+ """Is this a pinned commit sha rather than a moving ref like ``main``?"""
22
+ return len(revision) == 40 and all(c in "0123456789abcdef" for c in revision.lower())
23
+
24
+
25
  # Defaults below are written for `catalog_kind="manifest"` (nvidia/simready-assets).
26
  # `catalog_kind="wrapp"` (nvidia/srp-staging-controlled) and `catalog_kind=
27
  # "package_entry"` (nvidia/SimReady-Internal) each swap in their own defaults for
 
172
  return repo.layout or self.layout
173
 
174
  def repo(self, repo_type: str, repo_id: str) -> RepoConfig | None:
175
+ """The first entry for this repo. Use :meth:`repos_for` where a repo may be
176
+ served at several pinned revisions."""
177
+ return next(iter(self.repos_for(repo_type, repo_id)), None)
178
+
179
+ def repos_for(self, repo_type: str, repo_id: str) -> list[RepoConfig]:
180
+ """Every entry for this repo — one per revision served."""
181
  key = f"{repo_type}:{repo_id}"
182
+ return [r for r in self.repos if r.key == key]
183
 
184
  def host(self, repo: RepoConfig) -> str:
185
  endpoint = repo.endpoint or self.endpoint
docker/hf-space/hf-storage-api/hf_storage_api/catalog/urllist.py CHANGED
@@ -154,18 +154,9 @@ def parse_url_list(text: str, *, layout: LayoutConfig | None = None) -> Catalog:
154
  if not grouped:
155
  raise UrlListError("the URL list is empty")
156
 
157
- # One repo at two revisions cannot be served under one URI namespace: the
158
- # revision is configuration, not part of the address, so both would collide on
159
- # the same base_key. The `@<rev>` escape hatch is plan §6.1 / checklist 4.4.
160
- by_repo: dict[tuple[str, str, str], list[str]] = {}
161
- for endpoint, repo_type, repo_id, revision in grouped:
162
- by_repo.setdefault((endpoint, repo_type, repo_id), []).append(revision)
163
- for (_, _, repo_id), revisions in by_repo.items():
164
- if len(revisions) > 1:
165
- raise UrlListError(
166
- f"{repo_id} appears at {len(revisions)} revisions ({', '.join(sorted(revisions))}); "
167
- f"one URI namespace can only serve one revision per repo"
168
- )
169
 
170
  # The list names the manifest, so it also tells us the layout's marker — but
171
  # only adopt it when the list agrees with itself and differs from the default.
 
154
  if not grouped:
155
  raise UrlListError("the URL list is empty")
156
 
157
+ # One repo at several revisions becomes one RepoConfig per revision. Pinned
158
+ # shas are served side by side, told apart by the `/resolve/<sha>/` in their
159
+ # addresses; `validate_catalog` refuses the mix when one of them is a moving ref.
 
 
 
 
 
 
 
 
 
160
 
161
  # The list names the manifest, so it also tells us the layout's marker — but
162
  # only adopt it when the list agrees with itself and differs from the default.
docker/hf-space/hf-storage-api/hf_storage_api/catalog_sync/publish.py CHANGED
@@ -57,7 +57,7 @@ def review(
57
  Parsing is :func:`hf_storage_api.catalog.loader.parse_text` — the server's,
58
  not a copy — so this rejects exactly what the server would reject at load
59
  time: a truncated body, a URL pointing at a USD instead of a manifest, one
60
- repo pinned at two revisions.
61
  """
62
  if not _has_content(text):
63
  # Caught before parsing, purely for the error message. An emptied URL list
@@ -103,7 +103,8 @@ def publish(
103
  """Write ``text`` to ``target`` so that no reader can ever see it half-written."""
104
  digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
105
  assets = sum(len(repo.assets) for repo in catalog.repos)
106
- repo_ids = tuple(repo.repo_id for repo in catalog.repos)
 
107
  previous = read_meta(target)
108
 
109
  if previous.get("digest") == digest and target.exists():
 
57
  Parsing is :func:`hf_storage_api.catalog.loader.parse_text` — the server's,
58
  not a copy — so this rejects exactly what the server would reject at load
59
  time: a truncated body, a URL pointing at a USD instead of a manifest, one
60
+ repo at two revisions when one of them is a moving ref.
61
  """
62
  if not _has_content(text):
63
  # Caught before parsing, purely for the error message. An emptied URL list
 
103
  """Write ``text`` to ``target`` so that no reader can ever see it half-written."""
104
  digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
105
  assets = sum(len(repo.assets) for repo in catalog.repos)
106
+ # One entry per repo, not per revision served.
107
+ repo_ids = tuple(dict.fromkeys(repo.repo_id for repo in catalog.repos))
108
  previous = read_meta(target)
109
 
110
  if previous.get("digest") == digest and target.exists():
docker/hf-space/hf-storage-api/hf_storage_api/hf/client.py CHANGED
@@ -15,6 +15,7 @@ import logging
15
  import re
16
  from collections.abc import AsyncIterator, Sequence
17
  from typing import Any
 
18
 
19
  import httpx
20
 
@@ -28,6 +29,18 @@ logger = logging.getLogger(__name__)
28
  _API_SEGMENT = {"dataset": "datasets", "model": "models", "space": "spaces"}
29
  _URL_SEGMENT = {"dataset": "datasets", "model": "", "space": "spaces"}
30
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
32
 
33
  # HF does not send `Retry-After` — it sends `RateLimit` in the
@@ -106,7 +119,7 @@ class HFClient:
106
 
107
  def resolve_url(self, repo_type: str, repo_id: str, revision: str, path: str) -> str:
108
  segment = _URL_SEGMENT.get(repo_type, "datasets")
109
- parts = [self._endpoint, segment, repo_id, "resolve", revision, path]
110
  return "/".join([p for p in parts if p])
111
 
112
  def _headers(self, token: str | None) -> dict[str, str]:
@@ -177,7 +190,7 @@ class HFClient:
177
  token: str | None = None,
178
  ) -> list[TreeEntry]:
179
  """List a subtree. Follows the Hub's link-header pagination."""
180
- url = self.api_url(repo_type, repo_id, "tree", revision, path)
181
  params: dict[str, Any] = {}
182
  if recursive:
183
  params["recursive"] = "true"
 
15
  import re
16
  from collections.abc import AsyncIterator, Sequence
17
  from typing import Any
18
+ from urllib.parse import quote
19
 
20
  import httpx
21
 
 
29
  _API_SEGMENT = {"dataset": "datasets", "model": "models", "space": "spaces"}
30
  _URL_SEGMENT = {"dataset": "datasets", "model": "", "space": "spaces"}
31
 
32
+
33
+ def _quote_path(path: str) -> str:
34
+ """Percent-encode a repo path for a Hub URL.
35
+
36
+ The Hub decodes the URL path, so a raw `%40` in a file name resolves to `@`
37
+ and 404s. Every SimReady package carries one
38
+ (`.metadata/com.nvidia.simready.conformance.Package%401.0.0.json`), and `#`,
39
+ `?` or a space would break the URL outright.
40
+ """
41
+ return quote(path, safe="/")
42
+
43
+
44
  RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
45
 
46
  # HF does not send `Retry-After` — it sends `RateLimit` in the
 
119
 
120
  def resolve_url(self, repo_type: str, repo_id: str, revision: str, path: str) -> str:
121
  segment = _URL_SEGMENT.get(repo_type, "datasets")
122
+ parts = [self._endpoint, segment, repo_id, "resolve", revision, _quote_path(path)]
123
  return "/".join([p for p in parts if p])
124
 
125
  def _headers(self, token: str | None) -> dict[str, str]:
 
190
  token: str | None = None,
191
  ) -> list[TreeEntry]:
192
  """List a subtree. Follows the Hub's link-header pagination."""
193
+ url = self.api_url(repo_type, repo_id, "tree", revision, _quote_path(path))
194
  params: dict[str, Any] = {}
195
  if recursive:
196
  params["recursive"] = "true"
docker/hf-space/hf-storage-api/hf_storage_api/services/capabilities.py CHANGED
@@ -55,14 +55,15 @@ class CapabilitiesService:
55
  async def ListRoutes(self, request, context):
56
  """Wildcard patterns for the addresses this server serves.
57
 
58
- Unused by USD Search's Python client, but it is one of only two RPCs that
59
- could feed ``omni.client``'s ``on_registered`` callback, and it exists
60
- only in v1alpha — see plan §2.4. Cheap insurance against a silent Kit
61
- registration failure.
 
62
  """
63
  return capabilities_pb2.ListRoutesResponse(
64
  items=[
65
- capabilities_pb2.Route(wildcard_pattern=f"{address}*")
66
  for address in self._store.top_level_addresses()
67
  ]
68
  )
 
55
  async def ListRoutes(self, request, context):
56
  """Wildcard patterns for the addresses this server serves.
57
 
58
+ Unused by USD Search's Python client, but ``omni.client`` routes by
59
+ these, and it exists only in v1alpha — see plan §2.4. ``**``, not ``*``:
60
+ ovrtx's bundled ``omni.client`` (2.74.0) matches ``*`` within one path
61
+ segment, so a ``*`` route sends no nested address to this provider and
62
+ every asset fails with ``ERROR_NOT_SUPPORTED`` before any RPC is made.
63
  """
64
  return capabilities_pb2.ListRoutesResponse(
65
  items=[
66
+ capabilities_pb2.Route(wildcard_pattern=f"{address}**")
67
  for address in self._store.top_level_addresses()
68
  ]
69
  )
docker/hf-space/hf-storage-api/hf_storage_api/services/filefolder.py CHANGED
@@ -37,21 +37,22 @@ class FileFolderService:
37
  @handle_hf_errors_stream
38
  async def ListStat(self, request, context):
39
  uri = request.folder.uri
40
- repo = self._store.repo_for_root(uri)
41
 
42
- if repo is not None:
43
  # Repo root: the crawl surface, flat. Returning no subfolder_addresses
44
  # stops the client's breadth-first walk here, which is what keeps the
45
  # indexed set to one entry per asset.
46
  semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
47
 
48
- async def resolve(asset_name: str):
49
  async with semaphore:
50
- return await self._store.folder(repo, asset_name)
51
 
52
  entries: list[filefolder_service_pb2.ListItem] = []
53
- for completed in asyncio.as_completed([resolve(name) for name in repo.assets]):
54
- loaded = await completed
 
55
  if loaded is None:
56
  continue
57
  entry = loaded.entry(loaded.root_usd)
 
37
  @handle_hf_errors_stream
38
  async def ListStat(self, request, context):
39
  uri = request.folder.uri
40
+ repos = self._store.repos_for_root(uri)
41
 
42
+ if repos:
43
  # Repo root: the crawl surface, flat. Returning no subfolder_addresses
44
  # stops the client's breadth-first walk here, which is what keeps the
45
  # indexed set to one entry per asset.
46
  semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
47
 
48
+ async def resolve(repo, asset_name: str):
49
  async with semaphore:
50
+ return repo, await self._store.folder(repo, asset_name)
51
 
52
  entries: list[filefolder_service_pb2.ListItem] = []
53
+ pending = [resolve(repo, name) for repo in repos for name in repo.assets]
54
+ for completed in asyncio.as_completed(pending):
55
+ repo, loaded = await completed
56
  if loaded is None:
57
  continue
58
  entry = loaded.entry(loaded.root_usd)
docker/hf-space/hf-storage-api/hf_storage_api/services/fileobject.py CHANGED
@@ -1,7 +1,7 @@
1
  # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
  # SPDX-License-Identifier: Apache-2.0
3
 
4
- """``FileObjectService`` — ``Stat``, ``Enumerate``, ``ReadFromAddress``.
5
 
6
  Two surfaces (plan §6.2):
7
 
@@ -94,25 +94,27 @@ class FileObjectService:
94
  to come out of this one call (plan §2.4).
95
  """
96
  address = request.resource_address
97
- repo = self._store.repo_for_root(address)
98
- if repo is None:
99
  # Scoped to something other than a served root. Prefer NOT_FOUND:
100
  # any other status aborts the crawl (contract doc §3.4).
101
  await context.abort(grpc.StatusCode.NOT_FOUND, address)
102
 
103
  semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
104
 
105
- async def resolve(asset_name: str):
106
  async with semaphore:
107
- return asset_name, await self._store.folder(repo, asset_name)
108
 
109
  batch: list[fileobject_pb2.AddressInfo] = []
110
  # Stream as results arrive rather than resolving everything first: a
111
  # crawl that emits nothing for minutes is indistinguishable from a hang.
112
- tasks = [asyncio.create_task(resolve(name)) for name in repo.assets]
 
 
113
  try:
114
  for completed in asyncio.as_completed(tasks):
115
- asset_name, loaded = await completed
116
  if loaded is None:
117
  logger.warning("catalogued asset %s is absent upstream; skipping", asset_name)
118
  continue
@@ -156,7 +158,30 @@ class FileObjectService:
156
  yield fileobject_service_pb2.ReadFromAddressResponse(
157
  resource_info=_resource_info(self._store, entry, address)
158
  )
 
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  revision = await self._store.revision(resolved.repo)
161
  stream = self._store.hf.stream(
162
  resolved.repo.repo_type,
@@ -168,7 +193,7 @@ class FileObjectService:
168
  )
169
  try:
170
  async for chunk in stream:
171
- yield fileobject_service_pb2.ReadFromAddressResponse(chunk=fileobject_pb2.Chunk(chunk=chunk))
172
  except asyncio.CancelledError:
173
  # The client hung up: close the upstream response instead of draining
174
  # it into a dead RPC. With multi-hundred-MB assets that is a real
@@ -176,13 +201,6 @@ class FileObjectService:
176
  await stream.aclose()
177
  raise
178
 
179
- async def Read(self, request, context):
180
- # The client downloads by address, not by ResourceIdentity, so this is
181
- # unused. Implement only if the Phase-1.4 Kit log shows it (plan §2.4).
182
- await context.abort(
183
- grpc.StatusCode.UNIMPLEMENTED, "Read by identity is not supported; use ReadFromAddress"
184
- )
185
-
186
  # -- write path: read-only service (plan §6.8) ----------------------------
187
 
188
  async def FetchWriteTypeInfo(self, request, context):
 
1
  # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
  # SPDX-License-Identifier: Apache-2.0
3
 
4
+ """``FileObjectService`` — ``Stat``, ``Enumerate``, ``ReadFromAddress``, ``Read``.
5
 
6
  Two surfaces (plan §6.2):
7
 
 
94
  to come out of this one call (plan §2.4).
95
  """
96
  address = request.resource_address
97
+ repos = self._store.repos_for_root(address)
98
+ if not repos:
99
  # Scoped to something other than a served root. Prefer NOT_FOUND:
100
  # any other status aborts the crawl (contract doc §3.4).
101
  await context.abort(grpc.StatusCode.NOT_FOUND, address)
102
 
103
  semaphore = asyncio.Semaphore(self._settings.enumerate_concurrency)
104
 
105
+ async def resolve(repo, asset_name: str):
106
  async with semaphore:
107
+ return repo, asset_name, await self._store.folder(repo, asset_name)
108
 
109
  batch: list[fileobject_pb2.AddressInfo] = []
110
  # Stream as results arrive rather than resolving everything first: a
111
  # crawl that emits nothing for minutes is indistinguishable from a hang.
112
+ # Every revision served under this root: each asset's address carries its
113
+ # own `/resolve/<sha>/`, so they cannot collide.
114
+ tasks = [asyncio.create_task(resolve(repo, name)) for repo in repos for name in repo.assets]
115
  try:
116
  for completed in asyncio.as_completed(tasks):
117
+ repo, asset_name, loaded = await completed
118
  if loaded is None:
119
  logger.warning("catalogued asset %s is absent upstream; skipping", asset_name)
120
  continue
 
158
  yield fileobject_service_pb2.ReadFromAddressResponse(
159
  resource_info=_resource_info(self._store, entry, address)
160
  )
161
+ async for chunk in self._chunks(resolved):
162
+ yield fileobject_service_pb2.ReadFromAddressResponse(chunk=fileobject_pb2.Chunk(chunk=chunk))
163
 
164
+ @handle_hf_errors_stream
165
+ async def Read(self, request, context):
166
+ """Read by ``ResourceIdentity``, as ovrtx's bundled ``omni.client``
167
+ (2.74.0) does after the ``Stat`` that minted the identity; Kit and USD
168
+ Search's client use ``ReadFromAddress`` instead.
169
+
170
+ The identity is the content oid, so it is mapped back to the address it
171
+ was minted for (contract doc §5.1) and re-authorised like any address.
172
+ """
173
+ identity = request.resource_identity.encoded_identity
174
+ address = self._store.address_for_identity(identity)
175
+ found = await self._store.stat(address) if address else None
176
+ if found is None:
177
+ await context.abort(grpc.StatusCode.NOT_FOUND, identity)
178
+ resolved, entry = found
179
+
180
+ yield fileobject_service_pb2.ReadResponse(metadata=_metadata(entry))
181
+ async for chunk in self._chunks(resolved):
182
+ yield fileobject_service_pb2.ReadResponse(chunk=fileobject_pb2.Chunk(chunk=chunk))
183
+
184
+ async def _chunks(self, resolved):
185
  revision = await self._store.revision(resolved.repo)
186
  stream = self._store.hf.stream(
187
  resolved.repo.repo_type,
 
193
  )
194
  try:
195
  async for chunk in stream:
196
+ yield chunk
197
  except asyncio.CancelledError:
198
  # The client hung up: close the upstream response instead of draining
199
  # it into a dead RPC. With multi-hundred-MB assets that is a real
 
201
  await stream.aclose()
202
  raise
203
 
 
 
 
 
 
 
 
204
  # -- write path: read-only service (plan §6.8) ----------------------------
205
 
206
  async def FetchWriteTypeInfo(self, request, context):
docker/hf-space/hf-storage-api/hf_storage_api/services/metadata_service.py CHANGED
@@ -6,6 +6,7 @@
6
  from __future__ import annotations
7
 
8
  import logging
 
9
 
10
  import grpc
11
  from nvidia.omniverse.storage.metadata.v1alpha import metadata_pb2
@@ -39,9 +40,14 @@ class MetadataService:
39
  # Under `catalog_kind="wrapp"` the per-asset filenames use the short stem
40
  # from the root USD (e.g. `sm_IV_pole_a01_01`), not the full folder path
41
  # that `asset_name` is under a versioned layout (plan §6.3).
42
- stem = loaded.root_usd.rsplit("/", 1)[-1].removesuffix(".usd")
43
  manifest_path = f"{asset_name}/{layout.render(layout.manifest, asset_name, stem=stem)}"
44
  metadata_path = f"{asset_name}/{layout.render(layout.metadata, asset_name, stem=stem)}"
 
 
 
 
 
45
 
46
  manifest = await self._sidecar(resolved, manifest_path, loaded)
47
  asset_metadata = await self._sidecar(resolved, metadata_path, loaded)
 
6
  from __future__ import annotations
7
 
8
  import logging
9
+ import posixpath
10
 
11
  import grpc
12
  from nvidia.omniverse.storage.metadata.v1alpha import metadata_pb2
 
40
  # Under `catalog_kind="wrapp"` the per-asset filenames use the short stem
41
  # from the root USD (e.g. `sm_IV_pole_a01_01`), not the full folder path
42
  # that `asset_name` is under a versioned layout (plan §6.3).
43
+ stem = posixpath.splitext(loaded.root_usd.rsplit("/", 1)[-1])[0]
44
  manifest_path = f"{asset_name}/{layout.render(layout.manifest, asset_name, stem=stem)}"
45
  metadata_path = f"{asset_name}/{layout.render(layout.metadata, asset_name, stem=stem)}"
46
+ if layout.catalog_kind == "manifest" and metadata_path not in loaded.files:
47
+ # Same drift as the root USD's (store.py:_data_dir_usd): the
48
+ # descriptive metadata sits next to the root USD as `<stem>.json`,
49
+ # not at the `{data_dir}/{asset_name}.json` the convention names.
50
+ metadata_path = f"{posixpath.splitext(loaded.root_usd)[0]}.json"
51
 
52
  manifest = await self._sidecar(resolved, manifest_path, loaded)
53
  asset_metadata = await self._sidecar(resolved, metadata_path, loaded)
docker/hf-space/hf-storage-api/hf_storage_api/settings.py CHANGED
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
41
  catalog_path: Path | None = None
42
  catalog_url: str | None = None
43
  catalog_refresh_seconds: float = 900.0
 
 
 
 
 
44
 
45
  # A URL list has nowhere to put a layout, so a non-default one comes from the
46
  # environment instead. `manifest` is inferred from the list itself.
@@ -104,6 +109,10 @@ class Settings(BaseSettings):
104
  raise ValueError("chunk_size must stay well below the client's 4 MiB default limit")
105
  return self
106
 
 
 
 
 
107
  def layout_defaults(self):
108
  """Layout for formats that cannot carry one (plan §6.3)."""
109
  from .catalog.model import LayoutConfig
 
41
  catalog_path: Path | None = None
42
  catalog_url: str | None = None
43
  catalog_refresh_seconds: float = 900.0
44
+ # More catalog files, merged with the one above — e.g. a URL list the
45
+ # catalog-sync sidecar writes, next to a YAML catalog from a ConfigMap. Each is
46
+ # polled and guarded on its own. Comma-separated rather than `list[Path]`:
47
+ # pydantic-settings decodes complex types from env as JSON.
48
+ catalog_extra_paths: str = ""
49
 
50
  # A URL list has nowhere to put a layout, so a non-default one comes from the
51
  # environment instead. `manifest` is inferred from the list itself.
 
109
  raise ValueError("chunk_size must stay well below the client's 4 MiB default limit")
110
  return self
111
 
112
+ @property
113
+ def extra_catalog_paths(self) -> list[Path]:
114
+ return [Path(item.strip()) for item in self.catalog_extra_paths.split(",") if item.strip()]
115
+
116
  def layout_defaults(self):
117
  """Layout for formats that cannot carry one (plan §6.3)."""
118
  from .catalog.model import LayoutConfig
docker/hf-space/hf-storage-api/hf_storage_api/store.py CHANGED
@@ -24,6 +24,7 @@ from dataclasses import dataclass
24
  from . import addressing
25
  from .cache import MISSING, Missing, SingleFlightCache
26
  from .catalog.model import Catalog, LayoutConfig, RepoConfig
 
27
  from .hf.client import HFClient, HFNotFound
28
  from .hf.models import TreeEntry
29
  from .settings import Settings
@@ -135,7 +136,9 @@ class AssetStore:
135
  return None
136
 
137
  def top_level_addresses(self) -> list[str]:
138
- return [
 
 
139
  addressing.build(
140
  host=self._catalog.host(repo),
141
  repo_type=repo.repo_type,
@@ -144,14 +147,22 @@ class AssetStore:
144
  scheme=self._settings.uri_scheme,
145
  )
146
  for repo in self._catalog.repos
147
- ]
 
 
 
 
148
 
149
- def repo_for_root(self, address: str) -> RepoConfig | None:
 
 
 
 
 
150
  parsed = self._parse(address)
151
- if parsed is None:
152
- return None
153
- repo = self._catalog.repo(parsed.repo_type, parsed.repo_id)
154
- return repo if repo and self._host_matches(parsed, repo) else None
155
 
156
  def resolve(self, address: str) -> Resolved | None:
157
  """Map an address to its catalogued asset folder, using the catalog only.
@@ -164,26 +175,31 @@ class AssetStore:
164
  parsed = self._parse(address)
165
  if parsed is None:
166
  return None
167
- repo = self._catalog.repo(parsed.repo_type, parsed.repo_id)
168
- if repo is None or not self._host_matches(parsed, repo):
169
- return None
170
- layout = self._catalog.layout_for(repo)
171
- if parsed.revision and _looks_like_sha(repo.revision) and parsed.revision != repo.revision:
172
- # Pinned repo: a wrong revision is answerable from the catalog alone,
173
- # so it costs no upstream request.
174
- return None
175
-
176
- for asset_name in repo.assets:
177
- if addressing.is_within(parsed.path, asset_name):
178
- return Resolved(
179
- repo=repo,
180
- layout=layout,
181
- asset_name=asset_name,
182
- path=parsed.path,
183
- is_folder=parsed.is_folder,
184
- address_revision=parsed.revision,
185
  )
186
- return None
 
 
 
 
 
187
 
188
  def is_repo_root(self, address: str) -> bool:
189
  """Is this the root of a served repo, with or without a trailing slash?"""
@@ -212,6 +228,14 @@ class AssetStore:
212
  revision=self._emit_revision(repo, revision),
213
  )
214
 
 
 
 
 
 
 
 
 
215
  def _host_matches(self, parsed, repo: RepoConfig) -> bool:
216
  """The address must name the host this repo is configured against.
217
 
@@ -364,7 +388,11 @@ class AssetStore:
364
 
365
  ``catalog_kind="manifest"``: ``root_usds.json`` where it exists, else the
366
  naming convention (plan §3.3 — the index file was absent on 2 of 4
367
- sampled assets)."""
 
 
 
 
368
  if layout.catalog_kind == "wrapp":
369
  manifest_path = f"{asset_name}/{layout.output_manifest}"
370
  payload = (
@@ -428,7 +456,10 @@ class AssetStore:
428
  # entries[] are relative to the asset folder.
429
  return f"{asset_name}/{str(entries[0]).lstrip('/')}"
430
  logger.warning("%s has an empty entries[]; falling back to the naming convention", index_path)
431
- return f"{asset_name}/{layout.render(layout.content_fallback, asset_name)}"
 
 
 
432
 
433
  async def _sidecar_json(self, repo: RepoConfig, revision: str, path: str) -> dict | None:
434
  """Fetch and parse a small JSON sidecar. Immutable within a revision."""
@@ -528,10 +559,6 @@ class AssetStore:
528
  }
529
 
530
 
531
- def _looks_like_sha(revision: str) -> bool:
532
- return len(revision) == 40 and all(c in "0123456789abcdef" for c in revision.lower())
533
-
534
-
535
  def _top_level_usd(asset_name: str, files: Mapping[str, TreeEntry]) -> str | None:
536
  """The one USD file (``.usd``/``.usda``/``.usdc``/``.usdz``) sitting directly
537
  at the asset folder's root, if there is exactly one candidate set to choose
@@ -547,6 +574,30 @@ def _top_level_usd(asset_name: str, files: Mapping[str, TreeEntry]) -> str | Non
547
  return top_level_usds[0] if top_level_usds else None
548
 
549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  def _wrapp_folder_basename(asset_name: str) -> str:
551
  """The ``.wrapp`` file's own name embeds the asset folder's basename, not
552
  the full ``asset_name`` — which under ``catalog_kind: wrapp`` is
 
24
  from . import addressing
25
  from .cache import MISSING, Missing, SingleFlightCache
26
  from .catalog.model import Catalog, LayoutConfig, RepoConfig
27
+ from .catalog.model import looks_like_sha as _looks_like_sha
28
  from .hf.client import HFClient, HFNotFound
29
  from .hf.models import TreeEntry
30
  from .settings import Settings
 
136
  return None
137
 
138
  def top_level_addresses(self) -> list[str]:
139
+ """One root per repo, however many revisions of it are served: the root
140
+ carries no revision, and its listing spans all of them."""
141
+ addresses = (
142
  addressing.build(
143
  host=self._catalog.host(repo),
144
  repo_type=repo.repo_type,
 
147
  scheme=self._settings.uri_scheme,
148
  )
149
  for repo in self._catalog.repos
150
+ )
151
+ return list(dict.fromkeys(addresses))
152
+
153
+ def repos_for_root(self, address: str) -> list[RepoConfig]:
154
+ """Every revision served under this repo root; empty if it is not one.
155
 
156
+ An address *inside* the repo is not its root: ``ListStat`` and
157
+ ``Enumerate`` answer a root with the flat crawl surface, so matching a
158
+ nested folder here would hand ovrtx's client the crawl surface for an
159
+ asset's own folder — it stats a file by listing its parent, finds the
160
+ file missing, and fails every load.
161
+ """
162
  parsed = self._parse(address)
163
+ if parsed is None or parsed.path != "":
164
+ return []
165
+ return self._repos_named(parsed)
 
166
 
167
  def resolve(self, address: str) -> Resolved | None:
168
  """Map an address to its catalogued asset folder, using the catalog only.
 
175
  parsed = self._parse(address)
176
  if parsed is None:
177
  return None
178
+ matches: list[Resolved] = []
179
+ for repo in self._repos_named(parsed):
180
+ if parsed.revision and _looks_like_sha(repo.revision) and parsed.revision != repo.revision:
181
+ # Pinned repo: a wrong revision is answerable from the catalog
182
+ # alone, so it costs no upstream request. With several revisions
183
+ # served, this is also what picks the right one.
184
+ continue
185
+ asset_name = next((a for a in repo.assets if addressing.is_within(parsed.path, a)), None)
186
+ if asset_name is not None:
187
+ matches.append(
188
+ Resolved(
189
+ repo=repo,
190
+ layout=self._catalog.layout_for(repo),
191
+ asset_name=asset_name,
192
+ path=parsed.path,
193
+ is_folder=parsed.is_folder,
194
+ address_revision=parsed.revision,
195
+ )
196
  )
197
+ if len(matches) > 1:
198
+ # A revision-less address for a folder served at several revisions
199
+ # names no one of them; guessing would serve whichever came first.
200
+ logger.debug("%s matches %d revisions; it must name one", address, len(matches))
201
+ return None
202
+ return matches[0] if matches else None
203
 
204
  def is_repo_root(self, address: str) -> bool:
205
  """Is this the root of a served repo, with or without a trailing slash?"""
 
228
  revision=self._emit_revision(repo, revision),
229
  )
230
 
231
+ def _repos_named(self, parsed) -> list[RepoConfig]:
232
+ """Every revision served for the repo an address names, at any depth."""
233
+ return [
234
+ repo
235
+ for repo in self._catalog.repos_for(parsed.repo_type, parsed.repo_id)
236
+ if self._host_matches(parsed, repo)
237
+ ]
238
+
239
  def _host_matches(self, parsed, repo: RepoConfig) -> bool:
240
  """The address must name the host this repo is configured against.
241
 
 
388
 
389
  ``catalog_kind="manifest"``: ``root_usds.json`` where it exists, else the
390
  naming convention (plan §3.3 — the index file was absent on 2 of 4
391
+ sampled assets). When the convention's file is not in the folder either,
392
+ the USD sitting directly in ``data_dir`` — verified 2026-10-08: both
393
+ nvidia/simready-assets@2464ca32 and nvidia/srp-staging-controlled ship
394
+ ``simready_usd/sm_<asset>_01.usd`` with no index on most assets, and the
395
+ convention's ``simready_usd/<asset>.usd`` on none."""
396
  if layout.catalog_kind == "wrapp":
397
  manifest_path = f"{asset_name}/{layout.output_manifest}"
398
  payload = (
 
456
  # entries[] are relative to the asset folder.
457
  return f"{asset_name}/{str(entries[0]).lstrip('/')}"
458
  logger.warning("%s has an empty entries[]; falling back to the naming convention", index_path)
459
+ convention = f"{asset_name}/{layout.render(layout.content_fallback, asset_name)}"
460
+ if convention in files:
461
+ return convention
462
+ return _data_dir_usd(asset_name, layout, files) or convention
463
 
464
  async def _sidecar_json(self, repo: RepoConfig, revision: str, path: str) -> dict | None:
465
  """Fetch and parse a small JSON sidecar. Immutable within a revision."""
 
559
  }
560
 
561
 
 
 
 
 
562
  def _top_level_usd(asset_name: str, files: Mapping[str, TreeEntry]) -> str | None:
563
  """The one USD file (``.usd``/``.usda``/``.usdc``/``.usdz``) sitting directly
564
  at the asset folder's root, if there is exactly one candidate set to choose
 
574
  return top_level_usds[0] if top_level_usds else None
575
 
576
 
577
+ def _data_dir_usd(asset_name: str, layout: LayoutConfig, files: Mapping[str, TreeEntry]) -> str | None:
578
+ """The USD file directly inside the asset's ``data_dir``, for ``manifest``
579
+ assets that carry neither a root-USD index nor the conventional
580
+ ``<asset>.usd``.
581
+
582
+ Several candidates happen — a variant, a payload — so the SimReady naming
583
+ ``sm_<asset>_NN.*`` wins over the others, then alphabetical order, which puts
584
+ ``_01`` first.
585
+ """
586
+ data_dir = layout.render("{data_dir}", asset_name).strip("/")
587
+ folder = f"{asset_name}/{data_dir}" if data_dir else asset_name
588
+ candidates = sorted(
589
+ path
590
+ for path in files
591
+ if path.startswith(f"{folder}/")
592
+ and "/" not in path[len(folder) + 1 :]
593
+ and path.endswith((".usd", ".usda", ".usdc", ".usdz"))
594
+ )
595
+ if not candidates:
596
+ return None
597
+ preferred = f"{folder}/sm_{asset_name.rsplit('/', 1)[-1]}_"
598
+ return next((path for path in candidates if path.startswith(preferred)), candidates[0])
599
+
600
+
601
  def _wrapp_folder_basename(asset_name: str) -> str:
602
  """The ``.wrapp`` file's own name embeds the asset folder's basename, not
603
  the full ``asset_name`` — which under ``catalog_kind: wrapp`` is
docker/hf-space/nginx.conf.template CHANGED
@@ -73,11 +73,10 @@ server {
73
  proxy_pass http://127.0.0.1:8000;
74
  }
75
 
76
- # SimReady Central's validator (supervisord's deeptag-validate, 127.0.0.1:8020) answers here in
77
- # place of deepsearch-api's: the same judge plus each hit's path and caption and a verdict cache.
78
  location /vlm_validate/search_result {
79
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
80
- proxy_pass http://127.0.0.1:8020;
 
81
  }
82
 
83
  location /llm_parse/ {
@@ -86,11 +85,14 @@ server {
86
  proxy_pass http://127.0.0.1:8000;
87
  }
88
 
 
 
 
 
89
  location /download {
90
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
91
- rewrite ^/download(.*)$ /v3/download$1 break;
92
- proxy_pass http://127.0.0.1:8000;
93
- # Bundling an asset + all its dependencies can take longer than a single thumbnail.
94
  proxy_read_timeout 300s;
95
  }
96
 
@@ -263,12 +265,13 @@ server {
263
  try_files /category_hierarchy.json @slim_not_found;
264
  }
265
  # One request per result page (QA 6881558: Hugging Face limits each user to ~2,000 requests a minute,
266
- # and a page of per-image checks took 50): SimReady Central's validator (supervisord deeptag-validate,
267
- # 127.0.0.1:8020) streams one NDJSON verdict per hit, fetching each thumbnail through /images above
268
- # (SHIM_GATEWAY_BASE_URL). Unbuffered so verdicts land as they come; heartbeats keep the stream open.
269
  location = /vlm_validate/batch_stream {
270
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
271
- proxy_pass http://127.0.0.1:8020;
 
272
  proxy_buffering off;
273
  proxy_read_timeout 120s;
274
  gzip off;
 
73
  proxy_pass http://127.0.0.1:8000;
74
  }
75
 
 
 
76
  location /vlm_validate/search_result {
77
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
78
+ rewrite ^/vlm_validate/search_result(.*)$ /v3/deepsearch/vlm_validate/search_result$1 break;
79
+ proxy_pass http://127.0.0.1:8000;
80
  }
81
 
82
  location /llm_parse/ {
 
85
  proxy_pass http://127.0.0.1:8000;
86
  }
87
 
88
+ # package-download (127.0.0.1:8020, pipeline/package_download/), not deepsearch-api: it answers
89
+ # /download/asset with the asset's whole SimReady package folder, same response contract, and
90
+ # forwards scope=dependencies (and anything outside a catalogued package) to deepsearch-api's
91
+ # own /v3/download/asset. It serves the /download/... paths itself, so no rewrite.
92
  location /download {
93
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
94
+ proxy_pass http://127.0.0.1:8020;
95
+ # Fetching a whole package (or a forwarded dependency bundle) can take longer than a single thumbnail.
 
96
  proxy_read_timeout 300s;
97
  }
98
 
 
265
  try_files /category_hierarchy.json @slim_not_found;
266
  }
267
  # One request per result page (QA 6881558: Hugging Face limits each user to ~2,000 requests a minute,
268
+ # and a page of per-image checks took 50): deepsearch-api's /vlm_validate/search_result_batch streams one
269
+ # NDJSON verdict per hit. The Explorer calls this path; the request and the lines are the same.
270
+ # Unbuffered so verdicts land as they come; heartbeats keep the stream open.
271
  location = /vlm_validate/batch_stream {
272
  if ($request_method = OPTIONS) { add_header Content-Length 0 always; add_header Content-Type "text/plain" always; return 204; }
273
+ rewrite ^ /v3/deepsearch/vlm_validate/search_result_batch break;
274
+ proxy_pass http://127.0.0.1:8000;
275
  proxy_buffering off;
276
  proxy_read_timeout 120s;
277
  gzip off;
docker/hf-space/siglip2-model/SOURCE.txt CHANGED
@@ -1,4 +1,4 @@
1
  # Generated by scripts/extract_siglip2_model.sh — do not hand-edit.
2
- source_repo=/tmp/usdsearch-rc7
3
- source_commit=b045c1eb8d86f997c51ec454ba0506247af9c511
4
- extracted_at=2026-10-02T14:40:11Z
 
1
  # Generated by scripts/extract_siglip2_model.sh — do not hand-edit.
2
+ source_repo=/tmp/claude-1000/-home-arozantsev-Documents-projects-DeepSearch-simready-central-indexing-internal/4b40209a-9038-43e7-b70a-ed77e9f3ffdf/scratchpad/usdsearch-d94e39e4
3
+ source_commit=d94e39e458b982ec1854ceea6a6e948f995944f5
4
+ extracted_at=2026-10-08T10:11:36Z
docker/hf-space/siglip2-model/pyproject.toml CHANGED
@@ -11,4 +11,5 @@ dependencies = [
11
  "wheel>=0.46.3",
12
  "urllib3>=2.8.0",
13
  "starlette>=1.3.1",
 
14
  ]
 
11
  "wheel>=0.46.3",
12
  "urllib3>=2.8.0",
13
  "starlette>=1.3.1",
14
+ "fsspec>=2026.9.0",
15
  ]
docker/hf-space/siglip2-model/uv.lock CHANGED
@@ -65,11 +65,11 @@ wheels = [
65
 
66
  [[package]]
67
  name = "fsspec"
68
- version = "2026.4.0"
69
  source = { registry = "https://pypi.org/simple" }
70
- sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" }
71
  wheels = [
72
- { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" },
73
  ]
74
 
75
  [[package]]
@@ -462,6 +462,7 @@ name = "siglip2-triton-server"
462
  version = "0.1.0"
463
  source = { virtual = "." }
464
  dependencies = [
 
465
  { name = "numpy" },
466
  { name = "pillow" },
467
  { name = "starlette" },
@@ -473,6 +474,7 @@ dependencies = [
473
 
474
  [package.metadata]
475
  requires-dist = [
 
476
  { name = "numpy" },
477
  { name = "pillow", specifier = ">=12.3.0" },
478
  { name = "starlette", specifier = ">=1.3.1" },
 
65
 
66
  [[package]]
67
  name = "fsspec"
68
+ version = "2026.9.0"
69
  source = { registry = "https://pypi.org/simple" }
70
+ sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" }
71
  wheels = [
72
+ { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" },
73
  ]
74
 
75
  [[package]]
 
462
  version = "0.1.0"
463
  source = { virtual = "." }
464
  dependencies = [
465
+ { name = "fsspec" },
466
  { name = "numpy" },
467
  { name = "pillow" },
468
  { name = "starlette" },
 
474
 
475
  [package.metadata]
476
  requires-dist = [
477
+ { name = "fsspec", specifier = ">=2026.9.0" },
478
  { name = "numpy" },
479
  { name = "pillow", specifier = ">=12.3.0" },
480
  { name = "starlette", specifier = ">=1.3.1" },
docker/hf-space/supervisord-gpu.conf CHANGED
@@ -110,7 +110,22 @@ command=/bin/sh -c '\
110
  exec sleep infinity; \
111
  fi; \
112
  exec /app/.venv/bin/python -m hf_storage_api'
113
- environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  directory=/app
115
  user=1000
116
  autorestart=true
@@ -155,25 +170,6 @@ stdout_logfile_maxbytes=0
155
  stderr_logfile=/dev/stderr
156
  stderr_logfile_maxbytes=0
157
 
158
- [program:deeptag-validate]
159
- ; SimReady Central's validator (docker/hf-space/deeptag-validate/app.py, vendored from
160
- ; simready-central deeptag-validate/, !582) answers /vlm_validate/search_result in place of
161
- ; deepsearch-api's: the same Yes/No log-prob judge on the same engine, plus each hit's path
162
- ; and caption in words-only checks (JUDGE_ASSET_CONTEXT) and an in-memory verdict cache
163
- ; (VALIDATE_CACHE_SIZE, ~120 MB at 500k entries). /vlm_validate/batch_stream (one request per page)
164
- ; fetches each thumbnail through nginx's /images (SHIM_GATEWAY_BASE_URL). The engine settings are deepsearch-api's
165
- ; USDSEARCH_* variables, read when the shell execs (an unset one never stops supervisord).
166
- command=/usr/bin/env -u PYTHONPATH /bin/sh -c 'RERANKER_BASE_URL="$USDSEARCH_LLM_BASE_URL" RERANKER_MODEL="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_NAME="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_API_KEY="$USDSEARCH_LLM_API_KEY" exec /app/.venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8020 --log-level warning'
167
- environment=JUDGE_ASSET_CONTEXT="path+caption",VALIDATE_CACHE_SIZE="500000",VALIDATE_MAX_EDGE="448",RERANKER_MAX_CONCURRENCY="32",RERANKER_BATCH_CONCURRENCY="16",RERANKER_COVER_MIN="0.2",SHIM_GATEWAY_BASE_URL="http://127.0.0.1:%(ENV_PORT)s"
168
- directory=/app/deeptag-validate
169
- user=1000
170
- autorestart=true
171
- startsecs=3
172
- stdout_logfile=/dev/stdout
173
- stdout_logfile_maxbytes=0
174
- stderr_logfile=/dev/stderr
175
- stderr_logfile_maxbytes=0
176
-
177
  [program:nginx]
178
  command=/bin/sh -c "until curl -sf http://localhost:8000/health >/dev/null 2>&1 && curl -sf http://localhost:8010/docs >/dev/null 2>&1; do sleep 2; done; dl=false; case '%(ENV_DOWNLOAD_ENABLED)s' in [Tt]rue|1) if [ -f /app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml ]; then dl=true; else echo 'nginx: no hf-storage-api catalog, rendering download:false' >&2; fi;; esac; sed -e 's/__PORT__/%(ENV_PORT)s/' -e 's|__CORPUS_NAME__|%(ENV_CORPUS_NAME)s|g' -e 's/__DOWNLOAD_ENABLED__/'$dl'/' -e 's|__SUBMIT_CONTENT_URL__|%(ENV_SUBMIT_CONTENT_URL)s|g' /etc/nginx/hf-space.conf.template > /etc/nginx/conf.d/hf-space.conf && exec nginx -g 'daemon off;'"
179
  user=root
 
110
  exec sleep infinity; \
111
  fi; \
112
  exec /app/.venv/bin/python -m hf_storage_api'
113
+ environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_ADVERTISE_FILEFOLDER="true",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
114
+ directory=/app
115
+ user=1000
116
+ autorestart=true
117
+ startsecs=5
118
+ stdout_logfile=/dev/stdout
119
+ stdout_logfile_maxbytes=0
120
+ stderr_logfile=/dev/stderr
121
+ stderr_logfile_maxbytes=0
122
+
123
+ [program:package-download]
124
+ ; See docker/hf-space/supervisord.conf's own copy of this program for the full explanation (identical here
125
+ ; apart from `env -u PYTHONPATH`, like this file's other venv programs).
126
+ command=/usr/bin/env -u PYTHONPATH /bin/sh -c '\
127
+ until [ -f /app/hf-space-pipeline/.build/restore/.download-complete ]; do sleep 2; done; \
128
+ cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.package_download'
129
  directory=/app
130
  user=1000
131
  autorestart=true
 
170
  stderr_logfile=/dev/stderr
171
  stderr_logfile_maxbytes=0
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  [program:nginx]
174
  command=/bin/sh -c "until curl -sf http://localhost:8000/health >/dev/null 2>&1 && curl -sf http://localhost:8010/docs >/dev/null 2>&1; do sleep 2; done; dl=false; case '%(ENV_DOWNLOAD_ENABLED)s' in [Tt]rue|1) if [ -f /app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml ]; then dl=true; else echo 'nginx: no hf-storage-api catalog, rendering download:false' >&2; fi;; esac; sed -e 's/__PORT__/%(ENV_PORT)s/' -e 's|__CORPUS_NAME__|%(ENV_CORPUS_NAME)s|g' -e 's/__DOWNLOAD_ENABLED__/'$dl'/' -e 's|__SUBMIT_CONTENT_URL__|%(ENV_SUBMIT_CONTENT_URL)s|g' /etc/nginx/hf-space.conf.template > /etc/nginx/conf.d/hf-space.conf && exec nginx -g 'daemon off;'"
175
  user=root
docker/hf-space/supervisord.conf CHANGED
@@ -79,10 +79,15 @@ stderr_logfile_maxbytes=0
79
  ; Read-only Omniverse Storage API gRPC server backed by a HuggingFace
80
  ; dataset (docker/hf-space/hf-storage-api/, vendored+regenerated by
81
  ; scripts/extract_hf_storage_api.sh — see that script and
82
- ; docs/hf-space-deployment.md for why). Backs deepsearch-api's
83
- ; existing GET /v3/download/asset route (search_utils.storage_client's
84
- ; STORAGE_API_GRPC_ENDPOINT, baked in Dockerfile.hf-space) for a
85
- ; storage_api-backend corpus. Bound to loopback only, like every other
 
 
 
 
 
86
  ; backend process in this container — nginx is the sole externally-proxied
87
  ; port, and gRPC never needs to cross it (same shape as the GPU variant's
88
  ; own local siglip2-triton on :8001).
@@ -108,7 +113,31 @@ command=/bin/sh -c '\
108
  exec sleep infinity; \
109
  fi; \
110
  exec /app/.venv/bin/python -m hf_storage_api'
111
- environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  directory=/app
113
  user=1000
114
  autorestart=true
@@ -159,25 +188,6 @@ stdout_logfile_maxbytes=0
159
  stderr_logfile=/dev/stderr
160
  stderr_logfile_maxbytes=0
161
 
162
- [program:deeptag-validate]
163
- ; SimReady Central's validator (docker/hf-space/deeptag-validate/app.py, vendored from
164
- ; simready-central deeptag-validate/, !582) answers /vlm_validate/search_result in place of
165
- ; deepsearch-api's: the same Yes/No log-prob judge on the same engine, plus each hit's path
166
- ; and caption in words-only checks (JUDGE_ASSET_CONTEXT) and an in-memory verdict cache
167
- ; (VALIDATE_CACHE_SIZE, ~120 MB at 500k entries). /vlm_validate/batch_stream (one request per page)
168
- ; fetches each thumbnail through nginx's /images (SHIM_GATEWAY_BASE_URL). The engine settings are deepsearch-api's
169
- ; USDSEARCH_* variables, read when the shell execs (an unset one never stops supervisord).
170
- command=/usr/bin/env -u PYTHONPATH /bin/sh -c 'RERANKER_BASE_URL="$USDSEARCH_LLM_BASE_URL" RERANKER_MODEL="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_NAME="$USDSEARCH_VISION_VALIDATION_MODEL" RERANKER_API_KEY="$USDSEARCH_LLM_API_KEY" exec /app/.venv/bin/python -m uvicorn app:app --host 127.0.0.1 --port 8020 --log-level warning'
171
- environment=JUDGE_ASSET_CONTEXT="path+caption",VALIDATE_CACHE_SIZE="500000",VALIDATE_MAX_EDGE="448",RERANKER_MAX_CONCURRENCY="32",RERANKER_BATCH_CONCURRENCY="16",RERANKER_COVER_MIN="0.2",SHIM_GATEWAY_BASE_URL="http://127.0.0.1:%(ENV_PORT)s"
172
- directory=/app/deeptag-validate
173
- user=1000
174
- autorestart=true
175
- startsecs=3
176
- stdout_logfile=/dev/stdout
177
- stdout_logfile_maxbytes=0
178
- stderr_logfile=/dev/stderr
179
- stderr_logfile_maxbytes=0
180
-
181
  [program:nginx]
182
  ; nginx is the *only* process bound to the externally-proxied port, and
183
  ; Hugging Face's own Space "ready" detection is just "does something answer
 
79
  ; Read-only Omniverse Storage API gRPC server backed by a HuggingFace
80
  ; dataset (docker/hf-space/hf-storage-api/, vendored+regenerated by
81
  ; scripts/extract_hf_storage_api.sh — see that script and
82
+ ; docs/hf-space-deployment.md for why). Backs package-download's
83
+ ; whole-package downloads (below; FileFolder ListStat + ReadFromAddress —
84
+ ; hence HF_ADAPTER_ADVERTISE_FILEFOLDER, off by default upstream) and
85
+ ; deepsearch-api's own GET /v3/download/asset route (search_utils.
86
+ ; storage_client's STORAGE_API_GRPC_ENDPOINT, baked in Dockerfile.hf-space;
87
+ ; it ignores FileFolder, STORAGE_API_IGNORE_FILEFOLDER_API=true) for a
88
+ ; storage_api-backend corpus. Needs hf-storage-api >= 417d9af for that
89
+ ; (sub-folder ListStat and percent-encoded Hub paths, hf-storage-api-service#1
90
+ ; — see docs/development.md). Bound to loopback only, like every other
91
  ; backend process in this container — nginx is the sole externally-proxied
92
  ; port, and gRPC never needs to cross it (same shape as the GPU variant's
93
  ; own local siglip2-triton on :8001).
 
113
  exec sleep infinity; \
114
  fi; \
115
  exec /app/.venv/bin/python -m hf_storage_api'
116
+ environment=PYTHONPATH="/app/hf-storage-api/src",HF_ADAPTER_GRPC_HOST="127.0.0.1",HF_ADAPTER_GRPC_PORT="50051",HF_ADAPTER_CATALOG_SOURCE="file",HF_ADAPTER_CATALOG_PATH="/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml",HF_ADAPTER_CATALOG_FORMAT="yaml",HF_ADAPTER_HF_ENDPOINT="https://huggingface.co",HF_ADAPTER_URI_SCHEME="hf",HF_ADAPTER_ALLOW_ANONYMOUS="true",HF_ADAPTER_LOG_LEVEL="INFO",HF_ADAPTER_ADVERTISE_FILEFOLDER="true",HF_ADAPTER_HF_TOKEN="%(ENV_HF_STORAGE_API_TOKEN)s"
117
+ directory=/app
118
+ user=1000
119
+ autorestart=true
120
+ startsecs=5
121
+ stdout_logfile=/dev/stdout
122
+ stdout_logfile_maxbytes=0
123
+ stderr_logfile=/dev/stderr
124
+ stderr_logfile_maxbytes=0
125
+
126
+ [program:package-download]
127
+ ; Whole-package downloads (pipeline/package_download/, docs/plans/hf-space-package-download.md). nginx's
128
+ ; /download location proxies here instead of deepsearch-api: GET /download/asset returns the asset's whole
129
+ ; SimReady package folder (package marker, .metadata/, sidecar JSONs, thumbnails — everything on HF), read
130
+ ; through hf-storage-api above (its FileFolder service is on for this: HF_ADAPTER_ADVERTISE_FILEFOLDER), with
131
+ ; usdsearch's response contract. scope=dependencies, and any URL outside a catalogued package, is forwarded
132
+ ; unchanged to deepsearch-api's own /v3/download/asset. Loopback only. Holds no HF token.
133
+ ;
134
+ ; Waits for the seed's restore marker (.download-complete: the whole artifact set, catalog included, is on
135
+ ; disk — the catalog file alone can be seen half-written), then loads the same catalog hf-storage-api serves
136
+ ; from. Without one (an s3-backend corpus, or a run that has none) it forwards every request, so /download
137
+ ; behaves as it did before this program existed.
138
+ command=/bin/sh -c '\
139
+ until [ -f /app/hf-space-pipeline/.build/restore/.download-complete ]; do sleep 2; done; \
140
+ cd /app/hf-space-pipeline && exec /app/.venv/bin/python -m pipeline.package_download'
141
  directory=/app
142
  user=1000
143
  autorestart=true
 
188
  stderr_logfile=/dev/stderr
189
  stderr_logfile_maxbytes=0
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  [program:nginx]
192
  ; nginx is the *only* process bound to the externally-proxied port, and
193
  ; Hugging Face's own Space "ready" detection is just "does something answer
docker/hf-space/wheels/SOURCE.txt CHANGED
@@ -1,3 +1,3 @@
1
  # Generated by scripts/extract_usdsearch_wheels.sh — do not hand-edit.
2
- source_image=nvcr.io/m3sujtetvf5w/usdsearch/usdsearch:1.5.0-rc.7
3
- extracted_at=2026-10-02T14:39:50Z
 
1
  # Generated by scripts/extract_usdsearch_wheels.sh — do not hand-edit.
2
+ source_image=gitlab-master.nvidia.com:5005/omniverse/deeptag/usdsearch/usdsearch:fd61a332
3
+ extracted_at=2026-10-09T14:24:46Z
docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl CHANGED
Binary files a/docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/asset_graph_client-0.1.0-py3-none-any.whl differ
 
docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl CHANGED
Binary files a/docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/asset_graph_service-0.1.0-py3-none-any.whl differ
 
docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl CHANGED
Binary files a/docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/base_logging-0.1.0-py3-none-any.whl differ
 
docker/hf-space/wheels/constraints.txt CHANGED
@@ -37,7 +37,7 @@ fastapi-swagger==0.4.48
37
  filelock==3.29.0
38
  fire==0.7.1
39
  frozenlist==1.8.0
40
- fsspec==2026.3.0
41
  googleapis-common-protos==1.74.0
42
  greenlet==3.4.0
43
  grpcio==1.80.0
@@ -67,7 +67,7 @@ langchain-text-splitters==1.1.2
67
  langgraph==1.2.11
68
  langgraph-checkpoint==4.1.1
69
  langgraph-prebuilt==1.1.0
70
- langgraph-sdk==0.4.2
71
  langsmith==0.8.18
72
  markdown-it-py==4.0.0
73
  MarkupSafe==3.0.3
 
37
  filelock==3.29.0
38
  fire==0.7.1
39
  frozenlist==1.8.0
40
+ fsspec==2026.9.0
41
  googleapis-common-protos==1.74.0
42
  greenlet==3.4.0
43
  grpcio==1.80.0
 
67
  langgraph==1.2.11
68
  langgraph-checkpoint==4.1.1
69
  langgraph-prebuilt==1.1.0
70
+ langgraph-sdk==0.4.6
71
  langsmith==0.8.18
72
  markdown-it-py==4.0.0
73
  MarkupSafe==3.0.3
docker/hf-space/wheels/deepsearch_api-0.1.0-py3-none-any.whl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:57dcbdda688fd56743accba5eca5c5483677c8ce4408c765d31ea6991b795cae
3
- size 214351
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:380e2f6e9f261b3aedc41c6f3647017002cf68209fed3577eef9e3157209a272
3
+ size 221113
docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl CHANGED
Binary files a/docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/llm_client-0.1.0-py3-none-any.whl differ
 
docker/hf-space/wheels/search_utils-0.1.0-py3-none-any.whl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:123f44763c3c3e1561c1e3fec9dd4fac24305befa0a310c218725259a5af2e00
3
- size 286723
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:35f1cc7cf94f7bf845036cd7332ce2888944eb1c1309532b8863f9e7097c3092
3
+ size 286722
docker/hf-space/wheels/siglip2_triton_client-0.6.1-py3-none-any.whl CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:437965e33a16a83dd5540a5aad40ddbb3008226197cb92df6702095c55ed8ea6
3
- size 5482684
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:daba44a4ac31b836f233e1e7945ab5aaa8b8a2ebcf2702375166f87248f49fc0
3
+ size 5482694
docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl CHANGED
Binary files a/docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl and b/docker/hf-space/wheels/storage_api-0.1.0-py3-none-any.whl differ
 
licenses/fsspec-2026.4.0.txt DELETED
@@ -1,29 +0,0 @@
1
- BSD 3-Clause License
2
-
3
- Copyright (c) 2018, Martin Durant
4
- All rights reserved.
5
-
6
- Redistribution and use in source and binary forms, with or without
7
- modification, are permitted provided that the following conditions are met:
8
-
9
- * Redistributions of source code must retain the above copyright notice, this
10
- list of conditions and the following disclaimer.
11
-
12
- * Redistributions in binary form must reproduce the above copyright notice,
13
- this list of conditions and the following disclaimer in the documentation
14
- and/or other materials provided with the distribution.
15
-
16
- * Neither the name of the copyright holder nor the names of its
17
- contributors may be used to endorse or promote products derived from
18
- this software without specific prior written permission.
19
-
20
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
- AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
- DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
- SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
- CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
- OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
- OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
licenses/{fsspec-2026.3.0.txt → fsspec-2026.9.0.txt} RENAMED
File without changes
licenses/{langgraph-sdk-0.4.2.txt → langgraph-sdk-0.4.6.txt} RENAMED
File without changes
licenses/{os-redis-server-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-server-5-7.0.15-1ubuntu0.24.04.5.txt} RENAMED
File without changes
licenses/{os-redis-tools-5-7.0.15-1ubuntu0.24.04.4.txt → os-redis-tools-5-7.0.15-1ubuntu0.24.04.5.txt} RENAMED
File without changes
pipeline/bake.py CHANGED
@@ -408,20 +408,31 @@ def run(cfg: Config, push: bool = False) -> int:
408
 
409
  def download_smoke(base_url: str, timeout_s: float = 180.0) -> bool:
410
  """A storage_api corpus's download route answers for a real asset: search
411
- one hit, then ask /download/asset for its manifest (sizes only, no bytes).
412
- Polls, because hf-storage-api starts only once the seed restored its
413
- catalog."""
 
 
 
414
  import time
415
  import httpx
 
 
 
 
 
 
416
  deadline = time.monotonic() + timeout_s
417
  while time.monotonic() < deadline:
418
  try:
419
  hits = httpx.get(f"{base_url}/search", params={"query": "box", "limit": 1}, timeout=15.0).json()
420
  asset_url = hits[0]["url"] if hits else None
421
  if asset_url:
422
- resp = httpx.get(f"{base_url}/download/asset", params={"asset_url": asset_url, "manifest_only": "true"},
423
- timeout=60.0)
424
- if resp.status_code == 200 and (resp.json().get("file_count") or 0) > 0:
 
 
425
  return True
426
  except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError):
427
  pass
@@ -641,9 +652,9 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
641
  download_ok = True
642
  if healthy and manifest.get("storage_backend") == "storage_api":
643
  download_ok = download_smoke("http://localhost:17860")
644
- print(f" /download/asset manifest for a real asset: {download_ok}")
645
  if not download_ok:
646
- print(" FAIL: hf-space image's download route (hf-storage-api) does not answer")
647
  ok = False
648
 
649
  # Returned asset URLs are this run's public huggingface.co links
 
408
 
409
  def download_smoke(base_url: str, timeout_s: float = 180.0) -> bool:
410
  """A storage_api corpus's download route answers for a real asset: search
411
+ one hit, then ask /download/asset for its manifest (sizes only, no bytes) —
412
+ once as the default whole-package download (package-download, through
413
+ hf-storage-api's folder listing: scope "package", the asset itself among
414
+ the files) and once as scope=dependencies (forwarded to deepsearch-api's
415
+ own asset + dependencies bundle). Polls, because hf-storage-api and
416
+ package-download start only once the seed restored the catalog."""
417
  import time
418
  import httpx
419
+
420
+ def preview(asset_url: str, **params):
421
+ resp = httpx.get(f"{base_url}/download/asset",
422
+ params={"asset_url": asset_url, "manifest_only": "true", **params}, timeout=60.0)
423
+ return resp.json() if resp.status_code == 200 else None
424
+
425
  deadline = time.monotonic() + timeout_s
426
  while time.monotonic() < deadline:
427
  try:
428
  hits = httpx.get(f"{base_url}/search", params={"query": "box", "limit": 1}, timeout=15.0).json()
429
  asset_url = hits[0]["url"] if hits else None
430
  if asset_url:
431
+ package = preview(asset_url)
432
+ deps = preview(asset_url, scope="dependencies")
433
+ if (package and package.get("scope") == "package" and (package.get("file_count") or 0) > 0
434
+ and asset_url in {f["url"] for f in package["files"]}
435
+ and deps and (deps.get("file_count") or 0) > 0):
436
  return True
437
  except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError):
438
  pass
 
652
  download_ok = True
653
  if healthy and manifest.get("storage_backend") == "storage_api":
654
  download_ok = download_smoke("http://localhost:17860")
655
+ print(f" /download/asset package + dependencies manifests for a real asset: {download_ok}")
656
  if not download_ok:
657
+ print(" FAIL: hf-space image's download route (package-download / hf-storage-api) does not answer")
658
  ok = False
659
 
660
  # Returned asset URLs are this run's public huggingface.co links
pipeline/config.py CHANGED
@@ -51,6 +51,13 @@ HF_SPACE_GPU_HARDWARE_DEFAULT = "t4-medium"
51
  STORAGE_BACKENDS = ("s3", "storage_api")
52
  DEFAULT_STORAGE_BACKEND = "s3"
53
 
 
 
 
 
 
 
 
54
  REPO_ROOT = Path(__file__).resolve().parent.parent
55
  BUILD_DIR = REPO_ROOT / ".build"
56
  DEFAULT_ENV_FILE = REPO_ROOT / "config" / "pipeline.env"
@@ -386,6 +393,16 @@ class Config:
386
  "USDSEARCH_VISION_VALIDATION_LOGPROB_TOP_LOGPROBS": "20",
387
  "USDSEARCH_VISION_VALIDATION_LOGPROB_MAX_TOKENS": "1",
388
  "USDSEARCH_VISION_VALIDATION_MAX_TRIES": "2",
 
 
 
 
 
 
 
 
 
 
389
  }
390
 
391
  # ── VLM metadata extraction from thumbnails (indexing-time plugin) ──────
 
51
  STORAGE_BACKENDS = ("s3", "storage_api")
52
  DEFAULT_STORAGE_BACKEND = "s3"
53
 
54
+ # Content roots the validator's path fact reads below (usdsearch logprob_asset_path_roots): a Hugging Face dataset
55
+ # at any revision (hf://huggingface.co/datasets/<org>/<ds>/resolve/<rev>/...) and Isaac content at any release.
56
+ VALIDATION_ASSET_PATH_ROOTS = [
57
+ r"(?i)^(?:[a-z][a-z0-9+.-]*://)?huggingface\.co/datasets/[^/]+/[^/]+/(?:(?:resolve|blob)/[^/]+/)?",
58
+ r"/Isaac/[^/]+/Isaac/",
59
+ ]
60
+
61
  REPO_ROOT = Path(__file__).resolve().parent.parent
62
  BUILD_DIR = REPO_ROOT / ".build"
63
  DEFAULT_ENV_FILE = REPO_ROOT / "config" / "pipeline.env"
 
393
  "USDSEARCH_VISION_VALIDATION_LOGPROB_TOP_LOGPROBS": "20",
394
  "USDSEARCH_VISION_VALIDATION_LOGPROB_MAX_TOKENS": "1",
395
  "USDSEARCH_VISION_VALIDATION_MAX_TRIES": "2",
396
+ # As SimReady Central's validator judged on the Spaces until usdsearch shipped it: views at 448 px,
397
+ # a text query's judge reads each hit's path (below its dataset or Isaac content root) and vision
398
+ # caption, and an image + words query asks whether the candidate matches both (usdsearch !279).
399
+ "USDSEARCH_VISION_VALIDATION_IMAGE_MAX_PX": "448",
400
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_ASSET_FACTS": json.dumps(["path", "vlm_metadata_caption"]),
401
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_ASSET_PATH_ROOTS": json.dumps(VALIDATION_ASSET_PATH_ROOTS),
402
+ "USDSEARCH_VISION_VALIDATION_LOGPROB_IMAGE_TEXT_QUERY": "true",
403
+ # The Redis verdict cache (on: REDIS_URL is set) holds 250k verdicts (~0.5 GB); the Space's Redis
404
+ # has no maxmemory.
405
+ "USDSEARCH_VISION_VALIDATION_CACHE_MAX_ENTRIES": "250000",
406
  }
407
 
408
  # ── VLM metadata extraction from thumbnails (indexing-time plugin) ──────
pipeline/package_download/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Whole-package downloads for the HF Space (supervisord program ``package-download``).
2
+
3
+ nginx's ``/download`` location sends ``GET /download/asset?asset_url=…[&manifest_only=true]`` here instead of
4
+ to deepsearch-api. The response keeps usdsearch's download contract (the JSON preview, the ZIP with a
5
+ ``manifest.json`` at its root, the ``X-Download-Summary`` header, 404/413/503), but the ZIP holds the asset's whole
6
+ SimReady package folder — the nearest ancestor carrying ``com.nvidia.simready.packaging.json``, i.e. the asset
7
+ folder in hf-storage-api's own catalog — rather than the asset plus its USD dependency graph. So the package
8
+ marker, ``.metadata/*.json``, the ``sm_*.json`` sidecar, validation reports, licenses and thumbnails come along,
9
+ laid out exactly as on Hugging Face, contents at the archive root.
10
+
11
+ Bytes come through the in-container hf-storage-api gRPC server (FileFolderService.ListStat + FileObjectService.
12
+ ReadFromAddress), the same server deepsearch-api's own download already reads through: one HF token, one cache,
13
+ one access boundary (only catalogued folders resolve). This process holds no HF token.
14
+
15
+ ``scope=dependencies``, and any URL that is not inside a catalogued package, is forwarded unchanged to
16
+ deepsearch-api's ``/v3/download/asset`` — the old asset + dependencies bundle.
17
+
18
+ Design and decision log: docs/plans/hf-space-package-download.md.
19
+ """
pipeline/package_download/__main__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``python -m pipeline.package_download`` — supervisord's ``package-download`` program."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ import uvicorn
9
+
10
+ from .app import create_app
11
+ from .catalog import Catalog
12
+ from .settings import Settings
13
+
14
+
15
+ def main() -> int:
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ stream=sys.stdout,
19
+ format="%(levelname)s %(asctime)s %(name)s - %(message)s",
20
+ )
21
+ settings = Settings.from_env()
22
+ catalog = None
23
+ if not settings.catalog_path.is_file():
24
+ # An s3-backend corpus (or a run without a catalog): nothing is a package,
25
+ # so every request goes to deepsearch-api — the route behaves as before.
26
+ logging.getLogger("package_download").warning(
27
+ "no hf-storage-api catalog at %s: forwarding every download to deepsearch-api", settings.catalog_path
28
+ )
29
+ catalog = Catalog({})
30
+ uvicorn.run(create_app(settings, catalog=catalog), host=settings.host, port=settings.port, log_config=None)
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
pipeline/package_download/app.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP surface: ``GET /download/asset`` (also ``/v3/download/asset``), ``/health``, ``/metrics``.
2
+
3
+ Drop-in for usdsearch's download route (see the package docstring); nginx proxies ``/download`` here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import dataclasses
11
+ import json
12
+ import logging
13
+ import math
14
+ import shutil
15
+ import tempfile
16
+ import time
17
+ from typing import Any, Awaitable, Callable, Literal, Optional
18
+
19
+ import httpx
20
+ from fastapi import FastAPI, Query, Request
21
+ from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
22
+ from starlette.background import BackgroundTask
23
+
24
+ from . import bundle, telemetry
25
+ from .catalog import Catalog
26
+ from .settings import Settings
27
+
28
+ logger = logging.getLogger("package_download")
29
+
30
+ # Response headers worth passing back from a forwarded deepsearch-api download.
31
+ _FORWARD_RESPONSE_HEADERS = (
32
+ "content-type",
33
+ "content-length",
34
+ "content-encoding",
35
+ "content-disposition",
36
+ "x-download-summary",
37
+ "access-control-expose-headers",
38
+ "retry-after",
39
+ )
40
+ _HOP_BY_HOP = {"host", "connection", "keep-alive", "transfer-encoding", "upgrade", "content-length", "te", "trailer"}
41
+
42
+ # SourceError.kind -> (status, client-facing detail). "denied" is the backend's
43
+ # own HF token being refused, not the caller's access: answered as 502, never 403,
44
+ # which the Explorer would show as "Access denied" for a public asset.
45
+ _SOURCE_STATUS = {
46
+ "not_found": (404, "Asset not found"),
47
+ "denied": (502, "The storage backend could not read this package (check HF_STORAGE_API_TOKEN)."),
48
+ "busy": (503, "The storage backend is rate limited; retry shortly."),
49
+ "unavailable": (503, "The storage backend is unavailable; retry shortly."),
50
+ "error": (502, "The storage backend failed."),
51
+ }
52
+
53
+
54
+ UrlMapping = tuple # (to_internal(url), to_public_document(doc), to_public(url))
55
+
56
+
57
+ def _default_url_mapping() -> UrlMapping:
58
+ """deepsearch-api's own inbound/outbound URL translation (same USDSEARCH_URL_MAPPING_* env)."""
59
+ try:
60
+ from search_utils.url_mapping import rewrite_response_document, to_internal_url, to_public_url
61
+ except ImportError: # local tests without the usdsearch wheels
62
+ logger.warning("search_utils not installed: asset URLs are not translated")
63
+ return (lambda url: url), (lambda doc: doc), (lambda url: url)
64
+ return to_internal_url, rewrite_response_document, to_public_url
65
+
66
+
67
+ def _public_folder(to_public: Callable[[str], str], address: str) -> str:
68
+ """Public link for the package folder. rewrite_response_document only knows its
69
+ own URL fields, and the rules map to HF's file viewer (`/blob/<rev>/`); a folder's
70
+ page is `/tree/<rev>/`."""
71
+ public = to_public(address)
72
+ return public.replace("/blob/", "/tree/", 1) if public != address else public
73
+
74
+
75
+ class _Metrics:
76
+ def __init__(self):
77
+ try:
78
+ from prometheus_client import CollectorRegistry, Counter, Histogram, generate_latest
79
+ except ImportError:
80
+ self.registry = None
81
+ return
82
+ self.registry = CollectorRegistry()
83
+ self._generate = generate_latest
84
+ self.requests = Counter(
85
+ "package_download_requests_total",
86
+ "Download requests by route taken and outcome",
87
+ ["route", "mode", "outcome"],
88
+ registry=self.registry,
89
+ )
90
+ self.bytes = Counter("package_download_bytes_total", "Package bytes written into ZIPs", registry=self.registry)
91
+ self.seconds = Histogram(
92
+ "package_download_seconds",
93
+ "Wall clock of package downloads (headers sent)",
94
+ ["mode"],
95
+ buckets=(0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300),
96
+ registry=self.registry,
97
+ )
98
+
99
+ def observe(self, route: str, mode: str, outcome: str, seconds: Optional[float] = None, nbytes: int = 0):
100
+ if self.registry is None:
101
+ return
102
+ self.requests.labels(route, mode, outcome).inc()
103
+ if route == "package" and seconds is not None:
104
+ self.seconds.labels(mode).observe(seconds)
105
+ if nbytes:
106
+ self.bytes.inc(nbytes)
107
+
108
+ def render(self) -> Response:
109
+ if self.registry is None:
110
+ return Response("prometheus_client not installed\n", media_type="text/plain")
111
+ return Response(self._generate(self.registry), media_type="text/plain; version=0.0.4")
112
+
113
+
114
+ class _CleanupFileResponse(FileResponse):
115
+ """Serves the ZIP, then removes its temp dir and frees the slot — whatever happens.
116
+
117
+ Not Starlette's ``background=``: that only runs after a successful send, so a
118
+ client hanging up mid-download would leak the archive (usdsearch's
119
+ ``_TempDirFileResponse``, same reason).
120
+ """
121
+
122
+ def __init__(self, *args, cleanup_dir: str, on_close: Callable[[], None], **kwargs):
123
+ super().__init__(*args, **kwargs)
124
+ self._cleanup_dir = cleanup_dir
125
+ self._on_close = on_close
126
+
127
+ async def __call__(self, scope, receive, send) -> None:
128
+ try:
129
+ await super().__call__(scope, receive, send)
130
+ finally:
131
+ shutil.rmtree(self._cleanup_dir, ignore_errors=True)
132
+ self._on_close()
133
+
134
+
135
+ class _HTTPError(Exception):
136
+ def __init__(self, status: int, detail: str, headers: Optional[dict] = None):
137
+ self.status, self.detail, self.headers = status, detail, headers or {}
138
+
139
+
140
+ def _source_error(exc: bundle.SourceError) -> _HTTPError:
141
+ status, detail = _SOURCE_STATUS.get(exc.kind, _SOURCE_STATUS["error"])
142
+ if status != 503:
143
+ return _HTTPError(status, detail)
144
+ wait = "30" if exc.retry_after is None else str(math.ceil(exc.retry_after))
145
+ return _HTTPError(status, detail, {"Retry-After": wait})
146
+
147
+
148
+ class _ClientGone(Exception):
149
+ pass
150
+
151
+
152
+ async def _client_gone(request: Request) -> None:
153
+ """Returns once the client has hung up. uvicorn never cancels a handler itself; it
154
+ only answers ``receive()`` with ``http.disconnect`` (after the request's own body)."""
155
+ while (await request.receive())["type"] != "http.disconnect":
156
+ pass
157
+
158
+
159
+ async def _unless_client_gone(request: Request, work: Awaitable[Response]) -> Response:
160
+ """``work``'s response — unless the client hangs up first (or nginx gives up on it):
161
+ then ``work`` is cancelled and awaited, so every read it started has stopped and its
162
+ slot and temp dir are freed, and _ClientGone is raised."""
163
+ task = asyncio.ensure_future(work)
164
+ gone = asyncio.ensure_future(_client_gone(request))
165
+ try:
166
+ await asyncio.wait({task, gone}, return_when=asyncio.FIRST_COMPLETED)
167
+ finally:
168
+ gone.cancel()
169
+ if not task.done():
170
+ task.cancel()
171
+ await asyncio.wait({task})
172
+ if task.cancelled():
173
+ raise _ClientGone
174
+ return task.result()
175
+
176
+
177
+ def create_app(
178
+ settings: Optional[Settings] = None,
179
+ *,
180
+ catalog: Optional[Catalog] = None,
181
+ source: Any = None,
182
+ url_mapping: Optional[UrlMapping] = None,
183
+ fallback_transport: Optional[httpx.AsyncBaseTransport] = None,
184
+ ) -> FastAPI:
185
+ settings = settings or Settings.from_env()
186
+ to_internal, to_public, to_public_url = url_mapping or _default_url_mapping()
187
+
188
+ def public(doc: dict) -> dict:
189
+ doc = to_public(doc)
190
+ doc["package_root"] = _public_folder(to_public_url, doc["package_root"])
191
+ return doc
192
+
193
+ metrics = _Metrics()
194
+
195
+ @contextlib.asynccontextmanager
196
+ async def lifespan(app: FastAPI):
197
+ nonlocal catalog, source
198
+ if catalog is None:
199
+ catalog = Catalog.load(settings.catalog_path)
200
+ owned_source = source is None
201
+ if owned_source:
202
+ from .source import StorageApiSource
203
+
204
+ source = StorageApiSource(
205
+ settings.storage_api_endpoint,
206
+ list_timeout=settings.list_timeout,
207
+ read_timeout=settings.read_timeout,
208
+ )
209
+ app.state.fallback = httpx.AsyncClient(
210
+ base_url=settings.fallback_url,
211
+ timeout=httpx.Timeout(30.0, read=settings.fallback_timeout),
212
+ transport=fallback_transport,
213
+ )
214
+ logger.info(
215
+ "package-download: %d catalogued package(s), storage API %s, fallback %s",
216
+ len(catalog), settings.storage_api_endpoint, settings.fallback_url,
217
+ )
218
+ try:
219
+ yield
220
+ finally:
221
+ await app.state.fallback.aclose()
222
+ if owned_source:
223
+ await source.close()
224
+
225
+ app = FastAPI(title="package-download", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
226
+ # One slot per ZIP being assembled *and* sent: the archive occupies temp_dir until
227
+ # the client has it (usdsearch's DOWNLOAD_MAX_CONCURRENT, same reasoning).
228
+ app.state.slots = slots = asyncio.Semaphore(settings.max_concurrent) if settings.max_concurrent > 0 else None
229
+ # Reads from hf-storage-api in flight across every request — each one a live HF download.
230
+ app.state.connections = connections = (
231
+ asyncio.Semaphore(settings.hf_max_connections) if settings.hf_max_connections > 0 else None
232
+ )
233
+ # Listings in flight, capped apart from the reads: a listing (every manifest_only preview)
234
+ # never queues behind a ZIP's file reads.
235
+ app.state.listings = listings = (
236
+ asyncio.Semaphore(settings.hf_max_listings) if settings.hf_max_listings > 0 else None
237
+ )
238
+ retry = bundle.RetryPolicy(settings.retry_attempts, settings.retry_base_delay, settings.retry_max_delay)
239
+ # hf-storage-api already retries its HF tree fetch (5 tries, waiting out HF's rate-limit reset).
240
+ list_retry = dataclasses.replace(retry, attempts=min(retry.attempts, 2))
241
+ stored_extensions = settings.stored_extension_set()
242
+
243
+ @app.get("/health")
244
+ async def health():
245
+ return {"status": "ok", "packages": len(catalog) if catalog is not None else 0}
246
+
247
+ @app.get("/metrics")
248
+ async def prometheus():
249
+ return metrics.render()
250
+
251
+ async def forward(request: Request) -> Response:
252
+ """Hand the request to deepsearch-api's own /v3/download/asset, unchanged."""
253
+ params = [(k, v) for k, v in request.query_params.multi_items() if k != "scope"]
254
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP}
255
+ client: httpx.AsyncClient = request.app.state.fallback
256
+ try:
257
+ upstream = await client.send(
258
+ client.build_request("GET", "/v3/download/asset", params=params, headers=headers), stream=True
259
+ )
260
+ except httpx.HTTPError as exc:
261
+ logger.warning("forwarding to deepsearch-api failed: %s", exc)
262
+ metrics.observe("forward", "zip", "502")
263
+ return JSONResponse({"detail": "The dependency download service is unavailable."}, status_code=502)
264
+ out_headers = {k: v for k, v in upstream.headers.items() if k.lower() in _FORWARD_RESPONSE_HEADERS}
265
+ return StreamingResponse(
266
+ upstream.aiter_raw(),
267
+ status_code=upstream.status_code,
268
+ headers=out_headers,
269
+ background=BackgroundTask(upstream.aclose),
270
+ )
271
+
272
+ async def acquire_slot() -> Callable[[], None]:
273
+ if slots is None:
274
+ return lambda: None
275
+ try:
276
+ await asyncio.wait_for(slots.acquire(), timeout=settings.queue_timeout)
277
+ except (asyncio.TimeoutError, TimeoutError):
278
+ raise _HTTPError(503, "Too many downloads in progress; retry shortly.", {"Retry-After": "30"})
279
+ released = False
280
+
281
+ def release() -> None:
282
+ nonlocal released
283
+ if not released:
284
+ released = True
285
+ slots.release()
286
+
287
+ return release
288
+
289
+ async def list_package(package) -> list:
290
+ async with bundle.limited(listings):
291
+ return await source.list_package(package)
292
+
293
+ async def package_download(asset_url: str, package, manifest_only: bool, stats: dict) -> Response:
294
+ try:
295
+ listed = await bundle.retrying(lambda: list_package(package), list_retry, package.root_address)
296
+ except bundle.SourceError as exc:
297
+ logger.warning("listing %s failed: %s", package.root_address, exc)
298
+ raise _source_error(exc)
299
+ stats["n_listed"] = len(listed)
300
+ if not any(f.path == package.asset_path for f in listed):
301
+ raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' not found")
302
+ total = sum(f.size for f in listed)
303
+ stats["total_bytes"] = total
304
+
305
+ if manifest_only:
306
+ return JSONResponse(public(bundle.preview(asset_url, package, listed)))
307
+
308
+ if settings.max_bundle_bytes > 0 and total > settings.max_bundle_bytes:
309
+ raise _HTTPError(
310
+ 413, f"Download bundle is too large ({total} bytes; limit is {settings.max_bundle_bytes})."
311
+ )
312
+
313
+ release = await acquire_slot()
314
+ tmp_dir = tempfile.mkdtemp(prefix="package-download-", dir=settings.temp_dir or None)
315
+ try:
316
+ zip_path = f"{tmp_dir}/bundle.zip"
317
+ try:
318
+ result = await bundle.build_zip(
319
+ zip_path,
320
+ package,
321
+ listed,
322
+ source.read,
323
+ concurrency=settings.concurrency,
324
+ compression=settings.compression,
325
+ stored_extensions=stored_extensions,
326
+ spool_bytes=settings.spool_bytes,
327
+ max_bytes=settings.max_bundle_bytes,
328
+ spool_dir=tmp_dir,
329
+ read_ahead_bytes=settings.read_ahead_bytes,
330
+ connections=connections,
331
+ retry=retry,
332
+ )
333
+ except bundle.RootMissing as exc:
334
+ raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' could not be retrieved: {exc}")
335
+ except bundle.BundleTooLarge as exc:
336
+ raise _HTTPError(413, str(exc))
337
+ except bundle.SourceError as exc:
338
+ raise _source_error(exc)
339
+ stats.update(n_files=len(result.files), n_failed=len(result.errors), total_bytes=result.total_bytes,
340
+ fetch_ms=result.fetch_ms)
341
+ doc = public(bundle.manifest(asset_url, package, listed, result))
342
+ await asyncio.get_running_loop().run_in_executor(
343
+ None, bundle.finish_zip, zip_path, bundle.manifest_name(listed, package), doc
344
+ )
345
+ except BaseException:
346
+ # No response will carry these away (includes the client hanging up mid-bundle:
347
+ # _unless_client_gone cancels this).
348
+ shutil.rmtree(tmp_dir, ignore_errors=True)
349
+ release()
350
+ raise
351
+
352
+ return _CleanupFileResponse(
353
+ zip_path,
354
+ media_type="application/zip",
355
+ filename=f"{package.name}.zip",
356
+ headers={
357
+ "X-Download-Summary": json.dumps(doc["summary"]),
358
+ "Access-Control-Expose-Headers": "X-Download-Summary, Content-Disposition",
359
+ },
360
+ cleanup_dir=tmp_dir,
361
+ on_close=release,
362
+ )
363
+
364
+ async def download_asset(
365
+ request: Request,
366
+ asset_url: str = Query(description="Storage URL of the asset (public or internal form)."),
367
+ manifest_only: bool = Query(False, description="Return the JSON preview instead of the ZIP."),
368
+ scope: Literal["package", "dependencies"] = Query(
369
+ "package",
370
+ description="`package` (default): the asset's whole SimReady package folder. "
371
+ "`dependencies`: the asset + its USD dependency graph (deepsearch-api's own bundle).",
372
+ ),
373
+ ) -> Response:
374
+ mode = "manifest" if manifest_only else "zip"
375
+ internal_url = to_internal(asset_url)
376
+ package = catalog.package_for(internal_url) if scope == "package" else None
377
+ if package is None:
378
+ metrics.observe("forward", mode, "forwarded")
379
+ return await forward(request)
380
+
381
+ started = time.perf_counter()
382
+ stats: dict = {}
383
+ status, http_status, error_type = "success", 200, None
384
+ try:
385
+ return await _unless_client_gone(request, package_download(internal_url, package, manifest_only, stats))
386
+ except _ClientGone:
387
+ # Nobody is listening: the status is for the logs (nginx's "client closed request").
388
+ status, http_status = "cancelled", 499
389
+ logger.info("client went away; download of %s stopped", package.root_address)
390
+ return Response(status_code=499)
391
+ except _HTTPError as exc:
392
+ status, http_status, error_type = "error", exc.status, "HTTPException"
393
+ return JSONResponse({"detail": exc.detail}, status_code=exc.status, headers=exc.headers)
394
+ except Exception as exc:
395
+ status, http_status, error_type = "error", 500, type(exc).__name__
396
+ logger.exception("package download of %s failed", package.root_address)
397
+ return JSONResponse({"detail": "Internal error while building the package download."}, status_code=500)
398
+ finally:
399
+ elapsed = time.perf_counter() - started
400
+ sent = stats.get("total_bytes", 0) if status == "success" and not manifest_only else 0
401
+ metrics.observe("package", mode, str(http_status), elapsed, sent)
402
+ telemetry.emit(
403
+ asset_url=internal_url,
404
+ manifest_only=manifest_only,
405
+ scope=bundle.SCOPE,
406
+ package=package.name,
407
+ status=status,
408
+ error_type=error_type,
409
+ http_status=http_status,
410
+ processing_time_ms=elapsed * 1000,
411
+ user_agent=request.headers.get("user-agent"),
412
+ **stats,
413
+ )
414
+
415
+ for path in ("/download/asset", "/v3/download/asset"):
416
+ app.add_api_route(path, download_asset, methods=["GET"])
417
+ return app
pipeline/package_download/bundle.py ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Package listing -> usdsearch-contract preview/manifest, and the ZIP itself.
2
+
3
+ Contract (usdsearch ``routers_v3/download.py`` at the Space's wheel commit, and what the Explorer bundle reads —
4
+ docs/plans/hf-space-package-download.md §1.2/§1.4): the preview's ``total_size``/``file_count``, the
5
+ ``skipped[]`` list, and the ``X-Download-Summary`` keys. Every summary key is kept; the ``skipped_*`` counters
6
+ are always 0 here — a package is listed, not resolved, so nothing is ever skipped — which keeps the Explorer's
7
+ "some files skipped" toast for real failures (``download_failed``) only. ``scope``, ``package_files``,
8
+ ``package_root`` and ``root_path`` are additive.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import json
16
+ import logging
17
+ import math
18
+ import os
19
+ import random
20
+ import re
21
+ import tempfile
22
+ import threading
23
+ import time
24
+ import zipfile
25
+ from collections import deque
26
+ from concurrent.futures import ThreadPoolExecutor
27
+ from dataclasses import dataclass, field
28
+ from typing import AsyncIterator, Awaitable, Callable, Deque, Dict, List, Optional, Tuple, TypeVar
29
+
30
+ from .catalog import Package
31
+
32
+ logger = logging.getLogger("package_download")
33
+ T = TypeVar("T")
34
+
35
+ SCOPE = "package"
36
+ MANIFEST_NAME = "manifest.json"
37
+ # Used instead when a package carries its own root-level manifest.json, so the
38
+ # package itself still unpacks byte-identical.
39
+ MANIFEST_FALLBACK_NAME = "download-manifest.json"
40
+ SKIP_COUNTERS = (
41
+ "skipped_not_found",
42
+ "skipped_access_denied",
43
+ "skipped_no_access_or_missing",
44
+ "skipped_deleted",
45
+ "skipped_resolved_elsewhere",
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FileEntry:
51
+ path: str # repo-relative, as on Hugging Face (not percent-decoded)
52
+ size: int
53
+
54
+
55
+ class SourceError(Exception):
56
+ """A storage-side failure, classified so the HTTP layer needs no gRPC knowledge.
57
+
58
+ kind: "not_found" | "denied" (the backend's HF token was refused) | "busy"
59
+ (rate limited) | "unavailable" | "error". ``retryable``: a transient failure
60
+ worth another try (see `classify`); ``retry_after``: seconds the backend asked
61
+ the caller to wait, when it said.
62
+ """
63
+
64
+ def __init__(self, kind: str, message: str, *, retryable: bool = False, retry_after: Optional[float] = None):
65
+ super().__init__(message)
66
+ self.kind = kind
67
+ self.retryable = retryable
68
+ self.retry_after = retry_after
69
+
70
+
71
+ # gRPC status code name -> SourceError.kind
72
+ _KINDS = {
73
+ "NOT_FOUND": "not_found",
74
+ "INVALID_ARGUMENT": "not_found",
75
+ # hf-storage-api maps an HF 401/403 (dead or missing token) to PERMISSION_DENIED.
76
+ "PERMISSION_DENIED": "denied",
77
+ "UNAUTHENTICATED": "denied",
78
+ "RESOURCE_EXHAUSTED": "busy",
79
+ "UNAVAILABLE": "unavailable",
80
+ "DEADLINE_EXCEEDED": "unavailable",
81
+ }
82
+ # hf-storage-api (hf/client.py `_check`) words every HF status but 401/403/404 as
83
+ # UNAVAILABLE "<status> from <url>: …", and (errors.status_for) any other failure as
84
+ # INTERNAL "<exception class>: …"; these httpx transport errors are the transient ones.
85
+ _HF_STATUS = re.compile(r"(\d{3}) from ")
86
+ _TRANSPORT_ERRORS = frozenset({
87
+ "ConnectError", "ConnectTimeout", "ReadError", "ReadTimeout", "WriteError", "WriteTimeout",
88
+ "PoolTimeout", "RemoteProtocolError", "NetworkError",
89
+ })
90
+
91
+
92
+ def _seconds(value) -> Optional[float]:
93
+ try:
94
+ seconds = float(value)
95
+ except (TypeError, ValueError):
96
+ return None
97
+ return seconds if math.isfinite(seconds) and seconds >= 0 else None
98
+
99
+
100
+ def classify(code: str, details: str, retry_after: Optional[str] = None) -> SourceError:
101
+ """A gRPC failure from hf-storage-api (status code name, details, its ``retry-after`` trailer) as a SourceError.
102
+
103
+ Retryable: rate limiting, an HF 429/5xx, hf-storage-api unreachable (UNAVAILABLE with no HF
104
+ status: it is restarting), and a dropped HF connection. Never: DEADLINE_EXCEEDED (the whole-file
105
+ deadline — another try would run past nginx's own 300 s), any other HF 4xx (a per-file failure,
106
+ kind "error"), not found, denied, cancelled. Every retryable failure is kind "busy" or
107
+ "unavailable", so one that outlasts its retries fails the whole bundle with 503 + Retry-After,
108
+ never a 404 root or a ZIP with a hole. The details' wording is hf-storage-api's own — keep
109
+ in step with the vendored copy (tests pin it). It sends no ``retry-after`` today.
110
+ """
111
+ kind = _KINDS.get(code, "error")
112
+ retryable = kind == "busy"
113
+ if kind == "unavailable" and code != "DEADLINE_EXCEEDED":
114
+ status = _HF_STATUS.match(details)
115
+ http_status = int(status.group(1)) if status else None
116
+ if http_status == 429:
117
+ kind, retryable = "busy", True
118
+ elif http_status is not None and http_status < 500:
119
+ kind = "error"
120
+ else:
121
+ retryable = True
122
+ elif code == "INTERNAL" and details.split(":", 1)[0].strip() in _TRANSPORT_ERRORS:
123
+ kind, retryable = "unavailable", True # HF's connection dropped: as transient as a 5xx
124
+ return SourceError(kind, f"{code}: {details}", retryable=retryable, retry_after=_seconds(retry_after))
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class RetryPolicy:
129
+ """Retries of a transient SourceError: ``attempts`` tries in all (1 = none), exponential backoff with
130
+ full jitter (a random wait up to ``base_delay`` × 2^n, at most ``max_delay``). A ``retry_after``
131
+ is waited out as given, unless it is longer than ``max_delay``: then the failure stands, with it."""
132
+
133
+ attempts: int = 1
134
+ base_delay: float = 0.5
135
+ max_delay: float = 10.0
136
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
137
+ rand: Callable[[], float] = random.random
138
+
139
+ def delay(self, exc: SourceError, attempt: int) -> Optional[float]:
140
+ """Seconds to wait after failed try ``attempt`` (0-based) before the next one, or None to give up."""
141
+ if not exc.retryable or attempt + 1 >= self.attempts:
142
+ return None
143
+ if exc.retry_after is not None:
144
+ return exc.retry_after if exc.retry_after <= self.max_delay else None
145
+ return self.rand() * min(self.max_delay, self.base_delay * 2 ** attempt)
146
+
147
+
148
+ NO_RETRY = RetryPolicy()
149
+
150
+
151
+ async def retrying(call: Callable[[], Awaitable[T]], policy: RetryPolicy, what: str) -> T:
152
+ """``call()``, tried again per ``policy`` while it fails with a transient SourceError.
153
+
154
+ Only SourceError is caught: a cancellation (the client gone) ends it at once, backoff included.
155
+ """
156
+ attempt = 0
157
+ while True:
158
+ try:
159
+ return await call()
160
+ except SourceError as exc:
161
+ delay = policy.delay(exc, attempt)
162
+ if delay is None:
163
+ raise
164
+ logger.warning("%s: %s; try %d of %d in %.1f s", what, exc, attempt + 2, policy.attempts, delay)
165
+ await policy.sleep(delay)
166
+ attempt += 1
167
+
168
+
169
+ def limited(semaphore: Optional[asyncio.Semaphore]):
170
+ """``async with`` the semaphore, or nothing when there is no limit."""
171
+ return semaphore if semaphore is not None else contextlib.nullcontext()
172
+
173
+
174
+ class _ByteBudget:
175
+ """One bundle's read-ahead RAM (``limit`` bytes; 0 = unlimited), granted strictly first come, first served.
176
+
177
+ A request larger than the whole budget is granted once nothing else is held — so it never deadlocks
178
+ the bundle, and is fetched alone — and a small request never overtakes a waiting large one.
179
+ (build_zip never asks for more than the budget; a request of all of it is fetched alone.)
180
+ """
181
+
182
+ def __init__(self, limit: int):
183
+ self.limit = limit
184
+ self.used = 0
185
+ self._waiters: Deque[Tuple[int, asyncio.Future]] = deque()
186
+
187
+ def cost(self, size: int) -> int:
188
+ return min(size, self.limit) if self.limit > 0 else 0
189
+
190
+ def _fits(self, n: int) -> bool:
191
+ return self.limit <= 0 or self.used == 0 or self.used + n <= self.limit
192
+
193
+ async def acquire(self, n: int) -> None:
194
+ if not self._waiters and self._fits(n):
195
+ self.used += n
196
+ return
197
+ waiter = (n, asyncio.get_running_loop().create_future())
198
+ self._waiters.append(waiter)
199
+ try:
200
+ await waiter[1]
201
+ except asyncio.CancelledError:
202
+ if waiter[1].done() and not waiter[1].cancelled():
203
+ self.used -= n # granted just as we were cancelled: hand it back
204
+ elif waiter in self._waiters:
205
+ self._waiters.remove(waiter)
206
+ self._grant()
207
+ raise
208
+
209
+ def release(self, n: int) -> None:
210
+ self.used -= n
211
+ self._grant()
212
+
213
+ def _grant(self) -> None:
214
+ while self._waiters:
215
+ n, future = self._waiters[0]
216
+ if future.done(): # its task was cancelled; it cleans up after itself
217
+ self._waiters.popleft()
218
+ continue
219
+ if not self._fits(n):
220
+ return
221
+ self._waiters.popleft()
222
+ self.used += n
223
+ future.set_result(None)
224
+
225
+
226
+ class BundleTooLarge(Exception):
227
+ pass
228
+
229
+
230
+ class RootMissing(Exception):
231
+ pass
232
+
233
+
234
+ def summary(listed: int, downloaded: int, failed: int) -> dict:
235
+ """The per-bundle summary, in usdsearch's key order plus the additive keys."""
236
+ return {
237
+ # usdsearch counts the root's dependencies; here every other file in the
238
+ # package — the Explorer's preview shows "dependencies" as file_count - 1.
239
+ "dependencies_reported": max(listed - 1, 0),
240
+ "downloaded": downloaded,
241
+ **{key: 0 for key in SKIP_COUNTERS},
242
+ "respelled": 0,
243
+ "download_failed": failed,
244
+ "scope": SCOPE,
245
+ "package_files": listed,
246
+ }
247
+
248
+
249
+ def manifest_name(files: List[FileEntry], package: Package) -> str:
250
+ names = {package.relative(f.path) for f in files}
251
+ return MANIFEST_FALLBACK_NAME if MANIFEST_NAME in names else MANIFEST_NAME
252
+
253
+
254
+ def preview(asset_url: str, package: Package, files: List[FileEntry]) -> dict:
255
+ """The ``manifest_only=true`` response: what the ZIP would hold, no bytes moved."""
256
+ return {
257
+ "root_url": asset_url,
258
+ "file_count": len(files),
259
+ "total_size": sum(f.size for f in files),
260
+ "files": [{"url": package.address(f.path), "size": f.size} for f in files],
261
+ "errors": {},
262
+ "summary": {**summary(len(files), downloaded=len(files), failed=0), "accessible": len(files)},
263
+ "skipped": [],
264
+ "scope": SCOPE,
265
+ "package_root": package.root_address,
266
+ "root_path": package.root_path,
267
+ }
268
+
269
+
270
+ def _compress_type(arc: str, mode: str, stored_extensions: frozenset) -> int:
271
+ if mode == "stored":
272
+ return zipfile.ZIP_STORED
273
+ if mode == "deflate":
274
+ return zipfile.ZIP_DEFLATED
275
+ return zipfile.ZIP_STORED if os.path.splitext(arc)[1].lower() in stored_extensions else zipfile.ZIP_DEFLATED
276
+
277
+
278
+ def safe_arcname(package: Package, path: str) -> Optional[str]:
279
+ """In-archive name for a listed file, or None if it would escape the archive root."""
280
+ if not path.startswith(package.folder + "/"):
281
+ return None
282
+ arc = package.relative(path)
283
+ parts = arc.split("/")
284
+ if not arc or arc.startswith("/") or any(p in ("", ".", "..") for p in parts):
285
+ return None
286
+ return arc
287
+
288
+
289
+ @dataclass
290
+ class BundleResult:
291
+ files: List[dict] = field(default_factory=list) # {url, path, size} per member written
292
+ errors: Dict[str, str] = field(default_factory=dict) # address -> message
293
+ total_bytes: int = 0
294
+ fetch_ms: float = 0.0
295
+
296
+
297
+ Reader = Callable[[Package, str], AsyncIterator[bytes]] # an async generator
298
+ _COPY_CHUNK = 1024 * 1024
299
+
300
+
301
+ async def build_zip(
302
+ zip_path: str,
303
+ package: Package,
304
+ files: List[FileEntry],
305
+ read: Reader,
306
+ *,
307
+ concurrency: int,
308
+ compression: str,
309
+ stored_extensions: frozenset,
310
+ spool_bytes: int,
311
+ max_bytes: int = 0,
312
+ spool_dir: Optional[str] = None,
313
+ read_ahead_bytes: int = 0,
314
+ connections: Optional[asyncio.Semaphore] = None,
315
+ retry: RetryPolicy = NO_RETRY,
316
+ ) -> BundleResult:
317
+ """Fetch every listed file (bounded concurrency) and write it into a ZIP at ``zip_path``.
318
+
319
+ Each file is buffered in a SpooledTemporaryFile (RAM up to ``spool_bytes``, capped
320
+ at ``read_ahead_bytes``, then ``spool_dir``; ``spool_bytes`` 0 spills at once), then
321
+ copied into the archive by a single writer thread — ZipFile is not thread-safe, and
322
+ deflate stays off the event loop. A file that fails is recorded in ``errors`` and
323
+ left out; the root asset failing raises RootMissing (usdsearch's 404). Does not
324
+ write the manifest — see `finish_zip`.
325
+
326
+ Each file takes, in this order: the part of its listed size its spool keeps in RAM
327
+ from ``read_ahead_bytes`` (this bundle's RAM for bytes fetched but not yet written;
328
+ 0 = unlimited — what spills to disk is not charged, so large files still stream side
329
+ by side), a ``concurrency`` slot, then — only while its bytes stream, never during a
330
+ backoff — a ``connections`` slot (reads in flight across every bundle). A transient
331
+ failure is retried per ``retry`` from byte 0: nothing reaches the archive before a
332
+ file is whole. Cancelled, it stops every read and the writer before it returns.
333
+ """
334
+ result = BundleResult()
335
+ semaphore = asyncio.Semaphore(max(concurrency, 1))
336
+ budget = _ByteBudget(read_ahead_bytes)
337
+ # A spool's RAM before it spills: never more than the budget, so it holds no more than
338
+ # it is charged, and at least 1 — a SpooledTemporaryFile with max_size 0 never spills.
339
+ in_ram = max(min(spool_bytes, read_ahead_bytes) if read_ahead_bytes > 0 else spool_bytes, 1)
340
+ mode = compression.strip().lower()
341
+ started = time.perf_counter()
342
+
343
+ async def download(entry: FileEntry, spool) -> int:
344
+ async def attempt() -> int:
345
+ spool.seek(0)
346
+ spool.truncate() # a retry starts over: nothing of a failed try is kept
347
+ async with limited(connections):
348
+ async with contextlib.aclosing(read(package, entry.path)) as chunks:
349
+ async for chunk in chunks:
350
+ spool.write(chunk)
351
+ return spool.tell()
352
+
353
+ return await retrying(attempt, retry, package.address(entry.path))
354
+
355
+ async def fetch(entry: FileEntry):
356
+ # (entry, spool, size, exc, cost): `cost` stays reserved until the
357
+ # consumer has written the spool, or dropped it.
358
+ cost = budget.cost(min(entry.size, in_ram))
359
+ await budget.acquire(cost)
360
+ spool = tempfile.SpooledTemporaryFile(max_size=in_ram, dir=spool_dir)
361
+ try:
362
+ async with semaphore:
363
+ size = await download(entry, spool)
364
+ except asyncio.CancelledError:
365
+ spool.close()
366
+ budget.release(cost)
367
+ raise
368
+ except Exception as exc: # noqa: BLE001 — best-effort per file, like usdsearch
369
+ spool.close()
370
+ budget.release(cost)
371
+ return entry, None, 0, exc, 0
372
+ spool.seek(0)
373
+ return entry, spool, size, None, cost
374
+
375
+ tasks = [asyncio.create_task(fetch(f)) for f in files]
376
+ stop = threading.Event() # tells the writer thread to let go of the member it is copying
377
+ try:
378
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix="package-zip") as writer:
379
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
380
+ for completed in asyncio.as_completed(tasks):
381
+ entry, spool, size, exc, cost = await completed
382
+ address = package.address(entry.path)
383
+ if spool is None:
384
+ if isinstance(exc, SourceError) and exc.kind in ("denied", "busy", "unavailable"):
385
+ raise exc # the backend itself is down; a ZIP of holes helps nobody
386
+ if entry.path == package.asset_path:
387
+ raise RootMissing(str(exc) or "not found")
388
+ result.errors[address] = str(exc) or type(exc).__name__
389
+ continue
390
+ try:
391
+ with spool:
392
+ result.total_bytes += size
393
+ if max_bytes > 0 and result.total_bytes > max_bytes:
394
+ raise BundleTooLarge(f"Download bundle exceeded the size limit of {max_bytes} bytes.")
395
+ arc = safe_arcname(package, entry.path)
396
+ if arc is None:
397
+ result.errors[address] = "path outside the package"
398
+ continue
399
+ info = zipfile.ZipInfo(arc, date_time=time.localtime()[:6])
400
+ info.compress_type = _compress_type(arc, mode, stored_extensions)
401
+ info.external_attr = 0o644 << 16
402
+ await _write_member(writer, stop, zf, info, spool, size)
403
+ result.files.append({"url": address, "path": arc, "size": size})
404
+ finally:
405
+ budget.release(cost)
406
+ finally:
407
+ for task in tasks:
408
+ task.cancel()
409
+ if tasks:
410
+ # Until they have really stopped: a cancelled read ends its RPC (and
411
+ # hf-storage-api its HF request) before the caller frees the slot and temp dir.
412
+ await asyncio.wait(tasks)
413
+ # Close the spools of fetches that finished but were never written (an
414
+ # exception above): a disk-spilled one would otherwise hold a temp file.
415
+ for task in tasks:
416
+ if not task.cancelled() and task.exception() is None:
417
+ _, spool, _, _, cost = task.result()
418
+ if spool is not None and not spool.closed:
419
+ spool.close()
420
+ budget.release(cost)
421
+ result.fetch_ms = (time.perf_counter() - started) * 1000
422
+ return result
423
+
424
+
425
+ async def _write_member(writer: ThreadPoolExecutor, stop: threading.Event, zf, info, spool, size: int) -> None:
426
+ """Copy one spooled file into the archive on the writer thread.
427
+
428
+ Cancelled mid-copy, it stops the thread and waits for it to let go of the member
429
+ and the spool, without blocking the event loop: otherwise ZipFile.close() would
430
+ refuse an open member and the executor's shutdown would wait on the loop.
431
+ """
432
+ copying = writer.submit(_copy_member, zf, info, spool, size, stop)
433
+ try:
434
+ await asyncio.wrap_future(copying)
435
+ finally:
436
+ if not copying.done():
437
+ stop.set()
438
+ await asyncio.wait({asyncio.wrap_future(copying)})
439
+
440
+
441
+ def _copy_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo, src, size: int, stop: threading.Event) -> None:
442
+ with zf.open(info, "w", force_zip64=size >= zipfile.ZIP64_LIMIT) as dst:
443
+ while not stop.is_set(): # set only when the bundle is abandoned
444
+ chunk = src.read(_COPY_CHUNK)
445
+ if not chunk:
446
+ break
447
+ dst.write(chunk)
448
+
449
+
450
+ def finish_zip(zip_path: str, name: str, manifest: dict) -> None:
451
+ with zipfile.ZipFile(zip_path, "a") as zf:
452
+ zf.writestr(name, json.dumps(manifest, indent=2))
453
+
454
+
455
+ def manifest(asset_url: str, package: Package, listed: List[FileEntry], result: BundleResult) -> dict:
456
+ """The ``manifest.json`` written into the archive (usdsearch's shape + additive keys)."""
457
+ return {
458
+ "root_url": asset_url,
459
+ "file_count": len(result.files),
460
+ "total_size": result.total_bytes,
461
+ "files": sorted(result.files, key=lambda f: f["path"]),
462
+ "summary": summary(len(listed), downloaded=len(result.files), failed=len(result.errors)),
463
+ "skipped": [],
464
+ "errors": result.errors,
465
+ "scope": SCOPE,
466
+ "package_root": package.root_address,
467
+ "root_path": package.root_path,
468
+ }
pipeline/package_download/catalog.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``asset_url`` -> the SimReady package folder it belongs to, from hf-storage-api's own catalog.
2
+
3
+ The catalog's ``assets:`` are exactly the package folders: pipeline/hf_catalog.py maps every matched file to the
4
+ nearest ancestor holding the corpus's ``asset_marker`` (``com.nvidia.simready.packaging.json``). Reading the same
5
+ file hf-storage-api serves from means the two can't disagree about what a package is, and an address outside
6
+ every catalogued folder never becomes a package download (hf-storage-api's ``resolve()`` refuses it again
7
+ server-side).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path, PurePosixPath
14
+ from typing import Dict, FrozenSet, Optional, Tuple
15
+ from urllib.parse import quote
16
+
17
+ import yaml
18
+
19
+ from .. import hf_addressing
20
+
21
+
22
+ def _host(endpoint: str) -> str:
23
+ # hf-storage-api's Catalog.host(): the endpoint minus its scheme.
24
+ return endpoint.split("://", 1)[-1].rstrip("/")
25
+
26
+
27
+ def encode_path(path: str) -> str:
28
+ """Percent-encode a repo path for an address handed to hf-storage-api.
29
+
30
+ Its ``addressing.parse()`` unquotes every segment, so a file literally named
31
+ ``Package%401.0.0.json`` must travel as ``Package%25401.0.0.json`` to arrive
32
+ as itself. A no-op for ordinary names.
33
+ """
34
+ return quote(path, safe="/")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Package:
39
+ host: str
40
+ repo_type: str
41
+ repo_id: str
42
+ revision: str # the catalog's configured revision (a pinned sha for simready-assets)
43
+ folder: str # repo-relative package folder, e.g. "appliance_toaster_a01"
44
+ asset_path: str # repo-relative path of the requested asset inside it
45
+
46
+ @property
47
+ def name(self) -> str:
48
+ return PurePosixPath(self.folder).name
49
+
50
+ @property
51
+ def root_path(self) -> str:
52
+ """The requested asset's path inside the archive (package contents sit at its root)."""
53
+ return self.relative(self.asset_path)
54
+
55
+ def relative(self, path: str) -> str:
56
+ return path[len(self.folder) + 1:]
57
+
58
+ def address(self, path: str, *, is_folder: bool = False) -> str:
59
+ """The hf:// address hf-storage-api resolves for a repo path (encoded, see `encode_path`)."""
60
+ return hf_addressing.build(
61
+ host=self.host,
62
+ repo_type=self.repo_type,
63
+ repo_id=self.repo_id,
64
+ revision=self.revision,
65
+ path=encode_path(path),
66
+ is_folder=is_folder,
67
+ )
68
+
69
+ @property
70
+ def root_address(self) -> str:
71
+ return self.address(self.folder, is_folder=True)
72
+
73
+ @property
74
+ def repo_prefix(self) -> str:
75
+ """Every address hf-storage-api emits for this repo starts with this."""
76
+ return hf_addressing.build(
77
+ host=self.host, repo_type=self.repo_type, repo_id=self.repo_id, revision=self.revision, is_folder=True
78
+ )
79
+
80
+
81
+ class Catalog:
82
+ """The repos and package folders of one hf-storage-api catalog (YAML)."""
83
+
84
+ def __init__(self, doc: dict):
85
+ endpoint = doc.get("endpoint") or "https://huggingface.co"
86
+ self._repos: Dict[Tuple[str, str, str], Tuple[str, FrozenSet[str]]] = {}
87
+ for repo in doc.get("repos") or []:
88
+ key = (_host(repo.get("endpoint") or endpoint), repo.get("repo_type") or "dataset", repo["repo_id"])
89
+ # "" (a file at the repo root with no marker above it) is never a
90
+ # package: downloading it would mean the whole repo.
91
+ folders = frozenset(f for f in (str(a).strip("/") for a in repo.get("assets") or []) if f)
92
+ self._repos[key] = (str(repo.get("revision") or "main"), folders)
93
+
94
+ @classmethod
95
+ def load(cls, path: Path) -> "Catalog":
96
+ return cls(yaml.safe_load(Path(path).read_text()) or {})
97
+
98
+ def __len__(self) -> int:
99
+ return sum(len(folders) for _, folders in self._repos.values())
100
+
101
+ def package_for(self, address: str) -> Optional[Package]:
102
+ """The package folder an internal ``hf://`` asset address sits in, or None."""
103
+ try:
104
+ parsed = hf_addressing.parse(address)
105
+ except hf_addressing.AddressError:
106
+ return None
107
+ if parsed.is_folder or not parsed.path:
108
+ return None
109
+ entry = self._repos.get((parsed.host, parsed.repo_type, parsed.repo_id))
110
+ if entry is None:
111
+ return None
112
+ revision, folders = entry
113
+ # A pinned repo only serves its own sha (hf-storage-api's resolve() rule).
114
+ if parsed.revision and hf_addressing.looks_like_sha(revision) and parsed.revision != revision:
115
+ return None
116
+ parts = parsed.path.split("/")[:-1]
117
+ if ".." in parts:
118
+ return None
119
+ # Nearest catalogued ancestor, the same rule hf_catalog used to pick it.
120
+ for depth in range(len(parts), 0, -1):
121
+ folder = "/".join(parts[:depth])
122
+ if folder in folders:
123
+ return Package(
124
+ host=parsed.host,
125
+ repo_type=parsed.repo_type,
126
+ repo_id=parsed.repo_id,
127
+ revision=revision,
128
+ folder=folder,
129
+ asset_path=parsed.path,
130
+ )
131
+ return None
pipeline/package_download/settings.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``PACKAGE_DOWNLOAD_*`` settings, read once from the environment (baked by the Space Dockerfiles)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, fields
7
+ from pathlib import Path
8
+ from typing import Mapping, Optional
9
+
10
+ PREFIX = "PACKAGE_DOWNLOAD_"
11
+ # Where hf_space_seed restores the run's hf-storage-api catalog — the same file
12
+ # [program:hf-storage-api] serves from (supervisord.conf).
13
+ DEFAULT_CATALOG = "/app/hf-space-pipeline/.build/restore/hf-storage-api-catalog.yaml"
14
+ # Same list as usdsearch's DownloadSettings.stored_extensions: already-compressed
15
+ # containers are stored, everything else (.usda, .json, .mdl) is deflated.
16
+ DEFAULT_STORED_EXTENSIONS = ".png,.jpg,.jpeg,.webp,.avif,.jxl,.gif,.mp4,.mov,.zip,.usdz,.gz,.bz2,.xz,.7z,.ktx2,.basis"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Settings:
21
+ host: str = "127.0.0.1"
22
+ port: int = 8020
23
+ catalog_path: Path = Path(DEFAULT_CATALOG)
24
+ # hf-storage-api, loopback, no TLS (STORAGE_API_GRPC_ENDPOINT/STORAGE_API_SSL=false in the Dockerfiles).
25
+ storage_api_endpoint: str = "127.0.0.1:50051"
26
+ # deepsearch-api, for scope=dependencies and anything outside a catalogued package.
27
+ fallback_url: str = "http://127.0.0.1:8000"
28
+ # Concurrent file reads while assembling one ZIP.
29
+ concurrency: int = 8
30
+ # Concurrent file reads from hf-storage-api — each one a live Hugging Face download — across
31
+ # all requests; 0 = no cap. Keep it plus hf_max_listings under hf-storage-api's
32
+ # HF_ADAPTER_HTTP_MAX_CONNECTIONS (20), which deepsearch-api's scope=dependencies downloads
33
+ # share: a read queued in that pool fails after 30 s.
34
+ hf_max_connections: int = 12
35
+ # Concurrent package listings across all requests, capped apart from the reads so a listing
36
+ # (every manifest_only preview) never waits behind a ZIP's file reads; 0 = no cap. A listing is
37
+ # answered from hf-storage-api's cached tree of the package: at most one HF request at a time.
38
+ hf_max_listings: int = 4
39
+ # RAM of one ZIP's files fetched ahead of its writer (being read or waiting to be written):
40
+ # what their spools hold before spilling to temp_dir, which is not counted; 0 = unlimited.
41
+ # Worst case in all: max_concurrent × this.
42
+ read_ahead_bytes: int = 256 * 1024 * 1024
43
+ # A file read (or the listing) that fails transiently — HF 429/5xx, a dropped connection,
44
+ # hf-storage-api restarting — is retried with exponential backoff and full jitter: up to
45
+ # retry_attempts tries in all (1 = no retry), a random wait under retry_base_delay × 2^n,
46
+ # at most retry_max_delay seconds. A Retry-After longer than that is not waited out: the
47
+ # request fails 503 with it. The listing gets 2 tries at most (hf-storage-api retries it too).
48
+ retry_attempts: int = 4
49
+ retry_base_delay: float = 0.5
50
+ retry_max_delay: float = 10.0
51
+ # ZIPs assembled (and held on disk until sent) at once; further requests queue.
52
+ max_concurrent: int = 4
53
+ # Seconds a request waits for a free slot before 503 + Retry-After.
54
+ queue_timeout: float = 120.0
55
+ # Largest package served, in bytes; 0 = unlimited.
56
+ max_bundle_bytes: int = 0
57
+ # Directory for the temp ZIPs (None = system default). Must be disk-backed.
58
+ temp_dir: Optional[str] = None
59
+ # "auto" (store `stored_extensions`, deflate the rest), "deflate" or "stored".
60
+ compression: str = "auto"
61
+ stored_extensions: str = DEFAULT_STORED_EXTENSIONS
62
+ # A file read is buffered in RAM up to this size (at most read_ahead_bytes), then spills to
63
+ # temp_dir; 0 = straight to disk.
64
+ spool_bytes: int = 16 * 1024 * 1024
65
+ # gRPC deadlines (seconds): one ListStat call, one whole-file ReadFromAddress.
66
+ list_timeout: float = 60.0
67
+ read_timeout: float = 300.0
68
+ # Read timeout for the forwarded deepsearch-api request (it bundles, then answers).
69
+ fallback_timeout: float = 300.0
70
+
71
+ @classmethod
72
+ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
73
+ env = os.environ if env is None else env
74
+ values = {}
75
+ for field in fields(cls):
76
+ raw = env.get(PREFIX + field.name.upper())
77
+ if raw is None or raw == "":
78
+ continue
79
+ default = field.default
80
+ if isinstance(default, bool):
81
+ values[field.name] = raw.strip().lower() in ("1", "true", "yes", "on")
82
+ elif isinstance(default, int):
83
+ values[field.name] = int(raw)
84
+ elif isinstance(default, float):
85
+ values[field.name] = float(raw)
86
+ elif isinstance(default, Path):
87
+ values[field.name] = Path(raw)
88
+ else:
89
+ values[field.name] = raw
90
+ # deepsearch-api's own variable, so the two can't point at different servers.
91
+ if "storage_api_endpoint" not in values and env.get("STORAGE_API_GRPC_ENDPOINT"):
92
+ values["storage_api_endpoint"] = env["STORAGE_API_GRPC_ENDPOINT"]
93
+ return cls(**values)
94
+
95
+ def stored_extension_set(self) -> frozenset:
96
+ """Normalized (lowercase, dot-prefixed) extensions stored uncompressed under "auto"."""
97
+ out = set()
98
+ for raw in self.stored_extensions.split(","):
99
+ ext = raw.strip().lower()
100
+ if ext:
101
+ out.add(ext if ext.startswith(".") else f".{ext}")
102
+ return frozenset(out)
pipeline/package_download/source.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Package bytes through the in-container hf-storage-api (Omniverse Storage API over gRPC).
2
+
3
+ ``list_package``: a breadth-first ``FileFolderService.ListStat`` walk from the package root — one call per
4
+ sub-folder (~10 for a SimReady package), all answered from hf-storage-api's cached recursive tree of that one
5
+ folder (a single HF ``tree`` request per package per ``HF_ADAPTER_FOLDER_CACHE_TTL``). Needs the server started
6
+ with ``HF_ADAPTER_ADVERTISE_FILEFOLDER=true``, and hf-storage-api >= 417d9af: before it, ListStat on a folder
7
+ inside a repo crawled every catalogued asset (hf-storage-api-service#1).
8
+
9
+ ``read``: ``FileObjectService.ReadFromAddress``, consumed chunk by chunk — not search_utils'
10
+ ``download_file_content``, which buffers the whole file and stats it first.
11
+
12
+ The stubs are the ones the usdsearch ``storage_api`` wheel installs in the Space venv (the same ones
13
+ deepsearch-api and hf-storage-api itself use there).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import AsyncIterator, List
20
+
21
+ import grpc
22
+ from nvidia.omniverse.storage.filefolder.v1alpha import filefolder_service_pb2, filefolder_service_pb2_grpc
23
+ from nvidia.omniverse.storage.fileobject.v1alpha import fileobject_service_pb2, fileobject_service_pb2_grpc
24
+
25
+ from .bundle import FileEntry, SourceError, classify
26
+ from .catalog import Package
27
+
28
+
29
+ # hf-storage-api's addressing.build() escapes exactly these (hf-storage-api-service
30
+ # !6, the fix for its #1): `%` because parse() unquotes, `#`/`?` because urlsplit
31
+ # cuts at them. Undone in one pass — the exact inverse, so it is right for a server
32
+ # with that fix (`Package%25401.0.0.json` -> `Package%401.0.0.json`) and a no-op
33
+ # on one without it, which emits the name raw.
34
+ _ADDRESS_ESCAPE = re.compile(r"%(25|23|3F)")
35
+ _UNESCAPED = {"25": "%", "23": "#", "3F": "?"}
36
+
37
+
38
+ def _unescape(path: str) -> str:
39
+ return _ADDRESS_ESCAPE.sub(lambda m: _UNESCAPED[m.group(1)], path)
40
+
41
+
42
+ def _error(exc: grpc.aio.AioRpcError) -> SourceError:
43
+ # The classification (kind, retryable) is bundle.classify's, which tests can reach without grpc.
44
+ trailers = dict(exc.trailing_metadata() or ())
45
+ return classify(exc.code().name, exc.details() or "", trailers.get("retry-after"))
46
+
47
+
48
+ class StorageApiSource:
49
+ def __init__(self, endpoint: str, *, list_timeout: float = 60.0, read_timeout: float = 300.0):
50
+ # hf-storage-api's chunks stay well under 4 MiB (its own settings check),
51
+ # so the default receive limit is enough.
52
+ self._channel = grpc.aio.insecure_channel(endpoint)
53
+ self._folders = filefolder_service_pb2_grpc.FileFolderServiceStub(self._channel)
54
+ self._objects = fileobject_service_pb2_grpc.FileObjectServiceStub(self._channel)
55
+ self._list_timeout = list_timeout
56
+ self._read_timeout = read_timeout
57
+
58
+ async def close(self) -> None:
59
+ await self._channel.close()
60
+
61
+ @staticmethod
62
+ def _repo_path(address: str, package: Package) -> str:
63
+ """Repo-relative path of an address hf-storage-api emitted.
64
+
65
+ Sliced off the known repo prefix and unescaped with the exact inverse of
66
+ the server's own escaping, not parsed: a full percent-decode would turn a
67
+ name like ``Package%401.0.0.json`` into a file that doesn't exist.
68
+ """
69
+ prefix = package.repo_prefix
70
+ if not address.startswith(prefix):
71
+ raise SourceError("error", f"unexpected address from storage backend: {address!r}")
72
+ return _unescape(address[len(prefix):].strip("/"))
73
+
74
+ async def list_package(self, package: Package) -> List[FileEntry]:
75
+ files: List[FileEntry] = []
76
+ pending = [package.folder]
77
+ seen = set()
78
+ while pending:
79
+ folder = pending.pop()
80
+ if folder in seen:
81
+ continue
82
+ seen.add(folder)
83
+ request = filefolder_service_pb2.ListStatRequest(
84
+ folder=filefolder_service_pb2.FolderAddress(uri=package.address(folder, is_folder=True))
85
+ )
86
+ call = self._folders.ListStat(request, timeout=self._list_timeout)
87
+ try:
88
+ async for response in call:
89
+ for sub in response.subfolder_addresses:
90
+ path = self._repo_path(sub.uri, package)
91
+ if path.startswith(package.folder + "/"):
92
+ pending.append(path)
93
+ for item in response.entries:
94
+ path = self._repo_path(item.resource_address, package)
95
+ files.append(FileEntry(path=path, size=int(item.resource_info.metadata.data_object_size)))
96
+ except grpc.aio.AioRpcError as exc:
97
+ raise _error(exc) from None
98
+ finally:
99
+ call.cancel() # see read()
100
+ return sorted(files, key=lambda f: f.path)
101
+
102
+ async def read(self, package: Package, path: str) -> AsyncIterator[bytes]:
103
+ request = fileobject_service_pb2.ReadFromAddressRequest(resource_address=package.address(path))
104
+ call = self._objects.ReadFromAddress(request, timeout=self._read_timeout)
105
+ try:
106
+ async for response in call:
107
+ if response.chunk.chunk:
108
+ yield response.chunk.chunk
109
+ except grpc.aio.AioRpcError as exc:
110
+ raise _error(exc) from None
111
+ finally:
112
+ # A no-op once the call has finished. Left early otherwise (the consumer closed this
113
+ # generator: its spool failed, say), the call would stay open until its deadline,
114
+ # holding an HF download in hf-storage-api's pool. A cancelled task cancels it anyway.
115
+ call.cancel()
pipeline/package_download/telemetry.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The ``asset_load_total`` business event, in the shape deepsearch-api writes to stdout.
2
+
3
+ deepsearch-api emits one per ``/download/asset`` call (``telemetry_track_asset_load`` -> ``JSONStdoutSpanExporter``:
4
+ a flat JSON object, every key Elasticsearch-prefixed by value type). The HF Space log forwarder
5
+ (services/hf_space_log_forwarder/parsing.py) ships any stdout line whose JSON carries ``s_telemetry_key``, so a
6
+ package download has to emit the same record or it drops out of the download stream. ``service`` tells the two
7
+ apart; forwarded (``scope=dependencies``/non-package) requests are not recorded here — deepsearch-api records
8
+ those itself.
9
+
10
+ Gated like deepsearch-api's: ``USE_SEARCH_TELEMETRY`` (Dockerfiles bake it true) and ``SEARCH_TELEMETRY_STDOUT``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import os
18
+ import time
19
+ from typing import Any, Mapping, Optional
20
+
21
+ logger = logging.getLogger("package_download.telemetry")
22
+
23
+ _TRUE = ("1", "true", "yes", "on")
24
+
25
+
26
+ def enabled(env: Optional[Mapping[str, str]] = None) -> bool:
27
+ env = os.environ if env is None else env
28
+ return (
29
+ env.get("USE_SEARCH_TELEMETRY", "false").lower() in _TRUE
30
+ and env.get("SEARCH_TELEMETRY_STDOUT", "true").lower() in _TRUE
31
+ )
32
+
33
+
34
+ def _prefixed_key(key: str, value: Any) -> str:
35
+ """deepsearch-api's JSONStdoutSpanExporter._get_prefixed_key, for scalars."""
36
+ if key in ("time", "timestamp") and isinstance(value, int) and 1000000000 <= value <= 9999999999999:
37
+ return f"ts_{key}"
38
+ if isinstance(value, bool):
39
+ return f"b_{key}"
40
+ if isinstance(value, int):
41
+ return f"d_{key}" if key.endswith("_ms") or "time_ms" in key else f"l_{key}"
42
+ if isinstance(value, float):
43
+ return f"d_{key}"
44
+ return f"s_{key}"
45
+
46
+
47
+ def record(**attributes: Any) -> dict:
48
+ """The prefixed event; ``None`` values are dropped, as deepsearch-api does."""
49
+ event = {
50
+ "time": int(time.time() * 1000),
51
+ "service": "package-download",
52
+ "telemetry_key": "asset_load_total",
53
+ "event_type": "asset_load",
54
+ "username": "anonymous",
55
+ **attributes,
56
+ }
57
+ if isinstance(event.get("asset_url"), str):
58
+ event["asset_url"] = event["asset_url"][:1024]
59
+ return {_prefixed_key(k, v): v for k, v in event.items() if v is not None}
60
+
61
+
62
+ def emit(**attributes: Any) -> None:
63
+ if not enabled():
64
+ return
65
+ try:
66
+ logger.info(json.dumps(record(**attributes)))
67
+ except Exception: # noqa: BLE001 — telemetry must never break a download
68
+ logger.warning("Failed to record asset-load telemetry", exc_info=True)