Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,7 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str:

providers = ProvidersRouter(
provider_probe_service=provider_probe_service,
vault_service=vault_service,
)

webhooks = WebhooksRouter(
Expand Down
35 changes: 32 additions & 3 deletions api/oss/src/apis/fastapi/providers/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from datetime import datetime
from typing import Optional
from uuid import UUID

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator

from oss.src.core.providers.dtos import (
CredentialResult,
Expand All @@ -15,10 +17,37 @@ class ProbeProviderRequest(BaseModel):
`kind` is a StandardProviderKind or CustomProviderKind value; `provider` carries the
same field vocabulary the vault stores, so a card can probe what it is about to save
without reshaping it.

`secret_id` names a connection already stored in the caller's project, and is how a
write-only connection is testable at all: its value never comes back to the browser,
so there is nothing for the card to send. The stored kind and credentials are the
base; anything typed in this request replaces the stored value for that field, which
is what lets a card test an edit — a new base URL, say — before saving it.
"""

kind: str = Field(description="Provider kind, e.g. 'openai', 'azure', 'custom'.")
provider: ProviderCredentials
kind: Optional[str] = Field(
default=None,
description=(
"Provider kind, e.g. 'openai', 'azure', 'custom'. Optional when `secret_id` "
"is given: the stored kind is used unless this overrides it."
),
)
provider: ProviderCredentials = Field(default_factory=ProviderCredentials)
secret_id: Optional[UUID] = Field(
default=None,
description=(
"Test the credential stored under this secret, in the caller's project. "
"Fields sent in `provider` override the stored ones."
),
)

@model_validator(mode="after")
def require_something_to_probe(self):
"""A probe needs a subject: a kind to test against, or a stored secret to load."""
if self.kind is None and self.secret_id is None:
raise ValueError("provide `kind`, `secret_id`, or both")

return self


class ProbeProviderResponse(BaseModel):
Expand Down
114 changes: 112 additions & 2 deletions api/oss/src/apis/fastapi/providers/router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from typing import Optional, Tuple
from uuid import UUID

from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr

from oss.src.apis.fastapi.providers.models import (
ProbeProviderRequest,
Expand All @@ -8,15 +12,62 @@
from oss.src.apis.fastapi.vault.router import SecretSafeRoute
from oss.src.core.access.permissions.service import check_action_access
from oss.src.core.access.permissions.types import Permission
from oss.src.core.providers.dtos import ProviderCredentials
from oss.src.core.providers.exceptions import ProviderProbeError
from oss.src.core.providers.service import ProviderProbeService
from oss.src.core.secrets.redaction import PRIMARY_CREDENTIAL_FIELDS
from oss.src.core.secrets.services import VaultService
from oss.src.utils.exceptions import intercept_exceptions
from oss.src.utils.logging import get_module_logger


log = get_module_logger(__name__)


def _typed_or_stored(typed, stored):
if typed is None:
return stored

value = typed.get_secret_value() if isinstance(typed, SecretStr) else typed
if value in ("", {}, []):
return stored

return typed


def _stored_credential(secret, settings, extras):
container_name, field = PRIMARY_CREDENTIAL_FIELDS.get(
str(getattr(secret.kind, "value", secret.kind)), (None, None)
)

if container_name is not None:
container = getattr(secret.data, container_name, None)
primary = getattr(container, field, None) if container is not None else None
if primary:
return primary

return (extras or {}).get("api_key") or None


def _merged_extras(typed, stored):
if not typed:
return stored

merged = dict(stored or {})
for name, value in typed.items():
if value in (None, ""):
continue
merged[name] = value

return merged or None


def _stored_kind(secret) -> str:
kind = getattr(secret.data, "kind", None)

return str(getattr(kind, "value", kind))


class ProvidersRouter:
"""Credential test and model discovery for a provider connection.

Expand All @@ -27,8 +78,10 @@ class ProvidersRouter:
def __init__(
self,
provider_probe_service: ProviderProbeService,
vault_service: VaultService,
):
self.service = provider_probe_service
self.vault_service = vault_service

self.router = APIRouter(route_class=SecretSafeRoute)

Expand All @@ -40,6 +93,54 @@ def __init__(
response_model=ProbeProviderResponse,
)

async def _merge_stored_secret(
self,
*,
project_id: UUID,
secret_id: UUID,
kind: Optional[str],
typed: ProviderCredentials,
) -> Tuple[str, ProviderCredentials]:
secret = await self.vault_service.get_secret_by_id(
secret_id=secret_id,
project_id=project_id,
)

if secret is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Secret not found",
)

if secret.management is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Managed secrets cannot be probed.",
)

stored_kind = _stored_kind(secret)
settings = getattr(secret.data, "provider", None)
stored_extras = getattr(settings, "extras", None) if settings else None
stored_key = _stored_credential(secret, settings, stored_extras)

if kind is not None and kind != stored_kind and typed.key is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
"Testing this connection as a different provider requires its "
"credential; the stored one belongs to the saved provider."
),
)

merged = ProviderCredentials(
key=_typed_or_stored(typed.key, stored_key),
url=_typed_or_stored(typed.url, getattr(settings, "url", None)),
version=_typed_or_stored(typed.version, getattr(settings, "version", None)),
extras=_merged_extras(typed.extras, stored_extras),
)

return kind or stored_kind, merged

@intercept_exceptions()
async def probe_provider(self, request: Request, body: ProbeProviderRequest):
# EDIT_SECRET, not VIEW_SECRET: a probe spends a caller-supplied credential on an
Expand All @@ -57,10 +158,19 @@ async def probe_provider(self, request: Request, body: ProbeProviderRequest):
status_code=403,
)

kind, credentials = body.kind, body.provider
if body.secret_id is not None:
kind, credentials = await self._merge_stored_secret(
project_id=UUID(request.state.project_id),
secret_id=body.secret_id,
kind=kind,
typed=credentials,
)

try:
return await self.service.probe(
kind=body.kind,
credentials=body.provider,
kind=kind,
credentials=credentials,
)
except ProviderProbeError as e:
raise HTTPException(
Expand Down
Loading
Loading