feat: include Hugging Face model search
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user