2cb135b18f
Read-only. Changes nothing, disables nothing, proposes nothing — it makes visible what
drifts unseen. Fable's architecture pass named it the second card, after exposing the
catalog's fields, and deliberately as a diagnostic rather than a fix: the hand lists live
on hosts with running agents, so the cutover is an operator decision, not a script's.
Measured on pica: 13 providers, 53 entries — and SIX providers point at endpoints that no
longer answer (bosch-ornith, bosch-qwen, dirac:8081, and all three escher entries, escher
being the machine that went out for RMA). Of the entries that route through the gateway,
4 of 5 name models it no longer serves. deus carries a byte-identical file.
Two distinctions separate a finding from noise, and the first draft got both wrong, which
is why they are spelled out in the docstring:
* Normalise BOTH sides. The hand list stores gateway ids with the display tag
("[local] deepseek-v4-flash") and so does the catalog; stripping one side only
guarantees zero matches and reads as total drift. My first run reported 51 of 53
entries unknown — an artefact of my own comparison, not a fleet fault.
* Not every entry belongs in the catalog. A provider aimed straight at a backend
bypasses the gateway on purpose and is absent from the catalog without being dead.
Those are probed directly and reported separately. "Points at the gateway" is decided
by hostname AND port, since the same box also runs litellm on 4000 with its own id
namespace — a substring match put 36 litellm entries in the wrong bucket.
A 401 counts as answering: an authenticated endpoint that refuses us is alive, which is
the question being asked.
118 lines
4.7 KiB
Python
Executable File
118 lines
4.7 KiB
Python
Executable File
#!/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()
|