# Admin portal: profile visibility and selection Status: done -- profile CRUD in admin.py **Status: FINAL — decision-complete. §C was answered by the user on 2026-09-04: A, B and C are all in scope.** Written 2026-09-04 against `main` at `d08aed3`. ## The principle this serves Stated by the user: the admin portal does not need 100% coverage of every configurable lever, but **the main features should each have basic functionality and configurability there**. Named routing profiles shipped in PR #24 with no portal surface at all — they exist only as code defaults and an `auto:` string a client has to know to send. That fails the bar. ## What exists today - `BUILTIN_PROFILES` in `src/dispatcher.py:110-116` — five profiles, defined as predicates over catalog columns: | profile | definition | |---|---| | `default` | `RoutingProfile()` | | `batch` | `latency_tolerance="batch"` | | `locality` | `provider="ollama-local"` | | `bigboybritches` | `min_tier=3` | | `onlycheaps` | `max_cost_per_1m_completion=0.5` | - `config.RoutingProfile` (`src/config.py:197`) — fields `provider`, `min_tier`, `max_tier`, `latency_tolerance`, `max_cost_per_1m_completion`, `allowed_model_ids`. All optional. - A `profiles:` config section is supported but **no block exists in `config/config.yaml`**, so today every profile is a code default. - **No `routing.default_profile` knob.** Bare `auto` resolves to the built-in `default` and there is no way to change that without editing code. - `admin.py:_CONFIG_ALLOWLIST` — dotted config paths an operator may edit. Writes go through comment-preserving `ruamel.yaml` round-trip, are validated through `RouterConfig` before any byte reaches disk, and back up first. `routing.default_flex_preference` is already on it and is the **exact precedent** for what Part B needs. - Admin pages: `index.html`, `models.html`, `decisions.html`, `controls.html`. `decisions.html` already has a Profile column and filter (PR #24). ## Part A — Show what profiles are and what they resolve to Read-only, and the highest value per unit of work. A profile's *definition* is not the useful fact; what it **currently admits** is. Those differ in ways that are invisible today. Measured on the live catalog on 2026-09-04: - `bigboybritches` (`min_tier=3`) matches **7** models — but only **4** are reachable interactively, because three are `-flex` rows and the default `latency_tolerance=interactive` filters them at the hard-filter stage. - `locality` matches **1** model, and only for `file_summarization` / `diff_checking`, because per-model `eligible_categories` ANDs on top. - `onlycheaps` matches **4**, and the cheapest is the LOCAL model at $0.229/1M completion — a measured electricity figure sitting in the same column as cloud list prices. None of that is derivable from reading `min_tier=3`. **Add a Profiles panel** (new `profiles.html`, or a section on `models.html` — implementer's choice, state which and why) showing per profile: its name, whether it is built-in or config-defined, its definition rendered as fields, the **count and list of models it currently admits**, and the count reachable under `interactive` specifically. Compute admitted sets by calling `routing.select_candidates` with the profile's `restrict_to`, exactly as live routing does — do NOT reimplement the predicate. `metrics.context_ceilings` is the precedent: it delegates to `select_candidates` so its numbers cannot drift from routing's. **A profile that currently admits ZERO models must be visibly flagged.** That is the shape of the 422 that cost ~19 hours in incident #3, and a profile can reach it silently through admin deprecations — exactly what happened to the vision-capable set on 2026-09-04. ## Part B — Let the operator choose the default profile Add `routing.default_profile` (string, defaults to `"default"`), and put it on `_CONFIG_ALLOWLIST` so it is editable from `controls.html` alongside `routing.default_flex_preference`. Effect: bare `auto` — which is what `opencode.json` and every existing client sends — resolves to the operator's chosen profile instead of the hard-coded `default`. That is what makes profiles *usable* without touching client config, and it is the smallest change that turns them from a curiosity into an operational lever. Constraints: - Validate against the known profile set at config load. An unknown name must fail loudly at load, consistent with how `auto:nonsense` returns 422 rather than silently degrading. - `auto:` on a request still wins over the default. The default only fills in for a bare `auto`. - The control must be a **select populated from the live profile list**, not a free-text field. A typo here silently redirects all default traffic. - Warn in the UI if the chosen default currently admits zero models. ## Part C — SCOPE DECISION: create and edit custom profiles? **DECIDED 2026-09-04 by the user: IN SCOPE.** Ship A, B and C together. The cost note below still stands and should shape sequencing — do A and B first so the read-only view and the default selector exist before CRUD is layered on. C is most of the work; treat it as its own wave. The honest cost difference: `_CONFIG_ALLOWLIST` edits **named scalars**. A profile is a structured object with six optional fields, one of them a set of model ids. Persisting one means new machinery — a nested-write path through the ruamel round-trip, a create/delete flow, and a UI for six fields rather than a text input. That is most of the work in this plan. **Sequencing, not deferral:** build A and B before C. A and B make profiles visible, understandable and selectable — the "basic functionality and configurability" bar — at a fraction of the cost, and they give C something to build on: the read-only panel from A is where a created profile is verified, and B's `default_profile` is what C's delete-guard has to protect. Landing C first would mean writing CRUD against a surface nobody can see. ### C requirements, now that it is in scope - **Reuse the validated-write path**: comment-preserving `ruamel.yaml` round-trip, `RouterConfig` validation before any byte reaches disk, backup first. Do not add a second way to write `config.yaml`. - **Built-ins are not editable or deletable.** Render them read-only. A config-defined profile whose name collides with a built-in must be **rejected at config load** with a clear message, not silently override or be silently ignored. Ambiguity about which definition is live would be worse than either. - **`allowed_model_ids` needs a multi-select populated from the live catalog**, not free text. A typo'd model id yields a profile that silently admits fewer models than intended — the failure this plan exists to make visible. - **Deleting the profile named by `routing.default_profile` must be refused**, with a message saying which setting depends on it. Same for a rename. The alternative is a config that fails to load on next start, which is the refuse-at-load behaviour of Part B turned into an outage. - **Creating a profile that admits zero models is allowed but must warn** on save, consistent with Part A's flag. It may be deliberate (a profile for a model not yet in the catalog); it must not be silent. - Deleting or editing a profile takes effect on reload like any other persisted config edit. Say so in the UI rather than implying it is instant. ## Non-goals - No ranking changes. Profiles filter; they do not reorder. (Settled in `plans/named-routing-profiles.md` §3: filter-only.) - No new profile *fields* on `RoutingProfile`. - Do not surface profiles as runtime-only toggles that reset on restart. A default profile is a deployment preference and belongs in persisted config. - Do not let the portal auto-fix a zero-model profile by relaxing it. Warn; the operator decides. - Do not touch `auto:batch` resolution. ## Success criteria - The portal lists every profile — built-in and config-defined — with its definition and the models it **currently admits**, computed via `routing.select_candidates`, not a reimplementation. - A profile admitting zero models is visibly flagged. - `routing.default_profile` is editable from `controls.html` as a select populated from the live profile list, persists through the validated round-trip write, and takes effect for bare `auto` after reload. - An invalid `default_profile` fails at config load with a clear message. - A test proves `auto:` still overrides the configured default. - Full suite green with `local_energy.enabled` both true and false. - The user's `config/config.yaml` tariff lines remain uncommitted and verbatim. **Note for the implementer: this plan writes to `config/config.yaml` programmatically. The write path must preserve unrelated lines — the tariff included — and that is worth an explicit test, not an assumption.**