"""Domain and service layer for Cloud-managed LLM Provider profiles.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime from typing import Literal from urllib.parse import urlparse from uuid import uuid4 from cloud.provider_secrets import ProviderSecretBox, load_provider_secret_box ProviderType = Literal["anthropic", "openai-compatible"] SUPPORTED_PROVIDER_TYPES = frozenset({"anthropic", "openai-compatible"}) class LlmProviderValidationError(ValueError): pass class LlmProviderConflictError(RuntimeError): pass class LlmProviderActiveConflictError(LlmProviderConflictError): pass class LlmProviderResolutionError(RuntimeError): pass @dataclass(frozen=True) class LlmProviderProfile: id: str name: str name_normalized: str provider_type: ProviderType model: str base_url: str | None timeout_seconds: float api_key_ciphertext: str = field(repr=False) key_last_rotated_at: datetime enabled: bool revision: int created_at: datetime updated_at: datetime @dataclass(frozen=True) class LlmProviderSettings: active_profile_id: str | None revision: int = 0 updated_at: datetime | None = None @dataclass(frozen=True) class ResolvedLlmProviderProfile: profile: LlmProviderProfile api_key: str = field(repr=False) @dataclass(frozen=True) class LlmProviderProfileInput: name: str name_normalized: str provider_type: ProviderType model: str base_url: str | None timeout_seconds: float def validate_profile_input( *, name: str, provider_type: str, model: str, base_url: str | None, timeout_seconds: float, ) -> LlmProviderProfileInput: display_name = " ".join(name.split()) if not display_name or len(display_name) > 120: raise LlmProviderValidationError( "Provider name must contain 1 to 120 characters" ) normalized_name = display_name.casefold() if provider_type not in SUPPORTED_PROVIDER_TYPES: raise LlmProviderValidationError("Provider type is not supported") normalized_model = model.strip() if not normalized_model or len(normalized_model) > 200: raise LlmProviderValidationError("Model must contain 1 to 200 characters") if timeout_seconds <= 0 or timeout_seconds > 120: raise LlmProviderValidationError( "Timeout must be greater than zero and at most 120" ) normalized_base_url = _normalize_base_url(base_url) return LlmProviderProfileInput( name=display_name, name_normalized=normalized_name, provider_type=provider_type, # type: ignore[arg-type] model=normalized_model, base_url=normalized_base_url, timeout_seconds=timeout_seconds, ) class LlmProviderService: """Applies secret and active-profile invariants above the repository port.""" def __init__( self, repository, *, secret_box_factory: Callable[[], ProviderSecretBox] = load_provider_secret_box, ) -> None: self._repository = repository self._secret_box_factory = secret_box_factory def list_profiles(self) -> tuple[LlmProviderSettings, list[LlmProviderProfile]]: return ( self._repository.get_llm_provider_settings(), self._repository.list_llm_provider_profiles(), ) def create_profile( self, *, name: str, provider_type: str, model: str, base_url: str | None, timeout_seconds: float, api_key: str, now: datetime, ) -> LlmProviderProfile: if not api_key: raise LlmProviderValidationError("Provider API key must not be empty") values = validate_profile_input( name=name, provider_type=provider_type, model=model, base_url=base_url, timeout_seconds=timeout_seconds, ) return self._repository.create_llm_provider_profile( LlmProviderProfile( id=uuid4().hex, name=values.name, name_normalized=values.name_normalized, provider_type=values.provider_type, model=values.model, base_url=values.base_url, timeout_seconds=values.timeout_seconds, api_key_ciphertext=self._secret_box_factory().encrypt(api_key), key_last_rotated_at=now, enabled=True, revision=1, created_at=now, updated_at=now, ) ) def update_profile( self, profile_id: str, *, name: str | None, provider_type: str | None, model: str | None, base_url: str | None, base_url_supplied: bool, timeout_seconds: float | None, enabled: bool | None, api_key: str | None, expected_revision: int | None, now: datetime, ) -> LlmProviderProfile: current = self._repository.get_llm_provider_profile(profile_id) if current is None: raise KeyError(profile_id) values = validate_profile_input( name=current.name if name is None else name, provider_type=current.provider_type if provider_type is None else provider_type, model=current.model if model is None else model, base_url=current.base_url if not base_url_supplied else base_url, timeout_seconds=( current.timeout_seconds if timeout_seconds is None else timeout_seconds ), ) ciphertext = current.api_key_ciphertext key_last_rotated_at = current.key_last_rotated_at if api_key is not None: if not api_key: raise LlmProviderValidationError("Provider API key must not be empty") ciphertext = self._secret_box_factory().encrypt(api_key) key_last_rotated_at = now return self._repository.update_llm_provider_profile( profile_id, name=values.name, name_normalized=values.name_normalized, provider_type=values.provider_type, model=values.model, base_url=values.base_url, timeout_seconds=values.timeout_seconds, api_key_ciphertext=ciphertext, key_last_rotated_at=key_last_rotated_at, enabled=current.enabled if enabled is None else enabled, expected_revision=expected_revision, updated_at=now, ) def activate_profile( self, profile_id: str, *, expected_settings_revision: int | None, now: datetime, ) -> LlmProviderSettings: profile = self._repository.get_llm_provider_profile(profile_id) if profile is None: raise KeyError(profile_id) # Fail before persisting the activation when the credential cannot be used. self._secret_box_factory().decrypt(profile.api_key_ciphertext) return self._repository.activate_llm_provider_profile( profile_id, expected_settings_revision=expected_settings_revision, updated_at=now, ) def delete_profile( self, profile_id: str, *, expected_revision: int | None, ) -> None: self._repository.delete_llm_provider_profile( profile_id, expected_revision=expected_revision, ) def resolve_active_profile(self) -> ResolvedLlmProviderProfile: settings = self._repository.get_llm_provider_settings() if settings.active_profile_id is None: raise LlmProviderResolutionError("no active database Provider profile") profile = self._repository.get_llm_provider_profile(settings.active_profile_id) if profile is None or not profile.enabled: raise LlmProviderResolutionError( "active database Provider profile is unavailable" ) try: api_key = self._secret_box_factory().decrypt(profile.api_key_ciphertext) except ValueError as exc: raise LlmProviderResolutionError(str(exc)) from exc return ResolvedLlmProviderProfile(profile=profile, api_key=api_key) def _normalize_base_url(value: str | None) -> str | None: if value is None: return None normalized = value.strip().rstrip("/") if not normalized: return None parsed = urlparse(normalized) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise LlmProviderValidationError("Base URL must be an absolute HTTP(S) URL") return normalized