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
21 changes: 21 additions & 0 deletions api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@
from oss.src.core.embeds.service import EmbedsService
from oss.src.core.evaluations.service import SimpleQueuesService
from oss.src.core.tracing.service import SimpleTracesService
from oss.src.core.providers.service import ProviderProbeService

# Routers
from oss.src.apis.fastapi.vault.router import VaultRouter
from oss.src.apis.fastapi.providers.router import ProvidersRouter
from oss.src.apis.fastapi.webhooks.router import WebhooksRouter
from oss.src.apis.fastapi.auth.router import auth_router
from oss.src.apis.fastapi.otlp.router import OTLPRouter
Expand Down Expand Up @@ -595,6 +597,8 @@ async def lifespan(*args, **kwargs):
secrets_dao=secrets_dao,
)

provider_probe_service = ProviderProbeService()


webhooks_service = WebhooksService(
webhooks_dao=webhooks_dao,
Expand Down Expand Up @@ -944,6 +948,10 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str:
vault_service=vault_service,
)

providers = ProvidersRouter(
provider_probe_service=provider_probe_service,
)

webhooks = WebhooksRouter(
webhooks_service=webhooks_service,
)
Expand Down Expand Up @@ -1131,6 +1139,19 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str:
tags=["Secrets"],
)

app.include_router(
router=providers.router,
tags=["Secrets"],
)

# The probe is also reachable under the vault's legacy prefix, so a client that already
# addresses connections as /vault/v1/secrets/ can test one without switching base paths.
app.include_router(
router=providers.router,
prefix="/vault/v1",
include_in_schema=False,
)

## DEPRECATED
app.include_router(
router=secrets.router,
Expand Down
Empty file.
27 changes: 27 additions & 0 deletions api/oss/src/apis/fastapi/providers/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from datetime import datetime

from pydantic import BaseModel, Field

from oss.src.core.providers.dtos import (
CredentialResult,
DiscoveryResult,
ProviderCredentials,
)


class ProbeProviderRequest(BaseModel):
"""The credential to test. It is spent on one read and never persisted.

`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.
"""

kind: str = Field(description="Provider kind, e.g. 'openai', 'azure', 'custom'.")
provider: ProviderCredentials


class ProbeProviderResponse(BaseModel):
credential: CredentialResult
discovery: DiscoveryResult
fetched_at: datetime
69 changes: 69 additions & 0 deletions api/oss/src/apis/fastapi/providers/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse

from oss.src.apis.fastapi.providers.models import (
ProbeProviderRequest,
ProbeProviderResponse,
)
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.exceptions import ProviderProbeError
from oss.src.core.providers.service import ProviderProbeService
from oss.src.utils.exceptions import intercept_exceptions
from oss.src.utils.logging import get_module_logger


log = get_module_logger(__name__)


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

Shares the vault router's `SecretSafeRoute`: the probe body IS a credential, so a
validation error on this route must not echo what the caller sent.
"""

def __init__(
self,
provider_probe_service: ProviderProbeService,
):
self.service = provider_probe_service

self.router = APIRouter(route_class=SecretSafeRoute)

self.router.add_api_route(
"/providers/probe",
self.probe_provider,
methods=["POST"],
operation_id="probe_provider",
response_model=ProbeProviderResponse,
)

@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
# outbound request, so it belongs to whoever may change the connection.
has_permission = await check_action_access(
user_uid=str(request.state.user_id),
project_id=str(request.state.project_id),
permission=Permission.EDIT_SECRET,
)

if not has_permission:
error_msg = "You do not have access to perform this action. Please contact your organization admin."
return JSONResponse(
{"detail": error_msg},
status_code=403,
)

try:
return await self.service.probe(
kind=body.kind,
credentials=body.provider,
)
except ProviderProbeError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=e.message,
) from None
Empty file.
Loading
Loading