"""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)