plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.
Now `grep -H '^Status:' plans/*.md` is the answer:
50 done 3 in progress 2 planned 2 reference 1 parked
Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.
The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.
test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.
Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
378 lines
17 KiB
Markdown
378 lines
17 KiB
Markdown
# Admin portal functional sweep -- 2026-09-08
|
|
|
|
Status: in progress -- 12 findings; #1-#8, #10-#12 fixed, #9 was not a defect
|
|
|
|
Every page, control, and API endpoint under `/admin` exercised end to end
|
|
against real data, on a throwaway instance bound to 8081.
|
|
|
|
## How it was tested
|
|
|
|
```
|
|
sqlite3 /home/alee/Sources/6krrt/router.db \
|
|
"VACUUM INTO '<worktree>/router.db'" # real catalog + 23,844 decisions
|
|
PYTHONPATH=<worktree>/src HF_HOME=<repo>/.hf-cache \
|
|
.venv/bin/uvicorn dispatcher:app --host 127.0.0.1 --port 8081
|
|
```
|
|
|
|
cwd = the worktree, so `config/config.yaml`, `router.db` and the admin
|
|
overlay all resolve inside it. Production 8080 and
|
|
`/home/alee/Sources/6krrt/config/config.local.yaml` were untouched --
|
|
verified by md5 before and after (`1ec1734...`, unchanged) and by 8080
|
|
answering 200 throughout.
|
|
|
|
**Not run, deliberately.** `Seed Energy` spends real NeuralWatt credits
|
|
(`?samples=5`, 65+ billed completions) and `Restart Service` shells
|
|
`systemctl --user restart llm-router.service`, which is the *production*
|
|
unit regardless of which instance served the click. Both were read in
|
|
source instead.
|
|
|
|
## Findings
|
|
|
|
Ordered by consequence. Every item was reproduced, not inferred.
|
|
|
|
### 1. Profile edit silently drops an `allowed_model_ids` entry that is not in the current catalog
|
|
|
|
`admin/frontend/profiles.html`
|
|
|
|
The editor's `allowed_model_ids` control is a `<select multiple>` populated
|
|
from the 446-row catalog. An id stored in the profile that has no matching
|
|
`<option>` is simply not selected -- and Save posts the selection, so the
|
|
stored value is gone.
|
|
|
|
Reproduced: a profile holding `allowed_model_ids: ["totally-not-a-model"]`
|
|
was opened with Edit and saved **with no changes**. It went from
|
|
`admits 0 models` (warning triangle) to `no filters / admits 40 models`.
|
|
|
|
This fails **open**. The realistic trigger is a model that left the catalog
|
|
-- deprecated by the poller, renamed upstream, or pruned by the OpenRouter
|
|
allowlist -- at which point an unrelated edit to `min_tier` quietly converts
|
|
a locked-down profile into one that admits everything. Nothing warns.
|
|
|
|
Ids that *are* in the catalog round-trip correctly (verified: `gemma-4-31b`,
|
|
`kimi-k3` came back preselected), so this only bites on drift.
|
|
|
|
### 2. Admin request bodies are plain `BaseModel`, so unknown keys are ignored
|
|
|
|
`src/admin.py` -- `_CloudFallbackBody`, `_ProfileCreateBody`,
|
|
`_ProfileUpdateBody`, `_ProviderUpdateBody`, `_AvailabilityBody`,
|
|
`_AllowlistAddBody`, `_ValueBody`
|
|
|
|
CLAUDE.md already documents this exact hazard and closed it for config
|
|
models via `StrictModel(extra="forbid")`. The admin API surface never got
|
|
the same treatment.
|
|
|
|
Worst instance is destructive:
|
|
|
|
```
|
|
POST /admin/api/cloud-fallback-config
|
|
{"base_url":"https://api.neuralwatt.com/v1","model":"deepseek-v4-flash", ...}
|
|
-> 200 {"message":"A restart is required for this change to take effect"}
|
|
```
|
|
|
|
The body is nested (`{"cloud_fallback": {...}}`), so every flat key is
|
|
discarded, `cloud_fallback` defaults to `None`, and `None` is the documented
|
|
*remove the block* signal. A configured cloud fallback is **deleted** and the
|
|
caller is told the save succeeded. Confirmed: overlay went from a populated
|
|
`classifier.cloud_fallback` to no such key, with a 200 both times.
|
|
|
|
Note the asymmetry -- the inner dict *is* validated strictly
|
|
(`{"timeout_secondz":20}` -> 422 "Extra inputs are not permitted"); only the
|
|
outer wrapper is loose.
|
|
|
|
Same class, non-destructive: `POST /admin/api/profiles/`
|
|
`{"name":"typoprof","min_teir":3}` returns 200 and creates a profile with
|
|
every filter null.
|
|
|
|
### 3. The `stale` availability override does nothing
|
|
|
|
`admin/frontend/models.html` offers active / deprecated / stale. Only
|
|
`deprecated` is ever read -- `metrics._admin_deprecated_models` and
|
|
`dispatcher._admin_deprecated_models` both query
|
|
`WHERE availability = 'deprecated'`.
|
|
|
|
Measured on `/route` (`coding_refactor`, tier 1, 5k ctx):
|
|
|
|
| state | `candidates_considered` |
|
|
|---|---|
|
|
| `glm-5.2-fast` overridden to **stale** | 27 |
|
|
| stale override cleared | 27 |
|
|
| `gemma-4-31b` overridden to **deprecated** | 27 |
|
|
| deprecated override cleared | **28** |
|
|
|
|
Deprecated works. Stale is a control that persists a row and changes
|
|
nothing.
|
|
|
|
### 4. `/v1/models` advertises admin-deprecated models, and pins against them are not refused
|
|
|
|
`src/dispatcher.py:3645` reads `models.availability = 'active'` straight from
|
|
the table and never consults `admin_model_overrides`.
|
|
`_resolve_pinned_provider` has no override check either.
|
|
|
|
With `gemma-4-31b` admin-deprecated, it stayed in `/v1/models` (51 entries)
|
|
while `/route` correctly excluded it. So a client enumerating the endpoint
|
|
sees a model the operator has switched off, pins it, and gets it dispatched.
|
|
|
|
The same function already hides `ollama-local` rows under gaming mode, with
|
|
a comment giving the reason: listing one would "advertise a model every pin
|
|
against it is about to be refused for". The override case has neither the
|
|
hiding nor the refusal.
|
|
|
|
### 5. Decisions search is silently scoped to the newest 1000 rows
|
|
|
|
`admin/frontend/decisions.html`. Subtitle: "Full routing decision history --
|
|
filter, search, and inspect".
|
|
|
|
The page loads a fixed 1000-row window; every filter and the search box
|
|
operate on that client-side slice. The counter reads "N of 1000", which
|
|
looks like a total.
|
|
|
|
- `route_decisions` holds **23,844** rows
|
|
- **10,394** of them name a kimi model
|
|
- searching `kimi` on the page reports **"0 of 1000"**
|
|
|
|
A confident zero on a page that claims to hold the full history. The API
|
|
itself is fine (`/admin/api/decisions?limit=5000` returned 2000 rows), so
|
|
this is a frontend windowing choice, not a backend limit.
|
|
|
|
Also on this page: rows are not clickable and there is no detail affordance,
|
|
though the subtitle promises "inspect"; the FLAGS column has no legend.
|
|
|
|
### 6. Two reciprocal navigation gaps
|
|
|
|
- `admin/frontend/providers.html` has no `href="/admin/proficiency"`
|
|
- `admin/frontend/proficiency.html` has no `href="/admin/providers"`
|
|
|
|
Every other page carries both. Net effect: you cannot get from Providers to
|
|
Proficiency or back without editing the URL.
|
|
|
|
### 7. `POST /admin/api/providers/{name}` has no read-only guard, and the result can be unremovable
|
|
|
|
`src/admin.py:1378`. `admin_provider_update` validates the URL and the model,
|
|
then persists -- with no provenance check at all. Compare
|
|
`admin_provider_delete` (403 for a base-config provider) and
|
|
`admin_profile_update` (403, "add an overlay profile with the same name to
|
|
override it").
|
|
|
|
Editing `neuralwatt` -- shown in the UI as `base config` / `read-only` --
|
|
returned 200 and wrote a full `dispatch_providers.neuralwatt` block into
|
|
`config.local.yaml`.
|
|
|
|
The trap is what happens next: `DELETE /admin/api/providers/neuralwatt`
|
|
refuses with 422 because it is `dispatch_settings.default_provider`. The
|
|
overlay entry is now **unremovable through the portal**; the only way out is
|
|
hand-editing `config.local.yaml`, the one file in this deployment that is
|
|
not in git.
|
|
|
|
The UI hides the edit control for base-config providers, so this is
|
|
API-reachable only -- but the portal is the only thing that writes that file.
|
|
|
|
### 8. `cloud_fallback.base_url` is not URL-validated
|
|
|
|
```
|
|
POST /admin/api/cloud-fallback-config
|
|
{"cloud_fallback":{"base_url":"notaurl","model":"x", ...}} -> 200
|
|
```
|
|
|
|
and `base_url: notaurl` lands in the overlay. `POST /api/providers/{name}`
|
|
rejects `ftp://nope` with 422 via `_validate_provider_url`; the same check is
|
|
absent here, and this block is what the router falls back to when the local
|
|
classifier dies.
|
|
|
|
### 9. Allowlist delete always reports success
|
|
|
|
`DELETE /admin/api/providers/openrouter/allowlist/nope/nope` -> 200
|
|
`{"deleted":"nope/nope"}`. Bare `DELETE`, no rowcount check. Profile delete
|
|
and model-override delete both 404 correctly on a missing target.
|
|
|
|
### 10. Allowlist add cannot update a note
|
|
|
|
`INSERT OR IGNORE`. Re-adding `sandbox/test-model` with `note:"dup"`
|
|
returned 200 carrying the **original** note. There is no way to correct a
|
|
note through the API or the UI.
|
|
|
|
## Smaller things, all reproduced
|
|
|
|
- **"Apply Feedback" only previews.** `controls.html:541` sends
|
|
`?dry_run=true`. The button says Apply; there is no way to actually apply
|
|
from the portal.
|
|
- **"Seed Energy" spends money with no confirmation** and is styled
|
|
identically (`btn-success`) to the free Refresh Catalog next to it.
|
|
`?samples=5` across the catalog is 65+ billed completions.
|
|
- **"Restart Service" has no confirmation.** It is `btn-danger`, which
|
|
helps, but it drops in-flight requests on one click -- while deleting a
|
|
profile requires a two-step confirm.
|
|
- **`Manage` on a provider that has no allowlist is a dead end.**
|
|
neuralwatt's panel opens, says "No models on the allowlist" (the truth is
|
|
"this provider has no allowlist"), and Browse catalog preview fails with a
|
|
generic "Could not load catalog preview." The real reason -- 409
|
|
"allowlist is not required for this provider" -- never reaches the user.
|
|
The summary line "0 allowed" reads as *zero permitted* when it means
|
|
*unrestricted*.
|
|
- **The profile editor's model picker offers all 446 rows**, including
|
|
allowlist-pruned and deprecated OpenRouter models, with no filter. The
|
|
Models page has a "Show deprecated / stale" toggle defaulting to off;
|
|
this picker has none, so picking one silently yields a zero-admit profile.
|
|
- **Allowlist catalog search resets after every Add**, so adding several
|
|
models from one search means retyping it each time.
|
|
- **Allowlist Remove has no confirm step**, unlike profile Delete.
|
|
- **Gaming-mode 409 after saving cloud fallback in the portal** tells you to
|
|
"uncomment the classifier.cloud_fallback example in config/config.yaml"
|
|
rather than "restart to pick up the block you just saved". `cfg` binds at
|
|
import, so the refusal itself is correct; the message points at the wrong
|
|
remedy.
|
|
- **Switching classifier mode away from `local_encoder` leaves the
|
|
`encoder:` block in the overlay.** Only `cloud_primary` is cleaned
|
|
(`deletions`). Harmless -- validators accept it -- but it is stale state.
|
|
- Overlay YAML writes nulls as bare `provider:` keys (ruamel). Parses fine,
|
|
reads oddly.
|
|
|
|
## What works
|
|
|
|
Stated because it is most of the surface, and because the validation in
|
|
particular is unusually good.
|
|
|
|
**Pages.** All seven render and populate from real data. Dashboard
|
|
Calls/Cost/Energy toggle, all five history ranges, the warnings popover, the
|
|
quota strip, verdict mix, category breakdown. Models list + "Show deprecated
|
|
/ stale" (45 -> 446). Decisions filters (kind, category, tier, search),
|
|
pagination, page-size. Proficiency matrix, category column-narrowing,
|
|
model search, only-measured, sort, and the source control's cell dimming
|
|
(413/495 cells at `outcome_blended`) with a legend that distinguishes
|
|
measured from inherited.
|
|
|
|
**Profiles.** Create, duplicate-with-prefill (`onlycheaps` ->
|
|
`onlycheaps-copy`), inline edit, two-step delete, live admission counts that
|
|
match `select_candidates`, the zero-admit warning chip, and the
|
|
restart-required banner. Built-ins correctly read-only.
|
|
|
|
**Providers.** Create, delete, allowlist add/remove, live catalog preview
|
|
(422 models), search filter, "Added" state on entries already allowlisted.
|
|
|
|
**Model overrides.** Set and clear, including slash-bearing ids
|
|
(`deepseek/deepseek-v4-flash`), with `effective_availability` /
|
|
`is_overridden` reported correctly and deprecation genuinely removing the
|
|
model from routing.
|
|
|
|
**Controls.** All nine runtime knobs flip and read back. All twelve
|
|
persisted config keys write to the overlay with correct `base` / `overlay`
|
|
provenance.
|
|
|
|
**Error paths** -- every one of these returned the right status with a
|
|
message that explains itself:
|
|
|
|
| probe | result |
|
|
|---|---|
|
|
| `min_tier: 3, max_tier: 1` | 422, names both values |
|
|
| `latency_tolerance: "turbo"` | 422 |
|
|
| `logging.level: "screaming"` | 422, lists the four valid values |
|
|
| `quality_tolerance: -5` | 422, "must be in [0, 1)" |
|
|
| `objective.credit_attenuation.enabled` | 403, not editable (matches CLAUDE.md) |
|
|
| `default_profile: "ghostprofile"` | 422, lists the five real profiles |
|
|
| `confidence_threshold: 80` | 422, "returns a 0-1 probability, not a percent" |
|
|
| `encoder.device: "tpu"` | 422 |
|
|
| `cloud_llm` with neither primary nor auto | 422 |
|
|
| gaming mode without `cloud_fallback` | 409 (runtime) **and** 422 (persisted) |
|
|
| duplicate profile / builtin profile / missing profile | 409 / 403 / 404 |
|
|
| delete the current `default_profile` | 422, says to repoint it first |
|
|
| `history?range=bogus` | 400 |
|
|
| allowlist ops on a non-allowlist provider | 409 |
|
|
| unknown provider | 404 |
|
|
|
|
**Triggers.** `Refresh Catalog` ran clean (19 NeuralWatt + 29 OpenRouter
|
|
upserted, 393 pruned by the allowlist) and surfaced the catalog sanity-floor
|
|
warning CLAUDE.md asks for: "[openrouter] fetched only 29 models (current DB
|
|
has 425); proceeding but catalog may be truncated". `Apply Feedback`
|
|
(dry-run) produced a correct per-model failure/success tally.
|
|
|
|
## Two more, found while fixing #1
|
|
|
|
### 11. The model picker offered every deprecated row
|
|
|
|
The `allowed_model_ids` control was populated from an unfiltered
|
|
`/admin/api/models`: 446 options, of which 393 were OpenRouter rows the
|
|
provider allowlist had already pruned and the poller had marked
|
|
`deprecated`. Picking one produced a profile that admits nothing, with
|
|
nothing on screen saying why. models.html filtered the same payload behind a
|
|
"Show deprecated / stale" toggle defaulting off; the picker did not.
|
|
|
|
### 12. `config/config.local.yaml` breaks 9 tests
|
|
|
|
Not a portal bug, but it bites anyone running the suite on a configured
|
|
machine. Reproduced both directions on the same tree:
|
|
|
|
| overlay present | result |
|
|
|---|---|
|
|
| no | 1523 passed |
|
|
| yes (`classifier.mode: local_encoder`) | 9 failed, 1514 passed |
|
|
|
|
The nine are in `test_classifier_backoff.py` (5), `test_gaming_mode.py` (3)
|
|
and `test_classifier_modes_dispatch.py` (1) -- e.g.
|
|
`assert 'local_encoder' == 'local_llm'`. They load the repo's real
|
|
`config/config.yaml` and pick up the machine-local overlay beside it, so a
|
|
deployment that has configured `classifier.mode` cannot run its own tests
|
|
clean. This deployment has exactly that overlay, so `pytest` fails 9 today
|
|
regardless of any change.
|
|
|
|
## Fixed on `fix/admin-profile-model-visibility`
|
|
|
|
Findings **#1**, **#11**, and the picker half of the smaller items:
|
|
|
|
- `GET /admin/api/models` now defaults to routable rows only
|
|
(50 here, of which the 30 OpenRouter ones are exactly the 30 allowlist
|
|
entries). `?include_unroutable=true` returns all 446, and models.html --
|
|
the availability table, the one page that legitimately wants them -- is
|
|
the only caller that passes it. Every present and future picker gets the
|
|
filtered default. Allowlist a model, poll, and it appears.
|
|
- The profile detail pane renders `admitted_models` as chips instead of
|
|
hiding it in a `title=` tooltip truncated to 8 of 40.
|
|
- The `allowed_model_ids` control is a two-pane Available / Selected picker.
|
|
What is chosen is always on screen, filtering narrows Available only,
|
|
"Add all" takes the whole filtered set, and a stored id that is no longer
|
|
routable stays in Selected flagged `not routable` -- which is what closes
|
|
the silent-drop in #1: opening Edit and saving untouched now preserves it.
|
|
- The list summary says `2 models` rather than `1 filter`.
|
|
|
|
### The rest, in the order they were done
|
|
|
|
- **#2** `6b9826c` -- every admin body inherits `_AdminBody`
|
|
(`extra="forbid"`). Immediately found a real client/server mismatch: the
|
|
profiles UI sent `name` on the update path, which the body model has no
|
|
field for. It had been silently dropped.
|
|
- **#7** `6b9826c` -- `POST /api/providers/{name}` refuses a base-only
|
|
definition, as delete already did. Also corrected both profile 403s,
|
|
which advised "add an overlay profile with the same name to override it"
|
|
-- something `admin_profile_create` rejects with a 409.
|
|
- **#3 / #4** `43645d4` -- both off-states exclude, and the override binds
|
|
`/v1/models` and pinned requests as well as `/route`.
|
|
`_admin_deprecated_models` renamed `_admin_excluded_models`, since
|
|
"deprecated" no longer describes what it returns.
|
|
- **#8 / #10** `281f4fb` -- `_validate_provider_url` applied to
|
|
`cloud_fallback` and `cloud_primary`; allowlist add upserts so a note can
|
|
be corrected.
|
|
- **#5 / #6** `48df93b` -- `X-Total-Count` on the decisions endpoint, and
|
|
the page names the window and the history separately. Both nav gaps
|
|
closed, with a test that derives the page list so a new admin page fails
|
|
until it is linked.
|
|
- **#12** `80d0e6a` -- `ROUTER_IGNORE_LOCAL_CONFIG`, set in
|
|
`tests/conftest.py`. 1613 tests now pass identically with and without the
|
|
machine's overlay in place.
|
|
|
|
### #9 was not a defect
|
|
|
|
The sweep flagged the allowlist DELETE returning 200 for an absent entry as
|
|
a false confirmation, and the fix was written -- then reverted, because it
|
|
broke `test_allowlist_delete_idempotent`, a test that pins that exact
|
|
behaviour with a docstring saying so. That is a decision, not an oversight.
|
|
|
|
The inconsistency it noticed is still real and is the one open question
|
|
left here: profile delete and model-override delete both 404 on a missing
|
|
target while this one does not. Which way that resolves is a call to make,
|
|
not a bug to fix.
|
|
|
|
### Smaller items still open
|
|
|
|
The nine UX items listed above are unaddressed except the picker ones: the
|
|
"Apply Feedback" button still only previews, "Seed Energy" still spends
|
|
money with no confirmation, `Manage` on a non-allowlist provider still
|
|
opens a dead-end panel, and the decisions FLAGS column still has no legend.
|