271 lines
8.6 KiB
Python
271 lines
8.6 KiB
Python
"""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
|