Files
6krrt/admin/frontend/models.html
adlee-was-taken fd038125f7 feat(admin): the dashboard becomes a landing page, with Board and Live views
The dashboard was eight cards restating what the other seven pages own, so
it was the page you passed through rather than the one you started from. It
is now the landing page: no nav entry of its own, reached by the logo, with
two views over the one /api/snapshot payload so switching costs no request.

Board is one tile per destination, identical anatomy every time: what it is,
one number, one qualifier, a link. Under them, a full-width Activity band
plots routed decisions against billed requests on one axis, so the gap
between them -- local dispatch, cache hits, requests that never reached a
provider -- is readable, which it is in neither series alone. Its range
(24h/7d/30d) is separate from the tile sparklines, because it is the chart
you come to the page to read. Beside it, the eight busiest models ranked by
CALLS, not cost: cost coverage is partial, and an OpenRouter row with two
priced calls out of hundreds otherwise outranks the model doing the work.

Live loads the row itself -- category, model, provider, required context,
tier, classify time, exploratory flag, cost -- so the usual question does
not need the Decisions page, and groups rows by minute so a burst reads as
one. The dot is the classification source: green for a real classification,
amber for a degraded one, which still routes but never moves proficiency,
and until now was visible only in a /metrics warning. The rail beside it
answers what Board cannot: throughput against the last hour, classifier p50
and p95 (the latency floor every routed request pays before an upstream
token), degraded share, and the top five models by share of the live window
keyed on model AND provider, since the same model on two providers is two
different routing outcomes. Spend is not restated here; it belongs to Quota.

Warnings move behind a navbar bell. They are recomputed from live state on
every poll, so the only honest dismissal is "hide until the condition
changes": a dismissal is keyed on the warning's TEXT, so when the numbers
move the key stops matching and the warning returns on its own. Nothing has
to expire it. Per browser, in localStorage, wrapped in try/catch -- a
reading state, not a fact about the router.

Two tests changed shape rather than being deleted. The verdict-bar tests
pinned a card this page no longer has, but the lesson they recorded is the
expensive part and is preserved verbatim in the replacement's docstring:
`unverifiable` is not a finding, it is the default for prose and for any
turn ending in a tool call, and any bar scaled against it renders every
meaningful verdict at the 2px minimum. Exclude it from the scale, never from
the denominator. The Live rail states the same fact as one ratio, which
needs no scale. The nav test's rule is inverted for index.html rather than
skipped: the landing page must have no nav entry AND must claim no active
one, so neither can drift back in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-12 00:43:55 -04:00

851 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Models &mdash; LLM Router Admin</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3C/svg%3E">
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
<link rel="stylesheet" href="navbar.css">
<script src="navbar.js"></script>
<style>
/* ── Liquid glass: soft gradient wash behind everything so the blur has
something to catch, then translucent+blurred navbar/cards on top. Kept
subtle — this is a working dashboard, not a marketing page. ── */
body{
background:
radial-gradient(900px circle at 6% -8%, rgba(245,158,11,.10), transparent 55%),
radial-gradient(800px circle at 94% -6%, rgba(59,130,246,.10), transparent 50%),
radial-gradient(760px circle at 50% 108%, rgba(139,92,246,.07), transparent 55%),
#111827;
background-attachment: fixed;
}
header.navbar{
background: rgba(31,41,55,.6)!important;
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border-bottom: 1px solid rgba(255,255,255,.06);
}
.card{
background: rgba(31,41,55,.55);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border: 1px solid rgba(255,255,255,.07);
box-shadow: 0 10px 30px -14px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
}
.card-header{border-bottom-color: rgba(255,255,255,.06)}
/* ── The "gap only at wide windows" bug, root-caused: this Chrome/Linux
build reports a phantom left margin on <html> exactly equal to the
scrollbar's width whenever <html> itself is the scrolling element.
Moving the scroll context down to <body> sidesteps it. ── */
html{overflow:hidden;height:100%;margin:0!important;padding:0!important}
body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
/* ── Buttons: glass pills instead of flat saturated fills. ── */
.btn-success{background:rgba(34,197,94,.16)!important;border-color:rgba(74,222,128,.45)!important;color:#4ade80!important}
.btn-success:hover{background:rgba(34,197,94,.28)!important;border-color:rgba(74,222,128,.7)!important;color:#86efac!important}
.btn-danger{background:rgba(239,68,68,.16)!important;border-color:rgba(248,113,113,.45)!important;color:#f87171!important}
.btn-danger:hover{background:rgba(239,68,68,.28)!important;border-color:rgba(248,113,113,.7)!important;color:#fca5a5!important}
.btn{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
/* ── Badges: same glass treatment. ── */
.badge.bg-success{background:rgba(34,197,94,.20)!important;color:#4ade80!important}
.badge.bg-warning{background:rgba(245,158,11,.20)!important;color:#fbbf24!important}
.badge.bg-danger{background:rgba(239,68,68,.20)!important;color:#f87171!important}
.badge.bg-info{background:rgba(59,130,246,.20)!important;color:#93c5fd!important}
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
/* ── Progress bars: translucent track + glow instead of flat solid. ── */
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
.navbar-brand a{text-decoration:none!important;color:inherit}
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
.navbar-brand-image{filter:brightness(0) invert(1)} — attribute selector +
2 classes outranks a plain 2-class override, so `filter:none` alone loses
the cascade regardless of source order. !important is what actually beats
it. That filter was stripping the mascot's orange color, and the 2rem
.navbar-brand-image cap shrank his face to sub-pixel dots.
Restore color + force an explicit size (64px, below). ── */
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
/* ── 64px mascot, same bar height. His face is only ~40% of his height (the
rest is tail and wheels), so at 45px the eyes were ~4px and the whole point
of having a mascot was lost. The 19px comes out of padding, not out of the
navbar: the brand h1 carries 8px top and bottom, and the navbar itself 4px.
Zero the first and halve the second and the bar lands at 69px — 1px shorter
than the 45px version it replaces. Measure before changing either number. ── */
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
/* ── Speed lines: he is a wheeled rover, so give him a trail. Drawn as their
own inline SVG rather than baked into the logo, so assets/*.svg stays the
plain mascot and the lines can drop out on narrow screens (d-none d-md-flex).
The gradient fades AWAY from him (transparent at the far left, amber where
he is), which is what reads as speed rather than as three floating dashes;
it is userSpaceOnUse because an objectBoundingBox gradient on a horizontal
line has a zero-height bbox and is undefined. ── */
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
/* -20px: the logo's own viewBox carries ~18px of empty space to the left of
his tail at 64px, so the pull has to cover that before it buys any real
closeness. Measured gap from the middle line's tip to his back: ~4.5px. */
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
.brand-speedlines svg{filter:none!important}
/* Motion only on hover — a dashboard that twitches at rest is a dashboard you
stop looking at. */
@keyframes brand-dash{
0%{transform:translateX(-5px);opacity:.2}
55%{opacity:1}
100%{transform:translateX(4px);opacity:0}
}
.navbar-brand a:hover .brand-speedlines line{animation:brand-dash .75s ease-in infinite}
.navbar-brand a:hover .brand-speedlines line:nth-child(2){animation-delay:.12s}
.navbar-brand a:hover .brand-speedlines line:nth-child(3){animation-delay:.24s}
@media (prefers-reduced-motion: reduce){
.navbar-brand a:hover .brand-speedlines line{animation:none}
}
/* ── Tier colors on the availability table ── */
.tier-1{color:var(--tblr-success)}
.tier-2{color:var(--tblr-warning)}
.tier-3{color:var(--tblr-danger)}
/* ── Row density: same trade the decisions table makes, for the same
reason -- this is a scanning surface over 440 rows, so it gives up
card-table's comfortable spacing for smaller type AND tighter padding,
not one or the other. Rows were 57px against the decisions table's 36;
24px of that was td padding and 32px was the override select, so both
had to come down or the row would not move. ── */
#model-tbody td, thead th{padding-top:.2rem!important;padding-bottom:.2rem!important}
#model-tbody{font-size:.72rem}
#model-tbody td{line-height:1.25}
/* The select is the floor on row height: shrink it or nothing else matters. */
#model-tbody .model-override{height:24px;padding:.05rem .35rem;font-size:.7rem;line-height:1.2}
/* Numeric columns align right and share the tabular figures the rest of the
portal uses, so magnitudes line up down the column. */
#model-tbody td.num, thead th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.prof-n{color:var(--tblr-secondary);opacity:.6;font-size:.62rem;margin-left:1px}
.cost-free{color:#4ade80}
/* Capability chips reuse the decisions page's flag-chip language so the two
scanning tables read the same way. */
.cap-chip{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;
border-radius:4px;font-size:.58rem;font-weight:700;margin-right:2px;
background:rgba(255,255,255,.06);color:rgba(255,255,255,.28);cursor:default}
.cap-chip.on{background:rgba(59,130,246,.22);color:#93c5fd}
.tier-?{color:var(--tblr-secondary)}
/* Footer parts are laid out, not joined by a glyph. See
plans/admin-design-standards.md, "No middot separators". */
.footer-bits{display:inline-flex;flex-wrap:wrap;gap:.25rem 1.4rem}
/* Segmented control, shared with the home page and the quota graph. */
.btn-seg{display:inline-flex;border:1px solid rgba(255,255,255,.10);border-radius:6px;overflow:hidden}
.btn-seg button{background:none;border:0;color:var(--tblr-secondary);cursor:pointer;
padding:.2rem .7rem;font-size:.73rem;font-weight:600;line-height:1.6}
.btn-seg button+button{border-left:1px solid rgba(255,255,255,.08)}
.btn-seg button:hover{background:rgba(255,255,255,.05);color:var(--tblr-body-color)}
.btn-seg button.active{background:rgba(59,130,246,.18);color:#93c5fd}
/* Ranked usage rows. */
.usage-row{display:grid;grid-template-columns:minmax(0,15rem) 1fr auto;gap:.7rem;
align-items:center;padding:.28rem .35rem;border-radius:4px;cursor:pointer}
.usage-row:hover{background:rgba(255,255,255,.04)}
.usage-row:focus-visible{outline:2px solid rgba(59,130,246,.55);outline-offset:1px}
.usage-label{font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.usage-track{height:8px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}
.usage-track div{height:100%;border-radius:999px}
.usage-val{font-size:.75rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.detail-facts{display:grid;grid-template-columns:auto 1fr;gap:.3rem .9rem;
font-size:.82rem;align-items:baseline;margin:0}
.detail-facts dt{color:var(--tblr-secondary)}
.detail-facts dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}
</style>
</head>
<body>
<!-- ═══ Navbar ═══ -->
<header class="navbar navbar-expand-md navbar-dark" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<a href="/admin/" style="display:inline-flex;align-items:center;gap:8px">
<span class="brand-speedlines d-none d-md-flex" aria-hidden="true">
<svg viewBox="0 0 40 64" width="40" height="64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="speedfade" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="40" y2="0">
<stop offset="0" stop-color="#f59e0b" stop-opacity="0"/>
<stop offset="1" stop-color="#f59e0b" stop-opacity=".85"/>
</linearGradient>
</defs>
<g stroke="url(#speedfade)" stroke-linecap="round" fill="none">
<line x1="14" y1="20" x2="38" y2="20" stroke-width="5"/>
<line x1="2" y1="33" x2="36" y2="33" stroke-width="6"/>
<line x1="18" y1="46" x2="34" y2="46" stroke-width="4.5"/>
</g>
</svg>
</span>
<img class="navbar-brand-image" src="6krrt-logo.webp" width="64" height="64" style="width:64px;height:64px" alt="6krrt logo">
</a>
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="d-flex align-items-center gap-3">
<span id="nav-widgets" class="d-flex align-items-center"></span>
</div>
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
<ul class="navbar-nav">
<li class="nav-item active"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
</div>
</div>
</div>
</header>
<!-- ═══ Page shell ═══ -->
<div class="page">
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Models</h2>
<div class="text-muted mt-1" id="models-subtitle">Available models, their tier, and live availability overrides</div>
</div>
<!-- Two questions, one page: what CAN route, and what actually did.
Same segmented control as the home page and the quota graph. -->
<div class="col-auto ms-auto">
<div class="btn-seg" id="models-views">
<button type="button" data-view="available" class="active">Available</button>
<button type="button" data-view="usage">Usage</button>
</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards" id="view-available">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<h3 class="card-title mb-0">Model Availability</h3>
</div>
<div class="card-subtitle text-muted ms-2">Change the override dropdown to mark a model active, deprecated, or stale</div>
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap">
<span class="text-muted small" id="model-count">&mdash;</span>
</div>
</div>
<!-- Filter bar. 446 rows in one ungrouped table is not a list
anyone can work through; these are the four axes an
operator actually narrows by, in the order they reach for
them. Availability replaces the old show-deprecated
switch, which could only say all-or-nothing. -->
<div class="card-body border-bottom py-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="input-group input-group-sm" style="width:20rem;max-width:100%">
<span class="input-group-text">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="search" class="form-control" id="mf-search"
placeholder="Filter by model id" autocomplete="off">
</div>
<select class="form-select form-select-sm" id="mf-provider" style="width:auto"></select>
<select class="form-select form-select-sm" id="mf-tier" style="width:auto">
<option value="">All tiers</option>
<option value="1">Tier 1</option>
<option value="2">Tier 2</option>
<option value="3">Tier 3</option>
</select>
<select class="form-select form-select-sm" id="mf-availability" style="width:auto">
<option value="routable">Routable only</option>
<option value="">All statuses</option>
<option value="active">active</option>
<option value="deprecated">deprecated</option>
<option value="stale">stale</option>
</select>
<select class="form-select form-select-sm" id="mf-page-size" style="width:auto">
<option value="25">25 / page</option>
<option value="50" selected>50 / page</option>
<option value="100">100 / page</option>
</select>
<button class="btn btn-outline-secondary btn-sm" id="mf-clear">Clear</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-vcenter table-hover card-table">
<thead>
<tr>
<th>Model</th>
<th>Provider</th>
<th>Tier</th>
<th class="num" title="Effective context window">Ctx</th>
<th class="num" title="List price per 1M completion tokens -- the figure profiles filter on">$/1M out</th>
<th class="num" title="Mean expected pass rate across measured categories">Prof</th>
<th title="Capabilities: Tools, Vision, JSON mode, Reasoning">Caps</th>
<th>Status</th>
<th>Override</th>
</tr>
</thead>
<tbody id="model-tbody">
<tr><td colspan="9" class="text-muted">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
<div class="card-footer d-flex align-items-center gap-2 py-2" id="model-pager" style="display:none">
<span class="text-muted small" id="model-page-info"></span>
<div class="ms-auto d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" id="mf-prev">Prev</button>
<button class="btn btn-outline-secondary btn-sm" id="mf-next">Next</button>
</div>
</div>
</div>
</div>
</div><!-- /row row-cards, available view -->
<!-- USAGE VIEW. Moved off the dashboard, which is a home page now.
This is where per-model analytics belongs: the same objects the
Available view lists, seen by what they actually did. -->
<div class="row row-cards" id="view-usage" hidden>
<div class="col-12">
<div class="card">
<div class="card-header" style="gap:.75rem;flex-wrap:wrap">
<h3 class="card-title mb-0">Per-model usage</h3>
<span class="card-subtitle text-muted ms-2">last 30 days</span>
<div class="btn-seg ms-auto" id="usage-sort">
<button type="button" data-sort="calls" class="active">Calls</button>
<button type="button" data-sort="cost">Spend</button>
<button type="button" data-sort="tokens">Tokens</button>
</div>
</div>
<div class="card-body" id="usage-bars">
<div class="text-muted small">Loading&hellip;</div>
</div>
<div class="card-footer py-2">
<span class="text-muted" style="font-size:.7rem" id="usage-foot"></span>
</div>
</div>
</div>
</div>
</div><!-- /container-xl -->
</div><!-- /page-body -->
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<span class="footer-bits"><span>&copy; 2026 adLee</span><span>6krrt, local LLM model router</span><span>admin</span></span>
</div>
</footer>
</div>
</div>
<!-- Toast (Bootstrap native) -->
<!-- Per-model detail, same shape as proficiency.html's #cell-modal. -->
<div class="modal modal-blur fade" id="usage-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="usage-modal-title">&mdash;</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="usage-modal-body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<div id="toast class="toast align-items-center text-bg-info border-0 position-fixed bottom-0 end-0 p-3" role="alert" aria-live="assertive" aria-atomic="true"></div>
<script>
/* ═══════════════════════════════════════════════════
admin/frontend/models.html — model availability page
API base: relative (works under /admin/)
═══════════════════════════════════════════════════ */
const API = ''; // relative to /admin/
const SSE_URL = '/events/decisions'; // root-level endpoint
/* ── Toast (Bootstrap native via tabler wrapper) ── */
let _toastEl = null;
function toast(msg, type = 'info') {
const bgClass = { success: 'text-bg-success', error: 'text-bg-danger', info: 'text-bg-info' }[type] || 'text-bg-info';
if (!_toastEl) {
_toastEl = document.getElementById('toast');
_toastEl.innerHTML = `<div class="d-flex">
<div class="toast-body"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>`;
_toastEl._bt = new tabler.Toast(_toastEl, { delay: 4000 });
}
_toastEl.querySelector('.toast-body').textContent = msg;
_toastEl.className = `toast align-items-center ${bgClass} border-0 position-fixed bottom-0 end-0 p-3`;
_toastEl._bt.show();
}
/* ── Fetch Wrapper ── */
async function apiFetch(url, opts = {}) {
try {
const resp = await fetch(url, opts);
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
return await resp.json();
} catch (e) {
console.warn('API call failed:', url, e);
return null;
}
}
/* ═══════════════════════════════════════
SSE LIVE STREAM
═══════════════════════════════════════ */
let sseConn = null;
function connectSSE() {
try {
if (sseConn) { sseConn.close(); }
sseConn = new EventSource(SSE_URL);
navbarSse('connecting');
sseConn.onopen = () => {
navbarSse('live');
};
sseConn.onerror = () => {
navbarSse('offline', 'reconnecting');
};
} catch (e) {
navbarSse('offline');
}
}
/* ═══════════════════════════════════════
MODEL AVAILABILITY TABLE
═══════════════════════════════════════ */
let _modelsCache = [];
/* ── Filtering and paging ──
The page shipped as one ungrouped table of every routable row with a
single show-deprecated switch, which is workable at 45 rows and not at
446. State lives in module scope rather than being re-read from the DOM
on every render, so a filter keystroke does not have to rebuild the
controls it is being typed into. */
let _modelPage = 1;
let _modelPageSize = 50;
function modelFilters() {
const val = id => (document.getElementById(id) || {}).value || '';
return {
q: val('mf-search').trim().toLowerCase(),
provider: val('mf-provider'),
tier: val('mf-tier'),
availability: document.getElementById('mf-availability')
? document.getElementById('mf-availability').value
: 'routable',
};
}
function matchesModelFilters(m, f) {
const avail = String(m.effective_availability || '');
// "routable" is the default because it answers the question the page is
// usually open for -- what can the router actually pick right now. The
// explicit statuses are for auditing an override.
if (f.availability === 'routable') {
if (avail === 'deprecated' || avail === 'stale') return false;
} else if (f.availability && avail !== f.availability) {
return false;
}
if (f.provider && String(m.provider || '') !== f.provider) return false;
if (f.tier && String(m.tier || '') !== f.tier) return false;
if (f.q && !String(m.model_id || '').toLowerCase().includes(f.q)) return false;
return true;
}
function refreshProviderOptions(models) {
const sel = document.getElementById('mf-provider');
if (!sel) return;
const chosen = sel.value;
const providers = [...new Set(models.map(m => String(m.provider || '')).filter(Boolean))].sort();
sel.innerHTML = '<option value="">All providers</option>'
+ providers.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
if (providers.includes(chosen)) sel.value = chosen;
}
function bindModelFilters() {
const rerender = (resetPage = true) => {
if (resetPage) _modelPage = 1;
renderModels(_modelsCache);
};
const search = document.getElementById('mf-search');
if (search) search.addEventListener('input', () => rerender());
['mf-provider', 'mf-tier', 'mf-availability'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', () => rerender());
});
const size = document.getElementById('mf-page-size');
if (size) size.addEventListener('change', () => {
_modelPageSize = Number(size.value) || 50;
rerender();
});
const clear = document.getElementById('mf-clear');
if (clear) clear.addEventListener('click', () => {
if (search) search.value = '';
['mf-provider', 'mf-tier'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
const av = document.getElementById('mf-availability');
if (av) av.value = 'routable';
rerender();
});
const prev = document.getElementById('mf-prev');
if (prev) prev.addEventListener('click', () => { _modelPage -= 1; renderModels(_modelsCache); });
const next = document.getElementById('mf-next');
if (next) next.addEventListener('click', () => { _modelPage += 1; renderModels(_modelsCache); });
}
/* ── Cell formatters ──
Everything below already ships in /admin/api/models; none of it needed an
API change. These are the four facts a routing decision actually turns on
-- the context ceiling that produces a 422, the completion price profiles
filter on, the proficiency term that is the only category-dependent part
of the ranking, and the capability gates that hard-filter a request. */
function humanCtx(n) {
if (n === null || n === undefined || n === 0) return '<span class="text-muted">-</span>';
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${Math.round(n / 1000)}k`;
return String(n);
}
function humanCost(v) {
if (v === null || v === undefined) return '<span class="text-muted">-</span>';
// Free rows are the whole point of the cheap profiles, so they get a word
// rather than "$0.00", which reads as a rounding artifact.
if (Number(v) === 0) return '<span class="cost-free">free</span>';
return `$${Number(v).toFixed(2)}`;
}
function proficiencyCell(prof) {
const entries = Object.entries(prof || {}).filter(([, v]) => typeof v === 'number');
if (!entries.length) return '<td class="num"><span class="text-muted">-</span></td>';
const mean = entries.reduce((a, [, v]) => a + v, 0) / entries.length;
// The mean is a summary of a per-category vector, so the breakdown has to
// stay reachable -- a single number here would otherwise imply the model
// is uniformly that good, which is the exact thing the proficiency table
// exists to disprove (deepseek-v4-flash: 1.00 coding, 0.33 tool use).
const detail = entries
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${v.toFixed(2)}`)
.join('\n');
return `<td class="num" title="${escapeHtml(detail)}">${mean.toFixed(2)}`
+ `<span class="prof-n">/${entries.length}</span></td>`;
}
function capabilityChips(m) {
const caps = [
['T', m.supports_tools, 'tools'],
['V', m.supports_vision, 'vision'],
['J', m.supports_json_mode, 'json mode'],
['R', m.supports_reasoning, 'reasoning'],
];
return caps.map(([letter, on, name]) =>
`<span class="cap-chip${on ? ' on' : ''}" title="${name}${on ? '' : ' not supported'}">${letter}</span>`
).join('');
}
function renderModels(models) {
_modelsCache = models || [];
const tbody = document.getElementById('model-tbody');
if (!models || !models.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center">No models</td></tr>';
return;
}
const f = modelFilters();
const eligible = models.filter(m => (m.access_level === 'public') || !m.access_level);
const matched = eligible.filter(m => matchesModelFilters(m, f));
// Counts name the window and the whole, the way the decisions page does:
// "31 of 446" is a filter result, "446" alone is a claim about the catalog.
const countEl = document.getElementById('model-count');
if (countEl) {
countEl.textContent = matched.length === eligible.length
? `${eligible.length} models`
: `${matched.length} of ${eligible.length} models`;
}
const pager = document.getElementById('model-pager');
if (!matched.length) {
tbody.innerHTML = '<tr><td colspan="9" class="text-muted text-center">No models match the current filter</td></tr>';
if (pager) pager.style.display = 'none';
return;
}
const totalPages = Math.max(1, Math.ceil(matched.length / _modelPageSize));
_modelPage = Math.min(Math.max(1, _modelPage), totalPages);
const start = (_modelPage - 1) * _modelPageSize;
const visible = matched.slice(start, start + _modelPageSize);
if (pager) {
// Hidden entirely on a single page -- a pager that can only say
// "Page 1 of 1" is furniture.
pager.style.display = totalPages > 1 ? 'flex' : 'none';
document.getElementById('model-page-info').textContent =
`Showing ${start + 1}-${start + visible.length} of ${matched.length}, page ${_modelPage} of ${totalPages}`;
document.getElementById('mf-prev').disabled = _modelPage <= 1;
document.getElementById('mf-next').disabled = _modelPage >= totalPages;
}
const html = visible.map(m => {
const availClass = { active: 'var(--tblr-success)', deprecated: 'var(--tblr-danger)', stale: 'var(--tblr-warning)' }[m.effective_availability] || 'var(--tblr-secondary)';
const selectOpts = ['active','deprecated','stale'].map(a =>
`<option value="${a}"${m.effective_availability===a?' selected':''}>${a}</option>`
).join('');
const tierClass = `tier-${m.tier || '?'}`;
return `<tr data-model="${m.model_id}" data-provider="${m.provider}">
<td title="${escapeHtml(String(m.display_name || m.model_id))}">${escapeHtml(m.model_id)}</td>
<td>${escapeHtml(m.provider)}</td>
<td class="${tierClass}">${m.tier || '?'}</td>
<td class="num">${humanCtx(m.effective_context_window)}</td>
<td class="num">${humanCost(m.cost_per_1m_completion)}</td>
${proficiencyCell(m.proficiency)}
<td>${capabilityChips(m)}</td>
<td style="color:${availClass}"><span class="badge bg-secondary-subtle text-secondary">${escapeHtml(String(m.effective_availability))}</span></td>
<td><select class="form-select form-select-sm model-override" style="width:110px" onchange="updateAvailability('${escapeHtml(m.model_id)}','${escapeHtml(m.provider)}',this.value)">${selectOpts}</select><div class="inline-warning text-warning small d-none"></div></td>
</tr>`;
}).join('');
tbody.innerHTML = html;
}
async function updateAvailability(modelId, provider, avail) {
// Don't post if already that value via dropdown
const resp = await apiFetch(
`${API}api/models/${encodeURIComponent(modelId)}/${encodeURIComponent(provider)}/availability`,
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ availability: avail }) }
);
const tr = document.querySelector(`tr[data-model="${modelId}"][data-provider="${provider}"]`);
const warningDiv = tr ? tr.querySelector('.inline-warning') : null;
if (resp && resp.warnings && resp.warnings.length) {
const warningText = resp.warnings.join('\n');
if (warningDiv) {
warningDiv.textContent = resp.warnings.join('\n');
warningDiv.classList.remove('d-none');
}
toast(resp.warnings.join(' '), 'error');
} else if (resp) {
if (warningDiv) warningDiv.classList.add('d-none');
toast('Availability updated', 'success');
} else {
toast('Update failed', 'error');
}
}
/* ── Usage view ────────────────────────────────────────────────────────────
*
* Moved here from the dashboard, which became a home page. It belongs with the
* Available view: both describe the same objects, one by what CAN route and
* one by what actually did.
*
* The bar VALUE follows the sort key. Before, bar length tracked the key while
* the label always printed cost and kWh, so "sort by calls" produced bars
* ordered by call count labelled in dollars.
*/
const USAGE_KEYS = {
calls: p => p.calls || 0,
cost: p => p.sum_cost_usd || 0,
tokens: p => (p.sum_prompt_tokens || 0) + (p.sum_completion_tokens || 0),
};
const USAGE_VALUE = {
calls: p => `<b>${Number(p.calls || 0).toLocaleString()}</b> calls`,
cost: p => `<b>${fmtUsage(p.sum_cost_usd, true)}</b>`,
tokens: p => `<b>${(((p.sum_prompt_tokens || 0) + (p.sum_completion_tokens || 0)) / 1e6).toFixed(1)}M</b> tokens`,
};
const USAGE_COLORS = ['#4ade80','#93c5fd','#67e8f9','#fbbf24','#d8b4fe','#a3e635'];
let _usageRows = [];
let _usageSort = 'calls';
let _usageShown = [];
let _usageModal = null;
/* Unknown is a dash, never a zero: a provider that reports no per-request cost
has not told us the cost was nothing. */
function fmtUsage(v, dollar) {
if (v == null) return '&mdash;';
const n = Number(v);
return dollar ? '$' + n.toFixed(4) : n.toFixed(5);
}
function renderUsageBars() {
const el = document.getElementById('usage-bars');
if (!el) return;
// ollama-local has its own accounting on the Quota page and also appears in
// energy_observations, so counting it here would count the same draw twice.
const rows = _usageRows.filter(p => p.provider !== 'ollama-local');
if (!rows.length) {
el.innerHTML = '<div class="text-muted small">No usage recorded in the last 30 days</div>';
return;
}
const key = USAGE_KEYS[_usageSort] || USAGE_KEYS.calls;
const valFn = USAGE_VALUE[_usageSort] || USAGE_VALUE.calls;
const sorted = rows.slice().sort((a, b) => key(b) - key(a)).slice(0, 15);
const max = Math.max(...sorted.map(key), 1e-9);
const total = rows.reduce((t, p) => t + key(p), 0);
_usageShown = sorted;
el.innerHTML = sorted.map((p, i) => {
const v = key(p);
const pct = Math.max((v / max) * 100, 1.5);
const share = total ? (v / total * 100) : 0;
const label = `${escapeHtml(String(p.model_id))} / ${escapeHtml(String(p.provider))}`;
return `<div class="usage-row" data-usage-row="${i}" role="button" tabindex="0"
title="${label} — click for energy, carbon and attribution">
<span class="usage-label" title="${label}">${label}</span>
<div class="usage-track"><div style="width:${pct}%;background:${USAGE_COLORS[i % USAGE_COLORS.length]}"></div></div>
<span class="usage-val">${valFn(p)}<span class="text-muted"> ${share.toFixed(1)}%</span></span>
</div>`;
}).join('');
const unpriced = rows.filter(p => p.sum_cost_usd == null).length;
document.getElementById('usage-foot').innerHTML = unpriced
? `${unpriced} of ${rows.length} models report no per-request cost, so their spend shows as a dash rather than zero.`
: `${rows.length} models, all reporting per-request cost.`;
}
/* Energy, carbon, attribution and grid are inspection values, not ranking
values -- attribution alone spreads 750x between models and 1.8x within one.
They live behind a click, in the same modal pattern proficiency.html uses. */
function showUsageDetail(p) {
if (!_usageModal) _usageModal = new tabler.Modal(document.getElementById('usage-modal'));
document.getElementById('usage-modal-title').textContent = `${p.model_id} / ${p.provider}`;
const rows = [
['Calls', p.calls != null ? Number(p.calls).toLocaleString() : null],
['Spend', p.sum_cost_usd != null ? fmtUsage(p.sum_cost_usd, true) : null],
['Prompt tokens', p.sum_prompt_tokens ? Number(p.sum_prompt_tokens).toLocaleString() : null],
['Completion tokens', p.sum_completion_tokens ? Number(p.sum_completion_tokens).toLocaleString() : null],
['Cached prompt tokens', p.sum_cached_prompt_tokens ? Number(p.sum_cached_prompt_tokens).toLocaleString() : null],
['Mean completion length', p.avg_completion_tokens != null ? Math.round(p.avg_completion_tokens) + ' tokens' : null],
['Energy', p.sum_energy_kwh != null ? p.sum_energy_kwh.toFixed(5) + ' kWh' : null],
['Carbon', p.sum_carbon_g_co2eq != null ? p.sum_carbon_g_co2eq.toFixed(3) + ' gCO2eq' : null],
['Mean attribution', p.avg_attribution_ratio != null ? p.avg_attribution_ratio.toFixed(3) : null],
['Cost reported on', p.total_rows ? `${Number(p.cost_rows || 0).toLocaleString()} of ${Number(p.total_rows).toLocaleString()} calls` : null],
].filter(([, v]) => v != null);
document.getElementById('usage-modal-body').innerHTML =
'<dl class="detail-facts">'
+ rows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`).join('')
+ '</dl>'
+ (p.avg_attribution_ratio != null
? `<div class="text-muted" style="font-size:.7rem;line-height:1.5;margin-top:.6rem">
Attribution is this request's share of a shared multi-tenant GPU pool.
It is a stable per-model property, not noise, and it is most of why two
models with similar power draw bill so differently.</div>`
: '');
_usageModal.show();
}
async function loadUsage() {
const snap = await apiFetch(`${API}api/snapshot`);
if (!snap) return;
_usageRows = snap.per_model || [];
renderUsageBars();
}
function setModelsView(view) {
const avail = view !== 'usage';
document.getElementById('view-available').hidden = !avail;
document.getElementById('view-usage').hidden = avail;
document.getElementById('models-subtitle').textContent = avail
? 'Available models, their tier, and live availability overrides'
: 'What each model actually did over the last 30 days';
try { localStorage.setItem('6krrt.modelsView', view); } catch (_) { /* private window */ }
if (!avail && !_usageRows.length) loadUsage();
}
function bindModelsViews() {
const seg = document.getElementById('models-views');
if (seg) {
seg.addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-view]');
if (!b) return;
seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
setModelsView(b.getAttribute('data-view'));
});
}
const sort = document.getElementById('usage-sort');
if (sort) {
sort.addEventListener('click', (ev) => {
const b = ev.target.closest('button[data-sort]');
if (!b) return;
_usageSort = b.getAttribute('data-sort');
sort.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
renderUsageBars();
});
}
const bars = document.getElementById('usage-bars');
if (bars) {
const open = (ev) => {
const row = ev.target.closest('[data-usage-row]');
if (!row) return;
const p = _usageShown[Number(row.getAttribute('data-usage-row'))];
if (p) showUsageDetail(p);
};
bars.addEventListener('click', open);
bars.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); open(ev); }
});
}
// Land on the view last used in this browser.
let saved = 'available';
try { saved = localStorage.getItem('6krrt.modelsView') || 'available'; } catch (_) { /* ignore */ }
if (saved === 'usage') {
const b = seg && seg.querySelector('[data-view="usage"]');
if (b) { seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b)); }
setModelsView('usage');
}
}
async function loadModels() {
// This page is the availability table, so it is the one caller that wants
// the unfiltered set -- the availability filter is what narrows it, and it
// needs the deprecated rows present to be able to show them. Every other
// consumer takes the endpoint's routable-only default on purpose.
const models = await apiFetch(`${API}api/models?include_unroutable=true`);
if (!models) return;
refreshProviderOptions(models);
renderModels(models);
}
/* ═══════════════════════════════════════
UTILITIES
═══════════════════════════════════════ */
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
function init() {
// Bound once, before the first load: the controls are static markup, so
// they never get rebuilt and never need re-binding. (Rebuilding a control
// you are typing into is how the providers page ended up reversing text.)
bindModelFilters();
bindModelsViews();
loadModels();
connectSSE();
}
init();
</script>
</body>
</html>