bifroest/app/ai/routes.py
2026-10-27 08:34:17 +01:00

126 lines
6.6 KiB
Python

"""Administration von Endpunkten, Keys und Modellen; Modellwahl im Workspace."""
from typing import Annotated
from fastapi import APIRouter, Form, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import HTMLResponse, Response
from sqlalchemy import select
from app import web
from app.ai import service as ai
from app.ai.models import MODEL_CLASSES, Model, ProviderKey
from app.ai.provider import ProviderError, ResponsesProvider
from app.core.errors import Invalid
from app.identity.models import Team
Text = Annotated[str, Form()]
Flag = Annotated[bool, Form()]
def router() -> APIRouter:
r = APIRouter(prefix="")
def page(request: Request, principal, error: str = "", notice: str = "", status: int = 200) -> HTMLResponse:
ai.require_platform_admin(principal)
with web.db(request).session() as session:
context = {
"providers": ai.providers(session),
"keys": ai.keys(session),
"models": ai.models(session),
"teams": list(session.scalars(select(Team).order_by(Team.name))),
"classes": MODEL_CLASSES,
"vault_ready": web.vault(request).ready,
"error": error,
"notice": notice,
}
context["provider_names"] = {p.id: p.name for p in context["providers"]}
return web.render(request, "admin_models.html", context, status)
def act(request: Request, principal, action, notice: str) -> Response:
try:
with web.db(request).session() as session:
action(session)
except Invalid as e:
return page(request, principal, error=str(e), status=400)
return page(request, principal, notice=notice)
@r.get("/admin/models")
def overview(request: Request, principal: web.User) -> HTMLResponse:
return page(request, principal)
@r.post("/admin/providers")
def save_provider(request: Request, principal: web.Checked, name: Text, base_url: Text,
provider_id: Annotated[int | None, Form()] = None, active: Flag = False) -> Response:
return act(request, principal, lambda s: ai.save_provider(s, principal, provider_id, name=name,
base_url=base_url, active=active),
"Endpunkt gespeichert.")
@r.post("/admin/providers/{provider_id}/test")
async def test_provider(request: Request, principal: web.Checked, provider_id: int) -> HTMLResponse:
ai.require_platform_admin(principal)
def run() -> tuple[bool, str]:
with web.db(request).session() as session:
provider = ai.get_provider(session, provider_id)
key = session.scalar(select(ProviderKey).where(ProviderKey.provider_id == provider_id)
.order_by(ProviderKey.team_id.nulls_first()))
if key is None:
return False, "Für diesen Endpunkt ist noch kein Key hinterlegt."
target = ai.Target(base_url=provider.base_url, api_key=web.vault(request).decrypt(key.secret),
model="", model_label="", supports_reasoning=False)
known = set(session.scalars(select(Model.name).where(Model.provider_id == provider_id)))
try:
names = ResponsesProvider(timeout=15).list_models(target)
except ProviderError as e:
return False, f"{e.user_message} ({e})"[:300]
listed = ", ".join(n for n in names if n in known) or "keines der eingetragenen Modelle"
return True, f"Verbindung ok, {len(names)} Modelle verfügbar. Eingetragen und verfügbar: {listed}."
ok, message = await run_in_threadpool(run)
return web.render(request, "_notice.html", {"ok": ok, "message": message})
@r.post("/admin/keys")
def set_key(request: Request, principal: web.Checked, provider_id: Annotated[int, Form()], value: Text,
team_id: Text = "") -> Response:
team = int(team_id) if team_id else None
return act(request, principal, lambda s: ai.set_key(s, principal, web.vault(request), provider_id, team, value),
"Key gespeichert. Er wird nicht wieder angezeigt.")
@r.post("/admin/keys/{key_id}/delete")
def delete_key(request: Request, principal: web.Checked, key_id: int) -> Response:
return act(request, principal, lambda s: ai.delete_key(s, principal, key_id), "Key gelöscht.")
@r.post("/admin/models")
def save_model(request: Request, principal: web.Checked, provider_id: Annotated[int, Form()], name: Text,
label: Text = "", model_class: Text = "standard",
context_window: Annotated[int, Form()] = 128000, supports_tools: Flag = False,
supports_reasoning: Flag = False, active: Flag = False,
model_id: Annotated[int | None, Form()] = None, input_price: Text = "",
cached_price: Text = "", output_price: Text = "") -> Response:
return act(request, principal, lambda s: ai.save_model(
s, principal, model_id, provider_id=provider_id, name=name, label=label, model_class=model_class,
context_window=context_window, supports_tools=supports_tools, supports_reasoning=supports_reasoning,
active=active, input_price=input_price, cached_price=cached_price, output_price=output_price),
"Modell gespeichert.")
@r.post("/admin/models/{model_id}/default")
def set_default(request: Request, principal: web.Checked, model_id: int) -> Response:
return act(request, principal, lambda s: ai.set_default(s, principal, model_id), "Standardmodell gesetzt.")
@r.post("/admin/models/{model_id}/delete")
def delete_model(request: Request, principal: web.Checked, model_id: int) -> Response:
return act(request, principal, lambda s: ai.delete_model(s, principal, model_id), "Modell gelöscht.")
@r.post("/admin/providers/{provider_id}/delete")
def delete_provider(request: Request, principal: web.Checked, provider_id: int) -> Response:
return act(request, principal, lambda s: ai.delete_provider(s, principal, provider_id),
"Endpunkt mit seinen API-Keys gelöscht.")
@r.post("/workspaces/{workspace_id}/model")
def workspace_model(request: Request, principal: web.Checked, workspace_id: int, model_id: Text = "") -> Response:
with web.db(request).session() as session:
ai.set_workspace_model(session, principal, workspace_id, int(model_id) if model_id else None)
return web.redirect(f"/workspaces/{workspace_id}/settings?tab=general&saved=1")
return r