feat(cloud): manage LLM providers in database
Tests / Test passed: 664

This commit is contained in:
2026-07-14 00:31:47 +08:00
parent b613a315ff
commit a166ffd8a4
35 changed files with 2432 additions and 204 deletions
@@ -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