feat(admin): Modell-Browser (Cloud) mit Tool-Badge, Preisen & $/Antwort

Phase 1 (Cloud) des Modell-Auswahlmenues:
- Token je Turn im CostMeter (record_openrouter_cost liest prompt/completion_
  tokens) -> globales Turn-Profil (turn_profile, EMA) fuer die $/Antwort-Schaetzung.
- app/model_menu.py: gecachter OpenRouter-Katalog -> Chat-Modelle mit tool_supported
  (supported_parameters), eval_verified (kuratiert), Up/Down-Preisen pro Mio. Token
  und geschaetzter $/Antwort (Profil x Modellpreise).
- GET /api/admin/model-menu; durchsuchbarer Browser im System-Tab, "Verwenden"
  setzt openrouter_llm_model (Freitext/Preset bleiben).
Lokale Modelle (Durchsatz/Strom): Phase 2. Konzept in Ideen/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 22:37:36 +02:00
commit a87dc75a46
5 changed files with 233 additions and 3 deletions

View file

@ -1785,6 +1785,17 @@ async function loadStatus() {
${modelCard}
<!-- Modell-Browser (Cloud/OpenRouter) -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<div class="flex items-center justify-between mb-1">
<h3 class="font-semibold text-sm">Modell-Browser (Cloud)</h3>
<button id="model-menu-load" class="text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1 hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">Modelle laden</button>
</div>
<p class="text-xs text-slate-400 mb-2"> im Eval bestätigt · 🔧 Tools laut Anbieter · Preise pro Mio. Token ( Eingabe / Ausgabe) · ~$/Antwort geschätzt.</p>
<input id="model-menu-search" type="text" placeholder="Filtern (Name/ID) …" class="hidden w-full mb-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<div id="model-menu-results" class="text-xs"></div>
</div>
<!-- Provider -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-3">Provider (Standard-Route)</h3>
@ -1817,6 +1828,7 @@ async function loadStatus() {
</div>
`;
wireModelPicker();
wireModelBrowser();
} catch (e) {
console.error("loadStatus error:", e);
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
@ -1839,6 +1851,70 @@ function wireModelPicker() {
});
}
// Modell-Browser (Cloud): Katalog laden, filtern, ein Modell als openrouter_llm_model setzen.
let _modelMenu = [];
let _modelCurrent = "";
function wireModelBrowser() {
const btn = $("#model-menu-load");
if (btn) btn.addEventListener("click", loadModelMenu);
}
async function loadModelMenu() {
const box = $("#model-menu-results");
const search = $("#model-menu-search");
if (!box) return;
box.innerHTML = '<p class="text-slate-400 py-2">lade …</p>';
const data = await adminFetch("/api/admin/model-menu");
if (!data) { box.innerHTML = '<p class="text-red-500 py-2">Fehler beim Laden.</p>'; return; }
if (!data.available) {
box.innerHTML = `<p class="text-red-500 py-2">Katalog nicht verfügbar: ${escHtml(data.error || "unbekannt")}</p>`;
return;
}
_modelMenu = data.models || [];
_modelCurrent = data.current || "";
const p = data.profile || {};
box.dataset.note = `~$/Antwort bei Ø ${p.avg_prompt ?? "?"} Eingabe- + ${p.avg_completion ?? "?"} Ausgabe-Token (${p.measured ? "gemessen" : "Default"}).`;
if (search) {
search.classList.remove("hidden");
search.value = "";
search.oninput = () => renderModelMenu(search.value.trim().toLowerCase());
}
renderModelMenu("");
}
function renderModelMenu(q) {
const box = $("#model-menu-results");
if (!box) return;
const list = q
? _modelMenu.filter((m) => (m.id + " " + m.name).toLowerCase().includes(q))
: _modelMenu;
const fmt = (n) => "$" + (Number(n) || 0);
const rows = list.slice(0, 400).map((m) => {
const badge = m.eval_verified ? "✅" : (m.tool_supported ? "🔧" : "·");
const cur = m.id === _modelCurrent;
return `
<div class="flex items-center gap-2 py-1 border-b border-slate-100 dark:border-slate-800 ${cur ? "bg-blue-50 dark:bg-blue-900/20" : ""}">
<span class="w-4 text-center" title="${m.eval_verified ? "im Eval bestätigt" : m.tool_supported ? "Tools laut Anbieter" : "kein Tool-Support"}">${badge}</span>
<span class="flex-1 truncate font-mono" title="${escHtml(m.id)}">${escHtml(m.name)}</span>
<span class="w-16 text-right text-slate-500" title="Eingabe / Mio. Token">${fmt(m.price_in_per_mtok)}</span>
<span class="w-16 text-right text-slate-500" title="Ausgabe / Mio. Token">${fmt(m.price_out_per_mtok)}</span>
<span class="w-20 text-right text-slate-600 dark:text-slate-300" title="geschätzte Kosten pro Antwort">~$${(Number(m.est_cost_per_answer) || 0).toFixed(5)}</span>
<button class="btn-use-model text-xs rounded border border-slate-300 dark:border-slate-600 px-2 py-0.5 hover:bg-slate-100 dark:hover:bg-slate-700 ${cur ? "opacity-50" : ""}" data-id="${escHtml(m.id)}">${cur ? "aktiv" : "Verwenden"}</button>
</div>`;
}).join("");
box.innerHTML = `
<p class="text-slate-400 mb-1">${escHtml(box.dataset.note || "")} ${list.length} Modelle.</p>
<div class="max-h-80 overflow-y-auto">${rows || '<p class="text-slate-400 py-2">keine Treffer</p>'}</div>`;
box.querySelectorAll(".btn-use-model").forEach((b) =>
b.addEventListener("click", () => useModel(b.dataset.id)));
}
async function useModel(id) {
if (!confirm(`Cloud-LLM auf „${id}" setzen?\n(Greift nur, wenn der LLM-Provider „openrouter" ist.)`)) return;
const res = await adminFetch("/api/admin/config/openrouter_llm_model", "PUT", { value: id });
if (res) { _modelCurrent = id; loadStatus(); }
}
// ════════════════════════════════════════
// TAB: WARTUNG (Konfiguration)
// ════════════════════════════════════════