feat(3c): OpenRouter-Modelle live & selbst-aktualisierend statt erfunden

- Neuer Endpunkt GET /api/admin/openrouter/models (10 Min gecacht) liefert
  die echte Modell-Liste von OpenRouter (live 339 Modelle).
- Konfiguration: openrouter_llm_model/-tts_model nutzen jetzt ein
  <datalist> aus dieser Live-Liste (tippen/auswählen) — die fest
  verdrahteten, teils erfundenen Namen (z.B. gemini-3.1-flash-lite) sind weg.
- Test mit gemocktem httpx. app.js v=48. 220 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 05:01:05 +02:00
commit 2bec4e72db
4 changed files with 75 additions and 3 deletions

View file

@ -1,6 +1,9 @@
import asyncio
import time
from pathlib import Path
import httpx
import yaml
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
@ -134,6 +137,33 @@ async def add_user_memory(user_id: str, payload: MemoryCreate):
return MemoryOut(id=memory.id, content=memory.content, created_at=memory.created_at)
_OR_MODELS_CACHE = {"ts": 0.0, "ids": []}
@router.get("/admin/openrouter/models", dependencies=[Depends(require_admin)])
async def openrouter_models():
"""Live-Liste der OpenRouter-Modell-IDs (10 Min gecacht) für die Modell-Auswahl.
So stehen in der Konfiguration immer aktuelle, gültige Modellnamen statt
fest verdrahteter, teils veralteter/erfundener Namen.
"""
now = time.time()
if _OR_MODELS_CACHE["ids"] and now - _OR_MODELS_CACHE["ts"] < 600:
return {"models": _OR_MODELS_CACHE["ids"], "cached": True}
key = settings.openrouter_api_key.strip()
try:
async with httpx.AsyncClient(timeout=8.0) as client:
headers = {"Authorization": f"Bearer {key}"} if key else {}
r = await client.get("https://openrouter.ai/api/v1/models", headers=headers)
r.raise_for_status()
data = r.json()
ids = sorted(m["id"] for m in data.get("data", []) if m.get("id"))
_OR_MODELS_CACHE.update(ts=now, ids=ids)
return {"models": ids, "cached": False}
except Exception as exc: # best effort -> alte Liste / leer
return {"models": _OR_MODELS_CACHE["ids"], "error": str(exc)}
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
async def list_users():
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""

View file

@ -1706,8 +1706,8 @@ const FIELD_META = {
default_tts_provider: { ui: "combo", opts: ["openrouter","piper","chatterbox"], test: "tts" },
default_language: { ui: "combo", opts: ["de","en","fr","es","it","pt","pl","ar"],
labels: { de:"Deutsch",en:"Englisch",fr:"Französisch",es:"Spanisch",it:"Italienisch",pt:"Portugiesisch",pl:"Polnisch",ar:"Arabisch" } },
openrouter_llm_model: { ui: "combo", opts: ["google/gemini-3.1-flash-lite","openai/gpt-4.1-mini","google/gemini-2.5-flash","anthropic/claude-haiku-4-5-20251001","meta-llama/llama-3.3-70b-instruct"], test: "llm" },
openrouter_tts_model: { ui: "combo", opts: ["google/gemini-3.1-flash-tts-preview","openai/gpt-4o-mini-tts"], test: "tts", testProvider: "openrouter" },
openrouter_llm_model: { ui: "datalist", test: "llm" },
openrouter_tts_model: { ui: "datalist", test: "tts", testProvider: "openrouter" },
openrouter_tts_voice: { ui: "combo", opts: ["Zephyr","Puck","Charon","Kore","Fenrir","Leda","Orus","Aoede","Callirrhoe","Enceladus","Algieba","Despina","Achernar"], test: "tts", testProvider: "openrouter" },
piper_voice: { ui: "combo", opts: ["de_DE-thorsten-high","de_DE-kerstin-low","en_US-lessac-high","en_US-amy-medium","en_US-ryan-high","en_GB-cori-high","fr_FR-siwis-medium","fr_FR-tom-medium","es_ES-sharvard-medium","it_IT-paola-medium","it_IT-riccardo-x_low","pt_BR-faber-medium","pl_PL-gosia-medium","pl_PL-darkman-medium","ar_JO-kareem-medium","ru_RU-irina-medium","ru_RU-ruslan-medium","zh_CN-huayan-medium"], test: "tts", testProvider: "piper" },
local_llm_system_prompt: { ui: "textarea", test: "llm" },
@ -1774,6 +1774,20 @@ async function loadSettings() {
section.appendChild(body);
container.appendChild(section);
});
populateModelDatalists();
}
// Füllt alle Modell-<datalist>s mit der Live-Liste von OpenRouter (einmal gecacht).
let _orModels = null;
async function populateModelDatalists() {
const lists = document.querySelectorAll("datalist[data-or-models]");
if (!lists.length) return;
if (_orModels === null) {
const res = await adminFetch("/api/admin/openrouter/models");
_orModels = (res && res.models) || [];
}
const opts = _orModels.map((m) => `<option value="${escHtml(m)}"></option>`).join("");
lists.forEach((dl) => { dl.innerHTML = opts; });
}
function _buildControl(item, meta) {
@ -1807,6 +1821,14 @@ function _buildControl(item, meta) {
</div>`;
}
if (meta.ui === "datalist") {
// Live-Modell-Liste (OpenRouter) per <datalist>: tippen/auswählen, immer gültige Namen.
return `
<input id="${id}" list="${id}-list" type="text" value="${escHtml(val)}" placeholder="Modell eingeben oder wählen"
class="${cls} flex-1" />
<datalist id="${id}-list" data-or-models></datalist>`;
}
if (meta.ui === "range") {
return `
<div class="flex-1 flex items-center gap-3">

View file

@ -365,6 +365,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<script src="/app.js?v=47"></script>
<script src="/app.js?v=48"></script>
</body>
</html>