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

280 lines
13 KiB
Python

"""Verwaltung von Endpunkten, Keys und Modellen; Auflösung für einen Run (→ Konzept 8.1).
Verwalten darf nur der Plattform-Admin. Die Modellwahl je Workspace trifft der
Workspace-Admin, aber nur aus aktiven Modellen.
"""
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from urllib.parse import urlsplit
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from app import audit
from app.ai.models import MODEL_CLASSES, Model, Provider, ProviderKey
from app.core.crypto import Vault
from app.core.errors import Forbidden, Invalid, NotFound
from app.identity import Principal
from app.identity.models import Team
from app.workspaces import service as workspaces
from app.workspaces.models import Workspace
LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
class NotConfigured(Invalid):
"""Für diesen Workspace ist kein Modell nutzbar; die Meldung richtet sich an Nutzer."""
def require_platform_admin(principal: Principal) -> None:
if not principal.is_admin:
raise Forbidden
def check_base_url(url: str) -> str:
url = url.strip().rstrip("/")
parts = urlsplit(url)
if parts.username or parts.password:
raise Invalid("Keine Zugangsdaten in der URL. Der Key wird separat hinterlegt.")
local = parts.scheme == "http" and parts.hostname in LOCAL_HOSTS
if not (parts.scheme == "https" or local) or not parts.hostname:
raise Invalid("Nur https:// (http:// nur für localhost).")
return url
# --- Endpunkte ---
def providers(session: Session) -> list[Provider]:
return list(session.scalars(select(Provider).order_by(Provider.name)))
def get_provider(session: Session, provider_id: int) -> Provider:
provider = session.get(Provider, provider_id)
if provider is None:
raise NotFound
return provider
def save_provider(session: Session, principal: Principal, provider_id: int | None, *, name: str, base_url: str,
active: bool = True) -> Provider:
require_platform_admin(principal)
name = name.strip()
if not name:
raise Invalid("Bitte einen Namen angeben.")
base_url = check_base_url(base_url)
duplicate = session.scalar(select(Provider).where(Provider.name == name, Provider.id != (provider_id or 0)))
if duplicate:
raise Invalid("Diesen Namen gibt es schon.")
provider = get_provider(session, provider_id) if provider_id else Provider()
provider.name, provider.base_url, provider.active = name, base_url, active
session.add(provider)
session.flush()
audit.record(session, "provider.saved", user_id=principal.user_id, target_kind="provider",
target_id=provider.id, name=name, base_url=base_url, active=active)
return provider
# --- Keys ---
@dataclass(frozen=True)
class KeyRow:
key: ProviderKey
provider: Provider
team: Team | None
def keys(session: Session) -> list[KeyRow]:
rows = session.execute(select(ProviderKey, Provider, Team)
.join(Provider, Provider.id == ProviderKey.provider_id)
.outerjoin(Team, Team.id == ProviderKey.team_id)
.order_by(Provider.name, Team.name.nulls_first()))
return [KeyRow(*row) for row in rows]
def set_key(session: Session, principal: Principal, vault: Vault, provider_id: int, team_id: int | None,
value: str) -> ProviderKey:
"""Key anlegen oder ersetzen. Der Wert wird nie wieder angezeigt."""
require_platform_admin(principal)
get_provider(session, provider_id)
value = value.strip()
if len(value) < 8 or any(c.isspace() for c in value):
raise Invalid("Das sieht nicht nach einem API-Key aus.")
if team_id is not None and session.get(Team, team_id) is None:
raise Invalid("Unbekanntes Team.")
key = session.scalar(select(ProviderKey).where(ProviderKey.provider_id == provider_id,
ProviderKey.team_id.is_(None) if team_id is None
else ProviderKey.team_id == team_id))
key = key or ProviderKey(provider_id=provider_id, team_id=team_id)
key.secret, key.hint, key.updated_by = vault.encrypt(value), value[-4:], principal.user_id
session.add(key)
session.flush()
audit.record(session, "provider.key_set", user_id=principal.user_id, target_kind="provider",
target_id=provider_id, team_id=team_id, hint=key.hint)
return key
def delete_key(session: Session, principal: Principal, key_id: int) -> None:
require_platform_admin(principal)
key = session.get(ProviderKey, key_id)
if key is None:
raise NotFound
audit.record(session, "provider.key_deleted", user_id=principal.user_id, target_kind="provider",
target_id=key.provider_id, team_id=key.team_id, hint=key.hint)
session.delete(key)
# --- Modelle ---
def models(session: Session, active_only: bool = False) -> list[Model]:
query = select(Model).join(Provider, Provider.id == Model.provider_id).order_by(Model.label)
if active_only:
query = query.where(Model.active, Provider.active)
return list(session.scalars(query))
def parse_price(value: str) -> Decimal | None:
"""Preis je 1 Mio. Tokens; Komma oder Punkt als Dezimaltrenner, leer = kein Preis."""
value = value.strip().replace(",", ".")
if not value:
return None
try:
price = Decimal(value)
except InvalidOperation:
raise Invalid(f"{value!r} ist kein Preis.") from None
if not price.is_finite() or price < 0 or price > 100000:
raise Invalid("Preise zwischen 0 und 100.000 je 1 Mio. Tokens.")
return price.quantize(Decimal("0.0001"))
def save_model(session: Session, principal: Principal, model_id: int | None, *, provider_id: int, name: str,
label: str, model_class: str = "standard", context_window: int = 128000,
supports_tools: bool = True, supports_reasoning: bool = False, active: bool = True,
input_price: str = "", cached_price: str = "", output_price: str = "") -> Model:
require_platform_admin(principal)
get_provider(session, provider_id)
name, label = name.strip(), label.strip() or name.strip()
if not name:
raise Invalid("Bitte den Modellnamen angeben, wie ihn die API erwartet.")
if model_class not in MODEL_CLASSES:
raise Invalid("Unbekannte Modellklasse.")
if not 1000 <= context_window <= 10_000_000:
raise Invalid("Kontextgröße zwischen 1.000 und 10.000.000 Tokens.")
duplicate = session.scalar(select(Model).where(Model.provider_id == provider_id, Model.name == name,
Model.id != (model_id or 0)))
if duplicate:
raise Invalid("Dieses Modell gibt es für den Endpunkt schon.")
model = session.get(Model, model_id) if model_id else Model()
if model is None:
raise NotFound
if model.is_default and not active:
raise Invalid("Das Standardmodell kann nicht deaktiviert werden. Erst ein anderes zum Standard machen.")
model.provider_id, model.name, model.label, model.model_class = provider_id, name, label, model_class
model.context_window, model.supports_tools, model.supports_reasoning = context_window, supports_tools, supports_reasoning
model.active = active
model.input_price, model.cached_price, model.output_price = (
parse_price(input_price), parse_price(cached_price), parse_price(output_price))
if (model.input_price is None) != (model.output_price is None):
raise Invalid("Für die Kostenberechnung Eingabe- und Ausgabepreis angeben (oder beide leer lassen).")
session.add(model)
session.flush()
# Das erste Modell wird automatisch Standard
if session.scalar(select(Model.id).where(Model.is_default)) is None and active:
model.is_default = True
audit.record(session, "model.saved", user_id=principal.user_id, target_kind="model", target_id=model.id,
name=name, provider_id=provider_id, active=active)
return model
def set_default(session: Session, principal: Principal, model_id: int) -> None:
require_platform_admin(principal)
model = session.get(Model, model_id)
if model is None:
raise NotFound
if not model.active:
raise Invalid("Nur ein aktives Modell kann Standard sein.")
session.execute(update(Model).where(Model.is_default).values(is_default=False))
session.flush()
model.is_default = True
audit.record(session, "model.default_set", user_id=principal.user_id, target_kind="model", target_id=model.id)
def delete_model(session: Session, principal: Principal, model_id: int) -> int:
"""Modell entfernen; Workspaces, die es gewählt hatten, nutzen danach den Standard.
Der Verbrauch bleibt (mit Modellname)."""
require_platform_admin(principal)
model = session.get(Model, model_id)
if model is None:
raise NotFound
if model.is_default:
raise Invalid("Das Standardmodell kann nicht gelöscht werden; erst ein anderes zum Standard machen.")
affected = session.scalar(select(func.count()).select_from(Workspace).where(Workspace.model_id == model_id)) or 0
audit.record(session, "model.deleted", user_id=principal.user_id, target_kind="model", target_id=model.id,
name=model.name, workspaces=affected)
session.delete(model)
return affected
def delete_provider(session: Session, principal: Principal, provider_id: int) -> None:
"""Endpunkt entfernen, samt seinen API-Keys; nur ohne Modelle."""
require_platform_admin(principal)
provider = get_provider(session, provider_id)
if session.scalar(select(Model.id).where(Model.provider_id == provider_id).limit(1)):
raise Invalid("Der Endpunkt hat noch Modelle. Bitte erst die Modelle löschen oder einem anderen Endpunkt "
"zuordnen.")
audit.record(session, "provider.deleted", user_id=principal.user_id, target_kind="provider",
target_id=provider.id, name=provider.name)
session.delete(provider)
# --- Workspace ---
def set_workspace_model(session: Session, principal: Principal, workspace_id: int, model_id: int | None) -> None:
workspace = workspaces.require_admin(session, principal, workspace_id).workspace
if model_id is not None and model_id not in {m.id for m in models(session, active_only=True)}:
raise Invalid("Dieses Modell ist nicht freigegeben.")
workspace.model_id = model_id
audit.record(session, "workspace.model_set", user_id=principal.user_id, workspace_id=workspace_id,
target_kind="workspace", target_id=workspace_id, model_id=model_id)
@dataclass(frozen=True)
class Target:
"""Alles, was der Provider-Adapter für einen Aufruf braucht."""
base_url: str
api_key: str
model: str
model_label: str
supports_reasoning: bool
model_id: int | None = None
context_window: int = 128000
def effective_model(session: Session, workspace: Workspace) -> Model | None:
"""Gewähltes Modell des Workspace, falls noch aktiv; sonst das Standardmodell."""
if workspace.model_id:
chosen = session.get(Model, workspace.model_id)
if chosen and chosen.active and session.get(Provider, chosen.provider_id).active:
return chosen
return session.scalar(select(Model).join(Provider, Provider.id == Model.provider_id)
.where(Model.is_default, Model.active, Provider.active))
def resolve(session: Session, vault: Vault, workspace_id: int) -> Target:
"""Für den Worker: Modell, Endpunkt und Key des Teams, dem der Workspace gehört."""
workspace = session.get(Workspace, workspace_id)
model = effective_model(session, workspace) if workspace else None
if model is None:
raise NotConfigured("Es ist noch kein Modell eingerichtet. Bitte den Plattform-Admin ansprechen.")
provider = session.get(Provider, model.provider_id)
key = (session.scalar(select(ProviderKey).where(ProviderKey.provider_id == provider.id,
ProviderKey.team_id == workspace.team_id))
or session.scalar(select(ProviderKey).where(ProviderKey.provider_id == provider.id,
ProviderKey.team_id.is_(None))))
if key is None:
raise NotConfigured(f"Für dieses Team ist kein API-Key für {provider.name} hinterlegt. "
"Bitte den Plattform-Admin ansprechen.")
return Target(base_url=provider.base_url, api_key=vault.decrypt(key.secret), model=model.name,
model_label=model.label, supports_reasoning=model.supports_reasoning, model_id=model.id,
context_window=model.context_window)