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/10/12 03:33:31 UTC

(tooling-llmao) branch main updated (d682738 -> 7c12647)

This is an automated email from the ASF dual-hosted git repository.

gstein pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-llmao.git


    from d682738  docs: bring the docs up to date and point the README at the live Help
     new 990de39  help: send key requests to My Keys, not the retired Other Keys page
     new 28a8486  models: copy the model id from an icon, not a "Copy" link
     new 45da9c2  models: say when a model thinks by default, and tidy unavailable cards
     new 957d607  models: set the deployment status line out as separate facts
     new 035f521  nav: mark the current page, return to it after sign-in, and say Apache LLMAO
     new 1083409  nav: move Help to the right and add the ASF links
     new c33bf50  home: welcome newcomers with the steps to a first request
     new 7c12647  help: Grok Build needs only the config block, not environment variables

The 8 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 docs/STATUS.md             |  2 +-
 docs/help/faq.md           |  2 +-
 docs/help/grok-build.md    |  6 ++--
 pages.py                   | 47 +++++++++++++++++++++-----
 static/css/llmao.css       | 75 ++++++++++++++++++++++++++++++++++++++++-
 static/js/llmao.js         | 12 +++++--
 templates/footer.ezt       | 12 +++++++
 templates/header.ezt       | 35 +++++++++++++------
 templates/home.ezt         | 69 +++++++++++++++++++++++---------------
 templates/key_create.ezt   |  2 +-
 templates/models.ezt       | 24 ++++++++------
 tests/test_admin.py        |  2 ++
 tests/test_help.py         | 83 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_pages_models.py | 62 +++++++++++++++++++++++++++++++---
 14 files changed, 364 insertions(+), 69 deletions(-)


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 04/08: models: set the deployment status line out as separate facts

Posted by gs...@apache.org.
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 957d6077553ad0f76ee3b9b030dab9a561b4b052
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 21:49:40 2026 -0500

    models: set the deployment status line out as separate facts
    
    The line ran its fields together as one sentence, and ended with
    "LiteLLM health unhealthy 5m". Each fact now has a muted label and its
    value, with a dot between facts, and the health facts say what
    happened: "vLLM last healthy 24s ago", "LiteLLM check failed 5m ago".
    A failing fact is shown in the danger colour.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 pages.py                   | 26 +++++++++++++++++++-------
 static/css/llmao.css       | 19 ++++++++++++++++++-
 templates/models.ezt       |  4 ++--
 tests/test_pages_models.py | 18 ++++++++++++++++++
 4 files changed, 57 insertions(+), 10 deletions(-)

diff --git a/pages.py b/pages.py
index 32d8dd7..858af4a 100644
--- a/pages.py
+++ b/pages.py
@@ -795,16 +795,28 @@ def deployment_rows(fleet, *, admin: bool) -> list:
         skew_pills = [SKEW_PHRASE.get(n, n) for n in dep.skew] if admin else []
         diags = []
         if admin and state in (HEALTHY, UNHEALTHY):
+
+            def ago(age: str) -> str:
+                return f"{age} ago" if age and age not in ("—", "never") else ""
+
+            # Each fact names its subject, then says one thing about it.
+            vllm_fact = ""
+            if srv is not None:
+                vllm_fact = f"last healthy {ago(last_ok)}" if ago(last_ok) else "never healthy"
+            litellm_fact = ""
+            if litellm_health != "—":
+                verdict = "check passed" if dep.litellm_healthy else "check failed"
+                litellm_fact = f"{verdict} {ago(litellm_health_ago)}".strip()
             fields = [
                 # The public address only when Vast maps it to another port.
-                ("Public", public if srv is not None and public != listen else ""),
-                ("Context", context),
-                ("KV cache", kv_cache),
-                ("vLLM last healthy", last_ok if srv is not None else ""),
-                ("LiteLLM health", f"{litellm_health} {litellm_health_ago}" if litellm_health != "—" else ""),
-                ("Config served", config_ago),
+                ("Public", public if srv is not None and public != listen else "", False),
+                ("Context", context, False),
+                ("KV cache", kv_cache, False),
+                ("vLLM", vllm_fact, srv is not None and state == UNHEALTHY),
+                ("LiteLLM", litellm_fact, dep.litellm_healthy is False),
+                ("Config", f"served {ago(config_ago)}" if ago(config_ago) else "", False),
             ]
-            diags = [edict(label=k, value=v) for k, v in fields if v and v != "—"]
+            diags = [edict(label=k, value=v, bad=ezt.boolean(bad)) for k, v, bad in fields if v and v != "—"]
         lifecycle = deployment_status(snapshot_for_status(dep, cfg), cfg).lifecycle
         rows.append(
             edict(
diff --git a/static/css/llmao.css b/static/css/llmao.css
index d092263..79be9f0 100644
--- a/static/css/llmao.css
+++ b/static/css/llmao.css
@@ -80,10 +80,27 @@ body {
 .models-page .dep-reason { grid-area: reason; }
 .models-page .dep-flag { grid-area: flag; }
 .models-page .dep-actions { grid-area: actions; text-align: right; white-space: nowrap; }
+/* Separate facts with a dot between them: a quiet label, then its value. */
 .models-page .dep-diag {
   display: flex;
   flex-wrap: wrap;
-  gap: 0 1rem;
+  font-variant-numeric: tabular-nums;
+  /* Every fact leads with a dot; the one that starts a line is clipped. */
+  margin-left: -1.25rem;
+  clip-path: inset(0 0 0 1.25rem);
+}
+.models-page .dep-fact::before {
+  content: "\00b7";
+  display: inline-block;
+  width: 1.25rem;
+  text-align: center;
+  color: var(--bs-secondary-color);
+}
+.models-page .dep-fact-k {
+  color: var(--bs-secondary-color);
+}
+.models-page .dep-fact-bad {
+  color: var(--bs-danger-text-emphasis);
 }
 /* The accent replaces the 1px border, so the columns still line up. */
 .models-page .dep-attention {
diff --git a/templates/models.ezt b/templates/models.ezt
index 2d9634a..225db99 100644
--- a/templates/models.ezt
+++ b/templates/models.ezt
@@ -206,9 +206,9 @@
       <div class="small text-danger mt-1">Context window exceeds the measured KV cache. vLLM will hang on a request that needs the space rather than refuse it.</div>
       [end]
       [if-any models.deployments.diags]
-      <div class="dep-diag small text-muted mt-1">
+      <div class="dep-diag small mt-1">
         [for models.deployments.diags]
-        <span>[models.deployments.diags.label] [models.deployments.diags.value]</span>
+        <span class="dep-fact[if-any models.deployments.diags.bad] dep-fact-bad[end]"><span class="dep-fact-k">[models.deployments.diags.label]</span> [models.deployments.diags.value]</span>
         [end]
       </div>
       [end]
diff --git a/tests/test_pages_models.py b/tests/test_pages_models.py
index 6f71d90..73011c9 100644
--- a/tests/test_pages_models.py
+++ b/tests/test_pages_models.py
@@ -478,6 +478,24 @@ def test_healthy_row_has_one_menu_entry_and_no_clutter():
     # The diagnostics line leaves out anything it has no value for.
     assert all(d.value and d.value != "—" for d in row.diags)
     assert "Context" in [d.label for d in row.diags]
+    facts = {d.label: d for d in row.diags}
+    assert facts["vLLM"].value.startswith("last healthy ") and facts["vLLM"].value.endswith(" ago")
+    assert not any(d.bad for d in row.diags)
+
+
+def test_diagnostics_say_a_failed_litellm_check_plainly():
+    pages = _app()
+    fleet = _fleet(_server("gemma"))
+    dep = fleet.deployments[0]
+    dep.litellm_healthy = False
+    dep.litellm_health_at = time.time() - 300
+    row = pages.deployment_rows(fleet, admin=True)[0]
+    facts = {d.label: d for d in row.diags}
+    assert facts["LiteLLM"].value == "check failed 5m ago"
+    assert facts["LiteLLM"].bad
+    out = _render(pages.model_catalog_rows(fleet, [_catalog("gemma")], admin=True), admin=True)
+    assert "health unhealthy" not in out
+    assert "dep-fact-bad" in out
     out = _render(pages.model_catalog_rows(fleet, [_catalog("gemma")], admin=True), admin=True)
     assert "disabled" not in out
     assert "<table" not in out


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 07/08: home: welcome newcomers with the steps to a first request

Posted by gs...@apache.org.
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 c33bf5006c9646976c62ddf9d9ad431e6e8f6bf1
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 22:26:28 2026 -0500

    home: welcome newcomers with the steps to a first request
    
    The signed-in home page was a card saying "Signed in" with one button.
    It is now a welcome and four numbered steps: sign in, create a key,
    pick a model, connect a tool. Sign in shows as done once you have. The
    abbreviation "PAT" is gone; the help pages never used it.
    
    The model card's button said "Request a key" but created one directly,
    while My Keys uses that phrase for the approval flow. It now says
    "Create a key".
    
    The create-key form preselects your project when you have only one.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 pages.py                   |  3 ++
 static/css/llmao.css       | 39 ++++++++++++++++++++++++++
 templates/home.ezt         | 69 ++++++++++++++++++++++++++++------------------
 templates/key_create.ezt   |  2 +-
 templates/models.ezt       |  4 +--
 tests/test_help.py         | 53 +++++++++++++++++++++++++++++++++++
 tests/test_pages_models.py |  5 ++--
 7 files changed, 143 insertions(+), 32 deletions(-)

diff --git a/pages.py b/pages.py
index 8699bda..2124eed 100644
--- a/pages.py
+++ b/pages.py
@@ -1555,6 +1555,9 @@ async def keys_new_form(result):
     model = (quart.request.args.get("model") or "").strip()
     result.for_model = model
     result.has_for_model = ezt.boolean(bool(model))
+    # Nothing to choose between, so do not make them choose.
+    if not result.form_project and len(result.projects) == 1:
+        result.form_project = result.projects[0].name
     return result
 
 
diff --git a/static/css/llmao.css b/static/css/llmao.css
index fab1fb6..3703ea2 100644
--- a/static/css/llmao.css
+++ b/static/css/llmao.css
@@ -6,6 +6,45 @@ body {
   max-width: 40rem;
 }
 
+/* Home: the steps, numbered down a rail. */
+.home-page .home-steps {
+  counter-reset: step;
+  max-width: 40rem;
+}
+.home-page .home-step {
+  counter-increment: step;
+  position: relative;
+  padding: 0 0 1.75rem 3rem;
+}
+.home-page .home-step::before {
+  content: counter(step);
+  position: absolute;
+  left: 0;
+  top: -0.2rem;
+  width: 2rem;
+  height: 2rem;
+  line-height: 2rem;
+  border-radius: 50%;
+  text-align: center;
+  font-weight: 600;
+  background: var(--bs-secondary-bg);
+  color: var(--bs-body-color);
+}
+/* The rail joining one number to the next. */
+.home-page .home-step:not(:last-child)::after {
+  content: "";
+  position: absolute;
+  left: 1rem;
+  top: 2rem;
+  bottom: 0.2rem;
+  border-left: 2px solid var(--bs-border-color);
+}
+.home-page .home-step-done::before {
+  content: "\2713";
+  background: var(--bs-success);
+  color: #fff;
+}
+
 /* Dark grey bar (not pure black) so the logo disc edge stays visible. */
 .navbar.llmao-navbar {
   background-color: #343a40;
diff --git a/templates/home.ezt b/templates/home.ezt
index 38bf947..c3c551e 100644
--- a/templates/home.ezt
+++ b/templates/home.ezt
@@ -1,38 +1,53 @@
 [include "header.ezt"]
-<div class="container py-4">
+<div class="container py-4 home-page">
     <div class="mt-2">
         [include "flashes.ezt"]
     </div>
 
-    <h1 class="h3 mb-3">Apache LLMAO</h1>
+    <h1 class="h3 mb-3">Welcome to Apache LLMAO</h1>
     <p class="llmao-lede text-muted">
-        The ASF's gateway to AI models. Sign in with your ASF account,
-        create a personal access token (PAT), and use Foundation-sanctioned
-        models from any OpenAI&nbsp;API-compatible tool, with your
-        usage attributed to you.
-    </p>
-    <p class="text-muted">
-        Point your tool at <span class="font-monospace">https://llm.apache.org</span>
-        and use your PAT as the key. The endpoint follows the OpenAI API specification.
+        The ASF's gateway to AI models. Use Foundation-sanctioned models from
+        any OpenAI&nbsp;API-compatible tool, with your usage attributed to you.
+        Four steps get you there.
     </p>
 
-    [if-any uid]
-    <div class="card mb-3" style="max-width: 28rem;">
-        <div class="card-body">
-            <h2 class="h6 card-title">Signed in</h2>
-            <p class="card-text mb-0">
-                <span class="font-monospace">[uid]</span>
-                [if-any projects_label]
-                <br><span class="text-muted small">projects: [projects_label]</span>
-                [end]
+    <ol class="home-steps list-unstyled mt-4">
+        <li class="home-step[if-any uid] home-step-done[end]">
+            <h2 class="h6 mb-1">Sign in</h2>
+            [if-any uid]
+            <p class="text-muted mb-0">
+                Signed in as <span class="font-monospace">[uid]</span>[if-any projects_label]
+                &middot; projects: [projects_label][end]
             </p>
-            <a class="btn btn-primary btn-sm mt-3" href="/keys">Manage my keys</a>
-        </div>
-    </div>
-    [else]
-    <p>
-        <a class="btn btn-primary" href="/auth?login=/">Sign in with ASF</a>
-    </p>
-    [end]
+            [else]
+            <p class="text-muted mb-2">Use your ASF account.</p>
+            <a class="btn btn-primary btn-sm" href="/auth?login=/">Sign in with ASF</a>
+            [end]
+        </li>
+        <li class="home-step">
+            <h2 class="h6 mb-1">Create a key</h2>
+            <p class="text-muted mb-2">
+                A personal access token ties your usage to you and one of your
+                projects. The secret is shown once.
+            </p>
+            [if-any uid]<a class="btn btn-primary btn-sm" href="/keys">My Keys</a>[end]
+        </li>
+        <li class="home-step">
+            <h2 class="h6 mb-1">Pick a model</h2>
+            <p class="text-muted mb-2">
+                Each model lists its ID, its context window, and who can see
+                your prompts.
+            </p>
+            [if-any uid]<a class="btn btn-outline-primary btn-sm" href="/models">Models</a>[end]
+        </li>
+        <li class="home-step">
+            <h2 class="h6 mb-1">Connect a tool</h2>
+            <p class="text-muted mb-2">
+                Point it at <span class="font-monospace">https://llm.apache.org</span>
+                and use that token as the key. The endpoint follows the OpenAI API.
+            </p>
+            <a class="btn btn-outline-primary btn-sm" href="/help">Help</a>
+        </li>
+    </ol>
 </div>
 [include "footer.ezt"]
diff --git a/templates/key_create.ezt b/templates/key_create.ezt
index 1ec89c8..cefdc90 100644
--- a/templates/key_create.ezt
+++ b/templates/key_create.ezt
@@ -22,7 +22,7 @@
       <select class="form-select" name="project" id="project" required>
         <option value="">Select project…</option>
         [for projects]
-        <option value="[projects.name]">[projects.name]</option>
+        <option value="[projects.name]"[is projects.name form_project] selected[end]>[projects.name]</option>
         [end]
       </select>
     </div>
diff --git a/templates/models.ezt b/templates/models.ezt
index 225db99..2aa609d 100644
--- a/templates/models.ezt
+++ b/templates/models.ezt
@@ -106,10 +106,10 @@
             <span class="badge text-bg-success">[models.rollup_label]</span>
             [end]
             <a class="btn btn-outline-primary btn-sm"
-               href="/keys/new?model=[models.model_name]">Request a key</a>
+               href="/keys/new?model=[models.model_name]">Create a key</a>
             [else]
             <span class="badge text-bg-secondary">Unavailable</span>
-            <button type="button" class="btn btn-outline-secondary btn-sm" disabled>Request a key</button>
+            <button type="button" class="btn btn-outline-secondary btn-sm" disabled>Create a key</button>
             [end]
             <button type="button" class="btn btn-link btn-sm p-0 ms-auto"
                     data-bs-toggle="modal" data-bs-target="#modelDetailModal"
diff --git a/tests/test_help.py b/tests/test_help.py
index 25a9351..66aa49d 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -137,3 +137,56 @@ def test_home_page_does_not_list_api_paths():
     assert "/healthz" not in text
     assert "LiteLLM" not in text
     assert "llm.apache.org" in text
+    assert "PAT" not in text
+
+
+def _frame(**extra):
+    data = dict(
+        title="t",
+        flashes=[],
+        uid=None,
+        name=None,
+        projects=[],
+        projects_label=None,
+        is_site_admin=ezt.boolean(False),
+        nav="",
+        login_back="/",
+        restarted_at="",
+        repo="https://example.test/llmao",
+        commit="abc",
+        can_create_automation=ezt.boolean(False),
+    )
+    data.update(extra)
+    return edict(data)
+
+
+def _generate(name: str, data) -> str:
+    template = ezt.Template(str(THIS_DIR / "templates" / name), base_format=ezt.FORMAT_HTML)
+    buf = io.StringIO()
+    template.generate(buf, data)
+    return buf.getvalue()
+
+
+def test_home_page_is_the_steps_to_a_first_request():
+    signed_out = _generate("home.ezt", _frame())
+    assert signed_out.count('<li class="home-step') == 4
+    assert "home-step-done" not in signed_out
+    assert 'href="/auth?login=/"' in signed_out
+    assert 'href="/help"' in signed_out
+
+    signed_in = _generate("home.ezt", _frame(uid="gstein", name="Greg", projects_label="infra"))
+    assert signed_in.count("home-step-done") == 1
+    assert "Signed in as" in signed_in
+    for href in ("/keys", "/models", "/help"):
+        assert f'btn-sm" href="{href}"' in signed_in
+
+
+def test_create_key_form_preselects_a_given_project():
+    common = dict(uid="gstein", name="Greg", form_purpose="", for_model="", has_for_model=ezt.boolean(False))
+    one = _generate("key_create.ezt", _frame(projects=[edict(name="infra")], form_project="infra", **common))
+    assert '<option value="infra" selected>infra</option>' in one
+    two = _generate(
+        "key_create.ezt",
+        _frame(projects=[edict(name="infra"), edict(name="tooling")], form_project="", **common),
+    )
+    assert " selected>" not in two
diff --git a/tests/test_pages_models.py b/tests/test_pages_models.py
index f5b07e4..0b4a240 100644
--- a/tests/test_pages_models.py
+++ b/tests/test_pages_models.py
@@ -230,7 +230,8 @@ def test_models_template_renders_the_catalog_row():
     assert "<strong>Private.</strong>" in out
     assert "4 long requests at once" in out
     assert 'title="Accepts images"' in out
-    assert "Request a key" in out
+    assert "Create a key" in out
+    assert "Request a key" not in out
 
 
 def test_admin_rows_carry_their_models_deployments_and_summary():
@@ -565,7 +566,7 @@ def test_mixed_card_and_unavailable_card():
     assert "you can't choose which" in out
     assert "model-privacy-mixed" in out
     # The unavailable card keeps its button, disabled, and says why once.
-    assert "disabled>Request a key</button>" in out
+    assert "disabled>Create a key</button>" in out
     assert out.count(">Unavailable<") == 1
     assert "Unavailable right now" not in out
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 06/08: nav: move Help to the right and add the ASF links

Posted by gs...@apache.org.
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 10834091806b65dfe8f585291488d67210dc324c
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 22:11:43 2026 -0500

    nav: move Help to the right and add the ASF links
    
    Help is a utility, so it sits on the right with the account menu, and
    the left holds only the pages people work in.
    
    An ASF dropdown carries the links the project-site policy asks for in
    the main navigation: License, Sponsorship, Thanks, Security and
    Privacy, with the Foundation's home page. The footer repeats the ones
    people look for there and adds the trademark attribution. No trademark
    symbol on the name, which may change.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 templates/footer.ezt | 12 ++++++++++++
 templates/header.ezt | 19 +++++++++++++++++--
 tests/test_help.py   | 16 +++++++++++++++-
 3 files changed, 44 insertions(+), 3 deletions(-)

diff --git a/templates/footer.ezt b/templates/footer.ezt
index 1cca7a2..c84111c 100644
--- a/templates/footer.ezt
+++ b/templates/footer.ezt
@@ -6,6 +6,18 @@
             &middot; running <a href="[repo]/commit/[commit]">[commit]</a>
             [if-any is_site_admin]&middot; last restarted [restarted_at][end]
         </p>
+        <p class="text-muted mt-2 mb-0 small">
+            <a href="https://www.apache.org/">The Apache Software Foundation</a>
+            &middot; <a href="https://www.apache.org/licenses/">License</a>
+            &middot; <a href="https://www.apache.org/security/">Security</a>
+            &middot; <a href="https://privacy.apache.org/policies/privacy-policy-public.html">Privacy</a>
+        </p>
+        <p class="text-muted mt-2 mb-0 small">
+            Apache and the Apache logo are either registered trademarks or
+            trademarks of The Apache Software Foundation in the United States
+            and other countries. All other marks mentioned may be trademarks or
+            registered trademarks of their respective owners.
+        </p>
     </div>
 </footer>
 
diff --git a/templates/header.ezt b/templates/header.ezt
index 7777c09..69ec0e3 100644
--- a/templates/header.ezt
+++ b/templates/header.ezt
@@ -39,11 +39,26 @@
                 </li>
                 [end]
                 [end]
+            </ul>
+            <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
+                <li class="nav-item dropdown">
+                    <a class="nav-link dropdown-toggle" href="#" id="asfDropdown" role="button"
+                       data-bs-toggle="dropdown" aria-expanded="false">
+                        ASF
+                    </a>
+                    <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="asfDropdown">
+                        <li><a class="dropdown-item" href="https://www.apache.org/">The Apache Software Foundation</a></li>
+                        <li><hr class="dropdown-divider"></li>
+                        <li><a class="dropdown-item" href="https://www.apache.org/licenses/">License</a></li>
+                        <li><a class="dropdown-item" href="https://www.apache.org/foundation/sponsorship.html">Sponsorship</a></li>
+                        <li><a class="dropdown-item" href="https://www.apache.org/foundation/thanks.html">Thanks</a></li>
+                        <li><a class="dropdown-item" href="https://www.apache.org/security/">Security</a></li>
+                        <li><a class="dropdown-item" href="https://privacy.apache.org/policies/privacy-policy-public.html">Privacy</a></li>
+                    </ul>
+                </li>
                 <li class="nav-item">
                     <a class="nav-link[is nav "help"] active[end]" href="/help"[is nav "help"] aria-current="page"[end]>Help</a>
                 </li>
-            </ul>
-            <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
                 [if-any uid]
                 <li class="nav-item dropdown">
                     <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button"
diff --git a/tests/test_help.py b/tests/test_help.py
index 7e80d99..25a9351 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -85,7 +85,21 @@ def test_help_template_uses_the_page_frame(tmp_path):
     # Signing in from here comes back here.
     assert 'href="/auth?login=/help"' in out
     assert "Apache LLMAO" in out
-    assert "Apache LLMAO" in out
+    # Help is a utility: once in the bar, after the working pages.
+    assert out.count(">Help</a>") == 1
+    # The links the ASF asks project sites to carry in their navigation.
+    for text, url in (
+        ("License", "https://www.apache.org/licenses/"),
+        ("Sponsorship", "https://www.apache.org/foundation/sponsorship.html"),
+        ("Thanks", "https://www.apache.org/foundation/thanks.html"),
+        ("Security", "https://www.apache.org/security/"),
+        ("Privacy", "https://privacy.apache.org/policies/privacy-policy-public.html"),
+    ):
+        assert f'<a class="dropdown-item" href="{url}">{text}</a>' in out
+    assert 'href="https://www.apache.org/"' in out
+    footer = out[out.index("<footer") :]
+    assert "trademarks of The Apache Software Foundation" in footer
+    assert "™" not in out and "&trade;" not in out
     assert 'href="/help/faq"' in out
 
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 08/08: help: Grok Build needs only the config block, not environment variables

Posted by gs...@apache.org.
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 7c1264791e16a3d62fb14ba56b787f33f2b022f4
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 22:32:18 2026 -0500

    help: Grok Build needs only the config block, not environment variables
    
    The model block in config.toml carries the address and the key, so the
    two exports were unnecessary. Confirmed by setting it up with the toml
    change alone.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 docs/help/grok-build.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/help/grok-build.md b/docs/help/grok-build.md
index 1c3026d..da1922f 100644
--- a/docs/help/grok-build.md
+++ b/docs/help/grok-build.md
@@ -13,9 +13,9 @@ context_window = 131072
 ```
 
 ```sh
-export GROK_XAI_API_BASE_URL=https://llm.apache.org/v1
-export XAI_API_KEY=<your personal access token>
 grok -m qwen3.8-27b
 ```
 
-A `~/.grok/auth.json` left by `grok login` outranks the key and sends calls elsewhere. Move that file aside. Only API-key inference reads `GROK_XAI_API_BASE_URL`.
+The block carries the address and the key, so no environment variables are needed.
+
+A `~/.grok/auth.json` left by `grok login` outranks the key and sends calls elsewhere. Move that file aside.


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 03/08: models: say when a model thinks by default, and tidy unavailable cards

Posted by gs...@apache.org.
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 45da9c2036039d65e6147b59905e5ce8ae79b72e
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 21:40:13 2026 -0500

    models: say when a model thinks by default, and tidy unavailable cards
    
    The Thinking chip was the same for every reasoning model, though whether
    reasoning is on by default is what decides if a small output budget
    comes back empty. It now reads "Thinks by default" where that is so.
    
    An unavailable card said "Unavailable" twice. It says it once, and no
    longer borrows the roll-up label, which could read "Available" on a
    model that is healthy but has no route yet.
    
    Degraded models sort after available ones; unavailable stay last.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 pages.py                   |  3 ++-
 templates/models.ezt       |  9 ++++++---
 tests/test_pages_models.py | 23 +++++++++++++++++++++--
 3 files changed, 29 insertions(+), 6 deletions(-)

diff --git a/pages.py b/pages.py
index fdd3846..32d8dd7 100644
--- a/pages.py
+++ b/pages.py
@@ -676,7 +676,8 @@ def model_catalog_rows(fleet, catalog: list, *, admin: bool = False) -> list:
             row.deployments = [r for r in deployment_rows(fleet, admin=True) if r.model_name == row.model_name]
             row.replica_summary = _replica_summary(row.model_name, fleet)
         rows.append(row)
-    rows.sort(key=lambda r: (bool(r.unavailable), (r.display_name or "").lower()))
+    # Usable first: available, then degraded, then the ones nobody can call.
+    rows.sort(key=lambda r: (bool(r.unavailable), bool(r.degraded), (r.display_name or "").lower()))
     for i, row in enumerate(rows):
         row.index = i
     return rows
diff --git a/templates/models.ezt b/templates/models.ezt
index 50ea739..2d9634a 100644
--- a/templates/models.ezt
+++ b/templates/models.ezt
@@ -88,7 +88,11 @@
               <span class="badge text-bg-secondary" title="[models.chips.title]">[models.chips.label]</span>
               [end]
               [if-any models.supports_thinking]
-              <span class="badge text-bg-info" title="Reasons before answering">Thinking</span>
+              [if-any models.thinks_by_default]
+              <span class="badge text-bg-info" title="Reasons before answering unless you turn it off">Thinks by default</span>
+              [else]
+              <span class="badge text-bg-info" title="Can reason before answering, and does so only when asked">Thinking</span>
+              [end]
               [end]
             </div>
             <div class="small text-muted mt-2">
@@ -104,9 +108,8 @@
             <a class="btn btn-outline-primary btn-sm"
                href="/keys/new?model=[models.model_name]">Request a key</a>
             [else]
-            <span class="badge text-bg-secondary">[models.rollup_label]</span>
+            <span class="badge text-bg-secondary">Unavailable</span>
             <button type="button" class="btn btn-outline-secondary btn-sm" disabled>Request a key</button>
-            <span class="small text-muted">Unavailable right now</span>
             [end]
             <button type="button" class="btn btn-link btn-sm p-0 ms-auto"
                     data-bs-toggle="modal" data-bs-target="#modelDetailModal"
diff --git a/tests/test_pages_models.py b/tests/test_pages_models.py
index b0fb0a5..6f71d90 100644
--- a/tests/test_pages_models.py
+++ b/tests/test_pages_models.py
@@ -546,9 +546,28 @@ def test_mixed_card_and_unavailable_card():
     assert "<strong>Mixed.</strong>" in out
     assert "you can't choose which" in out
     assert "model-privacy-mixed" in out
-    # The unavailable card keeps its button, disabled, and says why in text.
+    # The unavailable card keeps its button, disabled, and says why once.
     assert "disabled>Request a key</button>" in out
-    assert "Unavailable right now" in out
+    assert out.count(">Unavailable<") == 1
+    assert "Unavailable right now" not in out
+
+
+def test_thinking_chip_says_when_it_is_the_default():
+    pages = _app()
+    quiet = _catalog("gemma")
+    loud = _catalog("qwen")
+    loud["thinks_by_default"] = ezt.boolean(True)
+    out = _render(pages.model_catalog_rows(_fleet(_server("gemma"), _server("qwen")), [quiet, loud]))
+    assert out.count('unless you turn it off">Thinks by default<') == 1
+    assert out.count('only when asked">Thinking<') == 1
+
+
+def test_cards_put_degraded_after_available_and_unavailable_last():
+    pages = _app()
+    fleet = _fleet(_server("alpha"), _server("beta"), _server("beta", host="10.0.0.9", ok=False))
+    rows = pages.model_catalog_rows(fleet, [_catalog("aaa-missing"), _catalog("beta"), _catalog("alpha")])
+    assert [r.model_name for r in rows] == ["alpha", "beta", "aaa-missing"]
+    assert rows[1].degraded
 
 
 def _render(rows, attention=None, *, admin=False, approve_choices=None) -> str:


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 01/08: help: send key requests to My Keys, not the retired Other Keys page

Posted by gs...@apache.org.
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 990de390804a6cd363af2a2ab2fec0648770c718
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 21:26:05 2026 -0500

    help: send key requests to My Keys, not the retired Other Keys page
    
    The FAQ pointed people at Other Keys for an automation key. That page is
    no longer in the navigation; the way in is Request a key on My Keys.
    STATUS.md named it as implemented UX too.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 docs/STATUS.md   | 2 +-
 docs/help/faq.md | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/STATUS.md b/docs/STATUS.md
index f77af92..fdf7bf9 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -19,7 +19,7 @@
 
 ## Done (today — thin)
 
-Implemented enough for local production-shaped use: asfquart OAuth; LiteLLMBackend + fail-fast team cache warm; `models.yaml` definitions; PAT UX (**My Keys** / **Other Keys**); **Models** page (supply-path redaction for non–site-admins); secrets as dual YAML / eyaml intent; system Postgres + prisma setup; offline `tests/mock_backend.py`.
+Implemented enough for local production-shaped use: asfquart OAuth; LiteLLMBackend + fail-fast team cache warm; `models.yaml` definitions; PAT UX (**My Keys**, including key requests; automation keys are listed for site admins as **Service keys**); **Models** page (supply-path redaction for non–site-admins); secrets as dual YAML / eyaml intent; system Postgres + prisma setup; offline `tests/mock_backend.py`.
 
 **Models page:** definitions-first table (name, id, Available Yes/No, Request a key → `/keys/new?model=`). Context, license, and hosting live in Details; supply-path fields stay site-admin in the modal. Available is policy (always true until P5) **and** in service **and** in LiteLLM. Self-hosted with nothing Healthy or no deployment is No. Sort puts No last. Per-process state is in the `/models` admin drill-down.
 
diff --git a/docs/help/faq.md b/docs/help/faq.md
index e89c554..a63c253 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -13,7 +13,7 @@ A personal key belongs to you and to one project you are a member of. The secret
 
 ## Can a script have its own key?
 
-Yes, as an automation key, which belongs to a project and not to a person. These are uncommon. A project admin creates one on [[]Other Keys](/keys/other) after a formal request, and is recorded as the person to ask about it.
+Yes. Ask for one under **Request a key** on [[]My Keys](/keys), and say what it is for. The same request covers work outside a project. If it is approved, you create the key there. The secret is shown once, to you, and passes through nobody else.
 
 ## How do I connect a tool?
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 05/08: nav: mark the current page, return to it after sign-in, and say Apache LLMAO

Posted by gs...@apache.org.
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 035f521f675fa0f276bb3d322b4a772d7fd13427
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 22:06:48 2026 -0500

    nav: mark the current page, return to it after sign-in, and say Apache LLMAO
    
    Nothing on the bar showed which page you were on. The link for the
    current section is now marked, visibly and with aria-current.
    
    "Sign in with ASF" always came back to the home page; it now returns to
    the page it was started from.
    
    The brand and the tab title read "Apache LLMAO", as the pages do.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 pages.py                   | 15 ++++++++++++++-
 static/css/llmao.css       |  5 +++++
 templates/header.ezt       | 16 ++++++++--------
 tests/test_admin.py        |  2 ++
 tests/test_help.py         | 16 ++++++++++++++++
 tests/test_pages_models.py |  2 ++
 6 files changed, 47 insertions(+), 9 deletions(-)

diff --git a/pages.py b/pages.py
index 858af4a..8699bda 100644
--- a/pages.py
+++ b/pages.py
@@ -24,7 +24,7 @@ import io
 import pathlib
 import re
 import time
-from urllib.parse import urlsplit
+from urllib.parse import quote, urlsplit
 
 import asfquart
 import asfquart.auth
@@ -164,11 +164,24 @@ def page(*extra_exc, title: str = "llmao", category: str = "warning"):
     return deco
 
 
+_NAV_SECTIONS = ("keys", "models", "projects", "admin", "help")
+
+
+def _nav_section(path: str) -> str:
+    """Which top-nav link a path belongs under, or "" for none (home)."""
+    first = path.strip("/").split("/", 1)[0]
+    return first if first in _NAV_SECTIONS else ""
+
+
 async def basic_info(title: str = "llmao") -> edict:
     """Base-level EZT template data shared by HTML pages."""
     basic = edict()
     basic.title = title
     basic.flashes = []
+    path = quart.request.path if quart.has_request_context() else "/"
+    basic.nav = _nav_section(path)
+    # Signing in returns to the page it was started from.
+    basic.login_back = quote(path, safe="/")
 
     # Form defaults for create pages
     basic.form_project = ""
diff --git a/static/css/llmao.css b/static/css/llmao.css
index 79be9f0..fab1fb6 100644
--- a/static/css/llmao.css
+++ b/static/css/llmao.css
@@ -20,6 +20,11 @@ body {
 .navbar.llmao-navbar .nav-link:focus {
   color: #fff;
 }
+/* The page you are on. */
+.navbar.llmao-navbar .nav-link.active {
+  color: #fff;
+  font-weight: 600;
+}
 
 /* Models page. */
 .models-page .model-card-off {
diff --git a/templates/header.ezt b/templates/header.ezt
index 058b7fa..7777c09 100644
--- a/templates/header.ezt
+++ b/templates/header.ezt
@@ -3,7 +3,7 @@
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>llmao: [title]</title>
+    <title>[title] · Apache LLMAO</title>
     <link href="/static/css/bootstrap.min.css" rel="stylesheet"
           integrity="sha384-4bw+/aepP/YC94hEpVNVgiZdgIC5+VKNBQNGCHeKRQN+PtmoHDEXuppvnDJzQIu9">
     <link href="/static/css/bootstrap-icons.css" rel="stylesheet">
@@ -15,7 +15,7 @@
     <div class="container-fluid">
         <a class="navbar-brand d-flex align-items-center" href="/">
             <img src="/static/llmao-logo.svg" alt="" width="28" height="28" class="me-2">
-            llmao
+            Apache LLMAO
         </a>
         <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
                 aria-controls="navbarContent" aria-expanded="false" aria-label="Toggle navigation">
@@ -25,22 +25,22 @@
             <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                 [if-any uid]
                 <li class="nav-item">
-                    <a class="nav-link" href="/keys">My Keys</a>
+                    <a class="nav-link[is nav "keys"] active[end]" href="/keys"[is nav "keys"] aria-current="page"[end]>My Keys</a>
                 </li>
                 <li class="nav-item">
-                    <a class="nav-link" href="/models">Models</a>
+                    <a class="nav-link[is nav "models"] active[end]" href="/models"[is nav "models"] aria-current="page"[end]>Models</a>
                 </li>
                 <li class="nav-item">
-                    <a class="nav-link" href="/projects">Projects</a>
+                    <a class="nav-link[is nav "projects"] active[end]" href="/projects"[is nav "projects"] aria-current="page"[end]>Projects</a>
                 </li>
                 [if-any is_site_admin]
                 <li class="nav-item">
-                    <a class="nav-link" href="/admin">Admin</a>
+                    <a class="nav-link[is nav "admin"] active[end]" href="/admin"[is nav "admin"] aria-current="page"[end]>Admin</a>
                 </li>
                 [end]
                 [end]
                 <li class="nav-item">
-                    <a class="nav-link" href="/help">Help</a>
+                    <a class="nav-link[is nav "help"] active[end]" href="/help"[is nav "help"] aria-current="page"[end]>Help</a>
                 </li>
             </ul>
             <ul class="navbar-nav ms-auto mb-2 mb-lg-0">
@@ -58,7 +58,7 @@
                 </li>
                 [else]
                 <li class="nav-item">
-                    <a class="nav-link" href="/auth?login=/">Sign in with ASF</a>
+                    <a class="nav-link" href="/auth?login=[login_back]">Sign in with ASF</a>
                 </li>
                 [end]
             </ul>
diff --git a/tests/test_admin.py b/tests/test_admin.py
index 30d385a..1c1a4ba 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -34,6 +34,8 @@ def _render(*, running, expected):
     data = edict(
         title="Admin",
         is_site_admin=ezt.boolean(True),
+        nav="admin",
+        login_back="/admin",
         restarted_at="10/08 14:30 (UTC)",
         reveal_supply=False,
         uid="u@example.apache.org",
diff --git a/tests/test_help.py b/tests/test_help.py
index 3b92b90..7e80d99 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -64,6 +64,8 @@ def test_help_template_uses_the_page_frame(tmp_path):
         uid=None,
         name=None,
         is_site_admin=ezt.boolean(False),
+        nav="help",
+        login_back="/help",
         restarted_at="",
         repo="https://example.test/llmao",
         commit="abc",
@@ -77,6 +79,12 @@ def test_help_template_uses_the_page_frame(tmp_path):
     assert "&lt;h1&gt;" not in out
     assert "Body text" in out
     assert 'href="/help"' in out
+    # The bar marks the page you are on, and only that one.
+    assert 'class="nav-link active" href="/help" aria-current="page"' in out
+    assert out.count('aria-current="page"') == 1
+    # Signing in from here comes back here.
+    assert 'href="/auth?login=/help"' in out
+    assert "Apache LLMAO" in out
     assert "Apache LLMAO" in out
     assert 'href="/help/faq"' in out
 
@@ -101,6 +109,14 @@ def test_index_links_each_tool_page():
     assert 'href="/keys"' in signed_in.html
 
 
+def test_nav_section_follows_the_first_path_segment():
+    pages = _pages()
+    assert pages._nav_section("/keys/new") == "keys"
+    assert pages._nav_section("/help/faq") == "help"
+    assert pages._nav_section("/") == ""
+    assert pages._nav_section("/do-add-deployment") == ""
+
+
 def test_home_page_does_not_list_api_paths():
     text = (THIS_DIR / "templates" / "home.ezt").read_text(encoding="utf-8")
     assert "/v1/projects" not in text
diff --git a/tests/test_pages_models.py b/tests/test_pages_models.py
index 73011c9..f5b07e4 100644
--- a/tests/test_pages_models.py
+++ b/tests/test_pages_models.py
@@ -592,6 +592,8 @@ def _render(rows, attention=None, *, admin=False, approve_choices=None) -> str:
     data = edict(
         title="Models",
         is_site_admin=ezt.boolean(admin),
+        nav="models",
+        login_back="/models",
         restarted_at="10/08 14:30 (UTC)",
         reveal_supply=False,
         uid="u@example.apache.org",


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-llmao) 02/08: models: copy the model id from an icon, not a "Copy" link

Posted by gs...@apache.org.
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 28a84862410b8be50d9da5c350e020664287b97d
Author: Greg Stein <gs...@gmail.com>
AuthorDate: Sun Oct 11 21:33:35 2026 -0500

    models: copy the model id from an icon, not a "Copy" link
    
    The word sat on the card's heading line beside the name and the id and
    read as a third piece of text. It is now a clipboard icon with a title
    and a matching aria-label, and turns into a check mark for a moment
    when the copy succeeds.
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
 static/css/llmao.css       | 12 ++++++++++++
 static/js/llmao.js         | 12 ++++++++++--
 templates/models.ezt       |  7 ++++---
 tests/test_pages_models.py | 14 ++++++++++++++
 4 files changed, 40 insertions(+), 5 deletions(-)

diff --git a/static/css/llmao.css b/static/css/llmao.css
index 543f02b..d092263 100644
--- a/static/css/llmao.css
+++ b/static/css/llmao.css
@@ -28,6 +28,18 @@ body {
 .models-page .model-id {
   font-size: 0.85rem;
 }
+/* An icon, not a link: muted until it is pointed at. */
+.models-page .model-copy {
+  color: var(--bs-secondary-color);
+  line-height: 1;
+}
+.models-page .model-copy:hover,
+.models-page .model-copy:focus-visible {
+  color: var(--bs-body-color);
+}
+.models-page .model-copy.model-copy-done {
+  color: var(--bs-success);
+}
 /* Privacy is a full sentence in its own block. Color only reinforces it. */
 .models-page .model-privacy {
   display: flex;
diff --git a/static/js/llmao.js b/static/js/llmao.js
index b5d01c1..e627319 100644
--- a/static/js/llmao.js
+++ b/static/js/llmao.js
@@ -66,10 +66,18 @@ document.addEventListener("DOMContentLoaded", function () {
     btn.addEventListener("click", function () {
       var text = btn.getAttribute("data-copy") || "";
       if (!text || !navigator.clipboard) return;
+      var icon = btn.querySelector(".bi");
+      var label = btn.getAttribute("title") || "";
       navigator.clipboard.writeText(text).then(function () {
-        btn.textContent = "Copied";
+        if (icon) icon.className = "bi bi-check-lg";
+        btn.classList.add("model-copy-done");
+        btn.setAttribute("title", "Copied");
+        btn.setAttribute("aria-label", "Copied");
         setTimeout(function () {
-          btn.textContent = "Copy";
+          if (icon) icon.className = "bi bi-clipboard";
+          btn.classList.remove("model-copy-done");
+          btn.setAttribute("title", label);
+          btn.setAttribute("aria-label", label);
         }, 1500);
       });
     });
diff --git a/templates/models.ezt b/templates/models.ezt
index fb749cb..50ea739 100644
--- a/templates/models.ezt
+++ b/templates/models.ezt
@@ -72,9 +72,10 @@
             <div class="d-flex align-items-baseline flex-wrap gap-2">
               <h2 class="h5 mb-0">[models.display_name]</h2>
               <code class="model-id">[models.model_name]</code>
-              <button type="button" class="btn btn-link btn-sm p-0 js-copy-model"
-                      aria-label="Copy the ID [models.model_name]"
-                      data-copy="[models.model_name]">Copy</button>
+              <button type="button" class="btn btn-link btn-sm p-0 model-copy js-copy-model"
+                      title="Copy [models.model_name] to the clipboard"
+                      aria-label="Copy [models.model_name] to the clipboard"
+                      data-copy="[models.model_name]"><i class="bi bi-clipboard" aria-hidden="true"></i></button>
             </div>
             [if-any models.privacy]
             <div class="model-privacy model-privacy-[models.privacy_key] mt-2">
diff --git a/tests/test_pages_models.py b/tests/test_pages_models.py
index 69e6b3f..b0fb0a5 100644
--- a/tests/test_pages_models.py
+++ b/tests/test_pages_models.py
@@ -515,6 +515,20 @@ def test_every_state_has_a_badge_class():
     assert pages._badge_class(HEALTHY, routed=False) == "text-bg-warning"
 
 
+def test_card_copies_the_model_id_from_an_icon():
+    pages = _app()
+    rows = pages.model_catalog_rows(_fleet(_server("gemma")), [_catalog("gemma")])
+    out = _render(rows)
+    button = out[out.index("js-copy-model") :]
+    button = button[: button.index("</button>")]
+    assert 'title="Copy gemma to the clipboard"' in button
+    assert 'aria-label="Copy gemma to the clipboard"' in button
+    assert 'data-copy="gemma"' in button
+    assert "bi-clipboard" in button
+    # The icon is the whole content; the name is in the attributes.
+    assert button.rstrip().endswith("</i>")
+
+
 def test_mixed_card_and_unavailable_card():
     pages = _app()
     box = _server("gemma")


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org