Files
6krrt/admin/frontend/controls.html
adlee-was-taken de4b766a5f fix(admin): update stale bart-large-mnli references left by PR #95's encoder swap
PR #95 swapped local_encoder's backend from NLI cross-encoding to
embedding+centroid and updated config.py's default/docstring, but missed two
consumers: the admin controls page's placeholder text (misleadingly showed
the old model name for the empty/default-inheriting field) and
docs/local-models.md's architecture description (still said "zero-shot NLI
encoder").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161UAZyxtvmKaVzasJsnqJc
2026-09-18 23:10:43 -04:00

1323 lines
69 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>Controls &mdash; LLM Router Admin</title>
<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 a sub-36px
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}
}
/* ── Settings rows: ONE row grammar shared by Runtime Knobs and Persisted
Config — `key ......... [meta] [control]` on a 3-track grid with a fixed
control track, so every switch/input/select lands on the same right edge
and the eye scans a single column instead of chasing floating controls.
The old layout used Bootstrap .row col-5/col-3/col-4, which put the
control mid-row and spent a whole column restating the value the control
already shows. ── */
/* Negative margin == row padding, so the keys line up with the card body's
own content edge while the hover tint still bleeds to the card's inner edge. */
.settings-list{margin:0 -.5rem}
.setting-row{
display:grid;
grid-template-columns:minmax(0,1fr) auto 176px;
align-items:center;
gap:.75rem;
/* Fixed row height: a switch and a text input are ~10px apart in natural
height, which made the list look ragged when the two alternate. */
min-height:38px;
padding:.25rem .5rem;
border-left:2px solid transparent;
border-radius:6px;
}
.setting-row+.setting-row{border-top:1px solid rgba(255,255,255,.05)}
.setting-row:hover{background:rgba(255,255,255,.025)}
.setting-row.is-dirty{border-left-color:#fbbf24;background:rgba(245,158,11,.06)}
.setting-key{
font-size:.82rem;line-height:1.25;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.setting-key .scope{color:var(--tblr-secondary);opacity:.7}
.setting-meta{font-size:.72rem;white-space:nowrap;color:var(--tblr-secondary);font-variant-numeric:tabular-nums}
.setting-control{display:flex;justify-content:flex-end;align-items:center;min-width:0}
.setting-control .form-control,
.setting-control .form-select{width:100%;font-variant-numeric:tabular-nums}
/* Bootstrap floats .form-check-input and reserves 1.5em of left padding;
both fight right-alignment inside the control track. */
.setting-control .form-check{margin:0;padding:0;min-height:0;display:flex;justify-content:flex-end}
.setting-control .form-check-input{margin:0;float:none}
.settings-hint{font-size:.75rem;margin-bottom:.5rem}
/* Card bodies that are pure lists: let rows own the vertical rhythm. */
.card-body.settings-body{padding:.75rem 1rem 1rem}
/* 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}
</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/">
<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"><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 active"><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">Controls</h2>
<div class="text-muted mt-1">Maintenance triggers, runtime knobs, and persisted configuration</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards align-items-start">
<!-- Row 1: Operational Triggers as a full-width strip. It is one
line of buttons, so as a half-width card beside the much taller
Runtime Knobs it left a column of dead space no matter what
align-items said. Buttons live in .card-actions; the body is a
single description/status line. -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="zap"></span>Operational Triggers</h3>
<div class="card-actions d-flex gap-2 flex-wrap">
<button class="btn btn-success btn-sm" onclick="triggerJob('refresh-catalog')">Refresh Catalog</button>
<button class="btn btn-success btn-sm" onclick="triggerJob('seed-energy')">Seed Energy</button>
<button class="btn btn-success btn-sm" onclick="triggerJob('apply-feedback')">Apply Feedback</button>
<button class="btn btn-danger btn-sm" onclick="triggerJob('restart-service')">Restart Service</button>
</div>
</div>
<div class="card-body py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<span class="text-muted" style="font-size:.78rem">Fire-and-forget maintenance jobs.</span>
<span id="job-status" class="text-muted text-end" style="font-size:0.75rem"></span>
</div>
</div>
</div>
<!-- Row 1b: Classifier. A dedicated card, not a row in the generic
Runtime Knobs list -- classifier.mode has cross-field structure
(cloud_llm needs a pinned primary OR auto; local_encoder needs
a model id) that a flat scalar/boolean input can't safely
represent, the same reasoning behind Local Compute's own card
in the gaming-mode work this mirrors. -->
<div class="col-12">
<div class="card" id="classifier-config-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Classifier</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="classifier-mode-badge" class="badge bg-secondary">&mdash;</span>
</div>
</div>
<div class="card-body py-3">
<p class="text-muted mb-3" style="font-size:.78rem">
Which implementation answers PRIMARY. A peer to the fallback cascade, not a
replacement for it: whichever mode is primary, a failure still walks the same
cascade (stale session &rarr; session history &rarr; <code>cloud_fallback</code> &rarr;
the static guess) unmodified.
</p>
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label" for="classifier-mode-select" style="font-size:.78rem">mode</label>
<select class="form-select form-select-sm" id="classifier-mode-select" onchange="onClassifierModeChange()">
<option value="local_llm">local_llm</option>
<option value="cloud_llm">cloud_llm</option>
<option value="local_encoder">local_encoder</option>
</select>
</div>
<div class="col-md-8" id="classifier-mode-fields"></div>
</div>
<div id="classifier-resolved-primary" class="text-muted mt-2" style="font-size:.75rem;display:none"></div>
<!-- Read-only: classifier.candidate_categories is a structural
list (a validated subset of proficiency.categories), not a
scalar knob, so it gets a readout rather than an editor.
The excluded half is the part worth showing -- it is the
answer to "why does nothing ever classify as X". -->
<div id="classifier-candidate-categories" class="text-muted mt-2" style="font-size:.75rem;display:none"></div>
<div id="classifier-config-error" class="alert alert-warning mt-3 mb-0 py-2" style="display:none;font-size:.78rem"></div>
<div class="d-flex align-items-center justify-content-between mt-3">
<span id="classifier-config-status" class="text-muted" style="font-size:.72rem"></span>
<button class="btn btn-primary btn-sm" onclick="saveClassifierConfig()">Save</button>
</div>
</div>
</div>
</div>
<!-- Row 1c: gaming mode. Deliberately NOT left to the generic knob
list below: this is the one toggle whose consequences an
operator has to read before flipping it, and a bare switch in a
column of eight says nothing about the cloud classifier it
requires or about what still runs for free ahead of it. -->
<div class="col-12">
<div class="card" id="local-compute-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Local Compute</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="local-compute-state" class="badge bg-secondary">&mdash;</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="local-compute-toggle" onchange="setLocalCompute(this.checked)">
</div>
</div>
</div>
<div class="card-body py-2">
<p class="text-muted mb-2" style="font-size:.78rem">
On means the router may use this machine's GPU. Turn it <strong>off</strong> when you
stop Ollama to play a game: every local call is then <em>skipped</em> rather than
attempted and timed out &mdash; classifier, health probe, local verification, the
local-vision fallback, and local dispatch rows, which drop out of the candidate
set instead of being picked and then failing.
</p>
<p class="text-muted mb-2" style="font-size:.78rem">
Turning it off <strong>requires <code>classifier.cloud_fallback</code></strong>. Skipping the
local classifier does not make classification remote &mdash; without a cloud
classifier it stops classifying, and every request falls through to a static
guess recorded as <code>general_chat</code>, which is indistinguishable from a real
classification afterwards.
</p>
<p class="text-muted mb-0" style="font-size:.78rem">
The cloud classifier replaces the <em>local model</em>, not the free steps ahead of
it: this session's cached classification and this session's history in
<code>route_decisions</code> are both real classifications of this same session, so paying
to re-derive an answer already held would be spending money for nothing.
</p>
<div id="local-compute-error" class="alert alert-warning mt-2 mb-0 py-2" style="display:none;font-size:.78rem"></div>
</div>
</div>
</div>
<!-- Row 1d: cloud_fallback. Independent always-available fallback
classifier, NOT a mode-choice option. Used only when the local
classifier is unreachable -- not a primary-mode setting. -->
<div class="col-12">
<div class="card" id="cloud-fallback-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Cloud Fallback</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="cloud-fallback-state" class="badge bg-secondary">&mdash;</span>
</div>
</div>
<div class="card-body py-3">
<p class="text-muted mb-3" style="font-size:.78rem">
A cloud classifier used only when the local classifier is unreachable.
Independent of the mode choice above — not a primary-mode option.
</p>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="cf-base-url" style="font-size:.78rem">base_url</label>
<input class="form-control form-control-sm" id="cf-base-url" placeholder="base_url">
</div>
<div class="col-md-6">
<label class="form-label" for="cf-model" style="font-size:.78rem">model</label>
<input class="form-control form-control-sm" id="cf-model" placeholder="model">
</div>
<div class="col-md-6">
<label class="form-label" for="cf-api-key-env" style="font-size:.78rem">api_key_env</label>
<input class="form-control form-control-sm" id="cf-api-key-env" placeholder="api_key_env (optional)">
</div>
<div class="col-md-3">
<label class="form-label" for="cf-timeout" style="font-size:.78rem">timeout_seconds</label>
<input class="form-control form-control-sm" type="number" id="cf-timeout" placeholder="timeout_seconds">
</div>
<div class="col-md-3">
<label class="form-label" for="cf-max-tokens" style="font-size:.78rem">max_output_tokens</label>
<input class="form-control form-control-sm" type="number" id="cf-max-tokens" placeholder="max_output_tokens">
</div>
</div>
<div id="cloud-fallback-error" class="alert alert-warning mt-3 mb-0 py-2" style="display:none;font-size:.78rem"></div>
<div class="d-flex align-items-center justify-content-between mt-3">
<span id="cloud-fallback-status" class="text-muted" style="font-size:.72rem"></span>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="clearCloudFallback()">Remove</button>
<button class="btn btn-primary btn-sm" onclick="saveCloudFallback()">Save</button>
</div>
</div>
</div>
</div>
</div>
<!-- Row 2: the two settings panels side by side. Same row grammar,
same control column, so they read as one table split in half:
left is what the process is running now, right is what the file
says on next start. -->
<div class="col-xl-6">
<div class="card">
<div class="card-header"><h3 class="card-title"><span class="me-2" data-icon="settings"></span>Runtime Knobs</h3></div>
<div class="card-body settings-body">
<p class="text-muted settings-hint">Live on the next request &mdash; in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
<div class="settings-list" id="runtime-list"></div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="database"></span>Persisted Config</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="config-save-status" class="text-muted" style="font-size:.72rem"></span>
<button class="btn btn-primary btn-sm" id="config-save-btn" onclick="saveAllConfig()" disabled>Save</button>
</div>
</div>
<div class="card-body settings-body">
<p class="text-muted settings-hint">Applied on restart &mdash; allowlisted keys, written to <code>config/config.local.yaml</code> (machine-local overlay; <code>config.yaml</code> stays clean).</p>
<div class="settings-list" id="config-list">
<div class="text-muted fst-italic small px-2 py-1">Loading config&hellip;</div>
</div>
</div>
</div>
</div>
</div><!-- /row row-cards -->
</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) -->
<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/controls.html — single-file controls page
API base: relative (works under /admin/)
═══════════════════════════════════════════════════ */
const API = ''; // relative to /admin/
const SSE_URL = '/events/decisions'; // root-level endpoint
/* ═─ Inline icon helper (no tabler-icons webfont) ═─ */
function icon(name, size) {
const s = size || 18;
const svgs = {
zap: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
database: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" 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>`,
cpu: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>`,
chevronDown:`<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
alert: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
info: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
default: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`,
};
return svgs[name] || svgs.default;
}
function renderStaticIcons() {
document.querySelectorAll('[data-icon]').forEach(el => {
el.innerHTML = icon(el.dataset.icon);
});
}
/* ── 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;
}
}
/* Preserves the server's `detail` on a non-2xx response instead of flattening
it to a generic status message -- the classifier-config validation errors
(e.g. "cloud_primary is required when cloud_primary_auto is false") are
exactly the information a save-time failure needs to show. */
async function apiFetchDetail(url, opts = {}) {
const resp = await fetch(url, opts);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
}
return body;
}
/* ═══════════════════════════════════════
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');
}
}
/* ═══════════════════════════════════════
OPERATIONAL TRIGGERS
═══════════════════════════════════════ */
async function triggerJob(action) {
const statusEl = document.getElementById('job-status');
let url;
switch(action) {
case 'refresh-catalog': url = `${API}api/refresh-catalog`; break;
case 'seed-energy': url = `${API}api/seed-energy?samples=5`; break;
case 'apply-feedback': url = `${API}api/apply-feedback?dry_run=true`; break;
case 'restart-service': url = `${API}api/restart-service`; break;
default: return;
}
statusEl.innerHTML = `<span style="color:var(--tblr-warning)">Running ${action}&hellip;</span>`;
const resp = await apiFetch(url, { method:'POST' });
if (resp) {
if (resp.status === 'restarting') {
statusEl.innerHTML = `<span style="color:var(--tblr-danger)">Service restarting, page will reload automatically</span>`;
toast('Service restart triggered', 'info');
setTimeout(() => location.reload(), 5000);
} else {
statusEl.innerHTML = `<span style="color:var(--tblr-success)">${action}: ${resp.status || 'done'}</span>`;
toast(`${action} completed`, 'success');
}
} else {
statusEl.innerHTML = `<span style="color:var(--tblr-danger)">${action} failed</span>`;
toast(`${action} failed`, 'error');
}
}
/* ═══════════════════════════════════════
RUNTIME KNOBS
═══════════════════════════════════════ */
function knobDisplayValue(v) {
if (v !== null && typeof v === 'object') {
if ('enabled' in v) return String(v.enabled);
try { return JSON.stringify(v); } catch (_) { return String(v); }
}
if (typeof v === 'boolean') return String(v);
return String(v);
}
// Knobs (and Persisted Config keys, below) with only a few legal values get
// a <select> instead of a free-text input — the values come straight from
// the Pydantic enum/validator on the backend, so this list stays in sync
// with config.py by hand, not by inspection.
const ENUM_VALUES = {
default_flex_preference: ['no-flex', 'auto', 'prefer-flex', 'force-flex'],
'routing.default_flex_preference': ['no-flex', 'auto', 'prefer-flex', 'force-flex'],
'logging.level': ['debug', 'info', 'warning', 'error'],
};
function enumSelect(key, value, { dataAttr = 'data-knob', onchangeExpr = '' } = {}) {
const opts = ENUM_VALUES[key].map(v =>
`<option value="${v}"${v === value ? ' selected' : ''}>${v}</option>`
).join('');
const onchange = onchangeExpr ? ` onchange="${onchangeExpr}"` : '';
return `<select class="form-select form-select-sm toggle-input" ${dataAttr}="${key}"${onchange}>${opts}</select>`;
}
// A dotted key reads as scope + leaf; dimming the scope makes a column of
// `objective.*` / `pinch.*` keys scannable by the part that differs.
function keyHtml(key) {
const cut = key.lastIndexOf('.');
if (cut === -1) return escapeHtml(key);
return `<span class="scope">${escapeHtml(key.slice(0, cut + 1))}</span>${escapeHtml(key.slice(cut + 1))}`;
}
// Units the config file expresses but the key name doesn't.
const UNITS = {
'objective.max_energy_per_request': 'kWh',
'objective.plan_kwh_per_period': 'kWh',
};
/* A consequence the control itself cannot show. Keyed by both the runtime knob
name and the persisted-config key, since these knobs appear in both panels.
Keep `text` to one short line -- the meta track shares a row with the key and
the control -- and put the rest in `title`.
`cls` picks the register: bg-warning (amber, alert glyph) where flipping the
control COSTS something, bg-info (blue, info glyph) for a plain relationship
between two rows. NOT bg-secondary -- that is the provenance badge's colour,
and two grey badges in one meta track read as one run-on label. */
const PROBE_NOTE = {
text: 'off stops the cache-loss measurement',
title: 'telemetry only: the evidence for whether context pruning needs rewriting',
cls: 'bg-warning',
glyph: 'alert',
};
/* The gate and the dial are two rows that only mean anything together, and
they render adjacent in both panels (admin.py keeps _runtime_state and
_CONFIG_GET_ORDER in this order). Saying so in four words beats a paragraph
above the list. */
const INCUMBENT_GATE_NOTE = {
text: 'enables the challenger dial',
title: 'Off: ranking is byte-identical to neutral and the challenger dial has no effect.',
cls: 'bg-info',
glyph: 'info',
};
const CHALLENGER_DIAL_NOTE = {
text: 'blank = neutral, 0 = full penalty',
title: 'Challenger cache rate, 0 to 1 (not a percentage). Blank follows objective.assumed_cache_rate, which costs challengers nothing; 0 prices them as fully cold prompts. Needs incumbent_cache_pricing on.',
cls: 'bg-info',
glyph: 'info',
};
/* Same pairing as the gate and the dial: a switch and the window it gates,
rendered adjacent in both panels. The window is the knob that decides how
long ONE classification keeps steering routing, so its note carries the
measurement rather than a description. */
const SESSION_CACHE_GATE_NOTE = {
text: 'enables the reuse window',
title: 'Off: every turn is classified fresh and the window below decides nothing.',
cls: 'bg-info',
glyph: 'info',
};
const SESSION_CACHE_TTL_NOTE = {
text: 'how long one label steers routing',
title: 'Seconds a session reuses its last classification, 5 to 7200. No blank and no zero: to stop reusing labels turn session_cache off, because 0 still caches and the classifier-failure cascade still replays the entry. Measured on live traffic: one classification drove 107 consecutive turns.',
cls: 'bg-info',
glyph: 'info',
};
const KNOB_NOTES = {
pinch_prefix_probe: PROBE_NOTE,
'pinch.prefix_probe': PROBE_NOTE,
session_cache_enabled: SESSION_CACHE_GATE_NOTE,
'session_cache.enabled': SESSION_CACHE_GATE_NOTE,
session_cache_staleness_seconds: SESSION_CACHE_TTL_NOTE,
'session_cache.staleness_seconds': SESSION_CACHE_TTL_NOTE,
incumbent_cache_pricing: INCUMBENT_GATE_NOTE,
'objective.incumbent_cache_pricing': INCUMBENT_GATE_NOTE,
incumbent_challenger_cache_rate: CHALLENGER_DIAL_NOTE,
'objective.incumbent_challenger_cache_rate': CHALLENGER_DIAL_NOTE,
};
function noteBadge(key) {
const note = KNOB_NOTES[key];
if (!note) return '';
return `<span class="badge ${note.cls}" title="${escapeHtml(note.title)}">${icon(note.glyph || 'alert', 12)} ${escapeHtml(note.text)}</span>`;
}
/* Bounds for the numeric runtime knobs, so the browser refuses out of range
before the server has to. A knob absent here renders as a plain number
field. `placeholder` is what an EMPTY field means, and for the dial that is
neutral -- never zero, which is the other end of the same dial. */
const NUMBER_BOUNDS = {
incumbent_challenger_cache_rate: { min: 0, max: 1, step: 0.001, placeholder: 'neutral' },
// No `placeholder`, deliberately: an empty field means nothing here, and a
// placeholder is how the dial above says that it does. Blank posts null and
// the server refuses it, naming session_cache_enabled as the switch the
// operator was reaching for. min is 5 rather than 0 -- 0 is not "off", it
// still caches and the classifier-failure cascade still replays the entry.
session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 },
};
function settingRow({ key, meta = '', control, dirtyAttrs = '' }) {
return `<div class="setting-row"${dirtyAttrs}>
<span class="setting-key" title="${escapeHtml(key)}">${keyHtml(key)}</span>
<span class="setting-meta">${meta}</span>
<span class="setting-control">${control}</span>
</div>`;
}
function renderRuntime(state) {
const el = document.getElementById('runtime-list');
const knobs = state || {};
const html = Object.keys(knobs).map(key => {
const knob = knobs[key];
const persisted = knob.persisted;
const runtime = knob.runtime;
const isDiff = JSON.stringify(persisted) !== JSON.stringify(runtime);
const runtimeStr = knobDisplayValue(runtime);
let control;
if (typeof persisted === 'boolean') {
control = `<div class="form-check form-switch">
<input class="form-check-input toggle-input" type="checkbox" data-knob="${key}" ${runtime ? 'checked' : ''} onchange="toggleKnob('${key}', this.checked)">
</div>`;
} else if (ENUM_VALUES[key]) {
control = enumSelect(key, runtime, { onchangeExpr: `toggleKnob('${key}', this.value)` });
} else if (typeof persisted === 'number') {
// Blank posts null, NOT 0. `Number('')` is 0 in JavaScript and 0 is a
// real, costly setting on both numeric knobs -- the maximum penalty on
// the challenger dial, and an out-of-range not-quite-off on the session
// cache window -- so clearing a field must never be read as choosing it.
// What null MEANS is the server's call: neutral for the dial, a refusal
// naming session_cache_enabled for the window.
const b = NUMBER_BOUNDS[key] || {};
const attrs = [
b.min !== undefined ? `min="${b.min}"` : '',
b.max !== undefined ? `max="${b.max}"` : '',
b.step !== undefined ? `step="${b.step}"` : '',
b.placeholder ? `placeholder="${escapeHtml(b.placeholder)}"` : '',
].filter(Boolean).join(' ');
const shown = (runtime === null || runtime === undefined) ? '' : String(runtime);
control = `<input class="form-control form-control-sm toggle-input" type="number" ${attrs} data-knob="${key}" value="${escapeHtml(shown)}" onchange="toggleKnob('${key}', this.value.trim() === '' ? null : Number(this.value))">`;
} else if (typeof persisted === 'string') {
control = `<input class="form-control form-control-sm toggle-input" type="text" data-knob="${key}" value="${escapeHtml(runtimeStr)}" onchange="toggleKnob('${key}', this.value)">`;
} else {
control = `<span class="text-muted small">${escapeHtml(runtimeStr)}</span>`;
}
// The control already shows the live value, so the only facts worth a
// second column are disagreement with the file and a consequence the
// switch cannot state for itself.
const diffBadge = isDiff
? `<span class="badge bg-warning-subtle text-warning" title="config.yaml says ${escapeHtml(knobDisplayValue(persisted))}; running with ${escapeHtml(runtimeStr)}">file: ${escapeHtml(knobDisplayValue(persisted))}</span>`
: '';
const meta = [noteBadge(key), diffBadge].filter(Boolean).join(' ');
return settingRow({ key, meta, control });
}).join('');
el.innerHTML = html || '<div class="text-muted fst-italic small px-2 py-1">No runtime knobs</div>';
}
/* A knob POST that keeps the server's `detail`. The generic apiFetch swallows
it, which is fine for a knob that cannot be refused — local_compute_enabled
can be, and "Failed to toggle" instead of the reason would defeat the whole
point of refusing rather than warning. */
async function postKnob(knob, value) {
const resp = await fetch(`${API}api/runtime/${encodeURIComponent(knob)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
return body;
}
async function toggleKnob(knob, value) {
const isSwitch = typeof value === 'boolean';
try {
await postKnob(knob, value);
toast(`${knob} ${isSwitch ? 'toggled' : 'set'}`, 'success');
if (knob === LOCAL_COMPUTE_KNOB) renderLocalCompute(value);
// A numeric knob can be stored as something other than what was typed --
// a blank dial posts null and the server resolves it to the neutral rate --
// so re-read rather than leave the field disagreeing with the router.
if (NUMBER_BOUNDS[knob] || value === null) loadControls();
} catch (e) {
toast(e.message || `Failed to ${isSwitch ? 'toggle' : 'set'} ${knob}`, 'error');
// Re-read rather than assume the inverse: the request may have failed
// after the flip, and a switch showing a value the router does not hold
// is worse than a round-trip.
loadControls();
}
}
/* ═══════════════════════════════════════
LOCAL COMPUTE ("gaming mode")
═══════════════════════════════════════ */
const LOCAL_COMPUTE_KNOB = 'local_compute_enabled';
function renderLocalCompute(enabled) {
const toggle = document.getElementById('local-compute-toggle');
const state = document.getElementById('local-compute-state');
if (!toggle || !state) return;
toggle.checked = enabled === true;
state.className = enabled ? 'badge bg-success' : 'badge bg-warning';
state.textContent = enabled ? 'local compute on' : 'gaming mode';
}
function showLocalComputeError(message) {
const box = document.getElementById('local-compute-error');
if (!box) return;
if (!message) {
box.style.display = 'none';
box.textContent = '';
return;
}
box.textContent = message;
box.style.display = 'block';
}
async function setLocalCompute(enabled) {
showLocalComputeError('');
try {
await postKnob(LOCAL_COMPUTE_KNOB, enabled);
renderLocalCompute(enabled);
toast(enabled ? 'Local compute enabled' : 'Gaming mode on — local calls skipped', 'success');
loadControls(); // The generic knob row mirrors this one; keep them agreed.
} catch (e) {
// The refusal is the feature. Park it above the toggle where it stays
// readable, rather than only in a toast that vanishes in four seconds.
showLocalComputeError(e.message || 'Failed to change local compute');
renderLocalCompute(!enabled);
}
}
/* ═══════════════════════════════════════
PERSISTED CONFIG EDITOR
═══════════════════════════════════════ */
let _profilesCache = null;
function renderConfig(config) {
const list = document.getElementById('config-list');
if (!config || !Object.keys(config).length) {
list.innerHTML = '<div class="text-muted fst-italic small px-2 py-1">No config data</div>';
return;
}
const html = Object.entries(config).map(([key, raw]) => {
const entry = raw || {};
const val = entry.value;
const source = entry.source;
const isBool = typeof val === 'boolean';
let control;
if (isBool) {
// A switch, matching Runtime Knobs — the bare checkbox here was the one
// control on the page that didn't look like the others.
control = `<div class="form-check form-switch">
<input class="form-check-input config-input toggle-input" type="checkbox" ${val ? 'checked' : ''} data-config-input>
</div>`;
} else if (key === 'routing.default_profile') {
control = profileSelect(val);
} else if (ENUM_VALUES[key]) {
control = enumSelect(key, val, { dataAttr: 'data-config-input' });
} else {
control = `<input class="form-control form-control-sm config-input toggle-input" type="text" value="${val === null ? '' : escapeHtml(String(val))}" placeholder="${val === null ? 'null' : ''}" data-config-input>`;
}
// Provenance badge is legitimate meta: it explains where the effective
// value came from without restating the value the control already shows.
let meta = [UNITS[key] ? escapeHtml(UNITS[key]) : '', noteBadge(key)].filter(Boolean).join(' ');
if (source === 'overlay') {
meta += `${meta ? ' ' : ''}<span class="badge bg-purple" title="set in config.local.yaml">overlay</span>`;
} else if (source === 'base') {
meta += `${meta ? ' ' : ''}<span class="badge bg-secondary" title="from config.yaml">base</span>`;
}
return settingRow({
key,
meta,
control,
dirtyAttrs: ` data-key="${escapeHtml(key)}" data-orig="${escapeHtml(String(val === null ? '' : val))}"`,
});
}).join('');
list.innerHTML = html;
updateDefaultProfileHint();
markDirty();
}
function profileSelect(currentValue) {
// Always build a fresh option list so a failed /admin/api/profiles fetch or a
// hand-edited default_profile value never leaves the control blank. If the
// persisted value is not in the live list, render it as the lone option so
// the operator still sees (and can save back) the current value.
const profiles = _profilesCache || [];
const options = profiles.slice();
const optionNames = new Set(profiles.map(p => p.name));
if (currentValue !== null && currentValue !== undefined && currentValue !== '' && !optionNames.has(currentValue)) {
options.push({ name: currentValue, zero_admit: false, source: 'orphan' });
}
const opts = options.map(p =>
`<option value="${escapeHtml(p.name)}"${p.name === currentValue ? ' selected' : ''}>${escapeHtml(p.name)}</option>`
).join('');
return `<select class="form-select form-select-sm config-input toggle-input" data-config-input data-profile-select>${opts}</select>`;
}
function updateDefaultProfileHint() {
const row = document.querySelector('#config-list .setting-row[data-key="routing.default_profile"]');
if (!row) return;
const input = row.querySelector('[data-profile-select]');
if (!input) return;
const profiles = _profilesCache || [];
const selected = profiles.find(p => p.name === input.value);
const meta = row.querySelector('.setting-meta');
const existing = meta.querySelector('.default-profile-hint');
if (existing) existing.remove();
if (selected && selected.zero_admit) {
const hint = document.createElement('span');
hint.className = 'badge bg-warning default-profile-hint ms-2';
hint.title = 'This profile currently admits no models';
hint.innerHTML = `${icon('alert', 12)} admits 0 models`;
meta.appendChild(hint);
}
}
/* The inputs hold the values, so the only thing worth flagging is an edit that
hasn't been written yet: an amber rail on the row, a live count on Save. */
function rowValue(row) {
const input = row.querySelector('[data-config-input]');
return input.type === 'checkbox' ? String(input.checked) : input.value;
}
function markDirty() {
const rows = document.querySelectorAll('#config-list .setting-row[data-key]');
let dirty = 0;
rows.forEach(row => {
const changed = rowValue(row) !== row.getAttribute('data-orig');
row.classList.toggle('is-dirty', changed);
if (changed) dirty += 1;
});
const btn = document.getElementById('config-save-btn');
btn.disabled = dirty === 0;
btn.textContent = dirty ? `Save ${dirty} change${dirty === 1 ? '' : 's'}` : 'Save';
return dirty;
}
async function saveAllConfig() {
const rows = [...document.querySelectorAll('#config-list .setting-row[data-key]')]
.filter(row => rowValue(row) !== row.getAttribute('data-orig'));
const statusEl = document.getElementById('config-save-status');
if (!rows.length) return;
let ok = 0;
let failed = 0;
for (const row of rows) {
const key = row.getAttribute('data-key');
const input = row.querySelector('[data-config-input]');
const val = input.type === 'checkbox' ? input.checked : input.value;
let parsed = val;
if (typeof val === 'string') {
if (val.trim() === '') {
parsed = null;
} else {
const num = Number(val);
if (!isNaN(num)) parsed = num;
}
}
statusEl.textContent = `saving ${key}…`;
statusEl.style.color = 'var(--tblr-secondary)';
const res = await apiFetch(
`${API}api/config/${encodeURIComponent(key)}`,
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ value: parsed }) }
);
if (res === null) {
failed += 1;
row.style.color = 'var(--tblr-danger)';
} else {
ok += 1;
row.style.color = '';
}
}
const allOk = failed === 0;
statusEl.textContent = allOk ? `saved ${ok}` : `${failed} write(s) failed, ${ok} saved`;
statusEl.style.color = allOk ? 'var(--tblr-success)' : 'var(--tblr-danger)';
if (allOk) toast('Config updated', 'success');
setTimeout(loadControls, 1500);
}
/* ═══════════════════════════════════════
CLASSIFIER CONFIG
═══════════════════════════════════════
The profiles-page lesson applied here rather than relearned: this must
show what dispatch actually does, not a static echo of the config. The
GET response's resolved_primary comes from the SAME function classify()
calls (routing.cheapest_classifier_candidate), computed server-side --
this file never re-derives it, it only renders what the server already
resolved live. */
let _classifierConfigData = null;
function classifierModeFieldsHtml(mode, data) {
const cp = (data && data.cloud_primary) || {};
if (mode === 'cloud_llm') {
const auto = data ? !!data.cloud_primary_auto : false;
return `
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="classifier-cloud-auto"
${auto ? 'checked' : ''} onchange="onClassifierCloudAutoToggle()">
<label class="form-check-label" for="classifier-cloud-auto" style="font-size:.78rem">
auto (resolve the cheapest routable model, live)
</label>
</div>
<div id="classifier-cloud-pinned-fields" class="row g-2" style="${auto ? 'display:none' : ''}">
<div class="col-md-6">
<input class="form-control form-control-sm" id="classifier-cloud-base-url"
placeholder="base_url" value="${escapeHtml(cp.base_url || '')}">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm" id="classifier-cloud-model"
placeholder="model" value="${escapeHtml(cp.model || '')}">
</div>
<div class="col-md-6">
<input class="form-control form-control-sm" id="classifier-cloud-api-key-env"
placeholder="api_key_env (optional)" value="${escapeHtml(cp.api_key_env || '')}">
</div>
<div class="col-md-3">
<input class="form-control form-control-sm" type="number" id="classifier-cloud-timeout"
placeholder="timeout_seconds" value="${cp.timeout_seconds != null ? cp.timeout_seconds : ''}">
</div>
</div>`;
}
if (mode === 'local_encoder') {
const enc = (data && data.encoder) || {};
return `
<div class="row g-2">
<div class="col-md-6">
<input class="form-control form-control-sm" id="classifier-encoder-model"
placeholder="model (default: BAAI/bge-large-en-v1.5)"
value="${escapeHtml(enc.model || '')}">
</div>
<div class="col-md-3">
<select class="form-select form-select-sm" id="classifier-encoder-device">
<option value="cpu" ${enc.device !== 'cuda' ? 'selected' : ''}>cpu</option>
<option value="cuda" ${enc.device === 'cuda' ? 'selected' : ''}>cuda</option>
</select>
</div>
<div class="col-md-3">
<div class="input-group input-group-sm">
<input class="form-control form-control-sm" type="number" step="1" min="0" max="100"
id="classifier-encoder-threshold" placeholder="confidence % (0-100)"
value="${enc.confidence_threshold != null ? Math.round(enc.confidence_threshold * 100) : ''}">
<span class="input-group-text">%</span>
</div>
</div>
</div>`;
}
return `<span class="text-muted" style="font-size:.78rem">Today's default: the local model configured under <code>classifier.model</code> above.</span>`;
}
function renderClassifierResolvedPrimary(data) {
const el = document.getElementById('classifier-resolved-primary');
if (data.mode === 'cloud_llm' && data.cloud_primary_auto) {
if (data.resolved_primary) {
el.style.display = '';
el.innerHTML = `resolves right now to <strong>${escapeHtml(data.resolved_primary.model_id)}</strong>
<span class="text-muted">(${escapeHtml(data.resolved_primary.provider)}, live)</span>`;
} else {
el.style.display = '';
el.innerHTML = `<span class="text-warning">no routable model currently has both a catalog price and clears the routing filters -- would fall through to the cascade</span>`;
}
} else {
el.style.display = 'none';
}
}
function onClassifierModeChange() {
const mode = document.getElementById('classifier-mode-select').value;
document.getElementById('classifier-mode-fields').innerHTML =
classifierModeFieldsHtml(mode, _classifierConfigData && _classifierConfigData.mode === mode ? _classifierConfigData : null);
document.getElementById('classifier-resolved-primary').style.display = 'none';
showClassifierConfigError('');
}
/* Deliberately NOT a call to onClassifierModeChange(): that fully
regenerates the fields from _classifierConfigData, which would discard
the very checkbox state this handler exists to react to (and anything
already typed into the pinned fields) the instant the box was ticked.
This only toggles which half is visible. */
function onClassifierCloudAutoToggle() {
const auto = document.getElementById('classifier-cloud-auto').checked;
const pinned = document.getElementById('classifier-cloud-pinned-fields');
if (pinned) pinned.style.display = auto ? 'none' : '';
document.getElementById('classifier-resolved-primary').style.display = 'none';
showClassifierConfigError('');
}
function showClassifierConfigError(message) {
const box = document.getElementById('classifier-config-error');
if (!message) { box.style.display = 'none'; box.textContent = ''; return; }
box.textContent = message;
box.style.display = 'block';
}
/* Read-only readout of classifier.candidate_categories. textContent rather
than innerHTML throughout: these strings come from a config file, and a
readout is not worth an XSS sink. */
function renderClassifierCandidateCategories(data) {
const el = document.getElementById('classifier-candidate-categories');
if (!el) return;
const candidates = data.candidate_categories || [];
const excluded = data.excluded_categories || [];
if (!candidates.length) { el.style.display = 'none'; el.textContent = ''; return; }
el.textContent = '';
const label = document.createElement('span');
label.textContent = 'labels the classifier may return: ';
el.appendChild(label);
const list = document.createElement('code');
list.textContent = candidates.join(', ');
el.appendChild(list);
if (excluded.length) {
const note = document.createElement('div');
note.className = 'mt-1';
const head = document.createElement('span');
head.textContent = 'excluded from the scoring axis, so they stop accumulating outcomes: ';
note.appendChild(head);
const ex = document.createElement('code');
ex.textContent = excluded.join(', ');
note.appendChild(ex);
el.appendChild(note);
}
el.style.display = '';
}
async function loadClassifierConfig() {
const data = await apiFetch(`${API}api/classifier-config`);
if (!data) return;
_classifierConfigData = data;
document.getElementById('classifier-mode-select').value = data.mode;
document.getElementById('classifier-mode-fields').innerHTML = classifierModeFieldsHtml(data.mode, data);
renderClassifierResolvedPrimary(data);
renderClassifierCandidateCategories(data);
const badge = document.getElementById('classifier-mode-badge');
badge.textContent = data.mode + (data.mode_source === 'overlay' ? ' (overlay)' : '');
badge.className = data.mode === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
}
function collectClassifierConfigBody() {
const mode = document.getElementById('classifier-mode-select').value;
const body = { mode };
if (mode === 'cloud_llm') {
const auto = document.getElementById('classifier-cloud-auto').checked;
body.cloud_primary_auto = auto;
if (!auto) {
const baseUrl = document.getElementById('classifier-cloud-base-url').value.trim();
const model = document.getElementById('classifier-cloud-model').value.trim();
// Send null, not {base_url: "", model: ""}, when either is blank --
// an object with keys is truthy, so the backend's own "cloud_primary
// is required" check would not catch an empty-but-present block.
body.cloud_primary = (baseUrl && model) ? { base_url: baseUrl, model } : null;
if (body.cloud_primary) {
const apiKeyEnv = document.getElementById('classifier-cloud-api-key-env').value.trim();
if (apiKeyEnv) body.cloud_primary.api_key_env = apiKeyEnv;
const timeout = document.getElementById('classifier-cloud-timeout').value;
if (timeout !== '') body.cloud_primary.timeout_seconds = parseInt(timeout, 10);
}
}
} else if (mode === 'local_encoder') {
const encoder = {};
const model = document.getElementById('classifier-encoder-model').value.trim();
if (model) encoder.model = model;
encoder.device = document.getElementById('classifier-encoder-device').value;
const thresholdPct = document.getElementById('classifier-encoder-threshold').value;
// The field is 0-100 for a human to type ("80" meaning 80%); the backend
// wants the 0.0-1.0 probability classify_zero_shot actually returns.
if (thresholdPct !== '') encoder.confidence_threshold = parseFloat(thresholdPct) / 100;
body.encoder = encoder;
}
return body;
}
async function saveClassifierConfig() {
showClassifierConfigError('');
const statusEl = document.getElementById('classifier-config-status');
try {
const result = await apiFetchDetail(`${API}api/classifier-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectClassifierConfigBody()),
});
statusEl.textContent = result.message || 'saved';
statusEl.style.color = 'var(--tblr-success)';
toast('Classifier config saved', 'success');
await loadClassifierConfig();
} catch (e) {
showClassifierConfigError(e.message || 'Save failed');
statusEl.textContent = '';
}
}
/* ═════════════════════════════════════════
CLOUD FALLBACK CONFIG
═════════════════════════════════════════ */
let _cloudFallbackConfigured = false;
async function loadCloudFallback() {
const data = await apiFetch(`${API}api/classifier-config`);
if (!data) return;
const cf = data.cloud_fallback;
const badge = document.getElementById('cloud-fallback-state');
const baseUrlEl = document.getElementById('cf-base-url');
const modelEl = document.getElementById('cf-model');
const apiKeyEl = document.getElementById('cf-api-key-env');
const timeoutEl = document.getElementById('cf-timeout');
const maxTokensEl = document.getElementById('cf-max-tokens');
if (cf) {
baseUrlEl.value = cf.base_url || '';
modelEl.value = cf.model || '';
apiKeyEl.value = cf.api_key_env || '';
timeoutEl.value = cf.timeout_seconds != null ? cf.timeout_seconds : '';
maxTokensEl.value = cf.max_output_tokens != null ? cf.max_output_tokens : '';
badge.textContent = 'configured';
badge.className = 'badge bg-success';
_cloudFallbackConfigured = true;
} else {
baseUrlEl.value = '';
modelEl.value = '';
apiKeyEl.value = '';
timeoutEl.value = '';
maxTokensEl.value = '';
badge.textContent = 'not configured';
badge.className = 'badge bg-secondary';
_cloudFallbackConfigured = false;
}
}
function collectCloudFallbackBody() {
const baseUrl = document.getElementById('cf-base-url').value.trim();
const model = document.getElementById('cf-model').value.trim();
// Send null, not {base_url: "", model: ""}, when either is blank --
// an object with keys is truthy, so the backend would not catch an
// empty-but-present block (same lesson as cloud_primary).
if (!baseUrl || !model) return null;
const body = { base_url: baseUrl, model };
const apiKeyEnv = document.getElementById('cf-api-key-env').value.trim();
if (apiKeyEnv) body.api_key_env = apiKeyEnv;
const timeout = document.getElementById('cf-timeout').value;
if (timeout !== '') body.timeout_seconds = parseInt(timeout, 10);
const maxTokens = document.getElementById('cf-max-tokens').value;
if (maxTokens !== '') body.max_output_tokens = parseInt(maxTokens, 10);
return body;
}
async function saveCloudFallback() {
showCloudFallbackError('');
const statusEl = document.getElementById('cloud-fallback-status');
try {
const result = await apiFetchDetail(`${API}api/cloud-fallback-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cloud_fallback: collectCloudFallbackBody() }),
});
statusEl.textContent = result.message || 'saved';
statusEl.style.color = 'var(--tblr-success)';
toast('Cloud fallback saved', 'success');
await loadCloudFallback();
} catch (e) {
showCloudFallbackError(e.message || 'Save failed');
statusEl.textContent = '';
}
}
function showCloudFallbackError(message) {
const box = document.getElementById('cloud-fallback-error');
if (!message) { box.style.display = 'none'; box.textContent = ''; return; }
box.textContent = message;
box.style.display = 'block';
}
function clearCloudFallback() {
document.getElementById('cf-base-url').value = '';
document.getElementById('cf-model').value = '';
document.getElementById('cf-api-key-env').value = '';
document.getElementById('cf-timeout').value = '';
document.getElementById('cf-max-tokens').value = '';
saveCloudFallback();
}
/* ═══════════════════════════════════════
DATA FETCHING
═══════════════════════════════════════ */
async function loadControls() {
const runtime = await apiFetch(`${API}api/runtime`);
if (runtime) {
renderRuntime(runtime);
if (runtime[LOCAL_COMPUTE_KNOB]) {
renderLocalCompute(runtime[LOCAL_COMPUTE_KNOB].runtime === true);
}
}
if (!_profilesCache) {
const profiles = await apiFetch(`${API}api/profiles`);
if (profiles) _profilesCache = profiles;
}
const config = await apiFetch(`${API}api/config`);
if (config) renderConfig(config);
await loadClassifierConfig();
await loadCloudFallback();
}
/* ═══════════════════════════════════════
UTILITIES
═══════════════════════════════════════ */
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
function init() {
renderStaticIcons();
// Delegated so it survives every re-render of the list.
const configList = document.getElementById('config-list');
configList.addEventListener('input', markDirty);
configList.addEventListener('change', (e) => {
markDirty();
if (e.target && e.target.dataset && e.target.dataset.profileSelect != null) {
updateDefaultProfileHint();
}
});
loadControls();
connectSSE();
}
init();
</script>
</body>
</html>