#!/usr/bin/env python3 """model_registry — reads model-registry.yaml (kb#133, A2A-1). Per DESIGN-a2a-agents.md v2.1 §2-3b: the model registry is data, not logic. a(t) probe *mechanics* (QuotaProbe, GPUResidencyProbe, ...) already live in kanboard/bin/kb_worker.py — this module does not reimplement them. It gives callers two things instead: * resolve(tier) — "an available model for tier X" without the caller naming a model. Structural availability (lifecycle, metered opt-in) is decided here from registry data; live a(t) truth (is the quota window open right now, is the GPU actually free) is decided by an optional `probe_check` callback the caller supplies (e.g. wired to kb_worker's Probe classes via to_probe_config()). * preload_check(...) — the §3b GPU pre-load check, expressed purely from registry numbers (never-evict reservations + candidate footprint) plus a headroom figure the caller supplies. It does not shell nvidia-smi itself — kb_worker.GPUResidencyProbe (or `nvidia-smi` directly) is the live-read path; this stays pure/testable. Usage (library): from model_registry import load_registry, resolve, to_probe_config, preload_check reg = load_registry() model = resolve(reg, tier="large") # -> the "kimi" entry cfg = to_probe_config(reg, "kimi") # -> kb_worker probe config dict ok, reason = preload_check(reg, "local-small", headroom_mb=1900) Usage (CLI, for manual verification): ./model_registry.py resolve --tier large ./model_registry.py resolve --tier large --allow-metered --opted-in agent:torgash ./model_registry.py probe-config --id kimi ./model_registry.py preload-check --id local-small --headroom-mb 1900 ./model_registry.py preload-check --id local-small --live # shells nvidia-smi ./model_registry.py list """ import argparse import json import os import subprocess import sys import yaml HERE = os.path.dirname(os.path.abspath(__file__)) DEFAULT_REGISTRY_PATH = os.path.join(HERE, "model-registry.yaml") class RegistryError(Exception): pass def load_registry(path=None): """Load and lightly validate model-registry.yaml.""" path = path or DEFAULT_REGISTRY_PATH with open(path) as f: reg = yaml.safe_load(f) if not reg or "models" not in reg: raise RegistryError(f"{path}: missing top-level 'models' list") ids = [m["id"] for m in reg["models"]] if len(ids) != len(set(ids)): raise RegistryError(f"{path}: duplicate model ids in {ids}") return reg def get_model(registry, model_id): for m in registry["models"]: if m["id"] == model_id: return m raise RegistryError(f"unknown model id: {model_id!r}") # --------------------------------------------------------------------------- # resolve — "an available model for tier X" without the caller naming one. # --------------------------------------------------------------------------- def resolve(registry, tier, allow_metered=False, opted_in_key=None, probe_check=None): """Return the first model in `tier`'s pool that is structurally usable, and (if probe_check is given) currently available. Structural filter (from registry data alone): - candidate must be listed under routing.tiers[tier] - a metered model is only a candidate at all when the CALLER passes allow_metered=True AND opted_in_key appears in routing.metered_opt_in (§3a: "no metered API by default" — an empty metered_opt_in list, the shipped default, makes every metered model structurally unreachable regardless of allow_metered). Live filter (optional): probe_check(model_dict) -> bool. Wire this to kb_worker's Probe.available() (via to_probe_config below) when the caller wants real a(t) truth instead of just structural eligibility. """ pools = registry.get("routing", {}).get("tiers", {}) if tier not in pools: raise RegistryError(f"unknown tier: {tier!r} (have: {sorted(pools)})") opted_in = set(registry.get("routing", {}).get("metered_opt_in", []) or []) candidates = [] for model_id in pools[tier]: m = get_model(registry, model_id) if m.get("metered"): if not allow_metered: continue if not m.get("opt_in_required", True): # Registry says this metered model doesn't need opt-in — treat # as a data error rather than silently routing to it. raise RegistryError( f"model {model_id!r} is metered but opt_in_required=false; " "fix the registry entry, this helper will not assume implicit access" ) if opted_in_key is None or opted_in_key not in opted_in: continue candidates.append(m) for m in candidates: if probe_check is None or probe_check(m): return m raise RegistryError( f"no available model for tier={tier!r} " f"(allow_metered={allow_metered}, opted_in_key={opted_in_key!r}); " f"checked candidates: {[m['id'] for m in candidates] or pools[tier]}" ) # --------------------------------------------------------------------------- # to_probe_config — bridges registry quota data into kb_worker's probe cfg # shape (kanboard/bin/kb_worker.py PROBE_BUILDERS), so probes read registry # numbers instead of the registry re-implementing probe logic. # --------------------------------------------------------------------------- def to_probe_config(registry, model_id): """Return a dict matching kb_worker.py's `build_probe(cfg)` input shape for `model_id`'s lifecycle. Raises if the model has no probe-relevant lifecycle (e.g. cost-gated with no probe_command wired yet).""" m = get_model(registry, model_id) lifecycle = m["lifecycle"] if lifecycle == "always-on": return {"type": "always_on"} if lifecycle == "quota-gated": q = m.get("quota") or {} windows = q.get("windows") or [] if not windows: raise RegistryError(f"{model_id}: quota-gated but no quota.windows configured") # kb_worker's QuotaProbe checks one field; the tightest (first-to-hit) # window in practice is the short one — default to the first entry, # callers needing multi-window gating build one probe per window. window = windows[0] return { "type": "quota", "command": q["probe_command"], "field": window["field"], "threshold_pct": q.get("threshold_pct", 95), } if lifecycle == "cost-gated": q = m.get("quota") or {} if not q.get("probe_command"): raise RegistryError( f"{model_id}: cost-gated but no budget probe wired yet " "(opt-in path incomplete — see registry comment)" ) return { "type": "budget", "command": q["probe_command"], "field": q["field"], "limit": q["limit"], } if lifecycle == "on-demand": ep = (m.get("endpoints") or [{}])[0] if not ep.get("health_url"): raise RegistryError(f"{model_id}: on-demand but no endpoint.health_url configured") return {"type": "on_demand", "url": ep["health_url"]} raise RegistryError(f"{model_id}: unknown lifecycle {lifecycle!r}") # --------------------------------------------------------------------------- # preload_check — §3b GPU pre-load check, pure registry-data math. The live # VRAM headroom READ is the caller's job (kb_worker.GPUResidencyProbe or # nvidia-smi directly) — see live_headroom_mb() below for a thin convenience # wrapper used only by this module's own CLI, not by the check itself. # --------------------------------------------------------------------------- def preload_check(registry, candidate_id, headroom_mb, resident_ids=None): """Would loading `candidate_id` fit, given `headroom_mb` free VRAM right now (as reported by a live probe) and `resident_ids` already loaded? Never-evict models are never subtracted from headroom by a caller in the first place (their footprint is a standing reservation baked into any correct live headroom read) — this function just re-asserts that policy from registry data: if `candidate_id` itself is never-evict, it always passes (it's not something a worker "loads speculatively" and might be told to skip); anything else must fit in the reported headroom. """ policy = registry.get("gpu_residency_policy") or {} m = get_model(registry, candidate_id) gr = m.get("gpu_residency") if not gr: return True, f"{candidate_id} has no gpu_residency entry (not a GPU-resident model)" if gr.get("never_evict"): return True, f"{candidate_id} is in the never-evict set — always resident by policy" required_mb = gr["vram_mb"] never_evict_ids = set(policy.get("never_evict_ids", [])) resident_ids = set(resident_ids or []) # Sanity: if the live headroom read already accounts for never-evict # reservations (the expected contract — see kb_worker.GPUResidencyProbe's # never_evict_reserved_mb param), this is just a straight comparison. # If a caller passes raw total-minus-used instead, warn via the reason # string rather than silently under/over-reserving. reserved_hint = sum( get_model(registry, mid)["gpu_residency"]["vram_mb"] for mid in never_evict_ids if mid not in resident_ids # already counted as "used" if resident_ids says so ) ok = headroom_mb >= required_mb reason = ( f"headroom={headroom_mb}MB required={required_mb}MB " f"(never-evict reserve expected already netted out by the caller's probe: " f"~{reserved_hint}MB across {sorted(never_evict_ids)})" ) return ok, reason def live_headroom_mb(never_evict_reserved_mb=0): """Convenience for manual CLI checks only — NOT used by preload_check() itself. Shells nvidia-smi the same way kb_worker.GPUResidencyProbe does.""" out = subprocess.run( ["nvidia-smi", "--query-gpu=memory.used,memory.total", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=5, check=True, ).stdout.strip().splitlines()[0] used, total = (int(x) for x in out.split(",")) return total - used - never_evict_reserved_mb # --------------------------------------------------------------------------- # CLI — manual verification only, not part of the library contract. # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--registry", default=None, help="path to model-registry.yaml (default: sibling file)") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("resolve") p.add_argument("--tier", required=True) p.add_argument("--allow-metered", action="store_true") p.add_argument("--opted-in", default=None, help="virtual key claimed to be opted in") p = sub.add_parser("probe-config") p.add_argument("--id", required=True) p = sub.add_parser("preload-check") p.add_argument("--id", required=True) p.add_argument("--headroom-mb", type=float, default=None) p.add_argument("--live", action="store_true", help="read live headroom via nvidia-smi instead of --headroom-mb") p.add_argument("--resident", action="append", default=[], help="repeatable: id already resident") sub.add_parser("list") args = ap.parse_args() reg = load_registry(args.registry) try: if args.cmd == "resolve": m = resolve(reg, args.tier, allow_metered=args.allow_metered, opted_in_key=args.opted_in) print(json.dumps(m, indent=2)) elif args.cmd == "probe-config": print(json.dumps(to_probe_config(reg, args.id), indent=2)) elif args.cmd == "preload-check": headroom = args.headroom_mb if args.live: policy = reg.get("gpu_residency_policy") or {} reserved = sum(get_model(reg, mid)["gpu_residency"]["vram_mb"] for mid in policy.get("never_evict_ids", [])) headroom = live_headroom_mb(never_evict_reserved_mb=reserved) if headroom is None: raise RegistryError("preload-check needs --headroom-mb or --live") ok, reason = preload_check(reg, args.id, headroom, resident_ids=args.resident) print(json.dumps({"ok": ok, "reason": reason})) sys.exit(0 if ok else 1) elif args.cmd == "list": for m in reg["models"]: print(f"{m['id']:16} tier={m['tier']:6} lifecycle={m['lifecycle']:13} " f"metered={m['metered']} role={m['role']}") except RegistryError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(2) if __name__ == "__main__": main()