""" Optional ONLINE lookup: "will this exact Hugging Face model run on my machine?" Deterministic — no AI involved. Given any repo id (or model page URL), this: 1. checks the local catalogue (offline) by repo id and aliases; 2. otherwise makes ONE metadata call to the Hub, reads the model-tree tags (base_model:finetune/adapter/quantized/merge), and walks up to 3 hops to find a catalogue ancestor — "your finetune runs because its base runs"; 3. otherwise falls back to raw parameter-count math, clearly labelled. This is the only part of FitCheck that touches the network at runtime, and the UI labels it as a live lookup. The deterministic advisor itself makes no network calls (the model bricks do: ZeroGPU narrator + spec parser). """ import re from functools import lru_cache from .hardware import HardwareSpec from .real_advisor import ( USE_CASES, _SAFETY_FILL, _C_MODEL, _C_WORK, _VERDICT_WORD, _evaluate, _option_json, catalogue, catalogue_date, advise_real, ) _RELATION = re.compile(r"^base_model:(finetune|adapter|quantized|merge):(.+)$") # Hugging Face pipeline_tag -> (our family, comparison use-case). Only text # families (llm/vlm) get a decode-speed chart and a catalogue comparison; other # families get a memory estimate only. (Compute-roofline FPS/latency exists for # vision-detection and diffusion, but it needs per-model GFLOPs calibrated in # the catalogue, which an arbitrary looked-up repo does not carry — so lookup # stays memory-only there rather than guessing.) Anything unrecognised is # "other": a specialised model (robot policy / VLA / 3D / etc.) we size but # don't pretend to benchmark or run in a chatbot app. _FAMILY_UC = { "text-generation": ("llm", "chat"), "text2text-generation": ("llm", "chat"), "image-text-to-text": ("vlm", "vlm"), "visual-question-answering": ("vlm", "vlm"), "image-to-text": ("vlm", "vlm"), "automatic-speech-recognition": ("audio", None), "text-to-speech": ("audio", None), "text-to-audio": ("audio", None), "text-to-image": ("imagegen", None), "image-to-image": ("imagegen", None), "object-detection": ("vision", None), "image-segmentation": ("vision", None), "image-classification": ("vision", None), "depth-estimation": ("vision", None), "image-feature-extraction": ("vision", None), "feature-extraction": ("embed", None), "sentence-similarity": ("embed", None), } # Effective bits-per-weight for a synthesised quant ladder (k-quant layouts). _SYNTH_QUANTS = [ ("Q8_0", "Near-full (8-bit)", 8.5), ("Q6_K", "High (6-bit)", 6.6), ("Q5_K_M", "Balanced+ (5-bit)", 5.7), ("Q4_K_M", "Balanced (4-bit)", 4.85), ("Q3_K_M", "Compact (3-bit)", 3.9), ] def _family_uc(pipeline_tag: str) -> tuple[str, str | None]: return _FAMILY_UC.get(pipeline_tag or "", ("other", None)) def _family_from_arch(arch) -> tuple[str, str | None] | None: """Fallback classifier from config.json `architectures` when pipeline_tag is missing (common for base/instruct LLM repos like Mistral-Nemo).""" names = " ".join(arch or []).lower() if not names: return None if "forcausallm" in names or "forconditionalgeneration" in names: if any(k in names for k in ("vl", "vision", "llava", "idefics", "paligemma", "florence")): return ("vlm", "vlm") return ("llm", "chat") if any(k in names for k in ("objectdetection", "imageclassification", "segmentation", "depthestimation")): return ("vision", None) return None def _synthetic_entry(repo_id: str, info, family: str) -> dict | None: """Build a catalogue-shaped entry for a model NOT in our catalogue. Text families get a quant ladder (so they flow through the GGUF memory + speed path); everything else gets one estimated memory figure and NO speed chart.""" st = getattr(info, "safetensors", None) total = getattr(st, "total", None) if st else None if not total: return None params_b = round(total / 1e9, 2) card = getattr(info, "card_data", None) lic = "" if card: lic = (card.get("license") if hasattr(card, "get") else getattr(card, "license", "")) or "" e = { "key": "_lookup", "name": repo_id.split("/")[-1], "repo_id": repo_id, "params_b": params_b, "provenance": "estimated", "good_for": f"{params_b:g}B parameters, looked up live on Hugging Face.", "license": lic, "gated": bool(getattr(info, "gated", False)), "links": {"hf": f"https://huggingface.co/{repo_id}"}, "use_cases": [], } if family in ("llm", "vlm"): e["family"] = family e["quants"] = [{"key": k, "plain": p, "file_gb": round(params_b * b / 8, 2)} for k, p, b in _SYNTH_QUANTS] else: # Flat memory: fp16 weights (~2 GB/B) plus working overhead. Conservative. e["family"] = family if family in ("vision", "imagegen", "audio", "embed") else "other" e["mem_gb"] = round(params_b * 2.2 + 0.6, 2) e["mem_provenance"] = "estimated" return e # Honest "how to run it" for a LOOKED-UP model: we have NOT verified a GGUF / # Ollama tag for it (unlike catalogue entries), so we never assert "ollama run # X". We point at the model card and the realistic tool category instead. def _lookup_run_guidance(family: str, repo_id: str) -> list[dict]: card = f"https://huggingface.co/{repo_id}" start = {"name": "Read the model card", "tag": "Start here", "what": "The model's own page lists the exact, up-to-date way to run it. Always start here.", "install": card} by_family = { "llm": [ {"name": "Ollama / LM Studio", "tag": "If a GGUF exists", "what": "These run GGUF builds. Many popular models have one, some don't — search the model name on ollama.com or in LM Studio first.", "install": "ollama.com / lmstudio.ai"}, {"name": "Transformers or vLLM", "tag": "Universal", "what": "Run the original weights directly, no GGUF needed. The fallback that always works.", "install": "pip install transformers"}, ], "vlm": [ {"name": "Transformers", "tag": "Universal", "what": "Most vision-language models run via Hugging Face Transformers. Some also have GGUF builds for Ollama/LM Studio — check the card.", "install": "pip install transformers"}, ], "vision": [ {"name": "Its own library", "tag": "Common", "what": "Vision models usually run through their framework (e.g. Ultralytics for YOLO) or Hugging Face Transformers. Not Ollama.", "install": "pip install transformers"}, ], "imagegen": [ {"name": "diffusers / ComfyUI", "tag": "Common", "what": "Image and video models run via Hugging Face diffusers or ComfyUI, not chat apps. The card says which.", "install": "pip install diffusers"}, ], "audio": [ {"name": "Transformers", "tag": "Common", "what": "Speech and audio models run via Hugging Face Transformers or the model's own library.", "install": "pip install transformers"}, ], "embed": [ {"name": "sentence-transformers", "tag": "Common", "what": "Embedding models load with sentence-transformers or Transformers.", "install": "pip install sentence-transformers"}, ], "other": [ {"name": "Its own framework", "tag": "Specialised", "what": "This is a specialised model (for example a robot policy / VLA, or a 3D model). It does NOT run in a chatbot app like Ollama. Follow the model card; these usually need a dedicated framework (e.g. the lerobot library for LeRobot policies).", "install": "see the model card"}, ], } return [start] + by_family.get(family, by_family["other"]) def _standalone_estimate(entry: dict, spec: HardwareSpec, finetune: bool) -> dict: """A memory-only estimate for a non-text model (no catalogue comparison, no decode-speed chart — both would be misleading for these families).""" r = _evaluate(entry, spec, USE_CASES["chat"]) est, v, need = r["est"], r["verdict"], r["est"]["total"] fast, total = spec.fast_budget_gb, spec.total_budget_gb has_fast = spec.has_fast_path if spec.is_apple_silicon: fast_label, total_label = "GPU can use", "Unified memory" elif has_fast: fast_label, total_label = "On the GPU (VRAM)", "GPU + system RAM" else: fast_label, total_label = "", "System RAM (no GPU)" name = entry["name"] opt = _option_json(r, spec) if finetune: # Training a specialised non-text model is NOT the inference path: do not # imply run-time "feel" or a fit verdict for fine-tuning. Be explicit that # we don't compute training memory for this family (audit P1 #11). opt["feel"] = "" head = f"Fine-tuning {name} isn't auto-estimated for this model type." detail = (f"{name} is a specialised model (~{entry['params_b']:g}B). FitCheck doesn't compute " f"training memory for this family, so it won't give a fit verdict here. For " f"reference, running it takes ~{need:g} GB (parameter-count estimate, not " f"measured). Follow the model card for its training recipe.") note = "Training memory and method for this family aren't modelled — this is a run-only estimate." else: head = (f"{name} should fit on this machine." if v == "great" else f"{name} is tight here, but should run." if v == "tight" else f"{name} is too big for this machine.") detail = (f"{name} is about {entry['params_b']:g}B parameters. A rough estimate puts it at " f"~{need:g} GB to run (weights plus working space), and you have ~" f"{fast:g} GB on the GPU / {total:g} GB total. This is a parameter-count " f"estimate for a specialised model, not a measured figure.") note = "" scale = max(total, need, 1) * 1.05 gauge = { "need_gb": f"{need:g} GB needed", "fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB", "fast_label": fast_label, "total_label": total_label, "has_fast": has_fast, "fill_pct": round(min(need / scale, 1.0) * 100, 1), "mark_pct": round(min(fast / scale, 1.0) * 100, 1), "total_pct": round(min(total / scale, 1.0) * 100, 1), "breakdown": [{"label": f"Model + working space {need:g} GB", "color": _C_MODEL}], } return { "catalogue_version": catalogue_date(), "verdict": ("tight" if finetune else v), "verdict_word": ("Training not estimated" if finetune else _VERDICT_WORD[v]), "headline": head, "detail": detail, "note": note, "gauge": gauge, "options": [opt], "tools": [], "commands": {"intro": "", "items": []}, "provenance": "The memory figure is estimated from the model's parameter count — conservative, not measured.", "speed": None, "meets_goal": (False if finetune else v in ("great", "tight")), "use_case": "this model", "usecase": "", "focus": name, "headline_model": name, } @lru_cache(maxsize=1) def _index() -> dict: idx = {} for e in catalogue()["entries"]: idx[e["repo_id"].lower()] = e for a in e.get("aliases", []): idx[a.lower()] = e return idx def normalize_repo_id(text: str) -> str: """Accept a bare repo id or any huggingface.co URL.""" text = (text or "").strip().rstrip("/") m = re.search(r"huggingface\.co/([\w.-]+/[\w.-]+)", text) if m: return m.group(1) return text def _relations(info) -> list[tuple[str, str]]: out = [] for t in (getattr(info, "tags", None) or []): m = _RELATION.match(t) if m: out.append((m.group(1), m.group(2))) if not out: # cardData fallback only when tags carry no typed relation — the tag # knows whether it's a finetune or a quantized copy; cardData doesn't. card = getattr(info, "card_data", None) if card: base = card.get("base_model") if hasattr(card, "get") else getattr(card, "base_model", None) if isinstance(base, str): out.append(("finetune", base)) elif isinstance(base, list): out.extend(("finetune", b) for b in base if isinstance(b, str)) return out def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict: """Returns {found, model, chain, verdict-ish fields} or {error}.""" repo_id = normalize_repo_id(repo_input) if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo_id): return {"error": f"'{repo_input}' doesn't look like a Hugging Face repo id " f"(expected something like author/model-name)."} uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"]) chain = [repo_id] # 1) Offline: direct catalogue hit (also via aliases). entry = _index().get(repo_id.lower()) via = None # 2) Online: one metadata call + base-model walk. info = None if entry is None: from huggingface_hub import HfApi api = HfApi() current = repo_id try: info = api.model_info(current, expand=["tags", "safetensors", "cardData", "pipeline_tag", "config", "gated"], timeout=10) except Exception as exc: # noqa: BLE001 — surface the real failure return {"error": f"Couldn't find '{repo_id}' on Hugging Face " f"({type(exc).__name__}). Check the spelling?"} hop_info = info for _hop in range(3): rels = _relations(hop_info) if not rels: break # Prefer finetune/merge (same memory as base) over quantized. rels.sort(key=lambda r: 0 if r[0] in ("finetune", "merge", "adapter") else 1) rel, parent = rels[0] chain.append(parent) entry = _index().get(parent.lower()) if entry is not None: via = {"relation": rel, "base": parent} break try: hop_info = api.model_info(parent, expand=["tags", "cardData"], timeout=10) except Exception: # noqa: BLE001 — chain ends here break if entry is not None: r = _evaluate(entry, spec, uc) opt = _option_json(r, spec) explain = f"{repo_id.split('/')[-1]} " if via: word = {"finetune": "is fine-tuned from", "merge": "is merged from", "adapter": "is an adapter on", "quantized": "is a compressed copy of"}[via["relation"]] explain += (f"{word} {entry['name']} — if the base runs, this runs, " f"with the same memory needs.") if via["relation"] == "adapter": explain += " Add roughly 0.1–0.5 GB for the adapter file." else: explain += f"is {entry['name']} in our catalogue." # The use case to re-render the full breakdown under (cross-family aware). fam = entry.get("family") uc_key = ("vlm" if fam == "vlm" else "chat" if fam == "llm" else (entry.get("use_cases") or [None])[0]) return {"found": True, "match": "catalogue", "chain": chain, "explain": explain, "option": opt, "live": via is not None or info is not None, "focus_name": entry["name"], "uc_key": uc_key} # 3) Not in the catalogue: build a synthetic entry from its real parameter # count, sized and described per its model TYPE (so a VLA / detector / image # model is not treated as a chatbot). Text models flow through the full # engine (gauge + speed + comparison); other models get a memory-only # estimate with honest run guidance. Sizes are labelled estimates. pipe = getattr(info, "pipeline_tag", None) or "" family, uc_key = _family_uc(pipe) if family == "other": # no usable pipeline_tag — try the model architecture cfg = getattr(info, "config", None) or {} arch = cfg.get("architectures") if isinstance(cfg, dict) else getattr(cfg, "architectures", None) fb = _family_from_arch(arch) if fb: family, uc_key = fb synth = _synthetic_entry(repo_id, info, family) if synth is None: return {"error": f"'{repo_id}' exists, but doesn't share its size or a known " f"base model, so an honest estimate isn't possible."} name = repo_id.split("/")[-1] finetune = payload.get("mode") == "finetune" if uc_key: # text family: full breakdown, comparison, and (capped) speed look_payload = dict(payload) look_payload["usecase"] = uc_key look_payload["focus"] = name if finetune: from .finetune import advise_finetune advice = advise_finetune(look_payload, spec, extra_entries=[synth]) else: advice = advise_real(look_payload, spec, extra_entries=[synth]) extra = " the speed chart," if not finetune else "" explain = (f"{name} isn't in our catalogue, so its sizes are estimated from its " f"{synth['params_b']:g}B parameters (not exact file sizes). The memory breakdown," f"{extra} and the comparison are computed the same way as for catalogue models.") else: # vision / image / audio / embed / other: memory-only estimate advice = _standalone_estimate(synth, spec, finetune) kind = {"vision": "a vision model", "imagegen": "an image/video model", "audio": "an audio model", "embed": "an embedding model"}.get(family, "a specialised model (it doesn't run in chatbot apps)") explain = (f"{name} isn't in our catalogue. It looks like {kind}, so this is a " f"memory estimate from its {synth['params_b']:g}B parameters — no decode-speed " f"chart, because tokens/second doesn't describe this kind of model.") # For RUNNING a looked-up model we have NOT verified a GGUF/Ollama tag, so # replace any asserted run commands with honest, model-card-first guidance. # Fine-tuning guidance (Unsloth/TRL + the QLoRA recipe) is generic and stays. if not (finetune and uc_key): advice["tools"] = _lookup_run_guidance(family, repo_id) advice["commands"] = {"intro": "", "items": []} return {"found": True, "match": "estimate", "chain": chain, "live": True, "explain": explain, "advice": advice}