54106c4210
tests/test_no_hardcoded_hosts.py verbietet Fleet-Hostnamen in Laufzeit-Strings und erlaubt genau einen Ort dafuer: lib/bullpen_config.py, wo alle Vorgaben per get() mit Umgebungsuebersteuerung liegen. bin/models-json trug 'hossenfelder-proxy' als Literal und riss den Test - eingeschleppt mit Roundhouse-Karte 3 am selben Tag. Der Wert ist ein Provider-LABEL, kein Hostname, enthaelt aber einen; die Regel prueft auf Substring, und das ist keine Ueberempfindlichkeit: ihr Zweck ist, dass hiesige Namen nicht in Code wandern, der veroeffentlicht wird. Uebersteuerbar bleibt er als BULLPEN_CATALOG_PROVIDER, jetzt nur an einer Stelle statt an zweien.
163 lines
7.0 KiB
Python
Executable File
163 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""models-json — render a pi-agent model list from the gateway catalog (Roundhouse card 3).
|
|
|
|
catalog-diff makes the drift visible; this closes it. The hand-kept list is what Fable's
|
|
architecture pass called "a frozen selection masquerading as a catalog": on 2026-08-02 six
|
|
of thirteen providers pointed at endpoints that no longer answered, and four of five
|
|
gateway entries named models the gateway no longer serves.
|
|
|
|
WHAT IT DOES NOT DO. It does not invent provider names and it does not rename anything.
|
|
Names are referenced from outside this file — bullpen's cfg.OC_MODEL is literally
|
|
"bosch-dspark/deepseek-v4-flash-dspark", and a running agent session pins the provider it
|
|
was started with. Renaming would be tidier and would break both. So:
|
|
|
|
* a provider pointing AT THE GATEWAY gets its model list regenerated from the catalog;
|
|
* a provider pointing straight at a backend is left alone if it answers — bypassing the
|
|
gateway is a deliberate choice, not drift — and dropped if it does not;
|
|
* nothing is added that was not asked for.
|
|
|
|
SELECTION IS POLICY, NOT FACT. The catalog holds 259 models; a picker with 259 entries is
|
|
not a picker. Default filter is local + free, which keeps the "no paid offloading" posture
|
|
visible in the tool a human actually looks at. Enforcement stays where it already is — the
|
|
gateway's cost regulator answers 403 — because a filter is a convenience and a regulator
|
|
is a guarantee.
|
|
|
|
bin/models-json <host> # print the diff, change nothing
|
|
bin/models-json <host> --apply # back up and install
|
|
"""
|
|
import argparse
|
|
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"
|
|
KEEP_CLASSES = ("local", "free")
|
|
# The one gateway provider that acts as a CATALOG - see cfg.CATALOG_PROVIDER for why
|
|
# the other providers on the same endpoint are aliases rather than catalogues. The
|
|
# default lives in bullpen_config because tests/test_no_hardcoded_hosts.py allows a
|
|
# fleet name in exactly that one file.
|
|
CATALOG_PROVIDER = cfg.CATALOG_PROVIDER
|
|
|
|
|
|
def _untag(mid):
|
|
for t in TAGS:
|
|
if mid.startswith(t):
|
|
return mid[len(t):]
|
|
return mid
|
|
|
|
|
|
def catalog():
|
|
url = cfg.PROXY.split("/v1/")[0].rstrip("/") + "/v1/models"
|
|
with urllib.request.urlopen(url, timeout=30) as r:
|
|
return json.load(r).get("data", []), url
|
|
|
|
|
|
def answers(base_url):
|
|
if not base_url:
|
|
return False
|
|
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 model_row(entry):
|
|
"""A catalog entry -> one pi-agent model row. Only fields the catalog actually carries;
|
|
a missing context window is left out rather than guessed, for the same reason the
|
|
catalog distinguishes a missing price from the price zero."""
|
|
row = {"id": _untag(entry["id"])}
|
|
if entry.get("name"):
|
|
row["name"] = entry["name"]
|
|
if entry.get("reasoning"):
|
|
row["reasoning"] = True
|
|
if entry.get("ctx"):
|
|
row["contextWindow"] = entry["ctx"]
|
|
row["compat"] = {"supportsDeveloperRole": False}
|
|
return row
|
|
|
|
|
|
def render(current, cat, gw):
|
|
"""Returns (new_providers, notes). Deterministic: same catalog, same output."""
|
|
keep = sorted((e for e in cat if e.get("cost_class") in KEEP_CLASSES
|
|
and e.get("reachable", True)),
|
|
key=lambda e: (e.get("cost_class"), e["id"]))
|
|
rows = [model_row(e) for e in keep]
|
|
|
|
out, notes = {}, []
|
|
for name in sorted(current):
|
|
p = dict(current[name])
|
|
base = urllib.parse.urlparse(p.get("baseUrl", ""))
|
|
if base.hostname == gw.hostname and base.port == gw.port:
|
|
if name == CATALOG_PROVIDER:
|
|
before = {m.get("id") for m in p.get("models", [])}
|
|
p["models"] = rows
|
|
after = {m["id"] for m in rows}
|
|
notes.append(f"{name}: regenerated from catalog "
|
|
f"({len(before)} -> {len(after)}; gone: "
|
|
f"{', '.join(sorted(before - after)) or 'none'})")
|
|
else:
|
|
# Alias: keep the pinned model, but say so when the catalog lost it —
|
|
# a pin at a model the gateway no longer serves is a silent 404 later.
|
|
known = {_untag(e["id"]) for e in cat}
|
|
pinned = [m for m in p.get("models", [])]
|
|
gone = [m.get("id") for m in pinned if _untag(m.get("id", "")) not in known]
|
|
notes.append(f"{name}: alias, {len(pinned)} pinned model(s)"
|
|
+ (f" — NOT IN CATALOG: {', '.join(gone)}" if gone else " — ok"))
|
|
out[name] = p
|
|
elif answers(p.get("baseUrl", "")):
|
|
notes.append(f"{name}: left alone (direct backend, answers)")
|
|
out[name] = p
|
|
else:
|
|
notes.append(f"{name}: DROPPED (direct backend, no answer at {p.get('baseUrl','')})")
|
|
return out, notes
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("host")
|
|
ap.add_argument("--apply", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
r = subprocess.run(["sic", a.host, "cat", MODELS_JSON], capture_output=True, text=True, timeout=60)
|
|
if r.returncode != 0 or not r.stdout.strip():
|
|
sys.exit(f"models-json: cannot read {a.host}:{MODELS_JSON}")
|
|
current = json.loads(r.stdout).get("providers", {})
|
|
|
|
cat, url = catalog()
|
|
gw = urllib.parse.urlparse(url)
|
|
new, notes = render(current, cat, gw)
|
|
for n in notes:
|
|
print(" " + n)
|
|
|
|
text = json.dumps({"providers": new}, indent=2, ensure_ascii=False) + "\n"
|
|
print(f"\n {len(current)} providers -> {len(new)}; "
|
|
f"{sum(len(p.get('models', [])) for p in current.values())} entries -> "
|
|
f"{sum(len(p['models']) for p in new.values())}")
|
|
if not a.apply:
|
|
print(" (dry run — nothing written; pass --apply)")
|
|
return
|
|
|
|
subprocess.run(["sic", a.host, "sh", "-c",
|
|
f"cp -a {MODELS_JSON} {MODELS_JSON}.bak-$(date +%Y%m%d-%H%M%S)"], check=True)
|
|
w = subprocess.run(["sic", a.host, "sh", "-c", f"cat > {MODELS_JSON}"],
|
|
input=text, capture_output=True, text=True, timeout=60)
|
|
if w.returncode != 0:
|
|
sys.exit(f"models-json: write failed: {w.stderr[:200]}")
|
|
chk = subprocess.run(["sic", a.host, "python3", "-c",
|
|
f"import json;d=json.load(open('{MODELS_JSON}'));"
|
|
f"print(len(d['providers']),'providers',"
|
|
f"sum(len(p.get('models',[])) for p in d['providers'].values()),'entries')"],
|
|
capture_output=True, text=True, timeout=60)
|
|
print(f" written and re-read: {chk.stdout.strip() or chk.stderr[:120]}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|