Modell-Dropdown via Endpoint-Discovery — Design¶
Datum: 2026-06-22
Status: ratifiziert (Brainstorming abgeschlossen) — vor Implementierungsplan
Scope: Im Modell-Picker („Konfig + Start") ein Dropdown der tatsächlich am Endpoint verfügbaren Modelle anbieten (Server fragt /v1/models der gewählten Config ab), aus dem man ein Modell wählt und der Lauf-Auswahl hinzufügt. Plus: den schlechten Default beheben (Picker startet nicht mehr auf der embed-Config).
Auslöser: Nach dem Modell-Picker (2026-06-21-modell-auswahl-gui-design.md) fiel beim Bedienen auf: der Picker zeigt nur das eine in der (default-gewählten) Config gelistete Modell — ein Embedding-Modell — und ein Wechsel geht nur per Ad-hoc-Tippen. Gewünscht: ein Dropdown echter, ladbarer Modelle.
Phase-Einordnung: Zweite Stufe von „Steuerung brauchbar machen / Variablen-Entkopplung". Die /v1/models-Auto-Discovery war im Picker-Spec als späterer Schnitt notiert — das ist er.
1 · Ziel & Motivation¶
Das Tool ist Johannes' Labor: „Modell X auf HW Y mit Settings Z taugt für Aufgabe B". Um X gezielt zu variieren, will man aus den real verfügbaren Modellen wählen — nicht raten, was am Endpoint geladen ist, und nicht jede id von Hand tippen. Der OpenAI-kompatible Endpoint (ollama :11434, LM Studio :1234) listet seine Modelle unter /v1/models; genau das wird die Quelle des Dropdowns.
2 · Verifizierte Ausgangslage (Code-fundiert)¶
touchstone/client.py—OpenAIStreamClient(base_url: str, api_key: str = "not-needed", *, engine=…, engine_version=…)kapseltself._client = OpenAI(base_url=…, api_key=…). Die OpenAI-SDK bietetself._client.models.list().data(jedes Element hat.id). Dies ist die einzige engine-bewusste Datei (Architektur-Regel) — die Modell-Liste gehört hierher.touchstone/config.py—Endpoint(base_url: str, api_key: str = "not-needed");Config.endpoint: Endpoint;load_config(path) -> Config.touchstone/gui/configs.py— pure Picker-Quelle (config_models,models_by_config), importiert bereitstouchstone.config./config-Route (app.py) —config_files = sorted(str(p) for p in Path(".").glob("config*.yaml")); reicht u. a.configs,models_by_configanconfig.html. Confine-Muster für cwd-Pfade:_confine_cwd(lehnt absolute/..-Pfade ab).model_picker.js— Alpine-KomponentemodelPicker(byConfig); Default-Config =Object.keys(byConfig)[0](= alphabetisch erste =config.embed.yaml). Lädt nicht-deferred (vor Alpine).
3 · Ratifizierte Entscheidungen¶
| # | Entscheidung | Begründung |
|---|---|---|
| D1 | Quelle = Auto-Discovery vom Endpoint (/v1/models der gewählten Config), server-seitig abgefragt. |
Echte, ladbare Modelle; entspricht der Labor-Idee. Server-seitig vermeidet CORS und nutzt den vorhandenen Client. |
| D2 | Dropdown fügt der Auswahl hinzu (eine angehakte/ad-hoc Zeile mit der gewählten id); Config-Checkboxen + Ad-hoc-Eingabe bleiben. |
Additiv, klein, reuse der bestehenden Auswahl-Maschinerie. Dropdown skaliert für viele Modelle (ollama). |
| D3 | Kurzer Timeout (~3 s), nie blockierend, nie 500. Endpoint offline/Timeout → leeres Dropdown + Hinweis, Ad-hoc bleibt. | Ein toter Endpoint darf das UI nicht hängen lassen oder die Seite brechen. |
| D4 | Default-Config-Fix: *embed*/*vlm*-Configs in der Eval-Form nach hinten ordnen → Picker startet auf einer normalen Chat-Config. |
Behebt die konkrete Beschwerde (embed-Modell als Default). Heuristik über den Dateinamen (Konvention im Repo). |
| D5 | list_models() in client.py (engine-bewusst), Discovery-Helfer mit injizierbarem Lister in configs.py. |
Hält die SDK-Berührung in der einen engine-Datei; DI macht die Discovery ohne Live-Endpoint testbar. |
4 · Architektur & Datenfluss¶
config <select> @change ──► model_picker.js: syncFromConfig()
├─ (sync) Config-Modelle als Checkboxen + Ad-hoc-Reset (wie bisher)
└─ (async) fetch('/endpoint-models?config='+config)
└─ Route GET /endpoint-models ──► configs.discover_endpoint_models(config)
└─ load_config → OpenAIStreamClient(endpoint, timeout=3s).list_models()
└─ {"models": [ids…], "error": null} | offline → {"models": [], "error": "…"}
◄── JSON ──┘
→ endpointModels füllt das Dropdown; Pick + „Hinzufügen" → ad-hoc Zeile {id, quant:""}
→ fließt (wie alle Auswahl) in das versteckte models_json → /runs/eval (unverändert)
5 · Komponenten (Verantwortung · Schnittstelle · Abhängigkeit)¶
touchstone/client.py— neue Methode:list_models(self) -> list[str]:return [m.id for m in self._client.models.list().data].__init__erhält optionaltimeout: float | None = None→OpenAI(base_url=…, api_key=…, timeout=timeout)(None = SDK-Default; bestehende Aufrufer unverändert).touchstone/gui/configs.py— neu:discover_endpoint_models(config_path: str | Path, *, lister: Callable[[], list[str]] | None = None) -> dict[str, Any]: ohnelisterbaut der Default-Listerload_config(config_path)+OpenAIStreamClient(cfg.endpoint.base_url, cfg.endpoint.api_key, timeout=3.0).list_models(). Fängt jede Exception →{"models": [], "error": f"Endpoint nicht erreichbar: {e}"}; Erfolg →{"models": lister(), "error": None}. Dedupliziert/erhält Reihenfolge der ids.order_configs(paths: list[str]) -> list[str]: stabil sortiert,*embed*/*vlm*(Dateiname, case-insensitive) ans Ende.touchstone/gui/app.py:/config-Route:config_files = configs_mod.order_configs([str(p) for p in Path(".").glob("config*.yaml")])(statt nursorted).- neue Route
@app.get("/endpoint-models")def endpoint_models(config: str) -> dict:_confine_cwd(config)→return configs_mod.discover_endpoint_models(config). Confine wirft 404 bei bösem Pfad; sonst immer 200 (Fehler imerror-Feld). touchstone/gui/static/model_picker.js— Komponente erweitern: StateendpointModels: [],endpointError: "",endpointLoading: false,endpointPick: "".syncFromConfig()ruft am EndefetchEndpointModels()(async, fire-and-forget).fetchEndpointModels():fetch('/endpoint-models?config='+encodeURIComponent(this.config))→endpointModels/endpointError; eigenertry/catch.addFromEndpoint(): bei gewähltemendpointPick→adhoc.push({id, quant:"", k:_nextK++}), dannendpointPick="".touchstone/gui/templates/config.html— im Modelle-Block (nur{% if not resume %}): ein<select x-model="endpointPick">mit<template x-for="mid in endpointModels">, ein „Hinzufügen"-Button (@click="addFromEndpoint()",:disabled="!endpointPick") und Status (endpointLoading/endpointError).
6 · Error-Handling¶
- Endpoint offline / Timeout / 404 auf
/v1/models:discover_endpoint_modelsfängt alles →{"models":[], "error":"Endpoint nicht erreichbar: …"}; Route liefert 200. Dropdown leer + Hinweis; Ad-hoc-Eingabe bleibt voll nutzbar; Start unbeeinträchtigt. - Kaputte/fehlende Config:
load_configwirft → vomtry/exceptalserrorgefangen (kein Crash). - Böser
config-Query-Pfad (absolut/..):_confine_cwd→HTTPException(404). fetchschlägt fehl (Netzwerk/Browser): JS-catch→endpointErrorgesetzt, Picker bleibt bedienbar.- Rückwärtskompat:
OpenAIStreamClient-Bestandsaufrufer (ohnetimeout) unverändert;/runs/eval,models_json, Resume unverändert.
7 · Teststrategie (TDD)¶
Unit (pur / DI):
- client.list_models: gemockter _client.models.list() (Objekt mit .data=[obj(id=…)]) → Liste der ids. (Konstruktor mit/ohne timeout.)
- discover_endpoint_models: injizierter Lister → {"models":[…],"error":None}; Lister wirft → {"models":[],"error": "…"} (kein Throw). DI-Lister deckt beide Zweige netzfrei ab; der Default-Lister (echter Client) ist triviale Komposition und wird vom headless-Smoke gegen einen echten Endpoint belegt — kein netzabhängiger Unit-Test (vermeidet Flakiness/Timeouts in der Suite).
- order_configs: ["config.embed.yaml","config.m5.yaml","config.x.vlm.yaml","config.a.yaml"] → embed/vlm hinten, Rest alphabetisch vorn.
Integration / Verhalten (TestClient):
- GET /endpoint-models?config=config.m5.yaml mit gemocktem/monkeygepatchtem discover_endpoint_models → JSON {"models":[…]}; Lister-Fehler → {"models":[],"error":…} mit 200; ?config=../etc → 404.
- /config: Default-Config ist nicht config.embed.yaml (Ordering greift); der Endpoint-Dropdown-Block (endpointPick, „Hinzufügen", /endpoint-models) ist im Markup; bei resume weiterhin aus.
Headless-Browser-Smoke (Chrome --dump-dom, Pflicht — die letzte Lücke): GUI starten, /v1/models mocken (oder gegen laufenden ollama), /config rendern und prüfen, dass das Dropdown sich füllt; per Mini-Interaktionsskript/--dump-dom belegen, dass die Komponente initialisiert (gerenderte <option>s). Mindestens: Alpine initialisiert den erweiterten modelPicker ohne JS-Fehler (gerenderte Checkbox wie beim letzten Fix).
Manuell (am Ende): GUI → /config → Default ist eine Chat-Config → Dropdown listet die ollama/LM-Studio-Modelle → eines wählen + „Hinzufügen" → erscheint als angehakte Zeile → „Eval starten" fährt es. Endpoint aus → Hinweis, Ad-hoc tippbar.
8 · Berührte / neue Dateien¶
| Datei | Änderung |
|---|---|
touchstone/client.py |
list_models() + optionaler timeout im Konstruktor |
touchstone/gui/configs.py |
discover_endpoint_models (DI-Lister) + order_configs |
touchstone/gui/app.py |
Route GET /endpoint-models (confined); /config nutzt order_configs |
touchstone/gui/static/model_picker.js |
Endpoint-Discovery-State + fetchEndpointModels + addFromEndpoint |
touchstone/gui/templates/config.html |
Endpoint-Dropdown + „Hinzufügen" + Status im Picker |
tests/ |
Tests je §7 |
AGENTS.md |
Notiz: Endpoint-Modell-Discovery (/endpoint-models, /v1/models, Timeout, nie 500) |
9 · Scope-Grenze (YAGNI)¶
In: server-seitige /v1/models-Discovery der gewählten Config; Dropdown → Auswahl hinzufügen; Offline-/Fehler-Handling; Default-Config-Ordering.
Bewusst NICHT: Endpoint-Modelle vorab anhaken; Caching der Discovery; quant/Metadaten aus /v1/models ziehen (id genügt; quant bleibt Label); Multi-Endpoint-Pools; Discovery für die Judge-Form; Auto-Refresh-Polling.