Spaces:
Running
Running
Publish AirGPT Hub H1.1
Browse files- README.md +15 -2
- app.js +109 -6
- app/catalog.py +48 -0
- app/huggingface.py +174 -0
- app/main.py +34 -3
- app/submissions.py +118 -0
- index.html +54 -4
- scripts/__init__.py +1 -0
- scripts/publish_space.py +1 -1
- scripts/review_candidate.py +102 -0
- styles.css +66 -1
- tests/test_app.py +61 -2
- tests/test_catalog.py +38 -2
- tests/test_huggingface.py +69 -0
- tests/test_review_candidate.py +57 -0
- tests/test_submissions.py +55 -0
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
|
| 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 |
-
|
| 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, {
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 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 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
<
|
| 51 |
-
<
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 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">×</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 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
+
)
|