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
+1
View File
@@ -18,6 +18,7 @@ PLUGINS_ADMIN_SCOPE = "plugins:admin"
USERS_ADMIN_SCOPE = "users:admin"
GOVERNANCE_READ_SCOPE = "governance:read"
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin"
@dataclass(frozen=True)
@@ -285,3 +285,45 @@ class TokenUsageEventRow(Base):
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
occurred_at: Mapped[str] = mapped_column(String, nullable=False)
class LlmProviderProfileRow(Base):
__tablename__ = "cloud_llm_provider_profiles"
__table_args__ = (
UniqueConstraint(
"name_normalized",
name="uq_cloud_llm_provider_profiles_name_normalized",
),
Index("ix_cloud_llm_provider_profiles_enabled", "enabled"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
name_normalized: Mapped[str] = mapped_column(String, nullable=False)
provider_type: Mapped[str] = mapped_column(String, nullable=False)
model: Mapped[str] = mapped_column(String, nullable=False)
base_url: Mapped[str | None] = mapped_column(String, nullable=True)
timeout_seconds: Mapped[float] = mapped_column(nullable=False)
api_key_ciphertext: Mapped[str] = mapped_column(Text, nullable=False)
key_last_rotated_at: Mapped[str] = mapped_column(String, nullable=False)
enabled: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
revision: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
created_at: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
class LlmProviderSettingsRow(Base):
__tablename__ = "cloud_llm_provider_settings"
id: Mapped[str] = mapped_column(String, primary_key=True)
active_profile_id: Mapped[str | None] = mapped_column(
ForeignKey("cloud_llm_provider_profiles.id"), nullable=True
)
revision: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
@@ -39,7 +39,9 @@ from cloud.internal_api.models import (
TerminalResultRequest,
TerminalResultResponse,
)
from cloud.planner_config import build_cloud_planner_client, load_cloud_planner_config
from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService
from cloud.planner_config import build_cloud_planner_client
from cloud.provider_secrets import ProviderSecretConfigurationError
from cloud.repository import (
DeviceEnrollmentConflictError,
HostEnrollmentConflictError,
@@ -65,6 +67,7 @@ def create_internal_router(
lease_duration_seconds: float = 60.0,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
planner_provider_service: LlmProviderService | None = None,
scheduler: TaskScheduler | None = None,
planner_token_reservation_ceiling: int = 4096,
planner_token_reservation_ttl_seconds: float = 300.0,
@@ -78,8 +81,6 @@ def create_internal_router(
if planner_token_reservation_ttl_seconds <= 0:
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero")
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
build_planner_client = planner_client_factory or _default_planner_client_factory
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
if principal is None:
@@ -395,6 +396,28 @@ def create_internal_router(
for tool in payload.tools
]
try:
if planner_client_factory is not None:
client = planner_client_factory()
resolved_provider = "test"
resolved_model = "test"
elif planner_provider_service is not None:
resolved = planner_provider_service.resolve_active_profile()
client = build_cloud_planner_client(resolved)
resolved_provider = resolved.profile.provider_type
resolved_model = resolved.profile.model
else:
raise LlmProviderResolutionError("no database Provider resolver configured")
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
logger.info(
"planner-decision request failed",
extra={"host_id": host_id, "error_class": type(exc).__name__},
)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
now = utc_now()
reservation = None
try:
@@ -415,7 +438,6 @@ def create_internal_router(
)
started_at = monotonic()
client = build_planner_client()
try:
decision = client.decide(
system_prompt=payload.system_prompt,
@@ -439,12 +461,11 @@ def create_internal_router(
)
usage = decision.usage
if reservation is not None and usage is not None and usage.total_tokens is not None:
planner_config = load_cloud_planner_config()
pool.store.settle_host_token_reservation(
reservation_id=reservation.id,
event_id=uuid4().hex,
provider=planner_config.provider,
model=planner_config.resolved_model(),
provider=resolved_provider,
model=resolved_model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
total_tokens=usage.total_tokens,
@@ -469,10 +490,6 @@ def create_internal_router(
return router
def _default_planner_client_factory() -> ToolCallingClient:
return build_cloud_planner_client(load_cloud_planner_config())
def _validate_assignment_identity(
*,
host_id: str,
@@ -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
@@ -0,0 +1,65 @@
"""Add durable, encrypted Cloud LLM Provider profiles."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0007_llm_provider_management"
down_revision = "0006_host_planner_transport"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"cloud_llm_provider_profiles",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("name_normalized", sa.String(), nullable=False),
sa.Column("provider_type", sa.String(), nullable=False),
sa.Column("model", sa.String(), nullable=False),
sa.Column("base_url", sa.String(), nullable=True),
sa.Column("timeout_seconds", sa.Float(), nullable=False),
sa.Column("api_key_ciphertext", sa.Text(), nullable=False),
sa.Column("key_last_rotated_at", sa.String(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False, server_default="1"),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.String(), nullable=False),
sa.Column("updated_at", sa.String(), nullable=False),
sa.UniqueConstraint(
"name_normalized", name="uq_cloud_llm_provider_profiles_name_normalized"
),
)
op.create_index(
"ix_cloud_llm_provider_profiles_enabled",
"cloud_llm_provider_profiles",
["enabled"],
)
op.create_table(
"cloud_llm_provider_settings",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"active_profile_id",
sa.String(),
sa.ForeignKey("cloud_llm_provider_profiles.id"),
nullable=True,
),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("updated_at", sa.String(), nullable=False),
)
op.execute(
"insert into cloud_llm_provider_settings "
"(id, active_profile_id, revision, updated_at) values "
"('global', null, 0, '1970-01-01T00:00:00+00:00')"
)
def downgrade() -> None:
op.drop_table("cloud_llm_provider_settings")
op.drop_index(
"ix_cloud_llm_provider_profiles_enabled",
table_name="cloud_llm_provider_profiles",
)
op.drop_table("cloud_llm_provider_profiles")
+14 -76
View File
@@ -1,86 +1,24 @@
"""Cloud Control Plane's own AI Planner provider configuration.
Analogous to ``runtime/planner_config.py``, but loaded from the Cloud API's
own process environment rather than a Host Agent's. There is no ``enabled``
flag here: the planner-decision endpoint always exists once the Cloud API is
running, and simply fails a given request if the configured provider call
fails (see ``cloud.internal_api.api``). Provider API keys
(``ANTHROPIC_API_KEY``/``OPENAI_API_KEY``) are not modeled as fields here --
like the Host Agent's direct-transport path, they are read implicitly by the
``anthropic``/``openai`` SDK clients from the process environment.
"""
"""Construct Cloud planner clients from resolved database Provider profiles."""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from cloud.llm_providers import ResolvedLlmProviderProfile
from runtime.tool_calling_client import (
AnthropicToolCallingClient,
OpenAIToolCallingClient,
ToolCallingClient,
)
DEFAULT_PROVIDER = "anthropic"
DEFAULT_MODEL_BY_PROVIDER = {
"anthropic": "claude-sonnet-5",
"openai": "gpt-5.6",
}
DEFAULT_TIMEOUT_SECONDS = 30.0
PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@dataclass(frozen=True)
class CloudPlannerConfig:
provider: str = DEFAULT_PROVIDER
model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS
def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
def load_cloud_planner_config(
env: Mapping[str, str] | None = None,
) -> CloudPlannerConfig:
values = env or os.environ
return CloudPlannerConfig(
provider=_parse_provider(values.get(PROVIDER_ENV)),
model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
)
def build_cloud_planner_client(config: CloudPlannerConfig) -> ToolCallingClient:
"""Construct the same provider client the Host Agent's direct transport uses.
Reuses ``runtime.tool_calling_client``'s Anthropic/OpenAI wire-format
translation (see design decision D1) instead of a second implementation.
"""
model = config.resolved_model()
if config.provider == "openai":
return OpenAIToolCallingClient(model=model)
return AnthropicToolCallingClient(model=model)
def _parse_provider(value: str | None) -> str:
if value is None:
return DEFAULT_PROVIDER
provider = value.strip().lower()
return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER
def _parse_timeout(value: str | None) -> float:
if value is None:
return DEFAULT_TIMEOUT_SECONDS
try:
timeout = float(value)
except ValueError:
return DEFAULT_TIMEOUT_SECONDS
return timeout if timeout > 0 else DEFAULT_TIMEOUT_SECONDS
def build_cloud_planner_client(
resolved: ResolvedLlmProviderProfile,
) -> ToolCallingClient:
"""Build a provider client without reading Cloud API environment variables."""
profile = resolved.profile
if profile.provider_type == "openai-compatible":
return OpenAIToolCallingClient(
model=profile.model,
api_key=resolved.api_key,
base_url=profile.base_url,
)
return AnthropicToolCallingClient(model=profile.model, api_key=resolved.api_key)
@@ -0,0 +1,56 @@
"""Encryption for Cloud-managed LLM Provider credentials."""
from __future__ import annotations
import os
from collections.abc import Mapping
from cryptography.fernet import Fernet, InvalidToken
ENCRYPTION_KEY_ENV = "CLOUD_LLM_PROVIDER_ENCRYPTION_KEY"
class ProviderSecretConfigurationError(ValueError):
"""Raised when deployment configuration cannot protect a Provider secret."""
class ProviderSecretDecryptionError(ValueError):
"""Raised when a persisted Provider secret cannot be decrypted."""
class ProviderSecretBox:
"""Small, testable boundary around Fernet encryption."""
def __init__(self, key: str) -> None:
try:
self._fernet = Fernet(key.encode("ascii"))
except (UnicodeEncodeError, ValueError) as exc:
raise ProviderSecretConfigurationError(
f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key"
) from exc
def encrypt(self, secret: str) -> str:
if not secret:
raise ValueError("Provider API key must not be empty")
return self._fernet.encrypt(secret.encode("utf-8")).decode("ascii")
def decrypt(self, ciphertext: str) -> str:
try:
return self._fernet.decrypt(ciphertext.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeDecodeError, UnicodeEncodeError) as exc:
raise ProviderSecretDecryptionError(
"stored Provider API key cannot be decrypted"
) from exc
def load_provider_secret_box(
env: Mapping[str, str] | None = None,
) -> ProviderSecretBox:
values = os.environ if env is None else env
key = values.get(ENCRYPTION_KEY_ENV, "")
if not key:
raise ProviderSecretConfigurationError(
f"{ENCRYPTION_KEY_ENV} must be configured for database Provider management"
)
return ProviderSecretBox(key)
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
TokenUsageEvent,
UserSubmissionPolicy,
)
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -341,6 +342,48 @@ class CloudRepository(Protocol):
self, *, host_id: str, limit: int, offset: int,
) -> list[TokenUsageEvent]: ...
def get_llm_provider_settings(self) -> LlmProviderSettings: ...
def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ...
def get_llm_provider_profile(self, profile_id: str) -> LlmProviderProfile | None: ...
def create_llm_provider_profile(
self, profile: LlmProviderProfile
) -> LlmProviderProfile: ...
def update_llm_provider_profile(
self,
profile_id: str,
*,
name: str,
name_normalized: str,
provider_type: ProviderType,
model: str,
base_url: str | None,
timeout_seconds: float,
api_key_ciphertext: str,
key_last_rotated_at: datetime,
enabled: bool,
expected_revision: int | None,
updated_at: datetime,
) -> LlmProviderProfile: ...
def activate_llm_provider_profile(
self,
profile_id: str,
*,
expected_settings_revision: int | None,
updated_at: datetime,
) -> LlmProviderSettings: ...
def delete_llm_provider_profile(
self,
profile_id: str,
*,
expected_revision: int | None,
) -> None: ...
def list_reserved_device_ids(self, *, now: datetime) -> set[str]: ...
def assign_task(
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0006_host_planner_transport"
HEAD_REVISION = "0007_llm_provider_management"
class SchemaVersionError(RuntimeError):
@@ -0,0 +1,268 @@
"""Administrator API for Cloud-managed LLM Provider profiles."""
from __future__ import annotations
from collections.abc import Callable
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request, status
from cloud.auth import AuthProvider, LLM_PROVIDERS_ADMIN_SCOPE, Principal
from cloud.llm_providers import (
LlmProviderActiveConflictError,
LlmProviderConflictError,
LlmProviderService,
LlmProviderValidationError,
)
from cloud.observability import current_correlation_id
from cloud.provider_secrets import (
ProviderSecretConfigurationError,
ProviderSecretDecryptionError,
)
from cloud.sdk.models import (
LlmProviderProfileActivateRequest,
LlmProviderProfileCreateRequest,
LlmProviderProfileListResponse,
LlmProviderProfileResponse,
LlmProviderProfileUpdateRequest,
LlmProviderSettingsResponse,
)
from cloud.user_auth import AuthAuditEvent
from core.models import utc_now
def create_llm_provider_router(
*,
service: LlmProviderService,
repository,
auth_provider: AuthProvider,
csrf_validator: Callable[[Request, Principal], bool],
version_prefix: str = "/v1",
) -> APIRouter:
router = APIRouter(prefix=version_prefix, tags=["llm-provider-management"])
def authorize(request: Request) -> Principal:
principal = auth_provider.authenticate(request)
if principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
if principal.must_change_password or not principal.has_scope(
LLM_PROVIDERS_ADMIN_SCOPE
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {LLM_PROVIDERS_ADMIN_SCOPE}",
)
return principal
def require_csrf(request: Request, principal: Principal) -> None:
if not csrf_validator(request, principal):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF validation failed",
)
@router.get("/planner/providers", response_model=LlmProviderProfileListResponse)
def list_profiles(request: Request) -> LlmProviderProfileListResponse:
authorize(request)
settings, profiles = service.list_profiles()
return LlmProviderProfileListResponse(
settings=_settings_response(settings),
items=[
_profile_response(profile, settings.active_profile_id)
for profile in profiles
],
)
@router.post(
"/planner/providers",
response_model=LlmProviderProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_profile(
payload: LlmProviderProfileCreateRequest,
request: Request,
) -> LlmProviderProfileResponse:
principal = authorize(request)
require_csrf(request, principal)
try:
profile = service.create_profile(
name=payload.name,
provider_type=payload.provider_type,
model=payload.model,
base_url=payload.base_url,
timeout_seconds=payload.timeout_seconds,
api_key=payload.api_key.get_secret_value(),
now=utc_now(),
)
except LlmProviderValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except LlmProviderConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except ProviderSecretConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
_audit(repository, principal, profile.id, "llm_provider_create")
return _profile_response(profile, None)
@router.patch(
"/planner/providers/{profile_id}", response_model=LlmProviderProfileResponse
)
def update_profile(
profile_id: str,
payload: LlmProviderProfileUpdateRequest,
request: Request,
) -> LlmProviderProfileResponse:
principal = authorize(request)
require_csrf(request, principal)
try:
profile = service.update_profile(
profile_id,
name=payload.name,
provider_type=payload.provider_type,
model=payload.model,
base_url=payload.base_url,
base_url_supplied="base_url" in payload.model_fields_set,
timeout_seconds=payload.timeout_seconds,
enabled=payload.enabled,
api_key=(
payload.api_key.get_secret_value()
if payload.api_key is not None
else None
),
expected_revision=payload.expected_revision,
now=utc_now(),
)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Provider profile not found",
) from exc
except LlmProviderValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except LlmProviderActiveConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except LlmProviderConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except ProviderSecretConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
settings = repository.get_llm_provider_settings()
_audit(repository, principal, profile.id, "llm_provider_update")
return _profile_response(profile, settings.active_profile_id)
@router.post(
"/planner/providers/{profile_id}/activate",
response_model=LlmProviderSettingsResponse,
)
def activate_profile(
profile_id: str,
payload: LlmProviderProfileActivateRequest,
request: Request,
) -> LlmProviderSettingsResponse:
principal = authorize(request)
require_csrf(request, principal)
try:
settings = service.activate_profile(
profile_id,
expected_settings_revision=payload.expected_settings_revision,
now=utc_now(),
)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Provider profile not found",
) from exc
except (LlmProviderConflictError, ProviderSecretDecryptionError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except ProviderSecretConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
_audit(repository, principal, profile_id, "llm_provider_activate")
return _settings_response(settings)
@router.delete(
"/planner/providers/{profile_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_profile(
profile_id: str,
request: Request,
expected_revision: int | None = Query(default=None, ge=1),
) -> None:
principal = authorize(request)
require_csrf(request, principal)
try:
service.delete_profile(profile_id, expected_revision=expected_revision)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Provider profile not found",
) from exc
except LlmProviderConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
_audit(repository, principal, profile_id, "llm_provider_delete")
return router
def _profile_response(
profile, active_profile_id: str | None
) -> LlmProviderProfileResponse:
return LlmProviderProfileResponse(
id=profile.id,
name=profile.name,
provider_type=profile.provider_type,
model=profile.model,
base_url=profile.base_url,
timeout_seconds=profile.timeout_seconds,
enabled=profile.enabled,
revision=profile.revision,
has_api_key=bool(profile.api_key_ciphertext),
key_last_rotated_at=profile.key_last_rotated_at,
created_at=profile.created_at,
updated_at=profile.updated_at,
active=profile.id == active_profile_id,
)
def _settings_response(settings) -> LlmProviderSettingsResponse:
return LlmProviderSettingsResponse(
active_profile_id=settings.active_profile_id,
revision=settings.revision,
updated_at=settings.updated_at,
)
def _audit(repository, principal: Principal, profile_id: str, action: str) -> None:
repository.record_auth_audit(
AuthAuditEvent(
id=uuid4().hex,
occurred_at=utc_now(),
actor_principal_id=principal.id,
target_user_id=None,
action=action,
outcome="success",
correlation_id=current_correlation_id(),
metadata={"provider_profile_id": profile_id},
)
)
+52 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, SecretStr
class TaskConstraintsModel(BaseModel):
@@ -212,3 +212,54 @@ class TokenUsageEventResponse(BaseModel):
output_tokens: int | None = None
total_tokens: int
occurred_at: datetime
class LlmProviderProfileCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
provider_type: Literal["anthropic", "openai-compatible"]
model: str = Field(min_length=1, max_length=200)
base_url: str | None = Field(default=None, max_length=500)
timeout_seconds: float = Field(gt=0, le=120)
api_key: SecretStr
class LlmProviderProfileUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
provider_type: Literal["anthropic", "openai-compatible"] | None = None
model: str | None = Field(default=None, min_length=1, max_length=200)
base_url: str | None = Field(default=None, max_length=500)
timeout_seconds: float | None = Field(default=None, gt=0, le=120)
enabled: bool | None = None
api_key: SecretStr | None = None
expected_revision: int | None = Field(default=None, ge=1)
class LlmProviderProfileActivateRequest(BaseModel):
expected_settings_revision: int | None = Field(default=None, ge=0)
class LlmProviderProfileResponse(BaseModel):
id: str
name: str
provider_type: Literal["anthropic", "openai-compatible"]
model: str
base_url: str | None = None
timeout_seconds: float
enabled: bool
revision: int
has_api_key: bool
key_last_rotated_at: datetime
created_at: datetime
updated_at: datetime
active: bool
class LlmProviderSettingsResponse(BaseModel):
active_profile_id: str | None = None
revision: int
updated_at: datetime | None = None
class LlmProviderProfileListResponse(BaseModel):
settings: LlmProviderSettingsResponse
items: list[LlmProviderProfileResponse]
+355 -40
View File
@@ -20,6 +20,8 @@ from cloud.db_models import (
TaskAttemptRow,
AuthAuditRow,
HostGovernancePolicyRow,
LlmProviderProfileRow,
LlmProviderSettingsRow,
LoginThrottleRow,
UserRow,
UserSessionRow,
@@ -42,6 +44,19 @@ class SQLAlchemyCloudRepository:
self._sessions = sessionmaker(bind=engine, expire_on_commit=False)
if create_schema:
Base.metadata.create_all(engine)
self._ensure_llm_provider_settings()
def _ensure_llm_provider_settings(self) -> None:
with self._sessions.begin() as session:
if session.get(LlmProviderSettingsRow, "global") is None:
session.add(
LlmProviderSettingsRow(
id="global",
active_profile_id=None,
revision=0,
updated_at=_iso(utc_now()),
)
)
def enroll_host(
self,
@@ -503,7 +518,9 @@ class SQLAlchemyCloudRepository:
created_at=_iso(user.created_at),
updated_at=_iso(user.updated_at),
last_login_at=(
_iso(user.last_login_at) if user.last_login_at is not None else None
_iso(user.last_login_at)
if user.last_login_at is not None
else None
),
)
session.add(row)
@@ -576,7 +593,9 @@ class SQLAlchemyCloudRepository:
raise LastAdministratorConflictError(
"cannot remove the last enabled administrator"
)
security_changed = next_role != row.role or next_enabled != bool(row.enabled)
security_changed = next_role != row.role or next_enabled != bool(
row.enabled
)
if display_name is not None:
row.display_name = display_name
row.role = next_role
@@ -764,7 +783,9 @@ class SQLAlchemyCloudRepository:
session.flush()
return _login_throttle_from_row(row)
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None:
def clear_login_throttle(
self, username_normalized: str, client_bucket: str
) -> None:
with self._sessions.begin() as session:
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
if row is not None:
@@ -843,7 +864,9 @@ class SQLAlchemyCloudRepository:
)
if row is None:
if expected_revision not in {None, 0}:
raise GovernancePolicyConflictError("submission policy revision changed")
raise GovernancePolicyConflictError(
"submission policy revision changed"
)
row = UserSubmissionPolicyRow(
user_id=user_id,
revision=1,
@@ -856,11 +879,10 @@ class SQLAlchemyCloudRepository:
)
session.add(row)
else:
if (
expected_revision is not None
and expected_revision != row.revision
):
raise GovernancePolicyConflictError("submission policy revision changed")
if expected_revision is not None and expected_revision != row.revision:
raise GovernancePolicyConflictError(
"submission policy revision changed"
)
row.revision += 1
row.submission_enabled = 1 if submission_enabled else 0
row.allowed_host_ids_json = _dump_optional_list(allowed_host_ids)
@@ -909,10 +931,7 @@ class SQLAlchemyCloudRepository:
)
session.add(row)
else:
if (
expected_revision is not None
and expected_revision != row.revision
):
if expected_revision is not None and expected_revision != row.revision:
raise GovernancePolicyConflictError("Host policy revision changed")
row.revision += 1
row.self_submission_enabled = 1 if self_submission_enabled else 0
@@ -922,6 +941,220 @@ class SQLAlchemyCloudRepository:
session.flush()
return _host_governance_policy_from_row(row)
# --------------------------------------------------------- LLM Providers
def get_llm_provider_settings(self) -> Any:
with self._sessions() as session:
row = session.get(LlmProviderSettingsRow, "global")
return _llm_provider_settings_from_row(row)
def list_llm_provider_profiles(self) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(LlmProviderProfileRow).order_by(
LlmProviderProfileRow.name_normalized
)
).all()
return [_llm_provider_profile_from_row(row) for row in rows]
def get_llm_provider_profile(self, profile_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(LlmProviderProfileRow, profile_id)
return _llm_provider_profile_from_row(row) if row is not None else None
def create_llm_provider_profile(self, profile: Any) -> Any:
from cloud.llm_providers import LlmProviderConflictError
try:
with self._sessions.begin() as session:
if (
session.scalars(
select(LlmProviderProfileRow).where(
LlmProviderProfileRow.name_normalized
== profile.name_normalized
)
).first()
is not None
):
raise LlmProviderConflictError(
"Provider profile name already exists"
)
row = LlmProviderProfileRow(
id=profile.id,
name=profile.name,
name_normalized=profile.name_normalized,
provider_type=profile.provider_type,
model=profile.model,
base_url=profile.base_url,
timeout_seconds=profile.timeout_seconds,
api_key_ciphertext=profile.api_key_ciphertext,
key_last_rotated_at=_iso(profile.key_last_rotated_at),
enabled=1 if profile.enabled else 0,
revision=profile.revision,
created_at=_iso(profile.created_at),
updated_at=_iso(profile.updated_at),
)
session.add(row)
session.flush()
return _llm_provider_profile_from_row(row)
except IntegrityError as exc:
raise LlmProviderConflictError(
"Provider profile name already exists"
) from exc
def update_llm_provider_profile(
self,
profile_id: str,
*,
name: str,
name_normalized: str,
provider_type: str,
model: str,
base_url: str | None,
timeout_seconds: float,
api_key_ciphertext: str,
key_last_rotated_at: datetime,
enabled: bool,
expected_revision: int | None,
updated_at: datetime,
) -> Any:
from cloud.llm_providers import (
LlmProviderActiveConflictError,
LlmProviderConflictError,
)
try:
with self._sessions.begin() as session:
row = session.get(
LlmProviderProfileRow,
profile_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
raise KeyError(profile_id)
if expected_revision is not None and expected_revision != row.revision:
raise LlmProviderConflictError("Provider profile revision changed")
existing_name = session.scalars(
select(LlmProviderProfileRow).where(
LlmProviderProfileRow.name_normalized == name_normalized,
LlmProviderProfileRow.id != profile_id,
)
).first()
if existing_name is not None:
raise LlmProviderConflictError(
"Provider profile name already exists"
)
settings = session.get(
LlmProviderSettingsRow,
"global",
with_for_update=self.engine.dialect.name == "postgresql",
)
if (
not enabled
and settings is not None
and settings.active_profile_id == profile_id
):
raise LlmProviderActiveConflictError(
"activate another Provider profile before disabling this one"
)
row.name = name
row.name_normalized = name_normalized
row.provider_type = provider_type
row.model = model
row.base_url = base_url
row.timeout_seconds = timeout_seconds
row.api_key_ciphertext = api_key_ciphertext
row.key_last_rotated_at = _iso(key_last_rotated_at)
row.enabled = 1 if enabled else 0
row.revision += 1
row.updated_at = _iso(updated_at)
session.flush()
return _llm_provider_profile_from_row(row)
except IntegrityError as exc:
raise LlmProviderConflictError(
"Provider profile name already exists"
) from exc
def activate_llm_provider_profile(
self,
profile_id: str,
*,
expected_settings_revision: int | None,
updated_at: datetime,
) -> Any:
from cloud.llm_providers import LlmProviderConflictError
with self._sessions.begin() as session:
profile = session.get(
LlmProviderProfileRow,
profile_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if profile is None:
raise KeyError(profile_id)
if not profile.enabled:
raise LlmProviderConflictError(
"Provider profile must be enabled before activation"
)
settings = session.get(
LlmProviderSettingsRow,
"global",
with_for_update=self.engine.dialect.name == "postgresql",
)
if settings is None:
if expected_settings_revision not in {None, 0}:
raise LlmProviderConflictError("Provider settings revision changed")
settings = LlmProviderSettingsRow(
id="global",
active_profile_id=profile_id,
revision=1,
updated_at=_iso(updated_at),
)
session.add(settings)
else:
if (
expected_settings_revision is not None
and expected_settings_revision != settings.revision
):
raise LlmProviderConflictError("Provider settings revision changed")
settings.active_profile_id = profile_id
settings.revision += 1
settings.updated_at = _iso(updated_at)
session.flush()
return _llm_provider_settings_from_row(settings)
def delete_llm_provider_profile(
self,
profile_id: str,
*,
expected_revision: int | None,
) -> None:
from cloud.llm_providers import (
LlmProviderActiveConflictError,
LlmProviderConflictError,
)
with self._sessions.begin() as session:
row = session.get(
LlmProviderProfileRow,
profile_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
raise KeyError(profile_id)
if expected_revision is not None and expected_revision != row.revision:
raise LlmProviderConflictError("Provider profile revision changed")
settings = session.get(
LlmProviderSettingsRow,
"global",
with_for_update=self.engine.dialect.name == "postgresql",
)
if settings is not None and settings.active_profile_id == profile_id:
raise LlmProviderActiveConflictError(
"activate another Provider profile before deleting this one"
)
session.delete(row)
def count_active_tasks_for_host(self, host_id: str) -> int:
with self._sessions() as session:
count = session.scalar(
@@ -958,24 +1191,36 @@ class SQLAlchemyCloudRepository:
if policy is None or policy.daily_token_budget is None:
return None
used = session.scalar(
select(func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)
).where(
TokenUsageEventRow.host_id == host_id,
TokenUsageEventRow.usage_day == usage_day,
)
)
reserved = session.scalar(
select(func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)
).where(
TokenReservationRow.host_id == host_id,
TokenReservationRow.usage_day == usage_day,
TokenReservationRow.expires_at > _iso(created_at),
)
)
if int(used or 0) + int(reserved or 0) + reserved_tokens > policy.daily_token_budget:
if (
int(used or 0) + int(reserved or 0) + reserved_tokens
> policy.daily_token_budget
):
raise TokenBudgetExceededError("Host daily token budget is exhausted")
row = TokenReservationRow(
id=reservation_id, host_id=host_id, usage_day=usage_day,
reserved_tokens=reserved_tokens, task_id=task_id, attempt=attempt,
created_at=_iso(created_at), expires_at=_iso(expires_at),
id=reservation_id,
host_id=host_id,
usage_day=usage_day,
reserved_tokens=reserved_tokens,
task_id=task_id,
attempt=attempt,
created_at=_iso(created_at),
expires_at=_iso(expires_at),
)
session.add(row)
session.flush()
@@ -995,16 +1240,24 @@ class SQLAlchemyCloudRepository:
) -> Any | None:
with self._sessions.begin() as session:
row = session.get(
TokenReservationRow, reservation_id,
TokenReservationRow,
reservation_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
return None
event = TokenUsageEventRow(
id=event_id, host_id=row.host_id, usage_day=row.usage_day,
task_id=row.task_id, attempt=row.attempt, provider=provider, model=model,
input_tokens=input_tokens, output_tokens=output_tokens,
total_tokens=total_tokens, occurred_at=_iso(occurred_at),
id=event_id,
host_id=row.host_id,
usage_day=row.usage_day,
task_id=row.task_id,
attempt=row.attempt,
provider=provider,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
occurred_at=_iso(occurred_at),
)
session.add(event)
session.delete(row)
@@ -1024,39 +1277,55 @@ class SQLAlchemyCloudRepository:
return len(rows)
def get_host_token_usage_summary(
self, *, host_id: str, usage_day: str, now: datetime,
self,
*,
host_id: str,
usage_day: str,
now: datetime,
) -> Any:
from cloud.governance import TokenUsageSummary
with self._sessions() as session:
policy = session.get(HostGovernancePolicyRow, host_id)
used = session.scalar(
select(func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)
).where(
TokenUsageEventRow.host_id == host_id,
TokenUsageEventRow.usage_day == usage_day,
)
)
reserved = session.scalar(
select(func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)
).where(
TokenReservationRow.host_id == host_id,
TokenReservationRow.usage_day == usage_day,
TokenReservationRow.expires_at > _iso(now),
)
)
return TokenUsageSummary(
host_id=host_id, usage_day=usage_day,
host_id=host_id,
usage_day=usage_day,
daily_token_budget=(policy.daily_token_budget if policy else None),
used_tokens=int(used or 0), reserved_tokens=int(reserved or 0),
used_tokens=int(used or 0),
reserved_tokens=int(reserved or 0),
)
def list_host_token_usage_events(
self, *, host_id: str, limit: int, offset: int,
self,
*,
host_id: str,
limit: int,
offset: int,
) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(TokenUsageEventRow)
.where(TokenUsageEventRow.host_id == host_id)
.order_by(TokenUsageEventRow.occurred_at.desc(), TokenUsageEventRow.id.desc())
.order_by(
TokenUsageEventRow.occurred_at.desc(), TokenUsageEventRow.id.desc()
)
.limit(limit)
.offset(offset)
).all()
@@ -1423,7 +1692,9 @@ def _host_from_row(row: HostRow) -> Any:
address=row.address,
last_seen_at=_parse_dt(row.last_seen_at) or utc_now(),
planner_transport=(
row.planner_transport if row.planner_transport in {"direct", "cloud"} else "direct"
row.planner_transport
if row.planner_transport in {"direct", "cloud"}
else "direct"
),
)
@@ -1519,7 +1790,7 @@ def _load_optional_string_tuple(value: str | None) -> tuple[str, ...] | None:
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
except TypeError, ValueError:
parsed = []
return tuple(item for item in parsed if isinstance(item, str))
@@ -1531,7 +1802,7 @@ def _load_optional_device_targets(
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
except TypeError, ValueError:
parsed = []
return tuple(
(item[0], item[1])
@@ -1574,8 +1845,12 @@ def _token_reservation_from_row(row: TokenReservationRow) -> Any:
from cloud.governance import TokenReservation
return TokenReservation(
id=row.id, host_id=row.host_id, usage_day=row.usage_day,
reserved_tokens=row.reserved_tokens, task_id=row.task_id, attempt=row.attempt,
id=row.id,
host_id=row.host_id,
usage_day=row.usage_day,
reserved_tokens=row.reserved_tokens,
task_id=row.task_id,
attempt=row.attempt,
created_at=_parse_dt(row.created_at) or utc_now(),
expires_at=_parse_dt(row.expires_at) or utc_now(),
)
@@ -1585,10 +1860,17 @@ def _token_usage_event_from_row(row: TokenUsageEventRow) -> Any:
from cloud.governance import TokenUsageEvent
return TokenUsageEvent(
id=row.id, host_id=row.host_id, usage_day=row.usage_day,
task_id=row.task_id, attempt=row.attempt, provider=row.provider, model=row.model,
input_tokens=row.input_tokens, output_tokens=row.output_tokens,
total_tokens=row.total_tokens, occurred_at=_parse_dt(row.occurred_at) or utc_now(),
id=row.id,
host_id=row.host_id,
usage_day=row.usage_day,
task_id=row.task_id,
attempt=row.attempt,
provider=row.provider,
model=row.model,
input_tokens=row.input_tokens,
output_tokens=row.output_tokens,
total_tokens=row.total_tokens,
occurred_at=_parse_dt(row.occurred_at) or utc_now(),
)
@@ -1733,3 +2015,36 @@ def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
),
bool(row.wired),
)
def _llm_provider_profile_from_row(row: LlmProviderProfileRow) -> Any:
from cloud.llm_providers import LlmProviderProfile
now = utc_now()
return LlmProviderProfile(
id=row.id,
name=row.name,
name_normalized=row.name_normalized,
provider_type=row.provider_type,
model=row.model,
base_url=row.base_url,
timeout_seconds=row.timeout_seconds,
api_key_ciphertext=row.api_key_ciphertext,
key_last_rotated_at=_parse_dt(row.key_last_rotated_at) or now,
enabled=bool(row.enabled),
revision=row.revision,
created_at=_parse_dt(row.created_at) or now,
updated_at=_parse_dt(row.updated_at) or now,
)
def _llm_provider_settings_from_row(row: LlmProviderSettingsRow | None) -> Any:
from cloud.llm_providers import LlmProviderSettings
if row is None:
return LlmProviderSettings(active_profile_id=None)
return LlmProviderSettings(
active_profile_id=row.active_profile_id,
revision=row.revision,
updated_at=_parse_dt(row.updated_at),
)