You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by gs...@apache.org on 2026/08/11 20:28:02 UTC
(tooling-llmao) 01/02: Add Models page with inventory table and detail modal.
This is an automated email from the ASF dual-hosted git repository.
gstein pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-llmao.git
commit 46c32bb94b73ff58a11bdcdf3cea427aa538b02a
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Tue Aug 11 11:40:16 2026 -0500
Add Models page with inventory table and detail modal.
Show public model_list fields for signed-in users; redact supply-path
details (weights, provider, provenance) for non-site-admins. Overview
table plus Bootstrap details modal; site admins see full public model_info.
Co-authored-by: Grok <gr...@x.ai>
---
docs/STATUS.md | 21 +++++---
llmao/models.py | 71 +++++++++++++++++++++++++++
pages.py | 19 ++++++++
static/js/llmao.js | 72 +++++++++++++++++++++------
templates/header.ezt | 3 ++
templates/models.ezt | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++
tests/test_models.py | 18 ++++++-
7 files changed, 317 insertions(+), 21 deletions(-)
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 3b0af53..bdea14f 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -68,14 +68,23 @@ Copy three YAMLs from `*.example` → system Postgres + `make db` → matching m
---
+## Models page (v1)
+
+| Item | Notes |
+|------|--------|
+| Route | `/models` (nav for signed-in users) |
+| Data | `model_list.yaml` via `public_models` / `ux_models` — no secrets or `api_base` |
+| UX | Overview table + Details modal; badges for modality / thinking / hosting |
+| Supply path | **Site admins only** (`is_site_admin`): weights, provider, provenance fields. Redacted for others (partnerships / ops). |
+| Not yet | Per-team/key model allow-list filtering (“available *to you*” hard filter) |
+
## Planned UX (top nav)
-Committed product sketch for signed-in top nav (beside **API keys**). **Not built yet.**
+| Nav | Intent | Status |
+|-----|--------|--------|
+| **Models** | Inventory (+ later allow-list scoped) | **v1 done** |
+| **Reports** | Usage, utilization, governance, Commons fairness | Not built |
-| Nav | Intent |
-|-----|--------|
-| **Models** | Browse **models available to this user** (identity / project / allow-list scoped—not necessarily the full global inventory). |
-| **Reports** | Usage, utilization, governance, and Commons-fairness views (see list below). |
### Reports (examples)
@@ -98,7 +107,7 @@ Data sources: LiteLLM spend/usage APIs where they apply; Infra / host observabil
3. Site admin via **`rai` PMC** (optional keep cfg list).
4. PMC notification email on key lifecycle.
5. Budgets / capacity limits UX.
-6. **Models** page + top nav: models available *to the user*.
+6. Models: per-team/key allow-list scoping when configured.
7. **Reports** page + top nav: PMC by-user, self-hosted utilization, Board roll-ups, donated credit burn-down, Commons abuse histograms.
8. **p6** Puppet: Postgres, eyaml→YAML, systemd, restart LiteLLM on config change.
9. Cleanup container paths; optional un-package.
diff --git a/llmao/models.py b/llmao/models.py
index e3d8244..41272ce 100644
--- a/llmao/models.py
+++ b/llmao/models.py
@@ -48,3 +48,74 @@ def public_models(path: Optional[pathlib.Path] = None, *, cfg: Any = None) -> Li
# Never surface api credentials or api_base in the public catalog.
out.append({"model_name": name, **info})
return out
+
+
+# Fields that describe how/where we obtain or serve a model (partnerships, HF paths).
+_SUPPLY_PATH_KEYS = frozenset({
+ "weights_distribution",
+ "training_data_provenance",
+ "provenance_record",
+ "provider", # may name commercial partners; generic hosting badge is separate
+})
+
+
+def _hosting_label(m: Dict[str, Any]) -> str:
+ if m.get("self_hosted") is True:
+ return "Self-hosted"
+ if m.get("self_hosted") is False:
+ return "External"
+ prov = (m.get("provider") or "").lower()
+ if prov in ("self-host", "selfhost", "self-hosted"):
+ return "Self-hosted"
+ if prov:
+ return "External"
+ return "—"
+
+
+def ux_models(
+ path: Optional[pathlib.Path] = None,
+ *,
+ cfg: Any = None,
+ reveal_supply: bool = False,
+) -> List[Dict[str, Any]]:
+ """Shape inventory for the Models page (table + detail modal).
+
+ When ``reveal_supply`` is False (normal committers), omit fields that
+ describe procurement, weight paths, or commercial partnerships.
+ """
+ rows = []
+ for m in public_models(path, cfg=cfg):
+ if not reveal_supply:
+ m = {k: v for k, v in m.items() if k not in _SUPPLY_PATH_KEYS}
+ name = m.get("model_name") or ""
+ display = m.get("display_name") or name
+ ctx = m.get("context_window")
+ rows.append({
+ "model_name": name,
+ "display_name": display,
+ "hosting_label": _hosting_label(m if reveal_supply else {
+ **m,
+ "self_hosted": m.get("self_hosted"),
+ }),
+ "self_hosted": bool(m.get("self_hosted")),
+ "context_window": ctx if ctx is not None else "—",
+ "license": m.get("license") or "—",
+ "modality": m.get("modality") or "",
+ "supports_thinking": bool(m.get("supports_thinking")),
+ "thinks_by_default": bool(m.get("thinks_by_default")),
+ "openness": m.get("openness") or "",
+ "notes": m.get("notes") or "",
+ "reveal_supply": reveal_supply,
+ # Admin-only supply fields (empty strings when redacted)
+ "provider": (m.get("provider") or "") if reveal_supply else "",
+ "weights_distribution": (
+ m.get("weights_distribution") or ""
+ ) if reveal_supply else "",
+ "training_data_provenance": (
+ m.get("training_data_provenance") or ""
+ ) if reveal_supply else "",
+ "provenance_record": (
+ m.get("provenance_record") or ""
+ ) if reveal_supply else "",
+ })
+ return rows
diff --git a/pages.py b/pages.py
index a8de6a7..882895c 100644
--- a/pages.py
+++ b/pages.py
@@ -30,6 +30,7 @@ from easydict import EasyDict as edict
from llmao.auth import current_identity
from llmao.litellm_client import BackendUnavailable, KeyInfo
+from llmao.models import ux_models
from llmao.seam import AuthzError
APP = asfquart.APP
@@ -131,6 +132,24 @@ async def home_page():
return result
+@APP.get("/models")
+@asfquart.auth.require
+@APP.use_template(TEMPLATES / "models.ezt")
+async def models_page():
+ """Gateway model inventory (public fields; supply path for site admins)."""
+ result = await basic_info()
+ result.title = "Models"
+ result.models = []
+ result.error = None
+ result.reveal_supply = bool(result.is_site_admin)
+ try:
+ raw = ux_models(cfg=APP.cfg, reveal_supply=result.reveal_supply)
+ result.models = [edict(m) for m in raw]
+ except (FileNotFoundError, ValueError, OSError) as e:
+ result.error = str(e)
+ return result
+
+
def _safe_after_path(raw: str | None, default: str = "/keys") -> str:
"""Only allow in-app keys paths as post-revoke/create redirects."""
if raw in ("/keys", "/keys/other"):
diff --git a/static/js/llmao.js b/static/js/llmao.js
index 3b855fb..5d40345 100644
--- a/static/js/llmao.js
+++ b/static/js/llmao.js
@@ -1,18 +1,62 @@
/* llmao front-end hooks */
document.addEventListener("DOMContentLoaded", function () {
var revokeModal = document.getElementById("revokeModal");
- if (!revokeModal) return;
- revokeModal.addEventListener("show.bs.modal", function (event) {
- var btn = event.relatedTarget;
- if (!btn) return;
- var tokenId = btn.getAttribute("data-token-id") || "";
- var purpose = btn.getAttribute("data-purpose") || "";
- var afterPath = btn.getAttribute("data-after-path") || "/keys";
- var tokenInput = document.getElementById("revokeTokenId");
- var purposeEl = document.getElementById("revokePurpose");
- var afterInput = document.getElementById("revokeAfterPath");
- if (tokenInput) tokenInput.value = tokenId;
- if (purposeEl) purposeEl.textContent = purpose;
- if (afterInput) afterInput.value = afterPath;
- });
+ if (revokeModal) {
+ revokeModal.addEventListener("show.bs.modal", function (event) {
+ var btn = event.relatedTarget;
+ if (!btn) return;
+ var tokenId = btn.getAttribute("data-token-id") || "";
+ var purpose = btn.getAttribute("data-purpose") || "";
+ var afterPath = btn.getAttribute("data-after-path") || "/keys";
+ var tokenInput = document.getElementById("revokeTokenId");
+ var purposeEl = document.getElementById("revokePurpose");
+ var afterInput = document.getElementById("revokeAfterPath");
+ if (tokenInput) tokenInput.value = tokenId;
+ if (purposeEl) purposeEl.textContent = purpose;
+ if (afterInput) afterInput.value = afterPath;
+ });
+ }
+
+ var modelModal = document.getElementById("modelDetailModal");
+ if (modelModal) {
+ modelModal.addEventListener("show.bs.modal", function (event) {
+ var btn = event.relatedTarget;
+ if (!btn) return;
+ function set(id, val) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = val || "—";
+ }
+ set("mdDisplayName", btn.getAttribute("data-display-name"));
+ set("mdModelName", btn.getAttribute("data-model-name"));
+ set("mdHosting", btn.getAttribute("data-hosting"));
+ set("mdContext", btn.getAttribute("data-context"));
+ set("mdLicense", btn.getAttribute("data-license"));
+ set("mdModality", btn.getAttribute("data-modality"));
+ set("mdOpenness", btn.getAttribute("data-openness"));
+ var thinking = btn.getAttribute("data-thinking") === "True" ||
+ btn.getAttribute("data-thinking") === "true";
+ var thinksDefault = btn.getAttribute("data-thinks-default") === "True" ||
+ btn.getAttribute("data-thinks-default") === "true";
+ var thinkText = thinking
+ ? (thinksDefault ? "Supported (on by default)" : "Supported (off by default)")
+ : "Not advertised";
+ set("mdThinking", thinkText);
+ set("mdNotes", btn.getAttribute("data-notes"));
+
+ var supply = document.getElementById("mdSupplyBlock");
+ var reveal = btn.getAttribute("data-reveal-supply") === "True" ||
+ btn.getAttribute("data-reveal-supply") === "true";
+ if (supply) {
+ if (reveal) {
+ supply.classList.remove("d-none");
+ set("mdProvider", btn.getAttribute("data-provider"));
+ set("mdWeights", btn.getAttribute("data-weights"));
+ set("mdTraining", btn.getAttribute("data-training"));
+ set("mdProvenance", btn.getAttribute("data-provenance"));
+ } else {
+ supply.classList.add("d-none");
+ }
+ }
+ });
+ }
});
diff --git a/templates/header.ezt b/templates/header.ezt
index 243a100..d4eef53 100644
--- a/templates/header.ezt
+++ b/templates/header.ezt
@@ -32,6 +32,9 @@
<a class="nav-link" href="/keys/other">Other Keys</a>
</li>
[end]
+ <li class="nav-item">
+ <a class="nav-link" href="/models">Models</a>
+ </li>
[end]
</ul>
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
diff --git a/templates/models.ezt b/templates/models.ezt
new file mode 100644
index 0000000..0d37140
--- /dev/null
+++ b/templates/models.ezt
@@ -0,0 +1,134 @@
+[include "header.ezt"]
+<div class="container py-4">
+ <div class="mt-2">[include "flashes.ezt"]</div>
+
+ <h1 class="h3 mb-3">Models</h1>
+ <p class="text-muted small">
+ Foundation model inventory offered through the llmao / LiteLLM gateway.
+ Use the model <strong>ID</strong> as the <code>model</code> parameter with
+ your PAT. Team or key allow-lists may further limit what a given credential
+ can call when configured.
+ </p>
+
+ [if-any error]
+ <div class="alert alert-warning">[error]</div>
+ [end]
+
+ [if-any models]
+ <div class="table-responsive">
+ <table class="table table-sm table-hover align-middle">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>ID</th>
+ <th>Hosting</th>
+ <th>Context</th>
+ <th>License</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ [for models]
+ <tr>
+ <td>
+ [models.display_name]
+ [if-any models.modality]
+ <span class="badge text-bg-secondary ms-1">[models.modality]</span>
+ [end]
+ [if-any models.supports_thinking]
+ <span class="badge text-bg-info ms-1">thinking</span>
+ [end]
+ </td>
+ <td class="font-monospace small">[models.model_name]</td>
+ <td>
+ [if-any models.self_hosted]
+ <span class="badge text-bg-success">[models.hosting_label]</span>
+ [else]
+ <span class="badge text-bg-warning text-dark">[models.hosting_label]</span>
+ [end]
+ </td>
+ <td class="small font-monospace">[models.context_window]</td>
+ <td class="small">[models.license]</td>
+ <td class="text-end">
+ <button type="button" class="btn btn-outline-secondary btn-sm"
+ data-bs-toggle="modal" data-bs-target="#modelDetailModal"
+ data-model-name="[models.model_name]"
+ data-display-name="[models.display_name]"
+ data-hosting="[models.hosting_label]"
+ data-context="[models.context_window]"
+ data-license="[models.license]"
+ data-modality="[models.modality]"
+ data-openness="[models.openness]"
+ data-thinking="[models.supports_thinking]"
+ data-thinks-default="[models.thinks_by_default]"
+ data-notes="[models.notes]"
+ data-reveal-supply="[models.reveal_supply]"
+ data-provider="[models.provider]"
+ data-weights="[models.weights_distribution]"
+ data-training="[models.training_data_provenance]"
+ data-provenance="[models.provenance_record]">
+ Details
+ </button>
+ </td>
+ </tr>
+ [end]
+ </tbody>
+ </table>
+ </div>
+ [else]
+ [if-any error][else]
+ <p class="text-muted">No models in the inventory.</p>
+ [end]
+ [end]
+</div>
+
+<div class="modal fade" id="modelDetailModal" tabindex="-1"
+ aria-labelledby="modelDetailModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h2 class="modal-title fs-5" id="modelDetailModalLabel">Model details</h2>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+ </div>
+ <div class="modal-body">
+ <dl class="row small mb-0" id="modelDetailBody">
+ <dt class="col-sm-3">Name</dt>
+ <dd class="col-sm-9" id="mdDisplayName"></dd>
+ <dt class="col-sm-3">ID</dt>
+ <dd class="col-sm-9 font-monospace" id="mdModelName"></dd>
+ <dt class="col-sm-3">Hosting</dt>
+ <dd class="col-sm-9" id="mdHosting"></dd>
+ <dt class="col-sm-3">Context</dt>
+ <dd class="col-sm-9 font-monospace" id="mdContext"></dd>
+ <dt class="col-sm-3">License</dt>
+ <dd class="col-sm-9" id="mdLicense"></dd>
+ <dt class="col-sm-3">Modality</dt>
+ <dd class="col-sm-9" id="mdModality"></dd>
+ <dt class="col-sm-3">Openness</dt>
+ <dd class="col-sm-9" id="mdOpenness"></dd>
+ <dt class="col-sm-3">Thinking</dt>
+ <dd class="col-sm-9" id="mdThinking"></dd>
+ <dt class="col-sm-3">Notes</dt>
+ <dd class="col-sm-9" id="mdNotes"></dd>
+ </dl>
+ <div id="mdSupplyBlock" class="mt-3 d-none">
+ <h3 class="h6 text-muted">Supply path (site admins)</h3>
+ <dl class="row small mb-0">
+ <dt class="col-sm-3">Provider</dt>
+ <dd class="col-sm-9" id="mdProvider"></dd>
+ <dt class="col-sm-3">Weights</dt>
+ <dd class="col-sm-9" id="mdWeights"></dd>
+ <dt class="col-sm-3">Training data</dt>
+ <dd class="col-sm-9" id="mdTraining"></dd>
+ <dt class="col-sm-3">Provenance record</dt>
+ <dd class="col-sm-9" id="mdProvenance"></dd>
+ </dl>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+</div>
+[include "footer.ezt"]
diff --git a/tests/test_models.py b/tests/test_models.py
index 216458d..261274e 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -10,7 +10,7 @@ import yaml
from easydict import EasyDict as edict
from litellm.types.router import Deployment
-from llmao.models import load_model_list, public_models, models_path_from_cfg
+from llmao.models import load_model_list, public_models, models_path_from_cfg, ux_models
ROOT = pathlib.Path(__file__).resolve().parent.parent
EXAMPLE = ROOT / "model_list.yaml.example"
@@ -45,6 +45,22 @@ def test_models_path_from_cfg():
assert p.is_file()
+def test_ux_models_redacts_supply_path_for_non_admins():
+ redacted = ux_models(EXAMPLE, reveal_supply=False)
+ full = ux_models(EXAMPLE, reveal_supply=True)
+ assert len(redacted) >= 2
+ assert len(full) == len(redacted)
+ for r in redacted:
+ assert r["model_name"]
+ assert r["display_name"]
+ assert r["provider"] == ""
+ assert r["weights_distribution"] == ""
+ assert r["hosting_label"] in ("Self-hosted", "External", "—")
+ for f in full:
+ # Example inventory includes weights_distribution for self-host models.
+ assert f.get("weights_distribution") or f.get("provider")
+
+
def test_litellm_deployment_preserves_model_info_extras():
"""Regression: LiteLLM Deployment/ModelInfo must accept ASF metadata fields.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org