mariskeke commited on
Commit
8731189
·
verified ·
1 Parent(s): dd40181

Publish AirGPT Hub H1.1

Browse files
README.md CHANGED
@@ -12,12 +12,17 @@ license: mit
12
 
13
  Vertical model discovery for embodied AI. The Space reads the public
14
  [`airgpt/embodied-model-index`](https://huggingface.co/datasets/airgpt/embodied-model-index)
15
- catalog and exposes a responsive browser plus a small read-only API.
16
 
17
  The interface shows verified runtime revisions, VRAM guidance, model licenses, real
18
  validation previews, and copyable AirGPT Runtime commands. Asset provenance is recorded
19
  in [`ASSET_NOTICES.md`](ASSET_NOTICES.md).
20
 
 
 
 
 
 
21
  ## Local development
22
 
23
  ```bash
@@ -30,9 +35,17 @@ Set `AIRGPT_CATALOG_URL` to use another catalog. If Hugging Face is unavailable,
30
  falls back to `data/index.json` bundled in the image.
31
 
32
  The organization Space uses the Docker SDK with FastAPI, a live five-minute catalog
33
- cache, and a read-only API. The synchronized `data/index.json` snapshot remains available
34
  when Hugging Face is temporarily unreachable.
35
 
 
 
 
 
 
 
 
 
36
  ## Publish
37
 
38
  ```bash
 
12
 
13
  Vertical model discovery for embodied AI. The Space reads the public
14
  [`airgpt/embodied-model-index`](https://huggingface.co/datasets/airgpt/embodied-model-index)
15
+ catalog and exposes a responsive browser plus a small API.
16
 
17
  The interface shows verified runtime revisions, VRAM guidance, model licenses, real
18
  validation previews, and copyable AirGPT Runtime commands. Asset provenance is recorded
19
  in [`ASSET_NOTICES.md`](ASSET_NOTICES.md).
20
 
21
+ Catalog entries are enriched with cached public Hugging Face metadata, including revision
22
+ drift, downloads, likes, gating, library, and pipeline information. The suggestion form
23
+ validates public model repositories and generates an unverified candidate manifest. It
24
+ does not store submissions or hold an organization token.
25
+
26
  ## Local development
27
 
28
  ```bash
 
35
  falls back to `data/index.json` bundled in the image.
36
 
37
  The organization Space uses the Docker SDK with FastAPI, a live five-minute catalog
38
+ and metadata cache. The synchronized `data/index.json` snapshot remains available
39
  when Hugging Face is temporarily unreachable.
40
 
41
+ Maintainers can review a candidate locally. The command prints a merged preview unless
42
+ `--publish` is explicitly supplied:
43
+
44
+ ```bash
45
+ python scripts/review_candidate.py candidate.json
46
+ HF_TOKEN=... python scripts/review_candidate.py candidate.json --publish
47
+ ```
48
+
49
  ## Publish
50
 
51
  ```bash
app.js CHANGED
@@ -10,6 +10,7 @@ const state = { models: [], task: "all", verifiedOnly: true, query: "", sort: "v
10
  const $ = (id) => document.getElementById(id);
11
  const grid = $("modelGrid");
12
  const dialog = $("modelDialog");
 
13
 
14
  function escapeHtml(value) {
15
  return String(value ?? "").replace(/[&<>'"]/g, (char) => ({
@@ -17,9 +18,22 @@ function escapeHtml(value) {
17
  })[char]);
18
  }
19
 
20
- async function api(path) {
21
- const response = await fetch(path, { headers: { Accept: "application/json" } });
22
- if (!response.ok) throw new Error(`Request failed (${response.status})`);
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return response.json();
24
  }
25
 
@@ -31,6 +45,10 @@ function badge(label, className = "") {
31
  return `<span class="badge ${className}">${escapeHtml(label)}</span>`;
32
  }
33
 
 
 
 
 
34
  function commandFor(model) {
35
  const path = `.cache/models/${model.slug}`;
36
  const dtype = model.runtime.dtype ? ` --dtype ${model.runtime.dtype}` : "";
@@ -112,15 +130,26 @@ function openModel(model, updateHistory = false) {
112
  $("detailTitle").textContent = model.display_name;
113
  $("detailSummary").textContent = model.summary;
114
  $("modelCardLink").href = `https://huggingface.co/${model.source.repo_id}`;
115
- $("detailBadges").innerHTML = badge(model.verification.status, "verified") + badge(taskLabels[model.task], model.task === "world" ? "world" : "") + badge(model.source.license, "license");
116
- $("detailSpecs").innerHTML = [
 
 
117
  spec("Recommended VRAM", `${model.hardware.recommended_vram_gb} GB`),
118
  spec("Minimum VRAM", `${model.hardware.minimum_vram_gb} GB`),
119
  spec("Backend", model.runtime.backend),
120
  spec("Adapter", model.runtime.adapter),
121
  spec("Revision", model.source.revision.slice(0, 12)),
122
  spec("Device", model.runtime.device),
123
- ].join("");
 
 
 
 
 
 
 
 
 
124
  $("commandOutput").textContent = commandFor(model);
125
  $("manifestOutput").textContent = JSON.stringify(model, null, 2);
126
  $("verificationPanel").innerHTML = `
@@ -159,6 +188,68 @@ function toast(message) {
159
  window.setTimeout(() => node.classList.remove("visible"), 1600);
160
  }
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  async function loadCatalog() {
163
  try {
164
  const body = await api("/api/catalog");
@@ -204,6 +295,18 @@ $("copyCommand").addEventListener("click", async () => {
204
  await navigator.clipboard.writeText(commandFor(state.current));
205
  toast("Command copied");
206
  });
 
 
 
 
 
 
 
 
 
 
 
 
207
  window.addEventListener("popstate", () => {
208
  const slug = new URLSearchParams(location.search).get("model");
209
  const model = state.models.find((item) => item.slug === slug);
 
10
  const $ = (id) => document.getElementById(id);
11
  const grid = $("modelGrid");
12
  const dialog = $("modelDialog");
13
+ const submissionDialog = $("submissionDialog");
14
 
15
  function escapeHtml(value) {
16
  return String(value ?? "").replace(/[&<>'"]/g, (char) => ({
 
18
  })[char]);
19
  }
20
 
21
+ async function api(path, options = {}) {
22
+ const response = await fetch(path, {
23
+ ...options,
24
+ headers: { Accept: "application/json", ...(options.headers || {}) },
25
+ });
26
+ if (!response.ok) {
27
+ let message = `Request failed (${response.status})`;
28
+ try {
29
+ const body = await response.json();
30
+ if (typeof body.detail === "string") message = body.detail;
31
+ else if (Array.isArray(body.detail)) message = body.detail.map((item) => item.msg).join("; ");
32
+ } catch (_) {
33
+ // Keep the status-based message when the response is not JSON.
34
+ }
35
+ throw new Error(message);
36
+ }
37
  return response.json();
38
  }
39
 
 
45
  return `<span class="badge ${className}">${escapeHtml(label)}</span>`;
46
  }
47
 
48
+ function compactNumber(value) {
49
+ return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(value || 0);
50
+ }
51
+
52
  function commandFor(model) {
53
  const path = `.cache/models/${model.slug}`;
54
  const dtype = model.runtime.dtype ? ` --dtype ${model.runtime.dtype}` : "";
 
130
  $("detailTitle").textContent = model.display_name;
131
  $("detailSummary").textContent = model.summary;
132
  $("modelCardLink").href = `https://huggingface.co/${model.source.repo_id}`;
133
+ const upstream = model.upstream;
134
+ const updateBadge = upstream?.update_available ? badge("update available", "update") : "";
135
+ $("detailBadges").innerHTML = badge(model.verification.status, "verified") + badge(taskLabels[model.task], model.task === "world" ? "world" : "") + badge(model.source.license, "license") + updateBadge;
136
+ const specs = [
137
  spec("Recommended VRAM", `${model.hardware.recommended_vram_gb} GB`),
138
  spec("Minimum VRAM", `${model.hardware.minimum_vram_gb} GB`),
139
  spec("Backend", model.runtime.backend),
140
  spec("Adapter", model.runtime.adapter),
141
  spec("Revision", model.source.revision.slice(0, 12)),
142
  spec("Device", model.runtime.device),
143
+ ];
144
+ if (upstream) {
145
+ specs.push(
146
+ spec("Downloads", compactNumber(upstream.downloads)),
147
+ spec("Likes", compactNumber(upstream.likes)),
148
+ spec("Latest revision", upstream.latest_revision.slice(0, 12)),
149
+ spec("Upstream status", upstream.update_available ? "Update available" : "Pinned revision current"),
150
+ );
151
+ }
152
+ $("detailSpecs").innerHTML = specs.join("");
153
  $("commandOutput").textContent = commandFor(model);
154
  $("manifestOutput").textContent = JSON.stringify(model, null, 2);
155
  $("verificationPanel").innerHTML = `
 
188
  window.setTimeout(() => node.classList.remove("visible"), 1600);
189
  }
190
 
191
+ function openSubmission() {
192
+ $("submissionForm").classList.remove("hidden");
193
+ $("submissionResult").classList.add("hidden");
194
+ $("submissionError").classList.add("hidden");
195
+ if (!submissionDialog.open) submissionDialog.showModal();
196
+ }
197
+
198
+ function closeSubmission() {
199
+ if (submissionDialog.open) submissionDialog.close();
200
+ }
201
+
202
+ function submissionPayload(form) {
203
+ const data = new FormData(form);
204
+ const optional = (name) => String(data.get(name) || "").trim() || null;
205
+ return {
206
+ repo_id: String(data.get("repo_id") || "").trim(),
207
+ slug: String(data.get("slug") || "").trim(),
208
+ display_name: optional("display_name"),
209
+ task: data.get("task"),
210
+ registry_model: String(data.get("registry_model") || "").trim(),
211
+ backend: String(data.get("backend") || "").trim(),
212
+ adapter: String(data.get("adapter") || "").trim(),
213
+ device: String(data.get("device") || "").trim(),
214
+ dtype: optional("dtype"),
215
+ minimum_vram_gb: Number(data.get("minimum_vram_gb")),
216
+ recommended_vram_gb: Number(data.get("recommended_vram_gb")),
217
+ summary: String(data.get("summary") || "").trim(),
218
+ tags: String(data.get("tags") || "").split(",").map((tag) => tag.trim()).filter(Boolean),
219
+ };
220
+ }
221
+
222
+ async function validateSubmission(event) {
223
+ event.preventDefault();
224
+ const form = event.currentTarget;
225
+ const submit = $("validateSubmission");
226
+ const error = $("submissionError");
227
+ error.classList.add("hidden");
228
+ submit.disabled = true;
229
+ submit.textContent = "Validating...";
230
+ try {
231
+ const result = await api("/api/submissions/validate", {
232
+ method: "POST",
233
+ headers: { "Content-Type": "application/json" },
234
+ body: JSON.stringify(submissionPayload(form)),
235
+ });
236
+ $("candidateOutput").textContent = JSON.stringify(result.candidate, null, 2);
237
+ $("submissionWarnings").innerHTML = result.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("");
238
+ $("submissionWarnings").classList.toggle("hidden", result.warnings.length === 0);
239
+ $("submissionStatus").textContent = result.warnings.length
240
+ ? `Validated with ${result.warnings.length} item${result.warnings.length === 1 ? "" : "s"} for maintainer review.`
241
+ : "Validated against the current Hugging Face revision.";
242
+ form.classList.add("hidden");
243
+ $("submissionResult").classList.remove("hidden");
244
+ } catch (requestError) {
245
+ error.textContent = requestError.message;
246
+ error.classList.remove("hidden");
247
+ } finally {
248
+ submit.disabled = false;
249
+ submit.textContent = "Validate candidate";
250
+ }
251
+ }
252
+
253
  async function loadCatalog() {
254
  try {
255
  const body = await api("/api/catalog");
 
295
  await navigator.clipboard.writeText(commandFor(state.current));
296
  toast("Command copied");
297
  });
298
+ $("openSubmission").addEventListener("click", openSubmission);
299
+ $("closeSubmission").addEventListener("click", closeSubmission);
300
+ $("submissionForm").addEventListener("submit", validateSubmission);
301
+ $("editSubmission").addEventListener("click", () => {
302
+ $("submissionResult").classList.add("hidden");
303
+ $("submissionForm").classList.remove("hidden");
304
+ });
305
+ $("copyCandidate").addEventListener("click", async () => {
306
+ await navigator.clipboard.writeText($("candidateOutput").textContent);
307
+ toast("Candidate copied");
308
+ });
309
+ submissionDialog.addEventListener("click", (event) => { if (event.target === submissionDialog) closeSubmission(); });
310
  window.addEventListener("popstate", () => {
311
  const slug = new URLSearchParams(location.search).get("model");
312
  const model = state.models.find((item) => item.slug === slug);
app/catalog.py CHANGED
@@ -9,6 +9,8 @@ from typing import Literal
9
  import httpx
10
  from pydantic import BaseModel, ConfigDict, Field, model_validator
11
 
 
 
12
 
13
  ROOT = Path(__file__).resolve().parents[1]
14
  DEFAULT_CATALOG_URL = (
@@ -56,6 +58,21 @@ class Verification(BaseModel):
56
  checks: list[str] = Field(default_factory=list)
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  class HubModel(BaseModel):
60
  model_config = ConfigDict(extra="forbid")
61
 
@@ -68,6 +85,7 @@ class HubModel(BaseModel):
68
  runtime: Runtime
69
  hardware: Hardware
70
  verification: Verification
 
71
 
72
 
73
  class Catalog(BaseModel):
@@ -93,6 +111,8 @@ class CatalogService:
93
  fallback_path: Path = DEFAULT_FALLBACK_PATH,
94
  ttl_seconds: int = 300,
95
  timeout_seconds: float = 15.0,
 
 
96
  ):
97
  self.catalog_url = (
98
  catalog_url
@@ -102,6 +122,8 @@ class CatalogService:
102
  self.fallback_path = fallback_path
103
  self.ttl_seconds = ttl_seconds
104
  self.timeout_seconds = timeout_seconds
 
 
105
  self.source = "not-loaded"
106
  self.error: str | None = None
107
  self._catalog: Catalog | None = None
@@ -123,6 +145,8 @@ class CatalogService:
123
  catalog = await self._load_remote() if self.catalog_url else None
124
  if catalog is None:
125
  catalog = self._load_fallback()
 
 
126
  self._catalog = catalog
127
  self._loaded_at = time.monotonic()
128
  return catalog
@@ -151,6 +175,30 @@ class CatalogService:
151
  self.source = "bundled"
152
  return catalog
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  async def model(self, slug: str) -> HubModel | None:
155
  catalog = await self.get()
156
  return next((model for model in catalog.models if model.slug == slug), None)
 
9
  import httpx
10
  from pydantic import BaseModel, ConfigDict, Field, model_validator
11
 
12
+ from .huggingface import HuggingFaceClient, HuggingFaceError
13
+
14
 
15
  ROOT = Path(__file__).resolve().parents[1]
16
  DEFAULT_CATALOG_URL = (
 
58
  checks: list[str] = Field(default_factory=list)
59
 
60
 
61
+ class UpstreamMetadata(BaseModel):
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ latest_revision: str
65
+ revision_current: bool
66
+ update_available: bool
67
+ downloads: int = Field(ge=0)
68
+ likes: int = Field(ge=0)
69
+ last_modified: str | None = None
70
+ gated: bool
71
+ license: str
72
+ library: str | None = None
73
+ pipeline_tag: str | None = None
74
+
75
+
76
  class HubModel(BaseModel):
77
  model_config = ConfigDict(extra="forbid")
78
 
 
85
  runtime: Runtime
86
  hardware: Hardware
87
  verification: Verification
88
+ upstream: UpstreamMetadata | None = None
89
 
90
 
91
  class Catalog(BaseModel):
 
111
  fallback_path: Path = DEFAULT_FALLBACK_PATH,
112
  ttl_seconds: int = 300,
113
  timeout_seconds: float = 15.0,
114
+ hf_client: HuggingFaceClient | None = None,
115
+ enrich_metadata: bool = True,
116
  ):
117
  self.catalog_url = (
118
  catalog_url
 
122
  self.fallback_path = fallback_path
123
  self.ttl_seconds = ttl_seconds
124
  self.timeout_seconds = timeout_seconds
125
+ self.hf_client = hf_client or HuggingFaceClient(ttl_seconds=ttl_seconds)
126
+ self.enrich_metadata = enrich_metadata
127
  self.source = "not-loaded"
128
  self.error: str | None = None
129
  self._catalog: Catalog | None = None
 
145
  catalog = await self._load_remote() if self.catalog_url else None
146
  if catalog is None:
147
  catalog = self._load_fallback()
148
+ if self.enrich_metadata:
149
+ catalog = await self._enrich(catalog)
150
  self._catalog = catalog
151
  self._loaded_at = time.monotonic()
152
  return catalog
 
175
  self.source = "bundled"
176
  return catalog
177
 
178
+ async def _enrich(self, catalog: Catalog) -> Catalog:
179
+ async def enrich_model(model: HubModel) -> HubModel:
180
+ try:
181
+ upstream = await self.hf_client.model(model.source.repo_id)
182
+ except (HuggingFaceError, ValueError):
183
+ return model
184
+ current = upstream.revision == model.source.revision
185
+ metadata = UpstreamMetadata(
186
+ latest_revision=upstream.revision,
187
+ revision_current=current,
188
+ update_available=not current,
189
+ downloads=upstream.downloads,
190
+ likes=upstream.likes,
191
+ last_modified=upstream.last_modified,
192
+ gated=upstream.gated,
193
+ license=upstream.license,
194
+ library=upstream.library,
195
+ pipeline_tag=upstream.pipeline_tag,
196
+ )
197
+ return model.model_copy(update={"upstream": metadata})
198
+
199
+ models = await asyncio.gather(*(enrich_model(model) for model in catalog.models))
200
+ return catalog.model_copy(update={"models": list(models)})
201
+
202
  async def model(self, slug: str) -> HubModel | None:
203
  catalog = await self.get()
204
  return next((model for model in catalog.models if model.slug == slug), None)
app/huggingface.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import time
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+
12
+ HF_API_URL = "https://huggingface.co/api/models"
13
+ REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$")
14
+ ROBOTICS_SIGNALS = {
15
+ "action",
16
+ "embodied",
17
+ "locomotion",
18
+ "manipulation",
19
+ "navigation",
20
+ "robot",
21
+ "robotics",
22
+ "state-space",
23
+ "vla",
24
+ "world-model",
25
+ }
26
+
27
+
28
+ class HuggingFaceError(RuntimeError):
29
+ pass
30
+
31
+
32
+ class HuggingFaceModel(BaseModel):
33
+ model_config = ConfigDict(extra="forbid")
34
+
35
+ repo_id: str
36
+ revision: str
37
+ downloads: int = Field(default=0, ge=0)
38
+ likes: int = Field(default=0, ge=0)
39
+ last_modified: str | None = None
40
+ gated: bool = False
41
+ license: str = "other"
42
+ library: str | None = None
43
+ pipeline_tag: str | None = None
44
+ tags: list[str] = Field(default_factory=list)
45
+
46
+ def has_robotics_signal(self) -> bool:
47
+ values = {tag.casefold() for tag in self.tags}
48
+ if self.pipeline_tag:
49
+ values.add(self.pipeline_tag.casefold())
50
+ return bool(values & ROBOTICS_SIGNALS) or any(
51
+ signal in value for value in values for signal in ROBOTICS_SIGNALS
52
+ )
53
+
54
+
55
+ def validate_repo_id(repo_id: str) -> str:
56
+ normalized = repo_id.strip()
57
+ if not REPO_ID_PATTERN.fullmatch(normalized):
58
+ raise ValueError("repo_id must use the owner/model format")
59
+ return normalized
60
+
61
+
62
+ def _license_from(payload: dict[str, Any], tags: list[str]) -> str:
63
+ card_data = payload.get("cardData") or {}
64
+ license_name = card_data.get("license")
65
+ if isinstance(license_name, str) and license_name.strip():
66
+ return license_name.strip().casefold()
67
+ for tag in tags:
68
+ if tag.casefold().startswith("license:"):
69
+ return tag.split(":", 1)[1].strip().casefold() or "other"
70
+ return "other"
71
+
72
+
73
+ def normalize_model(payload: dict[str, Any]) -> HuggingFaceModel:
74
+ repo_id = validate_repo_id(str(payload.get("id") or payload.get("modelId") or ""))
75
+ revision = str(payload.get("sha") or "").strip()
76
+ if not revision:
77
+ raise ValueError(f"Hugging Face did not return a revision for {repo_id}")
78
+ tags = [str(tag) for tag in payload.get("tags") or [] if isinstance(tag, str)]
79
+ gated_value = payload.get("gated", False)
80
+ return HuggingFaceModel(
81
+ repo_id=repo_id,
82
+ revision=revision,
83
+ downloads=max(int(payload.get("downloads") or 0), 0),
84
+ likes=max(int(payload.get("likes") or 0), 0),
85
+ last_modified=payload.get("lastModified"),
86
+ gated=gated_value is not False and gated_value is not None,
87
+ license=_license_from(payload, tags),
88
+ library=payload.get("library_name"),
89
+ pipeline_tag=payload.get("pipeline_tag"),
90
+ tags=tags,
91
+ )
92
+
93
+
94
+ class HuggingFaceClient:
95
+ def __init__(
96
+ self,
97
+ *,
98
+ ttl_seconds: int = 300,
99
+ timeout_seconds: float = 12.0,
100
+ transport: httpx.AsyncBaseTransport | None = None,
101
+ ):
102
+ self.ttl_seconds = ttl_seconds
103
+ self.timeout_seconds = timeout_seconds
104
+ self.transport = transport
105
+ self._cache: dict[str, tuple[float, Any]] = {}
106
+ self._lock = asyncio.Lock()
107
+
108
+ async def _get_json(self, url: str, *, params: dict[str, Any] | None = None) -> Any:
109
+ try:
110
+ async with httpx.AsyncClient(
111
+ timeout=self.timeout_seconds,
112
+ follow_redirects=True,
113
+ transport=self.transport,
114
+ ) as client:
115
+ response = await client.get(url, params=params)
116
+ response.raise_for_status()
117
+ return response.json()
118
+ except (httpx.HTTPError, ValueError) as exc:
119
+ raise HuggingFaceError(str(exc)) from exc
120
+
121
+ async def _cached(self, key: str, loader) -> Any:
122
+ now = time.monotonic()
123
+ cached = self._cache.get(key)
124
+ if cached and now - cached[0] < self.ttl_seconds:
125
+ return cached[1]
126
+ async with self._lock:
127
+ now = time.monotonic()
128
+ cached = self._cache.get(key)
129
+ if cached and now - cached[0] < self.ttl_seconds:
130
+ return cached[1]
131
+ value = await loader()
132
+ self._cache[key] = (time.monotonic(), value)
133
+ return value
134
+
135
+ async def model(self, repo_id: str, *, refresh: bool = False) -> HuggingFaceModel:
136
+ repo_id = validate_repo_id(repo_id)
137
+ key = f"model:{repo_id.casefold()}"
138
+ if refresh:
139
+ self._cache.pop(key, None)
140
+
141
+ async def load() -> HuggingFaceModel:
142
+ payload = await self._get_json(f"{HF_API_URL}/{repo_id}")
143
+ try:
144
+ return normalize_model(payload)
145
+ except (TypeError, ValueError) as exc:
146
+ raise HuggingFaceError(str(exc)) from exc
147
+
148
+ return await self._cached(key, load)
149
+
150
+ async def discover(self, query: str, *, limit: int = 12) -> list[HuggingFaceModel]:
151
+ query = query.strip()
152
+ if not query:
153
+ return []
154
+ safe_limit = min(max(limit, 1), 30)
155
+ key = f"discover:{query.casefold()}:{safe_limit}"
156
+
157
+ async def load() -> list[HuggingFaceModel]:
158
+ payload = await self._get_json(
159
+ HF_API_URL,
160
+ params={"search": query, "limit": safe_limit, "full": "true"},
161
+ )
162
+ if not isinstance(payload, list):
163
+ raise HuggingFaceError("unexpected Hugging Face search response")
164
+ models = []
165
+ for item in payload:
166
+ try:
167
+ model = normalize_model(item)
168
+ except (TypeError, ValueError):
169
+ continue
170
+ if model.has_robotics_signal():
171
+ models.append(model)
172
+ return models
173
+
174
+ return await self._cached(key, load)
app/main.py CHANGED
@@ -8,13 +8,20 @@ from fastapi.responses import FileResponse
8
  from fastapi.staticfiles import StaticFiles
9
 
10
  from .catalog import ROOT, CatalogService
 
 
11
 
12
 
13
  ASSET_DIR = ROOT / "assets"
14
 
15
 
16
- def create_app(catalog_service: CatalogService | None = None) -> FastAPI:
17
- service = catalog_service or CatalogService()
 
 
 
 
 
18
 
19
  @asynccontextmanager
20
  async def lifespan(_: FastAPI):
@@ -23,12 +30,13 @@ def create_app(catalog_service: CatalogService | None = None) -> FastAPI:
23
 
24
  app = FastAPI(
25
  title="AirGPT Hub",
26
- version="0.1.0",
27
  docs_url="/api/docs",
28
  openapi_url="/api/openapi.json",
29
  lifespan=lifespan,
30
  )
31
  app.state.catalog = service
 
32
  app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
33
 
34
  @app.get("/", include_in_schema=False)
@@ -92,6 +100,29 @@ def create_app(catalog_service: CatalogService | None = None) -> FastAPI:
92
  "models": results,
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  return app
96
 
97
 
 
8
  from fastapi.staticfiles import StaticFiles
9
 
10
  from .catalog import ROOT, CatalogService
11
+ from .huggingface import HuggingFaceClient, HuggingFaceError
12
+ from .submissions import SubmissionRequest, SubmissionResult, SubmissionService
13
 
14
 
15
  ASSET_DIR = ROOT / "assets"
16
 
17
 
18
+ def create_app(
19
+ catalog_service: CatalogService | None = None,
20
+ hf_client: HuggingFaceClient | None = None,
21
+ ) -> FastAPI:
22
+ shared_hf_client = hf_client or HuggingFaceClient()
23
+ service = catalog_service or CatalogService(hf_client=shared_hf_client)
24
+ submissions = SubmissionService(shared_hf_client)
25
 
26
  @asynccontextmanager
27
  async def lifespan(_: FastAPI):
 
30
 
31
  app = FastAPI(
32
  title="AirGPT Hub",
33
+ version="0.1.1",
34
  docs_url="/api/docs",
35
  openapi_url="/api/openapi.json",
36
  lifespan=lifespan,
37
  )
38
  app.state.catalog = service
39
+ app.state.huggingface = shared_hf_client
40
  app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
41
 
42
  @app.get("/", include_in_schema=False)
 
100
  "models": results,
101
  }
102
 
103
+ @app.get("/api/discover")
104
+ async def discover(q: str = Query(min_length=2, max_length=100)):
105
+ try:
106
+ models = await shared_hf_client.discover(q)
107
+ except HuggingFaceError as exc:
108
+ raise HTTPException(
109
+ status_code=502, detail=f"Hugging Face lookup failed: {exc}"
110
+ ) from exc
111
+ return {"query": q, "count": len(models), "models": models}
112
+
113
+ @app.post(
114
+ "/api/submissions/validate",
115
+ response_model=SubmissionResult,
116
+ response_model_exclude_none=True,
117
+ )
118
+ async def validate_submission(request: SubmissionRequest):
119
+ try:
120
+ return await submissions.validate(request)
121
+ except HuggingFaceError as exc:
122
+ raise HTTPException(
123
+ status_code=422, detail=f"Model repository could not be validated: {exc}"
124
+ ) from exc
125
+
126
  return app
127
 
128
 
app/submissions.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
7
+
8
+ from .catalog import Hardware, HubModel, Runtime, Source, Verification
9
+ from .huggingface import HuggingFaceClient, HuggingFaceModel, validate_repo_id
10
+
11
+
12
+ SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$")
13
+ ROBOTICS_TAGS = {"embodied", "manipulation", "navigation", "robot", "robotics", "vla"}
14
+
15
+
16
+ class SubmissionRequest(BaseModel):
17
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
18
+
19
+ repo_id: str
20
+ slug: str
21
+ display_name: str | None = Field(default=None, min_length=2, max_length=100)
22
+ task: Literal["vla", "world", "state_world"]
23
+ registry_model: str = Field(min_length=1, max_length=100)
24
+ backend: str = Field(min_length=1, max_length=100)
25
+ adapter: str = Field(min_length=1, max_length=100)
26
+ device: str = Field(default="cuda:0", min_length=1, max_length=50)
27
+ dtype: str | None = Field(default=None, max_length=50)
28
+ minimum_vram_gb: int = Field(ge=0, le=1024)
29
+ recommended_vram_gb: int = Field(ge=0, le=1024)
30
+ summary: str = Field(min_length=20, max_length=400)
31
+ tags: list[str] = Field(default_factory=list, max_length=12)
32
+
33
+ @field_validator("repo_id")
34
+ @classmethod
35
+ def valid_repo_id(cls, value: str) -> str:
36
+ return validate_repo_id(value)
37
+
38
+ @field_validator("slug")
39
+ @classmethod
40
+ def valid_slug(cls, value: str) -> str:
41
+ if not SLUG_PATTERN.fullmatch(value):
42
+ raise ValueError("slug must contain lowercase letters, numbers, and internal hyphens")
43
+ return value
44
+
45
+ @field_validator("tags")
46
+ @classmethod
47
+ def normalize_tags(cls, values: list[str]) -> list[str]:
48
+ normalized = []
49
+ for value in values:
50
+ tag = value.strip().casefold()
51
+ if tag and tag not in normalized:
52
+ normalized.append(tag)
53
+ return normalized
54
+
55
+ @model_validator(mode="after")
56
+ def valid_vram_range(self):
57
+ if self.minimum_vram_gb > self.recommended_vram_gb:
58
+ raise ValueError("recommended_vram_gb must be at least minimum_vram_gb")
59
+ return self
60
+
61
+
62
+ class SubmissionResult(BaseModel):
63
+ model_config = ConfigDict(extra="forbid")
64
+
65
+ candidate: HubModel
66
+ upstream: HuggingFaceModel
67
+ warnings: list[str]
68
+
69
+
70
+ class SubmissionService:
71
+ def __init__(self, hf_client: HuggingFaceClient):
72
+ self.hf_client = hf_client
73
+
74
+ async def validate(self, request: SubmissionRequest) -> SubmissionResult:
75
+ upstream = await self.hf_client.model(request.repo_id, refresh=True)
76
+ warnings = []
77
+ if upstream.gated:
78
+ warnings.append("This model is gated; users must authenticate and accept its terms.")
79
+ if upstream.license in {"", "other", "unknown"}:
80
+ warnings.append("The upstream license needs manual review before publication.")
81
+ combined_tags = list(dict.fromkeys([*request.tags, *upstream.tags]))
82
+ if not upstream.has_robotics_signal() and not (set(request.tags) & ROBOTICS_TAGS):
83
+ warnings.append("No clear robotics or embodied-AI tag was found upstream.")
84
+ if not upstream.library:
85
+ warnings.append(
86
+ "The Hugging Face library is not declared; verify the runtime backend manually."
87
+ )
88
+
89
+ display_name = request.display_name or request.repo_id.split("/", 1)[1].replace("-", " ")
90
+ candidate = HubModel(
91
+ slug=request.slug,
92
+ display_name=display_name,
93
+ summary=request.summary,
94
+ task=request.task,
95
+ tags=combined_tags[:12],
96
+ source=Source(
97
+ provider="huggingface",
98
+ repo_id=upstream.repo_id,
99
+ revision=upstream.revision,
100
+ gated=upstream.gated,
101
+ license=upstream.license,
102
+ license_url=f"https://huggingface.co/{upstream.repo_id}",
103
+ ),
104
+ runtime=Runtime(
105
+ registry_model=request.registry_model,
106
+ backend=request.backend,
107
+ adapter=request.adapter,
108
+ device=request.device,
109
+ dtype=request.dtype,
110
+ ),
111
+ hardware=Hardware(
112
+ minimum_vram_gb=request.minimum_vram_gb,
113
+ recommended_vram_gb=request.recommended_vram_gb,
114
+ notes="Community suggestion; hardware guidance is not yet verified.",
115
+ ),
116
+ verification=Verification(status="unverified"),
117
+ )
118
+ return SubmissionResult(candidate=candidate, upstream=upstream, warnings=warnings)
index.html CHANGED
@@ -47,10 +47,13 @@
47
  <button type="button" data-task="world" role="tab" aria-selected="false">World</button>
48
  <button type="button" data-task="state_world" role="tab" aria-selected="false">State space</button>
49
  </div>
50
- <label class="verified-toggle">
51
- <input id="verifiedOnly" type="checkbox" checked>
52
- <span>Verified only</span>
53
- </label>
 
 
 
54
  </div>
55
  </section>
56
 
@@ -106,6 +109,53 @@
106
  </article>
107
  </dialog>
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  <div id="toast" class="toast" role="status"></div>
110
  <script src="/app.js" defer></script>
111
  </body>
 
47
  <button type="button" data-task="world" role="tab" aria-selected="false">World</button>
48
  <button type="button" data-task="state_world" role="tab" aria-selected="false">State space</button>
49
  </div>
50
+ <div class="toolbar-actions">
51
+ <label class="verified-toggle">
52
+ <input id="verifiedOnly" type="checkbox" checked>
53
+ <span>Verified only</span>
54
+ </label>
55
+ <button id="openSubmission" class="primary-button" type="button">Suggest model</button>
56
+ </div>
57
  </div>
58
  </section>
59
 
 
109
  </article>
110
  </dialog>
111
 
112
+ <dialog id="submissionDialog" class="submission-dialog">
113
+ <article>
114
+ <header class="dialog-header">
115
+ <div>
116
+ <p class="eyebrow">Community catalog</p>
117
+ <h2>Suggest a model</h2>
118
+ </div>
119
+ <button id="closeSubmission" class="close-button inline-close" type="button" aria-label="Close model suggestion">&times;</button>
120
+ </header>
121
+ <form id="submissionForm">
122
+ <div class="submission-fields">
123
+ <label class="wide-field">Hugging Face repository<input name="repo_id" placeholder="organization/model" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9][A-Za-z0-9._\-]*"></label>
124
+ <label>Display name<input name="display_name" placeholder="Optional"></label>
125
+ <label>Catalog slug<input name="slug" placeholder="model-name" required pattern="[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]"></label>
126
+ <label>Task<select name="task" required><option value="vla">VLA</option><option value="world">World model</option><option value="state_world">State space</option></select></label>
127
+ <label>Runtime model<input name="registry_model" placeholder="openvla" required></label>
128
+ <label>Backend<input name="backend" value="transformers" required></label>
129
+ <label>Adapter<input name="adapter" placeholder="openvla" required></label>
130
+ <label>Device<input name="device" value="cuda:0" required></label>
131
+ <label>Dtype<input name="dtype" placeholder="bfloat16"></label>
132
+ <label>Minimum VRAM (GB)<input name="minimum_vram_gb" type="number" min="0" max="1024" value="16" required></label>
133
+ <label>Recommended VRAM (GB)<input name="recommended_vram_gb" type="number" min="0" max="1024" value="24" required></label>
134
+ <label class="wide-field">Tags<input name="tags" placeholder="robotics, manipulation, vla"></label>
135
+ <label class="wide-field">Summary<textarea name="summary" minlength="20" maxlength="400" placeholder="What this model does and which embodied task it supports" required></textarea></label>
136
+ </div>
137
+ <p class="submission-note">Validation reads public Hugging Face metadata and pins the current revision. Suggestions are not published automatically.</p>
138
+ <div id="submissionError" class="form-error hidden" role="alert"></div>
139
+ <div class="form-actions">
140
+ <a class="secondary-button" href="https://huggingface.co/datasets/airgpt/embodied-model-index/discussions" target="_blank" rel="noreferrer">Catalog discussions</a>
141
+ <button id="validateSubmission" class="primary-button" type="submit">Validate candidate</button>
142
+ </div>
143
+ </form>
144
+ <section id="submissionResult" class="submission-result hidden" aria-live="polite">
145
+ <div>
146
+ <h3>Candidate manifest</h3>
147
+ <p id="submissionStatus"></p>
148
+ </div>
149
+ <ul id="submissionWarnings" class="warning-list"></ul>
150
+ <pre id="candidateOutput" class="candidate-output"></pre>
151
+ <div class="form-actions">
152
+ <button id="editSubmission" class="secondary-action" type="button">Edit fields</button>
153
+ <button id="copyCandidate" class="primary-button" type="button">Copy candidate</button>
154
+ </div>
155
+ </section>
156
+ </article>
157
+ </dialog>
158
+
159
  <div id="toast" class="toast" role="status"></div>
160
  <script src="/app.js" defer></script>
161
  </body>
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Maintainer utilities for AirGPT Hub."""
scripts/publish_space.py CHANGED
@@ -44,7 +44,7 @@ def main() -> int:
44
  repo_id=args.repo_id,
45
  repo_type="space",
46
  folder_path=str(ROOT),
47
- commit_message="Publish AirGPT Hub H1",
48
  ignore_patterns=[
49
  ".git/**",
50
  ".pytest_cache/**",
 
44
  repo_id=args.repo_id,
45
  repo_type="space",
46
  folder_path=str(ROOT),
47
+ commit_message="Publish AirGPT Hub H1.1",
48
  ignore_patterns=[
49
  ".git/**",
50
  ".pytest_cache/**",
scripts/review_candidate.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import sys
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ sys.path.insert(0, str(ROOT))
16
+
17
+ from app.catalog import Catalog, HubModel # noqa: E402
18
+ from app.huggingface import HuggingFaceClient # noqa: E402
19
+
20
+
21
+ CATALOG_URL = "https://huggingface.co/datasets/airgpt/embodied-model-index/resolve/main/index.json"
22
+
23
+
24
+ def merge_candidate(catalog: Catalog, candidate: HubModel) -> Catalog:
25
+ if any(model.slug == candidate.slug for model in catalog.models):
26
+ raise ValueError(f"duplicate slug: {candidate.slug}")
27
+ if any(
28
+ model.source.repo_id.casefold() == candidate.source.repo_id.casefold()
29
+ for model in catalog.models
30
+ ):
31
+ raise ValueError(f"duplicate repository: {candidate.source.repo_id}")
32
+ if candidate.verification.status != "unverified":
33
+ raise ValueError("new candidates must have unverified status")
34
+ generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
35
+ return catalog.model_copy(
36
+ update={"generated_at": generated_at, "models": [*catalog.models, candidate]}
37
+ )
38
+
39
+
40
+ def load_candidate(path: Path) -> HubModel:
41
+ payload: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
42
+ if "candidate" in payload:
43
+ payload = payload["candidate"]
44
+ payload.pop("upstream", None)
45
+ return HubModel.model_validate(payload)
46
+
47
+
48
+ async def build_preview(candidate_path: Path) -> Catalog:
49
+ candidate = load_candidate(candidate_path)
50
+ upstream = await HuggingFaceClient().model(candidate.source.repo_id, refresh=True)
51
+ if upstream.revision != candidate.source.revision:
52
+ raise ValueError(
53
+ "candidate revision is stale: "
54
+ f"expected {candidate.source.revision}, upstream is {upstream.revision}"
55
+ )
56
+ candidate = candidate.model_copy(
57
+ update={
58
+ "source": candidate.source.model_copy(
59
+ update={"gated": upstream.gated, "license": upstream.license}
60
+ )
61
+ }
62
+ )
63
+ async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
64
+ response = await client.get(CATALOG_URL)
65
+ response.raise_for_status()
66
+ catalog = Catalog.model_validate(response.json())
67
+ return merge_candidate(catalog, candidate)
68
+
69
+
70
+ def publish(catalog: Catalog, token: str) -> None:
71
+ try:
72
+ from huggingface_hub import HfApi
73
+ except ImportError as exc:
74
+ raise RuntimeError("install huggingface_hub before publishing") from exc
75
+ HfApi(token=token).upload_file(
76
+ path_or_fileobj=catalog.model_dump_json(indent=2).encode(),
77
+ path_in_repo="index.json",
78
+ repo_id="airgpt/embodied-model-index",
79
+ repo_type="dataset",
80
+ commit_message="Add reviewed AirGPT Hub candidate",
81
+ )
82
+
83
+
84
+ def main() -> int:
85
+ parser = argparse.ArgumentParser(description="review an AirGPT Hub candidate manifest")
86
+ parser.add_argument("candidate", type=Path)
87
+ parser.add_argument("--publish", action="store_true", help="write the reviewed catalog to HF")
88
+ args = parser.parse_args()
89
+ catalog = asyncio.run(build_preview(args.candidate))
90
+ print(catalog.model_dump_json(indent=2))
91
+ if args.publish:
92
+ token = os.getenv("HF_TOKEN")
93
+ if not token:
94
+ parser.error("HF_TOKEN is required with --publish")
95
+ publish(catalog, token)
96
+ else:
97
+ print("\nPreview only. Re-run with --publish after manual review.", file=sys.stderr)
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
styles.css CHANGED
@@ -29,7 +29,7 @@ body {
29
  color: var(--ink);
30
  }
31
 
32
- button, input, select { font: inherit; }
33
 
34
  button, a { -webkit-tap-highlight-color: transparent; }
35
 
@@ -169,6 +169,22 @@ input[type="search"]:focus, select:focus { border-color: var(--green); box-shado
169
 
170
  .verified-toggle input { width: 17px; height: 17px; accent-color: var(--green); }
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  .catalog-content { max-width: 1552px; margin: 0 auto; padding: 28px clamp(16px, 4vw, 56px) 52px; }
173
 
174
  .model-grid {
@@ -226,6 +242,7 @@ input[type="search"]:focus, select:focus { border-color: var(--green); box-shado
226
  .badge.verified { background: var(--green-soft); border-color: #afd9c8; color: var(--green-dark); }
227
  .badge.world { background: var(--blue-soft); border-color: #bfd0ed; color: var(--blue); }
228
  .badge.license { background: var(--amber-soft); border-color: #ead29a; color: var(--amber); }
 
229
 
230
  .card-body { min-height: 268px; padding: 18px; display: flex; flex-direction: column; }
231
  .repo-name { margin: 0 0 5px; color: var(--muted); font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
@@ -370,6 +387,47 @@ pre {
370
  }
371
  .toast.visible { opacity: 1; transform: translate(-50%, 0); }
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  @media (max-width: 1050px) {
374
  .model-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
375
  .model-dialog article { grid-template-columns: minmax(240px, 34%) minmax(0, 1fr); }
@@ -391,6 +449,7 @@ pre {
391
  .segmented { width: 100%; grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
392
  .segmented button { padding: 0 9px; }
393
  .verified-toggle { min-height: 32px; }
 
394
  .catalog-content { padding: 18px 14px 36px; }
395
  .model-grid { grid-template-columns: 1fr; }
396
  .card-body { min-height: 0; }
@@ -408,6 +467,12 @@ pre {
408
  .detail-specs div:nth-child(even) { padding-left: 0; border-left: 0; }
409
  .detail-tabs { overflow-x: auto; }
410
  .detail-tabs button { white-space: nowrap; }
 
 
 
 
 
 
411
  }
412
 
413
  @media (prefers-reduced-motion: reduce) {
 
29
  color: var(--ink);
30
  }
31
 
32
+ button, input, select, textarea { font: inherit; }
33
 
34
  button, a { -webkit-tap-highlight-color: transparent; }
35
 
 
169
 
170
  .verified-toggle input { width: 17px; height: 17px; accent-color: var(--green); }
171
 
172
+ .toolbar-actions { display: flex; align-items: center; gap: 18px; }
173
+
174
+ .primary-button, .secondary-action {
175
+ min-height: 38px;
176
+ padding: 8px 13px;
177
+ border-radius: 6px;
178
+ cursor: pointer;
179
+ font-size: 13px;
180
+ font-weight: 750;
181
+ }
182
+
183
+ .primary-button { border: 1px solid var(--green); background: var(--green); color: white; }
184
+ .primary-button:hover { background: var(--green-dark); border-color: var(--green-dark); }
185
+ .primary-button:disabled { cursor: wait; opacity: 0.65; }
186
+ .secondary-action { border: 1px solid var(--border-strong); background: var(--surface); color: #33413b; }
187
+
188
  .catalog-content { max-width: 1552px; margin: 0 auto; padding: 28px clamp(16px, 4vw, 56px) 52px; }
189
 
190
  .model-grid {
 
242
  .badge.verified { background: var(--green-soft); border-color: #afd9c8; color: var(--green-dark); }
243
  .badge.world { background: var(--blue-soft); border-color: #bfd0ed; color: var(--blue); }
244
  .badge.license { background: var(--amber-soft); border-color: #ead29a; color: var(--amber); }
245
+ .badge.update { background: #fde8e7; border-color: #e8b8b4; color: #963c34; }
246
 
247
  .card-body { min-height: 268px; padding: 18px; display: flex; flex-direction: column; }
248
  .repo-name { margin: 0 0 5px; color: var(--muted); font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
 
387
  }
388
  .toast.visible { opacity: 1; transform: translate(-50%, 0); }
389
 
390
+ .submission-dialog {
391
+ width: min(760px, calc(100vw - 32px));
392
+ max-height: calc(100vh - 32px);
393
+ padding: 0;
394
+ overflow: hidden;
395
+ border: 0;
396
+ border-radius: 8px;
397
+ background: var(--surface);
398
+ box-shadow: var(--shadow);
399
+ }
400
+
401
+ .submission-dialog::backdrop { background: rgba(17, 28, 23, 0.6); }
402
+ .submission-dialog article { max-height: calc(100vh - 32px); padding: 28px; overflow-y: auto; }
403
+ .dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
404
+ .dialog-header h2 { margin: 3px 0 0; font-size: 25px; letter-spacing: 0; }
405
+ .inline-close { position: static; flex: 0 0 auto; }
406
+ .submission-fields { margin-top: 24px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
407
+ .submission-fields label { min-width: 0; color: #53615b; font-size: 12px; font-weight: 700; }
408
+ .submission-fields .wide-field { grid-column: 1 / -1; }
409
+ .submission-fields input, .submission-fields select, .submission-fields textarea {
410
+ width: 100%;
411
+ margin-top: 6px;
412
+ border: 1px solid var(--border-strong);
413
+ border-radius: 6px;
414
+ background: var(--surface);
415
+ color: var(--ink);
416
+ outline: none;
417
+ }
418
+ .submission-fields input, .submission-fields select { height: 42px; padding: 0 11px; }
419
+ .submission-fields textarea { min-height: 88px; padding: 10px 11px; resize: vertical; line-height: 1.45; }
420
+ .submission-fields input:focus, .submission-fields select:focus, .submission-fields textarea:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(20, 120, 91, 0.12); }
421
+ .submission-note { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
422
+ .form-actions { margin-top: 18px; display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
423
+ .form-error { margin-top: 14px; padding: 10px 12px; border-left: 3px solid #a6463d; background: #fdeceb; color: #7f3029; font-size: 13px; }
424
+ .submission-result { padding-top: 22px; }
425
+ .submission-result h3 { margin: 0; font-size: 18px; letter-spacing: 0; }
426
+ .submission-result p { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
427
+ .warning-list { margin: 16px 0 0; padding: 0; display: grid; gap: 7px; list-style: none; }
428
+ .warning-list li { padding: 9px 11px; border-left: 3px solid var(--amber); background: var(--amber-soft); color: #684707; font-size: 13px; line-height: 1.45; }
429
+ .candidate-output { max-height: 410px; }
430
+
431
  @media (max-width: 1050px) {
432
  .model-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
433
  .model-dialog article { grid-template-columns: minmax(240px, 34%) minmax(0, 1fr); }
 
449
  .segmented { width: 100%; grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
450
  .segmented button { padding: 0 9px; }
451
  .verified-toggle { min-height: 32px; }
452
+ .toolbar-actions { justify-content: space-between; }
453
  .catalog-content { padding: 18px 14px 36px; }
454
  .model-grid { grid-template-columns: 1fr; }
455
  .card-body { min-height: 0; }
 
467
  .detail-specs div:nth-child(even) { padding-left: 0; border-left: 0; }
468
  .detail-tabs { overflow-x: auto; }
469
  .detail-tabs button { white-space: nowrap; }
470
+ .submission-dialog { width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
471
+ .submission-dialog article { max-height: 100vh; padding: 22px 16px 30px; }
472
+ .submission-fields { grid-template-columns: 1fr; }
473
+ .submission-fields .wide-field { grid-column: auto; }
474
+ .form-actions { align-items: stretch; flex-direction: column-reverse; }
475
+ .form-actions .secondary-button, .form-actions button { width: 100%; text-align: center; }
476
  }
477
 
478
  @media (prefers-reduced-motion: reduce) {
tests/test_app.py CHANGED
@@ -1,17 +1,48 @@
1
  from pathlib import Path
2
 
3
  from fastapi.testclient import TestClient
 
4
 
5
  from app.catalog import CatalogService
 
6
  from app.main import create_app
7
 
8
 
9
  ROOT = Path(__file__).resolve().parents[1]
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def client():
13
- service = CatalogService(catalog_url="", fallback_path=ROOT / "data" / "index.json")
14
- return TestClient(create_app(service))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  def test_catalog_ui_and_health():
@@ -49,3 +80,31 @@ def test_catalog_api_search_and_model_routes():
49
  assert test_client.get("/api/models/missing").status_code == 404
50
  assert test_client.get("/model/openvla-7b").status_code == 200
51
  assert test_client.get("/model/missing").status_code == 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
 
3
  from fastapi.testclient import TestClient
4
+ import httpx
5
 
6
  from app.catalog import CatalogService
7
+ from app.huggingface import HuggingFaceClient
8
  from app.main import create_app
9
 
10
 
11
  ROOT = Path(__file__).resolve().parents[1]
12
 
13
 
14
+ def hf_payload(repo_id="openvla/openvla-7b"):
15
+ return {
16
+ "id": repo_id,
17
+ "sha": "a" * 40,
18
+ "downloads": 1200,
19
+ "likes": 45,
20
+ "lastModified": "2026-07-24T10:00:00.000Z",
21
+ "gated": False,
22
+ "tags": ["robotics", "vla", "license:apache-2.0"],
23
+ "pipeline_tag": "robotics",
24
+ "library_name": "transformers",
25
+ }
26
+
27
+
28
  def client():
29
+ def handler(request: httpx.Request):
30
+ if request.url.path == "/api/models":
31
+ text_model = hf_payload("example/text-model")
32
+ text_model.update(
33
+ tags=["text-generation", "license:mit"],
34
+ pipeline_tag="text-generation",
35
+ )
36
+ return httpx.Response(200, json=[hf_payload(), text_model])
37
+ return httpx.Response(200, json=hf_payload(request.url.path.removeprefix("/api/models/")))
38
+
39
+ hf_client = HuggingFaceClient(transport=httpx.MockTransport(handler))
40
+ service = CatalogService(
41
+ catalog_url="",
42
+ fallback_path=ROOT / "data" / "index.json",
43
+ enrich_metadata=False,
44
+ )
45
+ return TestClient(create_app(service, hf_client))
46
 
47
 
48
  def test_catalog_ui_and_health():
 
80
  assert test_client.get("/api/models/missing").status_code == 404
81
  assert test_client.get("/model/openvla-7b").status_code == 200
82
  assert test_client.get("/model/missing").status_code == 404
83
+
84
+
85
+ def test_discovery_and_submission_validation_routes():
86
+ with client() as test_client:
87
+ discovery = test_client.get("/api/discover", params={"q": "openvla"}).json()
88
+ assert discovery["count"] == 1
89
+ assert discovery["models"][0]["repo_id"] == "openvla/openvla-7b"
90
+
91
+ response = test_client.post(
92
+ "/api/submissions/validate",
93
+ json={
94
+ "repo_id": "openvla/openvla-7b",
95
+ "slug": "openvla-community",
96
+ "task": "vla",
97
+ "registry_model": "openvla",
98
+ "backend": "transformers",
99
+ "adapter": "openvla",
100
+ "minimum_vram_gb": 24,
101
+ "recommended_vram_gb": 32,
102
+ "summary": "A community suggestion for an OpenVLA robot policy.",
103
+ "tags": ["robotics", "manipulation"],
104
+ },
105
+ )
106
+ assert response.status_code == 200
107
+ result = response.json()
108
+ assert result["candidate"]["source"]["revision"] == "a" * 40
109
+ assert result["candidate"]["verification"]["status"] == "unverified"
110
+ assert "upstream" not in result["candidate"]
tests/test_catalog.py CHANGED
@@ -1,8 +1,10 @@
1
  from pathlib import Path
2
 
 
3
  import pytest
4
 
5
  from app.catalog import CatalogService
 
6
 
7
 
8
  ROOT = Path(__file__).resolve().parents[1]
@@ -10,7 +12,9 @@ ROOT = Path(__file__).resolve().parents[1]
10
 
11
  @pytest.mark.asyncio
12
  async def test_catalog_fallback_and_search():
13
- service = CatalogService(catalog_url="", fallback_path=ROOT / "data" / "index.json")
 
 
14
  catalog = await service.get()
15
  assert service.source == "bundled"
16
  assert len(catalog.models) == 3
@@ -23,6 +27,38 @@ async def test_catalog_fallback_and_search():
23
 
24
  @pytest.mark.asyncio
25
  async def test_catalog_model_lookup():
26
- service = CatalogService(catalog_url="", fallback_path=ROOT / "data" / "index.json")
 
 
27
  assert (await service.model("openvla-7b")).runtime.registry_model == "openvla"
28
  assert await service.model("missing") is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from pathlib import Path
2
 
3
+ import httpx
4
  import pytest
5
 
6
  from app.catalog import CatalogService
7
+ from app.huggingface import HuggingFaceClient
8
 
9
 
10
  ROOT = Path(__file__).resolve().parents[1]
 
12
 
13
  @pytest.mark.asyncio
14
  async def test_catalog_fallback_and_search():
15
+ service = CatalogService(
16
+ catalog_url="", fallback_path=ROOT / "data" / "index.json", enrich_metadata=False
17
+ )
18
  catalog = await service.get()
19
  assert service.source == "bundled"
20
  assert len(catalog.models) == 3
 
27
 
28
  @pytest.mark.asyncio
29
  async def test_catalog_model_lookup():
30
+ service = CatalogService(
31
+ catalog_url="", fallback_path=ROOT / "data" / "index.json", enrich_metadata=False
32
+ )
33
  assert (await service.model("openvla-7b")).runtime.registry_model == "openvla"
34
  assert await service.model("missing") is None
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_catalog_enriches_metadata_and_detects_revision_drift():
39
+ async def handler(request: httpx.Request):
40
+ repo_id = request.url.path.removeprefix("/api/models/")
41
+ return httpx.Response(
42
+ 200,
43
+ json={
44
+ "id": repo_id,
45
+ "sha": "f" * 40,
46
+ "downloads": 99,
47
+ "likes": 7,
48
+ "gated": False,
49
+ "tags": ["robotics", "license:mit"],
50
+ "library_name": "transformers",
51
+ "pipeline_tag": "robotics",
52
+ },
53
+ )
54
+
55
+ service = CatalogService(
56
+ catalog_url="",
57
+ fallback_path=ROOT / "data" / "index.json",
58
+ hf_client=HuggingFaceClient(transport=httpx.MockTransport(handler)),
59
+ )
60
+ model = await service.model("openvla-7b")
61
+ assert model.upstream.latest_revision == "f" * 40
62
+ assert model.upstream.revision_current is False
63
+ assert model.upstream.update_available is True
64
+ assert model.upstream.downloads == 99
tests/test_huggingface.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import pytest
3
+
4
+ from app.huggingface import HuggingFaceClient, normalize_model, validate_repo_id
5
+
6
+
7
+ def payload(repo_id="owner/robot-model", **updates):
8
+ result = {
9
+ "id": repo_id,
10
+ "sha": "1" * 40,
11
+ "downloads": 42,
12
+ "likes": 3,
13
+ "lastModified": "2026-07-24T10:00:00.000Z",
14
+ "gated": "manual",
15
+ "tags": ["robotics", "license:apache-2.0"],
16
+ "pipeline_tag": "robotics",
17
+ "library_name": "transformers",
18
+ }
19
+ result.update(updates)
20
+ return result
21
+
22
+
23
+ def test_metadata_normalization_and_repo_validation():
24
+ model = normalize_model(payload())
25
+ assert model.repo_id == "owner/robot-model"
26
+ assert model.gated is True
27
+ assert model.license == "apache-2.0"
28
+ assert model.has_robotics_signal() is True
29
+ with pytest.raises(ValueError):
30
+ validate_repo_id("https://example.com/model")
31
+ with pytest.raises(ValueError):
32
+ validate_repo_id("owner/model/extra")
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_model_metadata_cache_and_refresh():
37
+ calls = 0
38
+
39
+ async def handler(_: httpx.Request):
40
+ nonlocal calls
41
+ calls += 1
42
+ return httpx.Response(200, json=payload())
43
+
44
+ client = HuggingFaceClient(transport=httpx.MockTransport(handler))
45
+ await client.model("owner/robot-model")
46
+ await client.model("owner/robot-model")
47
+ assert calls == 1
48
+ await client.model("owner/robot-model", refresh=True)
49
+ assert calls == 2
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_discovery_filters_non_robotics_models():
54
+ async def handler(_: httpx.Request):
55
+ return httpx.Response(
56
+ 200,
57
+ json=[
58
+ payload(),
59
+ payload(
60
+ "owner/text-model",
61
+ tags=["text-generation", "license:mit"],
62
+ pipeline_tag="text-generation",
63
+ ),
64
+ ],
65
+ )
66
+
67
+ client = HuggingFaceClient(transport=httpx.MockTransport(handler))
68
+ results = await client.discover("robot")
69
+ assert [model.repo_id for model in results] == ["owner/robot-model"]
tests/test_review_candidate.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from app.catalog import Catalog, HubModel
6
+ from scripts.review_candidate import load_candidate, merge_candidate
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+
11
+
12
+ def test_review_rejects_duplicate_slug_and_repository(tmp_path):
13
+ catalog = Catalog.model_validate_json((ROOT / "data" / "index.json").read_text())
14
+ candidate = catalog.models[0].model_copy(
15
+ update={
16
+ "verification": catalog.models[0].verification.model_copy(
17
+ update={"status": "unverified"}
18
+ )
19
+ }
20
+ )
21
+ with pytest.raises(ValueError, match="duplicate slug"):
22
+ merge_candidate(catalog, candidate)
23
+
24
+ different_slug = candidate.model_copy(update={"slug": "different-model"})
25
+ with pytest.raises(ValueError, match="duplicate repository"):
26
+ merge_candidate(catalog, different_slug)
27
+
28
+
29
+ def test_load_candidate_accepts_validation_response(tmp_path):
30
+ model = Catalog.model_validate_json((ROOT / "data" / "index.json").read_text()).models[0]
31
+ candidate = model.model_copy(
32
+ update={"verification": model.verification.model_copy(update={"status": "unverified"})}
33
+ )
34
+ path = tmp_path / "candidate.json"
35
+ path.write_text(
36
+ '{"candidate": ' + candidate.model_dump_json() + ', "warnings": []}',
37
+ encoding="utf-8",
38
+ )
39
+ assert isinstance(load_candidate(path), HubModel)
40
+
41
+
42
+ def test_review_appends_a_new_unverified_candidate():
43
+ catalog = Catalog.model_validate_json((ROOT / "data" / "index.json").read_text())
44
+ model = catalog.models[0]
45
+ candidate = model.model_copy(
46
+ update={
47
+ "slug": "new-community-policy",
48
+ "source": model.source.model_copy(
49
+ update={"repo_id": "example/new-community-policy", "revision": "c" * 40}
50
+ ),
51
+ "verification": model.verification.model_copy(update={"status": "unverified"}),
52
+ }
53
+ )
54
+ merged = merge_candidate(catalog, candidate)
55
+ assert len(merged.models) == len(catalog.models) + 1
56
+ assert merged.models[-1].slug == "new-community-policy"
57
+ assert merged.generated_at.endswith("Z")
tests/test_submissions.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import pytest
3
+
4
+ from app.huggingface import HuggingFaceClient
5
+ from app.submissions import SubmissionRequest, SubmissionService
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_submission_candidate_is_pinned_and_warns_for_manual_review():
10
+ async def handler(_: httpx.Request):
11
+ return httpx.Response(
12
+ 200,
13
+ json={
14
+ "id": "lab/custom-policy",
15
+ "sha": "b" * 40,
16
+ "downloads": 5,
17
+ "likes": 1,
18
+ "gated": "manual",
19
+ "tags": ["custom", "license:other"],
20
+ },
21
+ )
22
+
23
+ service = SubmissionService(HuggingFaceClient(transport=httpx.MockTransport(handler)))
24
+ result = await service.validate(
25
+ SubmissionRequest(
26
+ repo_id="lab/custom-policy",
27
+ slug="custom-policy",
28
+ task="vla",
29
+ registry_model="custom",
30
+ backend="transformers",
31
+ adapter="custom",
32
+ minimum_vram_gb=12,
33
+ recommended_vram_gb=16,
34
+ summary="A custom policy submitted for laboratory robot evaluation.",
35
+ )
36
+ )
37
+ assert result.candidate.source.revision == "b" * 40
38
+ assert result.candidate.source.gated is True
39
+ assert result.candidate.verification.status == "unverified"
40
+ assert len(result.warnings) == 4
41
+
42
+
43
+ def test_submission_rejects_invalid_ranges_and_repository_urls():
44
+ with pytest.raises(ValueError):
45
+ SubmissionRequest(
46
+ repo_id="https://huggingface.co/lab/model",
47
+ slug="lab-model",
48
+ task="vla",
49
+ registry_model="lab",
50
+ backend="transformers",
51
+ adapter="lab",
52
+ minimum_vram_gb=32,
53
+ recommended_vram_gb=16,
54
+ summary="A long enough summary for validation to reach the range check.",
55
+ )