@@ -0,0 +1,274 @@
|
||||
"""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)
|
||||
if provider_type == "anthropic" and normalized_base_url is not None:
|
||||
raise LlmProviderValidationError(
|
||||
"Anthropic profiles do not support a custom 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
|
||||
Reference in New Issue
Block a user