feat: include Hugging Face model search

This commit is contained in:
Hermes Agent
2026-08-28 11:33:41 +10:00
parent 2612e90dbf
commit 1a54b41c33
6 changed files with 187 additions and 6 deletions
+118
View File
@@ -70,6 +70,7 @@ def _discover_ollama_endpoint() -> None:
LOCAL_OLLAMA = _ollama_base_url()
REMOTE_OLLAMA = "https://ollama.com"
HUGGINGFACE_API = "https://huggingface.co/api"
CATALOG_FILE = "catalog.json"
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$")
MELBOURNE = ZoneInfo("Australia/Melbourne")
@@ -747,6 +748,14 @@ def _json_request(url: str, method: str = "GET", payload: Any = None, timeout: i
return value if isinstance(value, dict) else {}
def _json_list_request(url: str, timeout: int = 30) -> list[dict[str, Any]]:
request = Request(url, headers={"Accept": "application/json", "User-Agent": "Hermes-Ollama-Models/1.7.3"})
with urlopen(request, timeout=timeout) as response:
raw = response.read()
value = json.loads(raw.decode("utf-8")) if raw else []
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
def _valid_name(name: str) -> str:
name = str(name or "").strip()
if not MODEL_RE.fullmatch(name):
@@ -1361,6 +1370,98 @@ def _model_view(raw: dict[str, Any], loaded: dict[str, Any] | None = None, sourc
}
def _huggingface_model_view(raw: dict[str, Any]) -> dict[str, Any]:
repo_id = str(raw.get("id") or raw.get("modelId") or "").strip()
raw_tags = raw.get("tags")
tags = [str(tag).strip() for tag in raw_tags if str(tag).strip()][:32] if isinstance(raw_tags, list) else []
pipeline = str(raw.get("pipeline_tag") or "").strip()
library = str(raw.get("library_name") or "").strip()
searchable = " ".join([repo_id, pipeline, library, *tags])
capabilities = _infer_capabilities(repo_id, library, {}, ["completion"] if pipeline in {"text-generation", "text2text-generation", "image-text-to-text"} else [])
if pipeline == "image-text-to-text" and "vision" not in capabilities:
capabilities.append("vision")
if pipeline in {"text-to-image", "image-to-image", "image-classification"} and "vision" not in capabilities:
capabilities.append("vision")
is_moe = bool(re.search(r"(?:moe|mixture.of.experts|a\d+b)", searchable, re.I))
return {
"name": repo_id,
"source": "huggingface",
"source_label": "Hugging Face",
"downloadable": False,
"installed": False,
"loaded": False,
"size_bytes": 0,
"size_gb": None,
"size_label": "Hub repository",
"loaded_bytes": 0,
"loaded_vram_bytes": 0,
"digest": str(raw.get("sha") or ""),
"modified_at": raw.get("lastModified"),
"family": library or "Hugging Face model",
"architecture": pipeline or "Unknown",
"is_moe": is_moe,
"parameter_size": "unknown",
"activated_parameter_size": None,
"parameter_summary": "unknown",
"description": f"Hugging Face model · {pipeline or 'pipeline unavailable'}" + (f" · {library}" if library else ""),
"quantization": "see repository files",
"format": library or "Hub format",
"context_length": None,
"input_modalities": ["Text", "Image"] if "vision" in capabilities else ["Text"],
"embedding_length": None,
"capabilities": list(dict.fromkeys(capabilities)),
"capability_breakdown": {cap: CAPABILITY_INFO[cap] for cap in capabilities if cap in CAPABILITY_INFO},
"strengths": [pipeline or "model repository", "Hugging Face Hub metadata"],
"expected_ram_gb": None,
"expected_ram_label": "Unknown · inspect repository requirements",
"expected_ram_basis": "Hugging Face does not provide a reliable universal runtime RAM estimate in search results.",
"hf_url": f"https://huggingface.co/{repo_id}",
"hf_downloads": int(raw.get("downloads") or 0),
"hf_likes": int(raw.get("likes") or 0),
"hf_pipeline_tag": pipeline,
"hf_library": library,
"hf_tags": tags,
}
def _search_huggingface(query: str, limit: int = 30) -> list[dict[str, Any]]:
query = str(query or "").strip()
if len(query) < 2:
return []
limit = max(1, min(int(limit), 50))
url = f"{HUGGINGFACE_API}/models?{urlencode({'search': query[:120], 'limit': limit, 'sort': 'downloads', 'direction': '-1', 'full': 'false'})}"
try:
rows = _json_list_request(url, timeout=20)
except (HTTPError, URLError, OSError, ValueError):
return []
return [_huggingface_model_view(row) for row in rows if (row.get("id") or row.get("modelId"))]
def _search_ollama_catalog(query: str, limit: int = 50) -> list[dict[str, Any]]:
needle = str(query or "").strip().lower()
if len(needle) < 2:
return []
catalog = _ensure_catalog()
rows = list(catalog.get("models", []))
family_map: dict[str, Any] = {}
raw_families = catalog.get("families")
if isinstance(raw_families, dict):
family_map = raw_families
rows.extend(variant for variants in family_map.values() if isinstance(variants, list) for variant in variants if isinstance(variant, dict))
matches: list[dict[str, Any]] = []
seen: set[str] = set()
for raw in rows:
name = str(raw.get("name") or raw.get("model") or "")
if not name or name in seen:
continue
view = _model_view(raw, source="catalog")
haystack = " ".join([name, view.get("family", ""), view.get("description", ""), *view.get("capabilities", []), *view.get("strengths", [])]).lower()
if needle in haystack:
seen.add(name)
matches.append(view)
return matches[:max(1, min(int(limit), 100))]
class _VariantPageParser(HTMLParser):
"""Extract the public Ollama tag rows without depending on third-party HTML packages."""
@@ -2679,6 +2780,23 @@ def catalog_refresh() -> dict[str, Any]:
return {"ok": bool(catalog.get("models")), "updated_at": catalog.get("fetched_at"), "count": len(catalog.get("models", [])), "error": catalog.get("last_error")}
@router.get("/catalog/search")
def catalog_search(q: str = "", limit: int = 30) -> dict[str, Any]:
query = str(q or "").strip()[:120]
if len(query) < 2:
return {"query": query, "results": [], "sources": ["ollama", "huggingface"]}
ollama = _search_ollama_catalog(query, limit=limit)
huggingface = _search_huggingface(query, limit=limit)
combined = ollama + huggingface
return {
"query": query,
"results": combined,
"ollama_results": ollama,
"huggingface_results": huggingface,
"sources": ["ollama", "huggingface"],
}
@router.post("/pull")
def pull_model(body: ModelRequest) -> dict[str, Any]:
name = _valid_name(body.name)