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
299 changes: 297 additions & 2 deletions backend/app/api/routes/guardrails.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import logging
from typing import Annotated, Any
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import JSONResponse
from opentelemetry import trace

from app.api.deps import AuthContextDep, SessionDep
Expand All @@ -17,6 +19,7 @@
GuardrailsRequest,
)
from app.services.guardrails.jobs import start_job
from app.services.llm.guardrails import proxy_guardrails_request
from app.utils import APIResponse, load_description, validate_callback_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -91,6 +94,298 @@ def apply_guardrails_endpoint(
)


def _upstream_response(status_code: int, payload: Any) -> Response:
"""An empty upstream body must stay empty (204s cannot carry one).

Tenant-scoped data must never be cached by a shared/intermediary cache
(CWE-525); `no-store` is stronger than `private` for that guarantee.
"""
headers = {"Cache-Control": "no-store"}
if payload is None:
return Response(status_code=status_code, headers=headers)
return JSONResponse(status_code=status_code, content=payload, headers=headers)


# ROUTE ORDERING: these fixed paths must stay above GET /guardrails/{job_id} — FastAPI matches in declaration order.


@router.get(
"/guardrails",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def list_guardrails_validator_types(_current_user: AuthContextDep) -> Response:
"""List the validator types supported upstream and their JSON schemas."""
status_code, payload = proxy_guardrails_request(
"GET",
"/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.post(
"/guardrails/ban_lists",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def create_guardrails_ban_list(
_current_user: AuthContextDep, body: dict[str, Any]
) -> Response:
status_code, payload = proxy_guardrails_request(
"POST",
"/ban_lists/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
json_body=body,
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/ban_lists",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def list_guardrails_ban_lists(
_current_user: AuthContextDep,
domain: str | None = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int | None, Query(ge=1, le=100)] = None,
) -> Response:
status_code, payload = proxy_guardrails_request(
"GET",
"/ban_lists/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
params={"domain": domain, "offset": offset, "limit": limit},
)
return _upstream_response(status_code, payload)


@router.post(
"/guardrails/llm_prompt_configs",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def create_guardrails_llm_prompt_config(
_current_user: AuthContextDep, body: dict[str, Any]
) -> Response:
status_code, payload = proxy_guardrails_request(
"POST",
"/llm_prompt_configs/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
json_body=body,
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/llm_prompt_configs",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def list_guardrails_llm_prompt_configs(
_current_user: AuthContextDep,
validator_name: str | None = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int | None, Query(ge=1, le=100)] = None,
) -> Response:
status_code, payload = proxy_guardrails_request(
"GET",
"/llm_prompt_configs/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
params={"validator_name": validator_name, "offset": offset, "limit": limit},
)
return _upstream_response(status_code, payload)


@router.post(
"/guardrails/validators/configs",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def create_guardrails_validator_config(
_current_user: AuthContextDep, body: dict[str, Any]
) -> Response:
status_code, payload = proxy_guardrails_request(
"POST",
"/validators/configs/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
json_body=body,
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/validators/configs",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def list_guardrails_validator_configs(
_current_user: AuthContextDep,
ids: Annotated[list[UUID] | None, Query()] = None,
stage: str | None = None,
type: str | None = None,
) -> Response:
status_code, payload = proxy_guardrails_request(
"GET",
"/validators/configs/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
params={
"ids": [str(config_id) for config_id in ids] if ids else None,
"stage": stage,
"type": type,
},
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/validators/configs/{config_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def get_guardrails_validator_config(
_current_user: AuthContextDep, config_id: UUID
) -> Response:
status_code, payload = proxy_guardrails_request(
"GET",
f"/validators/configs/{config_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.patch(
"/guardrails/validators/configs/{config_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def update_guardrails_validator_config(
_current_user: AuthContextDep, config_id: UUID, body: dict[str, Any]
) -> Response:
status_code, payload = proxy_guardrails_request(
"PATCH",
f"/validators/configs/{config_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
json_body=body,
)
return _upstream_response(status_code, payload)


@router.delete(
"/guardrails/validators/configs/{config_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def delete_guardrails_validator_config(
_current_user: AuthContextDep, config_id: UUID
) -> Response:
status_code, payload = proxy_guardrails_request(
"DELETE",
f"/validators/configs/{config_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/ban_lists/{ban_list_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def get_guardrails_ban_list(
_current_user: AuthContextDep, ban_list_id: UUID
) -> Response:
status_code, payload = proxy_guardrails_request(
"GET",
f"/ban_lists/{ban_list_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.patch(
"/guardrails/ban_lists/{ban_list_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def update_guardrails_ban_list(
_current_user: AuthContextDep, ban_list_id: UUID, body: dict[str, Any]
) -> Response:
status_code, payload = proxy_guardrails_request(
"PATCH",
f"/ban_lists/{ban_list_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
json_body=body,
)
return _upstream_response(status_code, payload)


@router.delete(
"/guardrails/ban_lists/{ban_list_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def delete_guardrails_ban_list(
_current_user: AuthContextDep, ban_list_id: UUID
) -> Response:
status_code, payload = proxy_guardrails_request(
"DELETE",
f"/ban_lists/{ban_list_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/llm_prompt_configs/{prompt_config_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def get_guardrails_llm_prompt_config(
_current_user: AuthContextDep, prompt_config_id: UUID
) -> Response:
status_code, payload = proxy_guardrails_request(
"GET",
f"/llm_prompt_configs/{prompt_config_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.patch(
"/guardrails/llm_prompt_configs/{prompt_config_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def update_guardrails_llm_prompt_config(
_current_user: AuthContextDep, prompt_config_id: UUID, body: dict[str, Any]
) -> Response:
status_code, payload = proxy_guardrails_request(
"PATCH",
f"/llm_prompt_configs/{prompt_config_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
json_body=body,
)
return _upstream_response(status_code, payload)


@router.delete(
"/guardrails/llm_prompt_configs/{prompt_config_id}",
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def delete_guardrails_llm_prompt_config(
_current_user: AuthContextDep, prompt_config_id: UUID
) -> Response:
status_code, payload = proxy_guardrails_request(
"DELETE",
f"/llm_prompt_configs/{prompt_config_id}",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)


@router.get(
"/guardrails/{job_id}",
response_model=APIResponse[GuardrailsJobPublic],
Expand All @@ -112,7 +407,7 @@ def get_guardrails_job_status(
tag="guardrails",
system="guardrails",
lifecycle="api.guardrails.status",
job_id=job_id,
job_id=str(job_id),
project_id=project_id,
organization_id=_current_user.organization_.id,
):
Expand Down
1 change: 1 addition & 0 deletions backend/app/celery/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def _initialize_worker_observability() -> None:
release=settings.API_VERSION,
instrumenter="otel",
traces_sample_rate=1.0,
max_request_body_size="never",
enable_logs=True,
before_send_transaction=before_send_transaction_filter,
integrations=[
Expand Down
27 changes: 15 additions & 12 deletions backend/app/celery/tasks/job_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"""

import logging
from typing import TYPE_CHECKING
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar

from asgi_correlation_id import correlation_id
from celery import Task, current_task
Expand All @@ -32,6 +33,8 @@

logger = logging.getLogger(__name__)

T = TypeVar("T")

# Sentinel correlation id used when no trace id is propagated from the
# enqueueing request. Matches the codebase-wide "N/A" default (see
# app/core/logger.py and app/celery/utils.py).
Expand All @@ -43,7 +46,7 @@ def _set_trace(trace_id: str) -> None:
logger.info(f"[_set_trace] Set correlation ID: {trace_id}")


def _extract_parent_context(task_instance) -> otel_context.Context:
def _extract_parent_context(task_instance: Task) -> otel_context.Context:
"""Extract OTel parent context from Celery headers if available."""
headers = getattr(task_instance.request, "headers", None) or {}
carrier: dict[str, str] = {}
Expand All @@ -62,20 +65,20 @@ def _extract_parent_context(task_instance) -> otel_context.Context:
return extract(carrier)


def _run_with_otel_parent(task_instance, fn):
"""Attach extracted parent context and execute function.

When Celery auto-instrumentation is active, there is already a current
`run/...` span. Re-attaching extracted parent context here would make
service spans become siblings of `run/...` instead of children.
def _run_with_otel_parent(
task_instance: Task, fn: Callable[[], T]
) -> T: # noqa: UP047 (black doesn't support PEP 695 generics yet)
"""Attach the extracted parent context and execute `fn` under it.

We only attach extracted context as a fallback when no active span exists.
opentelemetry-instrumentation-celery's CeleryGetter misses propagation
headers (they live under `.headers`, not top-level task attrs), so its
span is always unparented; we extract and attach the context ourselves.
"""
Comment on lines +71 to 76

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is too long comment, make this also bit sorter.

current_ctx = trace.get_current_span().get_span_context()
if current_ctx and current_ctx.is_valid:
parent_ctx = _extract_parent_context(task_instance)
parent_span_ctx = trace.get_current_span(parent_ctx).get_span_context()
if not (parent_span_ctx and parent_span_ctx.is_valid):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fn()

parent_ctx = _extract_parent_context(task_instance)
token = otel_context.attach(parent_ctx)
try:
return fn()
Expand Down
Loading
Loading