diff --git a/app/api/admin.py b/app/api/admin.py index cb0a7ce..68e59f0 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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.""" diff --git a/app/web/app.js b/app/web/app.js index c3da81f..199859c 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -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-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) => ``).join(""); + lists.forEach((dl) => { dl.innerHTML = opts; }); } function _buildControl(item, meta) { @@ -1807,6 +1821,14 @@ function _buildControl(item, meta) { `; } + if (meta.ui === "datalist") { + // Live-Modell-Liste (OpenRouter) per : tippen/auswählen, immer gültige Namen. + return ` + + `; + } + if (meta.ui === "range") { return `
diff --git a/app/web/index.html b/app/web/index.html index b5bc8a6..f1c782c 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -365,6 +365,6 @@
- + diff --git a/tests/test_admin_settings.py b/tests/test_admin_settings.py index a37f7f9..325cfa2 100644 --- a/tests/test_admin_settings.py +++ b/tests/test_admin_settings.py @@ -215,3 +215,23 @@ def test_admin_can_flag_user_as_admin(): assert r.status_code == 200 and r.json()["is_admin"] is True users = client.get("/api/admin/users", headers=ADM_HDR).json() assert next(u for u in users if u["user_id"] == uid)["is_admin"] is True + + +def test_openrouter_models_endpoint_returns_sorted_ids(monkeypatch): + import app.api.admin as adm + import httpx + adm._OR_MODELS_CACHE.update(ts=0.0, ids=[]) + + class FakeResp: + def raise_for_status(self): + pass + def json(self): + return {"data": [{"id": "b/model"}, {"id": "a/model"}, {"x": 1}]} + + async def fake_get(self, url, headers=None): + return FakeResp() + + monkeypatch.setattr(httpx.AsyncClient, "get", fake_get) + r = client.get("/api/admin/openrouter/models", headers=ADM_HDR) + assert r.status_code == 200 + assert r.json()["models"] == ["a/model", "b/model"]