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