@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"alembic>=1.14.0",
|
||||
"argon2-cffi>=25.0.0",
|
||||
"cryptography>=45.0.0",
|
||||
"device-agent-runtime==0.1.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
|
||||
Reference in New Issue
Block a user