Spec: Einzel-Modell-Picker für „Eval starten"¶
Datum: 2026-06-25 · Branch: feat/gui-single-model-picker · Status: approved
Problem¶
Der heutige Modell-Picker auf der „Eval starten"-Seite (macros/_config_eval.html,
static/model_picker.js) ist verwirrend und messtechnisch riskant:
- Falsche Wahrheitsquelle als Default. Vorausgewählt werden die in der Config deklarierten
Modelle (
models_by_config), nicht die auf dem Endpoint real geladenen. Default-Config istconfig.example.yaml→ der Platzhalterqwen3-8b · Q5_K_Mist vorgehakt, obwohl er evtl. gar nicht geladen (und der bloße String mehrdeutig: Qwen3 gibt es als Chat- und Embedding-8B) ist. - Drei überlappende Wege für eine Aufgabe: Checkboxen (aus Config) · „+ Modell" (Freitext) · „Vom Endpoint hinzufügen" (Dropdown). Überladungs-Antipattern.
- Mehrfachauswahl ist für die Kern-Metrik des Tools (RAM) defekt. Verifiziert (file:line,
2026-06-25): ein Run kann mehrere Modelle fahren, aber RAM-Delta ist ab dem zweiten Modell
unbrauchbar — geteilte Baseline (
sampler.py:245) + nie ein Unload + selbe Baseline pro Fenster (merge.py:84). Hält der Endpoint das Vormodell resident (LM Studio JIT, Ollamakeep_alive), bläht A's Residual-Gewicht das Delta von B. Siehe Memorymultimodel-ram-confound.
Ziel¶
Den Multi-Select-Picker durch eine Einzel-Modell-Auswahl ersetzen, gespeist aus den real
geladenen /v1/models des gewählten Config-Endpoints. Config-Knobs (quant, max_tokens_default,
reasoning_headroom_tokens, extra_body) bleiben erhalten, wenn das gewählte Modell config-deklariert
ist. Graceful Offline-Fallback + manueller Notausgang. Backend unverändert: der Picker emittiert
ein models_json mit genau einem Element → models_from_json/start_eval/RunRegistry bleiben
unangetastet und vorwärtskompatibel zur späteren sequenziellen Nacht-Queue.
Nicht-Ziele¶
- Keine Nacht-/Batch-Queue (eigenes späteres Feature, eigener Brainstorm).
- Keine Änderung am Messlayer (sampler/merge/baseline).
- Keine Änderung an
/runs/eval,models_from_json,apply_models_override, RunRegistry, Resume. - Resume-Pfad unverändert (Resume überschreibt Modelle nie).
Design¶
Pure Logik — touchstone/gui/configs.py¶
Neue reine, unit-testbare Funktion:
def eval_model_options(
config_models: list[dict], endpoint_models: list[str]
) -> dict[str, Any]:
"""Merge config-declared models with the endpoint's actually-served ids into one
single-select option list + a sensible default. Pure; no I/O."""
- Output:
{"options": [opt, ...], "default_id": str | None}, jedesopt={id, quant, max_tokens_default, reasoning_headroom_tokens, extra_body, served: bool, source}mitsource ∈ {"endpoint","config","both"}. - Regeln:
- Für jede Endpoint-id (in Endpoint-Reihenfolge):
served=True. Match inconfig_models→ Config-Knobs übernehmen,source="both". Sonst Defaults (quant="", max_tokens_default=400, reasoning_headroom_tokens=0, extra_body={}),source="endpoint". - Config-Modelle, deren id nicht serviert wird: anhängen,
served=False,source="config", mit ihren Knobs. default_id= id der ersten served Option; sonst erste Config-Option; sonstNone.- id-Dedup (erste gewinnt), falls eine Config-id doppelt ist.
Route (dünnes I/O) — touchstone/gui/app.py¶
GET /eval-model-options?config=<path>
→ guard config ∈ glob("config*.yaml"), sonst 404 (wie /endpoint-models)
→ disc = configs_mod.discover_endpoint_models(config) # {models, error}, nie raise, 3s-Bound
→ opts = configs_mod.eval_model_options(
[m.model_dump() for m in configs_mod.config_models(config)], disc["models"])
→ {"options": opts["options"], "default_id": opts["default_id"], "error": disc["error"]}
error gesetzt, options = Config-Fallback).
Template — macros/_config_eval.html (Nicht-Resume-Zweig ersetzen)¶
- Endpoint-Statuszeile: „Endpoint: N geladen ✓" / „⚠ Endpoint nicht erreichbar" — Form+Text redundant kodiert (Icon und Label, nicht nur Farbe; Johannes hat Rot-Grün-Schwäche).
- Ein
<select>x-model="chosen": - served-Optionen zuerst, Label
"<id> · <quant>"(kein „· quant" wenn leer); - nicht-servierte Config-Optionen danach, Label
"<id> (nicht geladen)"; - finale Option
__manual__→ „✎ andere Modell-ID…". chosen === '__manual__'→ TextfeldmanualId(+ optionalmanualQuant) einblenden.- Hidden
name="models_json":value="modelsJson()"— Ein-Element-Array mit vollen Knobs. - Submit
:disabled="count() === 0"; Hinweis „Wähle ein Modell." wenn 0. - Default-Auswahl =
default_id(bei Load + Config-Wechsel gesetzt).
JS — static/model_picker.js (modelPicker vereinfachen)¶
- Entfernen: Checkbox-Liste,
adhoc-Array, Endpoint-Dropdown,addFromEndpoint/addAdhoc/removeAdhoc. - State:
config, options, chosen, manualId, manualQuant, endpointError, endpointLoading, defaultId. init/syncFromConfig→fetch('/eval-model-options?config=' + …);options+chosen=default_id. Stale-Response-Guard (überholte Config ignorieren) wie heute beibehalten. Initial-Seed ausmodels_by_config[config](served=false) gegen Flash/Offline, dann von der Route überschrieben.modelsJson():chosen === '__manual__'→manualId.trim()?[{id, quant: manualQuant.trim(), max_tokens_default:400}]:[]- sonst Option per id finden →
[{id, quant, max_tokens_default, reasoning_headroom_tokens, extra_body}] - sonst
[] count()→ 1 wenn ein Modell auflösbar, sonst 0.
Tests (TDD)¶
Pure eval_model_options (tests/test_gui_configs.py o. ä.):
- Endpoint hat Modelle, keins in Config → alle served, source endpoint, Defaults, default_id = erstes Endpoint.
- Endpoint-Modell auch in Config → source both, Config-Knobs übernommen (quant/reasoning_headroom/extra_body/max_tokens_default).
- Config-Modell nicht serviert → angehängt, served false, source config.
- Endpoint leer/offline → options = Config-Modelle served false, default_id = erstes Config oder None.
- Beide leer → options [], default_id None.
- Reihenfolge: Endpoint-Reihenfolge erhalten, Config-only danach; id-Dedup.
Route /eval-model-options (TestClient, discover_endpoint_models gemockt/lister injiziert):
- Happy: {options, default_id, error:null}-Shape.
- Endpoint-Error wird durchgereicht, nie 500.
- Pfad-Guard: config ∉ glob → 404.
Bestehende Tests: test_gui_config_flow.py an den neuen Picker-Kontrakt anpassen
(alte Checkbox/Adhoc-Assertions). start_eval/models_json-Tests bleiben grün (Backend unverändert;
Ein-Element-Array validiert).
Headless-Smoke (Memory gui-restart-after-changes): Server neu starten, /config lädt 200 mit
neuem Picker-Markup; /eval-model-options?config=… antwortet im erwarteten Shape.
Berührte Dateien¶
touchstone/gui/configs.py—eval_model_options(pure).touchstone/gui/app.py—GET /eval-model-options.touchstone/gui/static/model_picker.js—modelPicker→ Einzelauswahl.touchstone/gui/templates/macros/_config_eval.html— Modell-Block ersetzen.tests/— Unit-Testseval_model_options+ Route; bestehende Config-Flow-Tests anpassen.
Risiken / Edge-Cases¶
- Endpoint-Latenz (3s) — wie heute async, „lädt…"-State.
- Config mit mehreren deklarierten Modellen (keine mitgeliefert, aber möglich) → alle als Optionen, Nutzer wählt eins (kein Auto-Run-aller mehr — beabsichtigt).
- Manuelle id ohne Endpoint → funktioniert (Notausgang).
models_jsonEin-Element-Array → vonmodels_from_jsonsauber dedupt.