#!/usr/bin/env python3 """catalog-diff — a hand-kept model list against the gateway's catalog. Read-only. Roundhouse card 2. It changes nothing, disables nothing and proposes nothing; it makes visible what currently drifts unseen. Measured on 2026-08-02: the pi-agents carry 13 providers and 53 model entries, byte-identical on two hosts, and six of those providers point at endpoints that no longer answer. Two distinctions decide whether this is a finding or noise, and the first draft got both wrong: * NORMALISE BOTH SIDES. The hand list stores gateway ids WITH the display tag ("[local] deepseek-v4-flash") and so does the catalog. Stripping only one side guarantees zero matches and looks exactly like total drift. * NOT EVERY ENTRY BELONGS IN THE CATALOG. A provider pointing straight at a backend (boltzmann:8085, dirac:8081) deliberately bypasses the gateway — its model is absent from the catalog without being dead. Only providers aimed AT the gateway have to be found there; the rest are probed directly and reported separately. bin/catalog-diff [host ...] # default: the pi-agent hosts Needs `sic` to read the remote lists, so it runs on the coordinator, not inside the gateway's own container. """ import json import os import subprocess import sys import urllib.parse import urllib.request sys.path[:0] = [p for p in (os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "lib"), "/usr/local/lib/bullpen") if os.path.isdir(p)] import bullpen_config as cfg TAGS = ("[$] ", "[free] ", "[local] ") MODELS_JSON = ".pi/agent/models.json" def _untag(model_id): for t in TAGS: if model_id.startswith(t): return model_id[len(t):] return model_id def catalog(): url = cfg.PROXY.split("/v1/")[0].rstrip("/") + "/v1/models" with urllib.request.urlopen(url, timeout=30) as r: return {_untag(m["id"]): m for m in json.load(r).get("data", [])}, url def hand_list(host): r = subprocess.run(["sic", host, "cat", MODELS_JSON], capture_output=True, text=True, timeout=60) if r.returncode != 0 or not r.stdout.strip(): return None out = [] for name, p in json.loads(r.stdout).get("providers", {}).items(): if not isinstance(p, dict): continue for m in p.get("models", []): mid = m.get("id") if isinstance(m, dict) else m if mid: out.append((name, _untag(mid), p.get("baseUrl", ""))) return out def answers(base_url): """True/False, or None when there is no URL to probe. A 401 counts as answering — an authenticated endpoint that refuses us is alive, which is the question here.""" if not base_url: return None r = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-m", "6", base_url.rstrip("/") + "/models"], capture_output=True, text=True) return r.stdout.strip() not in ("000", "") def main(): hosts = sys.argv[1:] or ["pica", "deus"] kat, url = catalog() gw = urllib.parse.urlparse(url) print(f"catalog: {len(kat)} models at {url}\n") for host in hosts: entries = hand_list(host) if entries is None: print(f"{host}: no {MODELS_JSON} readable\n") continue provs = {} for name, mid, base in entries: provs.setdefault(name, {"base": base, "models": []})["models"].append(mid) # "Points at the gateway" is decided by HOSTNAME, not by substring: the same box # also runs litellm on another port, and that is a different id namespace. via_gw = {n: i for n, i in provs.items() if urllib.parse.urlparse(i["base"]).hostname == gw.hostname and urllib.parse.urlparse(i["base"]).port == gw.port} direct = {n: i for n, i in provs.items() if n not in via_gw} print(f"== {host}: {len(provs)} providers, {len(entries)} entries ==") print(f"-- through the gateway ({len(via_gw)}): must appear in the catalog") for name, info in sorted(via_gw.items()): missing = [m for m in info["models"] if m not in kat] verdict = "ok" if not missing else f"{len(missing)}/{len(info['models'])} missing" print(f" {name:<24} {verdict}") for m in sorted(missing): print(f" - {m}") print(f"-- straight at a backend ({len(direct)}): bypassing the gateway on purpose") for name, info in sorted(direct.items()): up = answers(info["base"]) state = "answers" if up else ("DEAD" if up is False else "?") print(f" {name:<24} {state:<8} {len(info['models'])} model(s) {info['base'][:44]}") print() if __name__ == "__main__": main()