280 lines
13 KiB
Python
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)
|