From ffed94cdced01704e895820e27518905b419a03e Mon Sep 17 00:00:00 2001 From: Prajna1999 Date: Tue, 18 Aug 2026 09:18:37 +0530 Subject: [PATCH 1/2] feat: add header decorator to /guardrails --- backend/app/services/llm/guardrails.py | 36 ++++++++++--------- .../app/tests/services/llm/test_guardrails.py | 12 +++++-- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/backend/app/services/llm/guardrails.py b/backend/app/services/llm/guardrails.py index 5b85e08b8..f3a70d2ec 100644 --- a/backend/app/services/llm/guardrails.py +++ b/backend/app/services/llm/guardrails.py @@ -13,6 +13,23 @@ logger = logging.getLogger(__name__) +def _guardrails_headers( + organization_id: int | None, project_id: int | None +) -> dict[str, str]: + """Guardrails is internal-only: tenant travels in headers set from the + auth context, never from caller-supplied body/query fields.""" + headers = { + "accept": "application/json", + "Authorization": f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}", + "Content-Type": "application/json", + } + if organization_id is not None: + headers["X-ORGANIZATION-ID"] = str(organization_id) + if project_id is not None: + headers["X-PROJECT-ID"] = str(project_id) + return headers + + @dataclass class GuardrailsOutcome: """Result of a single guardrails service call, in domain-agnostic form. @@ -166,8 +183,6 @@ def run_guardrails_validation( payload = { "request_id": str(job_id), - "project_id": project_id, - "organization_id": organization_id, "input": input_text, "validators": validators, } @@ -175,11 +190,7 @@ def run_guardrails_validation( if output_text is not None: payload["output"] = output_text - headers = { - "accept": "application/json", - "Authorization": f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}", - "Content-Type": "application/json", - } + headers = _guardrails_headers(organization_id, project_id) url = f"{settings.KAAPI_GUARDRAILS_URL}/" payload_bytes = json.dumps(payload).encode() @@ -247,21 +258,14 @@ def list_validators_config( if not input_validator_config_ids and not output_validator_config_ids: return [], [] - headers = { - "accept": "application/json", - "Authorization": f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}", - "Content-Type": "application/json", - } + headers = _guardrails_headers(organization_id, project_id) endpoint = f"{settings.KAAPI_GUARDRAILS_URL}/validators/configs/" def _build_params(validator_ids: list[UUID]) -> dict[str, Any]: - params = { - "organization_id": organization_id, - "project_id": project_id, + return { "ids": [str(validator_config_id) for validator_config_id in validator_ids], } - return {key: value for key, value in params.items() if value is not None} try: with httpx.Client(timeout=10.0) as client: diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py index 22004d179..ef96280f3 100644 --- a/backend/app/tests/services/llm/test_guardrails.py +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -58,8 +58,10 @@ def test_run_guardrails_validation_success(mock_client_cls) -> None: assert kwargs["json"]["input"] == TEST_TEXT assert kwargs["json"]["validators"] == TEST_CONFIG assert kwargs["json"]["request_id"] == str(TEST_JOB_ID) - assert kwargs["json"]["project_id"] == TEST_PROJECT_ID - assert kwargs["json"]["organization_id"] == TEST_ORGANIZATION_ID + assert "project_id" not in kwargs["json"] + assert "organization_id" not in kwargs["json"] + assert kwargs["headers"]["X-PROJECT-ID"] == str(TEST_PROJECT_ID) + assert kwargs["headers"]["X-ORGANIZATION-ID"] == str(TEST_ORGANIZATION_ID) assert kwargs["params"]["suppress_pass_logs"] == "true" assert kwargs["headers"]["Authorization"].startswith("Bearer ") assert kwargs["headers"]["Content-Type"] == "application/json" @@ -254,6 +256,8 @@ def test_list_validators_config_fetches_input_and_output_by_refs( assert second_call_kwargs["params"]["ids"] == [ str(v.validator_config_id) for v in output_validator_configs ] + assert first_call_kwargs["headers"]["X-ORGANIZATION-ID"] == "1" + assert first_call_kwargs["headers"]["X-PROJECT-ID"] == "1" @patch("app.services.llm.guardrails.httpx.Client") @@ -273,7 +277,7 @@ def test_list_validators_config_empty_short_circuits_without_http( @patch("app.services.llm.guardrails.httpx.Client") -def test_list_validators_config_omits_none_query_params(mock_client_cls) -> None: +def test_list_validators_config_omits_none_tenant(mock_client_cls) -> None: input_validator_configs = [Validator(validator_config_id=uuid.uuid4())] mock_response = MagicMock() @@ -297,6 +301,8 @@ def test_list_validators_config_omits_none_query_params(mock_client_cls) -> None ] assert "organization_id" not in kwargs["params"] assert "project_id" not in kwargs["params"] + assert "X-ORGANIZATION-ID" not in kwargs["headers"] + assert "X-PROJECT-ID" not in kwargs["headers"] @patch("app.services.llm.guardrails.httpx.Client") From 707cfa576fe134ed9055656c3abf0b86ea4bebb6 Mon Sep 17 00:00:00 2001 From: Prajna1999 Date: Tue, 18 Aug 2026 10:58:39 +0530 Subject: [PATCH 2/2] feat(guardrails): proxy management API and fail closed on auth errors Expose the internal kaapi-guardrails management API (validator catalogue, ban lists, LLM prompt configs, validator configs) through 16 passthrough routes behind project auth. Tenant travels only in X-ORGANIZATION-ID / X-PROJECT-ID headers set from the auth context; 401/403/422 from the service fail the job instead of bypassing, with client-visible errors sanitized to status codes only. Co-Authored-By: Claude Fable 5 --- backend/app/api/routes/guardrails.py | 301 +++++++++++++++++- backend/app/services/llm/guardrails.py | 112 ++++++- .../app/tests/api/routes/test_guardrails.py | 194 ++++++++++- .../app/tests/services/llm/test_guardrails.py | 52 ++- docs/wiki/modules/llm-call.md | 7 +- 5 files changed, 659 insertions(+), 7 deletions(-) diff --git a/backend/app/api/routes/guardrails.py b/backend/app/api/routes/guardrails.py index 3f0db3ad1..874b6349a 100644 --- a/backend/app/api/routes/guardrails.py +++ b/backend/app/api/routes/guardrails.py @@ -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 @@ -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__) @@ -91,6 +94,300 @@ def apply_guardrails_endpoint( ) +def _upstream_response(status_code: int, payload: Any) -> Response: + """An empty upstream body must stay empty (204s cannot carry one).""" + if payload is None: + return Response(status_code=status_code) + return JSONResponse(status_code=status_code, content=payload) + + +BAN_LISTS_PATH = "/ban_lists" +LLM_PROMPT_CONFIGS_PATH = "/llm_prompt_configs" +VALIDATOR_CONFIGS_PATH = "/validators/configs" + + +# ROUTE ORDERING: every fixed single-segment path below collides with the +# GET /guardrails/{job_id} route declared after this section. FastAPI matches in +# declaration order and does not fall through when {job_id} fails UUID parsing, +# so these must stay above it. + + +@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", + f"{BAN_LISTS_PATH}/", + 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, + 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", + f"{BAN_LISTS_PATH}/", + organization_id=_current_user.organization_.id, + project_id=_current_user.project_.id, + params={"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", + f"{LLM_PROMPT_CONFIGS_PATH}/", + 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", + f"{LLM_PROMPT_CONFIGS_PATH}/", + 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", + f"{VALIDATOR_CONFIGS_PATH}/", + 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", + f"{VALIDATOR_CONFIGS_PATH}/", + 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"{VALIDATOR_CONFIGS_PATH}/{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"{VALIDATOR_CONFIGS_PATH}/{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"{VALIDATOR_CONFIGS_PATH}/{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_PATH}/{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_PATH}/{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_PATH}/{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_PATH}/{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_PATH}/{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_PATH}/{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], @@ -112,7 +409,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, ): diff --git a/backend/app/services/llm/guardrails.py b/backend/app/services/llm/guardrails.py index f3a70d2ec..09e31e2c9 100644 --- a/backend/app/services/llm/guardrails.py +++ b/backend/app/services/llm/guardrails.py @@ -1,11 +1,13 @@ import json import logging import time +from collections.abc import Sequence from dataclasses import dataclass, field -from typing import Any +from typing import Any, TypeGuard from uuid import UUID import httpx +from fastapi import HTTPException from app.core.config import settings from app.models.llm.request import Validator @@ -13,6 +15,9 @@ logger = logging.getLogger(__name__) +GUARDRAILS_PROXY_TIMEOUT_SECONDS = 30.0 + + def _guardrails_headers( organization_id: int | None, project_id: int | None ) -> dict[str, str]: @@ -30,6 +35,81 @@ def _guardrails_headers( return headers +def proxy_guardrails_request( + method: str, + path: str, + *, + organization_id: int, + project_id: int, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, +) -> tuple[int, Any]: + """Forward a management-API call to the guardrails service verbatim. + + No fail-open: these are synchronous CRUD calls, so upstream status codes and + bodies (including its 422s) are handed back to the caller unchanged. + """ + url = f"{settings.KAAPI_GUARDRAILS_URL}{path}" + headers = _guardrails_headers(organization_id, project_id) + # Unset query params must be omitted, not sent as empty values. + query = {k: v for k, v in (params or {}).items() if v is not None} + + logger.info( + f"[proxy_guardrails_request] Forwarding to guardrails | method: {method}, " + f"url: {url}, organization_id: {organization_id}, project_id: {project_id}" + ) + + try: + with httpx.Client(timeout=GUARDRAILS_PROXY_TIMEOUT_SECONDS) as client: + response = client.request( + method, url, params=query, json=json_body, headers=headers + ) + except httpx.RequestError as e: + logger.error( + f"[proxy_guardrails_request] [KAAPI] Could not reach the guardrails service — " + f"retry shortly, and contact Kaapi if it persists (code: {type(e).__name__}) | " + f"method: {method}, url: {url}", + exc_info=True, + ) + raise HTTPException( + status_code=502, detail="Guardrails service unavailable" + ) from e + + if response.status_code >= 500: + logger.error( + f"[proxy_guardrails_request] [GUARDRAILS] Upstream error " + f"(code: {response.status_code}) | method: {method}, url: {url}" + ) + elif response.status_code >= 400: + logger.warning( + f"[proxy_guardrails_request] [GUARDRAILS] Request rejected " + f"(code: {response.status_code}) | method: {method}, url: {url}" + ) + + if not response.content: + return response.status_code, None + try: + return response.status_code, response.json() + except ValueError: + logger.error( + f"[proxy_guardrails_request] [GUARDRAILS] Non-JSON response body " + f"(code: {response.status_code}) | method: {method}, url: {url}" + ) + raise HTTPException( + status_code=502, detail="Guardrails service returned an invalid response" + ) from None + + +def _is_auth_error(e: Exception) -> TypeGuard[httpx.HTTPStatusError]: + # 422 included: a missing/invalid tenant header is a backend bug, not a + # transient outage, so it must not fall open like one. + return isinstance(e, httpx.HTTPStatusError) and e.response.status_code in ( + 401, + 403, + 422, + ) + + @dataclass class GuardrailsOutcome: """Result of a single guardrails service call, in domain-agnostic form. @@ -151,7 +231,7 @@ def apply_guardrails( def run_guardrails_validation( input_text: str, - guardrail_config: list[Validator | dict[str, Any]], + guardrail_config: Sequence[Validator | dict[str, Any]], job_id: UUID, project_id: int | None, organization_id: int | None, @@ -219,6 +299,21 @@ def run_guardrails_validation( return response.json() except Exception as e: elapsed_ms = int((time.monotonic() - started) * 1000) + if _is_auth_error(e): + # Auth failure means a broken deploy (token/IP mismatch), not a + # transient outage — fail the job instead of silently bypassing. + logger.error( + f"[run_guardrails_validation] Guardrails auth failed. " + f"job_id={job_id}, elapsed_ms={elapsed_ms}, error={e}" + ) + status_code = e.response.status_code + return { + "success": False, + "bypassed": False, + # Status only — str(e) embeds the internal service URL and this + # string is client-visible via job.error_message. + "error": f"Guardrails service rejected the request (HTTP {status_code})", + } logger.warning( f"[run_guardrails_validation] Service unavailable. Bypassing guardrails. " f"job_id={job_id}, elapsed_ms={elapsed_ms}, error={e}" @@ -304,6 +399,19 @@ def _fetch_by_ids(validator_ids: list[UUID]) -> list[dict[str, Any]]: return input_guardrails, output_guardrails except Exception as e: + if _is_auth_error(e): + # Propagate so job executors fail the job instead of running + # without guardrails on a misconfigured token/IP. Sanitized: + # str(e) embeds the internal service URL and the executors put + # this message into the client-visible job error. + logger.error( + f"[list_validators_config] Guardrails auth failed | " + f"organization_id={organization_id}, project_id={project_id}, " + f"endpoint={endpoint}, error={e}" + ) + raise ValueError( + f"Guardrails config fetch rejected (HTTP {e.response.status_code})" + ) from e logger.warning( "[list_validators_config] Guardrails service unavailable or invalid response. " "Proceeding without input/output guardrails. " diff --git a/backend/app/tests/api/routes/test_guardrails.py b/backend/app/tests/api/routes/test_guardrails.py index 8b34a67ce..66f97c70c 100644 --- a/backend/app/tests/api/routes/test_guardrails.py +++ b/backend/app/tests/api/routes/test_guardrails.py @@ -1,6 +1,10 @@ -from unittest.mock import patch +from contextlib import contextmanager +from typing import Any +from unittest.mock import MagicMock, patch from uuid import uuid4 +import httpx +import pytest from fastapi.testclient import TestClient from sqlmodel import Session @@ -218,9 +222,197 @@ def test_get_guardrails_failed_returns_error_message( assert data["guardrails_response"] is None +# ---------- management-API proxy routes ---------- + + +class TestProxyPassthrough: + @pytest.mark.parametrize( + "status_code, body", + [ + (200, {"success": True, "data": [{"name": "pii"}]}), + (422, {"detail": [{"loc": ["body", "name"], "msg": "field required"}]}), + (404, {"detail": "Ban list not found"}), + ], + ) + def test_upstream_status_and_body_echoed( + self, + client: TestClient, + user_api_key_header: dict[str, str], + status_code: int, + body: dict[str, Any], + ) -> None: + with _mock_upstream(status_code=status_code, json_body=body): + resp = client.get("api/v1/guardrails", headers=user_api_key_header) + + assert resp.status_code == status_code + assert resp.json() == body + + def test_empty_upstream_body_returns_status_with_no_body( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + with _mock_upstream(status_code=204, content=b""): + resp = client.delete( + f"api/v1/guardrails/ban_lists/{uuid4()}", headers=user_api_key_header + ) + + assert resp.status_code == 204 + assert resp.content == b"" + + def test_create_echoes_upstream_status_and_body( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + created = {"id": str(uuid4()), "validator_name": "toxicity"} + # Upstream create routes return FastAPI's default 200. + with _mock_upstream(status_code=200, json_body=created) as calls: + resp = client.post( + "api/v1/guardrails/llm_prompt_configs", + json={"validator_name": "toxicity", "prompt": "be nice"}, + headers=user_api_key_header, + ) + + assert resp.status_code == 200 + assert resp.json() == created + assert calls[0]["kwargs"]["json"] == { + "validator_name": "toxicity", + "prompt": "be nice", + } + + def test_connect_error_returns_502( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + with _mock_upstream(raises=httpx.ConnectError("connection refused")): + resp = client.get("api/v1/guardrails", headers=user_api_key_header) + + assert resp.status_code == 502 + assert resp.json()["error"] == "Guardrails service unavailable" + + def test_non_json_upstream_body_returns_502( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + with _mock_upstream(status_code=200, content=b"gateway"): + resp = client.get("api/v1/guardrails", headers=user_api_key_header) + + assert resp.status_code == 502 + assert resp.json()["error"] == "Guardrails service returned an invalid response" + + +class TestProxyForwardedRequest: + def test_unset_limit_is_dropped_from_forwarded_params( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + with _mock_upstream(json_body={"data": []}) as calls: + client.get("api/v1/guardrails/ban_lists", headers=user_api_key_header) + + params = calls[0]["kwargs"]["params"] + assert params == {"offset": 0} + + def test_ids_forwarded_as_list( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + first, second = str(uuid4()), str(uuid4()) + with _mock_upstream(json_body={"data": []}) as calls: + client.get( + f"api/v1/guardrails/validators/configs?ids={first}&ids={second}" + "&stage=input", + headers=user_api_key_header, + ) + + assert calls[0]["kwargs"]["params"] == { + "ids": [first, second], + "stage": "input", + } + + def test_tenant_headers_come_from_auth_context_not_request( + self, + client: TestClient, + user_api_key: TestAuthContext, + user_api_key_header: dict[str, str], + ) -> None: + with _mock_upstream(status_code=200, json_body={"id": str(uuid4())}) as calls: + client.post( + "api/v1/guardrails/ban_lists?organization_id=999", + json={"name": "slurs", "organization_id": 999, "project_id": 888}, + headers=user_api_key_header, + ) + + headers = calls[0]["kwargs"]["headers"] + assert headers["X-ORGANIZATION-ID"] == str(user_api_key.organization_id) + assert headers["X-PROJECT-ID"] == str(user_api_key.project_id) + + def test_ban_list_detail_path_forwarded( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + ban_list_id = uuid4() + with _mock_upstream(json_body={"id": str(ban_list_id)}) as calls: + resp = client.patch( + f"api/v1/guardrails/ban_lists/{ban_list_id}", + json={"name": "renamed"}, + headers=user_api_key_header, + ) + + assert resp.status_code == 200 + method, url = calls[0]["args"] + assert method == "PATCH" + assert url.endswith(f"/ban_lists/{ban_list_id}") + + +class TestProxyRouteOrdering: + def test_ban_lists_list_route_wins_over_job_status_route( + self, client: TestClient, user_api_key_header: dict[str, str] + ) -> None: + with _mock_upstream(json_body={"data": []}) as calls: + resp = client.get( + "api/v1/guardrails/ban_lists", headers=user_api_key_header + ) + + assert resp.status_code == 200 + assert calls[0]["args"][1].endswith("/ban_lists/") + + +def test_list_ban_lists_requires_auth(client: TestClient) -> None: + resp = client.get("api/v1/guardrails/ban_lists") + assert resp.status_code in (401, 403) + + # ---------- helpers ---------- +@contextmanager +def _mock_upstream( + *, + status_code: int = 200, + json_body: Any = None, + content: bytes | None = None, + raises: Exception | None = None, +): + """Stub the guardrails HTTP boundary; yields the recorded client.request calls.""" + calls: list[dict[str, Any]] = [] + + response = MagicMock() + response.status_code = status_code + if content is None: + import json as _json + + response.content = _json.dumps(json_body).encode() + response.json.return_value = json_body + else: + response.content = content + response.json.side_effect = ValueError("not json") + + def _request(*args: Any, **kwargs: Any): + calls.append({"args": args, "kwargs": kwargs}) + if raises is not None: + raise raises + return response + + client = MagicMock() + client.request.side_effect = _request + + with patch("app.services.llm.guardrails.httpx.Client") as mock_client_cls: + mock_client_cls.return_value.__enter__.return_value = client + yield calls + + def _stub_job() -> Job: """Minimal in-memory Job for routes that only read id/status/timestamps.""" from datetime import datetime, timezone diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py index ef96280f3..87a4c261d 100644 --- a/backend/app/tests/services/llm/test_guardrails.py +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -70,8 +70,9 @@ def test_run_guardrails_validation_success(mock_client_cls) -> None: @patch("app.services.llm.guardrails.httpx.Client") def test_run_guardrails_validation_http_error_bypasses(mock_client_cls) -> None: mock_response = MagicMock() + mock_response.status_code = 500 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( - "bad", request=None, response=None + "bad", request=MagicMock(), response=mock_response ) mock_client = MagicMock() @@ -91,6 +92,55 @@ def test_run_guardrails_validation_http_error_bypasses(mock_client_cls) -> None: assert result["data"]["safe_text"] == TEST_TEXT +@pytest.mark.parametrize("status_code", [401, 403, 422]) +@patch("app.services.llm.guardrails.httpx.Client") +def test_run_guardrails_validation_auth_error_fails_closed( + mock_client_cls, status_code +) -> None: + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "unauthorized", request=MagicMock(), response=mock_response + ) + + mock_client = MagicMock() + mock_client.post.return_value = mock_response + mock_client_cls.return_value.__enter__.return_value = mock_client + + result = run_guardrails_validation( + TEST_TEXT, + TEST_CONFIG, + TEST_JOB_ID, + TEST_PROJECT_ID, + TEST_ORGANIZATION_ID, + ) + + assert result["success"] is False + assert result.get("bypassed") is False + assert "rejected the request" in result["error"] + + +@patch("app.services.llm.guardrails.httpx.Client") +def test_list_validators_config_auth_error_raises(mock_client_cls) -> None: + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "forbidden", request=MagicMock(), response=mock_response + ) + + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client_cls.return_value.__enter__.return_value = mock_client + + with pytest.raises(ValueError, match=r"rejected \(HTTP 403\)"): + list_validators_config( + input_validator_configs=[Validator(validator_config_id=uuid.uuid4())], + output_validator_configs=[], + organization_id=1, + project_id=1, + ) + + @patch("app.services.llm.guardrails.httpx.Client") def test_run_guardrails_validation_uses_settings(mock_client_cls) -> None: mock_response = MagicMock() diff --git a/docs/wiki/modules/llm-call.md b/docs/wiki/modules/llm-call.md index 462fbc048..4b8c728ba 100644 --- a/docs/wiki/modules/llm-call.md +++ b/docs/wiki/modules/llm-call.md @@ -10,7 +10,12 @@ All paths relative to `backend/app/`. - `api/routes/llm_chain.py` — chains - `api/routes/llm_sts.py` — speech-to-speech - `api/routes/config/config.py`, `api/routes/config/version.py` — saved config CRUD + versions -- `api/routes/guardrails.py` — guardrail validators +- `api/routes/guardrails.py` — `POST /guardrails` (async job) + `GET /guardrails/{job_id}` (poll), plus thin proxies over the internal kaapi-guardrails management API: + - `GET /guardrails` — validator catalogue + - `/guardrails/ban_lists` — POST, GET (`offset`, `limit`); `/{id}` GET/PATCH/DELETE + - `/guardrails/llm_prompt_configs` — POST, GET (`validator_name`, `offset`, `limit`); `/{id}` GET/PATCH/DELETE + - `/guardrails/validators/configs` — POST, GET (`ids`, `stage`, `type`); `/{id}` GET/PATCH/DELETE + - Gotcha: the fixed `/guardrails/*` paths must stay declared above `GET /guardrails/{job_id}` — FastAPI matches in declaration order and won't fall through on a UUID parse failure. ## Tables (SQLModel) | Table | Model |