From 1b9f375379fdaf23785d72e58d9cb3182c48b046 Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Tue, 3 Feb 2026 14:24:07 +0530 Subject: [PATCH 1/8] Added code for kaapi integration --- .env.example | 1 + .env.test.example | 1 + backend/app/core/config.py | 1 + backend/app/models/llm/request.py | 14 +++++++ backend/app/services/llm/jobs.py | 70 ++++++++++++++++++++++++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 60c3ec58a..5617244b3 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,4 @@ CALLBACK_READ_TIMEOUT = 10 # require as a env if you want to use doc transformation OPENAI_API_KEY="" +KAAPI_GUARDRAILS_AUTH="" \ No newline at end of file diff --git a/.env.test.example b/.env.test.example index f938561d9..f9a49fdb7 100644 --- a/.env.test.example +++ b/.env.test.example @@ -32,3 +32,4 @@ AWS_S3_BUCKET_PREFIX="bucket-prefix-name" # Callback Timeouts (in seconds) CALLBACK_CONNECT_TIMEOUT = 3 CALLBACK_READ_TIMEOUT = 10 +KAAPI_GUARDRAILS_AUTH="" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 40c770541..75e4a23e2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -49,6 +49,7 @@ class Settings(BaseSettings): POSTGRES_USER: str POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" + KAAPI_GUARDRAILS_AUTH: str = "" @computed_field # type: ignore[prop-decorator] @property diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index fc44235f9..4da892e92 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -208,6 +208,20 @@ class LLMCallRequest(SQLModel): "in production, always use the id + version." ), ) + input_guardrails: list[dict[str, Any]] | None = Field( + default=None, + description=( + "Optional guardrails configuration to apply input validation. " + "If not provided, no guardrails will be applied." + ), + ) + output_guardrails: list[dict[str, Any]] | None = Field( + default=None, + description=( + "Optional guardrails configuration to apply output validation. " + "If not provided, no guardrails will be applied." + ), + ) callback_url: HttpUrl | None = Field( default=None, description="Webhook URL for async response delivery" ) diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index f4700b51b..339fad414 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -4,10 +4,12 @@ from asgi_correlation_id import correlation_id from fastapi import HTTPException from sqlmodel import Session +import httpx from app.celery.utils import start_high_priority_job from app.core.db import engine from app.core.langfuse.langfuse import observe_llm_execution +from app.core.config import settings from app.crud.config import ConfigVersionCrud from app.crud.credentials import get_provider_credential from app.crud.jobs import JobCrud @@ -18,6 +20,7 @@ from app.utils import APIResponse, send_callback logger = logging.getLogger(__name__) +GUARDRAILS_URL = "http://host.docker.internal:8001/api/v1/guardrails/" def start_job( @@ -134,14 +137,44 @@ def execute_job( # one of (id, version) or blob is guaranteed to be present due to prior validation config = request.config + input_query = request.query.input + input_guardrails = request.input_guardrails + output_guardrails = request.output_guardrails callback_response = None config_blob: ConfigBlob | None = None logger.info( - f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " + f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, input_guardrail={input_guardrails}, input_query={input_query}." ) try: + if input_guardrails: + safe_input = call_guardrails(input_query, input_guardrails, job_id) + logger.info( + f"[execute_job] Input guardrail validation | Original query={input_query}, safe_input={safe_input}/" + ) + if safe_input["success"] == True and safe_input["data"]["rephrase_needed"] == False: + request.query.input = safe_input["data"]["safe_text"] + elif safe_input["success"] == True and safe_input["data"]["rephrase_needed"] == True: + request.query.input = safe_input["data"]["safe_text"] + callback_response = APIResponse.failure_response( + error=request.query.input, + metadata=request.request_metadata, + ) + return handle_job_error( + job_id, request.callback_url, callback_response + ) + else: + request.query.input = safe_input["error"] + + callback_response = APIResponse.failure_response( + error=safe_input["error"], + metadata=request.request_metadata, + ) + return handle_job_error( + job_id, request.callback_url, callback_response + ) + with Session(engine) as session: # Update job status to PROCESSING job_crud = JobCrud(session=session) @@ -226,6 +259,17 @@ def execute_job( ) if response: + if output_guardrails: + output_text = response.response.output.text + safe_output = call_guardrails(output_text, output_guardrails, job_id) + logger.info( + f"[execute_job] Output guardrail validation | Original output={output_text}, safe_output={safe_output}/" + ) + if safe_output["success"] == True: + response.response.output.text = safe_output["data"]["safe_text"] + else: + response.response.output.text = safe_output["error"] + callback_response = APIResponse.success_response( data=response, metadata=request.request_metadata ) @@ -263,3 +307,27 @@ def execute_job( exc_info=True, ) return handle_job_error(job_id, request.callback_url, callback_response) + + +def call_guardrails(input_text: str, guardrail_config: list[dict], job_id: UUID): + payload = { + "request_id": str(job_id), + "input": input_text, + "validators": guardrail_config + } + + headers = { + "accept": "application/json", + "Authorization": f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}", + "Content-Type": "application/json", + } + + with httpx.Client(timeout=10.0) as client: + response = client.post( + GUARDRAILS_URL, + json=payload, + headers=headers, + ) + + response.raise_for_status() + return response.json() From c336ca7674e3e637e3b2419a72f71562a4a6747a Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 14:33:55 +0530 Subject: [PATCH 2/8] resolved comments --- .env.example | 4 +- .env.test.example | 5 ++ backend/app/core/config.py | 1 + backend/app/services/llm/guardrail.py | 61 +++++++++++++++++++++ backend/app/services/llm/jobs.py | 79 +++++++++++++-------------- 5 files changed, 107 insertions(+), 43 deletions(-) create mode 100644 backend/app/services/llm/guardrail.py diff --git a/.env.example b/.env.example index 5617244b3..b33546234 100644 --- a/.env.example +++ b/.env.example @@ -81,4 +81,6 @@ CALLBACK_READ_TIMEOUT = 10 # require as a env if you want to use doc transformation OPENAI_API_KEY="" -KAAPI_GUARDRAILS_AUTH="" \ No newline at end of file + +KAAPI_GUARDRAILS_AUTH="" +KAAPI_GUARDRAILS_URL = "" diff --git a/.env.test.example b/.env.test.example index f9a49fdb7..0a4f17a82 100644 --- a/.env.test.example +++ b/.env.test.example @@ -32,4 +32,9 @@ AWS_S3_BUCKET_PREFIX="bucket-prefix-name" # Callback Timeouts (in seconds) CALLBACK_CONNECT_TIMEOUT = 3 CALLBACK_READ_TIMEOUT = 10 + +OPENAI_API_KEY="" + KAAPI_GUARDRAILS_AUTH="" +KAAPI_GUARDRAILS_URL = "" + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 75e4a23e2..a7cb7376a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -50,6 +50,7 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" KAAPI_GUARDRAILS_AUTH: str = "" + KAAPI_GUARDRAILS_URL: str = "" @computed_field # type: ignore[prop-decorator] @property diff --git a/backend/app/services/llm/guardrail.py b/backend/app/services/llm/guardrail.py new file mode 100644 index 000000000..3e6cbcae2 --- /dev/null +++ b/backend/app/services/llm/guardrail.py @@ -0,0 +1,61 @@ +from typing import Any +from uuid import UUID +import logging + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +def call_guardrails(input_text: str, guardrail_config: list[dict], job_id: UUID) -> dict[str, Any]: + """ + Call the Kaapi guardrails service to validate and process input text. + + Args: + input_text: Text to validate and process. + guardrail_config: List of validator configurations to apply. + job_id: Unique identifier for the request. + + Returns: + JSON response from the guardrails service with validation results. + + Raises: + httpx.HTTPError: If the request fails. + """ + payload = { + "request_id": str(job_id), + "input": input_text, + "validators": guardrail_config + } + + headers = { + "accept": "application/json", + "Authorization": f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}", + "Content-Type": "application/json", + } + + try: + with httpx.Client(timeout=10.0) as client: + response = client.post( + settings.KAAPI_GUARDRAILS_URL, + json=payload, + headers=headers, + ) + + response.raise_for_status() + return response.json() + except Exception as e: + logger.warning( + f"[guardrails] Service unavailable. Bypassing guardrails. job_id={job_id}. error={e}" + ) + + return { + "success": False, + "bypassed": True, + "data": { + "safe_text": input_text, + "rephrase_needed": False, + }, + } diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index 339fad414..441033009 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -4,23 +4,21 @@ from asgi_correlation_id import correlation_id from fastapi import HTTPException from sqlmodel import Session -import httpx from app.celery.utils import start_high_priority_job from app.core.db import engine from app.core.langfuse.langfuse import observe_llm_execution -from app.core.config import settings from app.crud.config import ConfigVersionCrud from app.crud.credentials import get_provider_credential from app.crud.jobs import JobCrud from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest from app.models.llm.request import ConfigBlob, LLMCallConfig, KaapiCompletionConfig +from app.services.llm.guardrail import call_guardrails from app.services.llm.providers.registry import get_llm_provider from app.services.llm.mappers import transform_kaapi_config_to_native from app.utils import APIResponse, send_callback logger = logging.getLogger(__name__) -GUARDRAILS_URL = "http://host.docker.internal:8001/api/v1/guardrails/" def start_job( @@ -144,26 +142,30 @@ def execute_job( config_blob: ConfigBlob | None = None logger.info( - f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, input_guardrail={input_guardrails}, input_query={input_query}." - ) + f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " ) try: if input_guardrails: safe_input = call_guardrails(input_query, input_guardrails, job_id) + logger.info( - f"[execute_job] Input guardrail validation | Original query={input_query}, safe_input={safe_input}/" + f"[execute_job] Input guardrail validation | success={safe_input['success']}." ) - if safe_input["success"] == True and safe_input["data"]["rephrase_needed"] == False: - request.query.input = safe_input["data"]["safe_text"] - elif safe_input["success"] == True and safe_input["data"]["rephrase_needed"] == True: + + if safe_input["bypassed"] == True: + logger.info("[execute_job] Guardrails bypassed (service unavailable)") + + elif safe_input["success"] == True: request.query.input = safe_input["data"]["safe_text"] - callback_response = APIResponse.failure_response( - error=request.query.input, - metadata=request.request_metadata, - ) - return handle_job_error( - job_id, request.callback_url, callback_response - ) + + if safe_input["data"]["rephrase_needed"] == True: + callback_response = APIResponse.failure_response( + error=request.query.input, + metadata=request.request_metadata, + ) + return handle_job_error( + job_id, request.callback_url, callback_response + ) else: request.query.input = safe_input["error"] @@ -262,14 +264,31 @@ def execute_job( if output_guardrails: output_text = response.response.output.text safe_output = call_guardrails(output_text, output_guardrails, job_id) + logger.info( - f"[execute_job] Output guardrail validation | Original output={output_text}, safe_output={safe_output}/" + f"[execute_job] Output guardrail validation | success={safe_output['success']}." ) - if safe_output["success"] == True: + + if safe_output["bypassed"] == True: + logger.info("[execute_job] Guardrails bypassed (service unavailable)") + + elif safe_output["success"] == True: response.response.output.text = safe_output["data"]["safe_text"] + + if safe_output["data"]["rephrase_needed"] == True: + callback_response = APIResponse.failure_response( + error=request.query.input, + metadata=request.request_metadata, + ) + else: response.response.output.text = safe_output["error"] + callback_response = APIResponse.failure_response( + error=safe_output["error"], + metadata=request.request_metadata, + ) + callback_response = APIResponse.success_response( data=response, metadata=request.request_metadata ) @@ -307,27 +326,3 @@ def execute_job( exc_info=True, ) return handle_job_error(job_id, request.callback_url, callback_response) - - -def call_guardrails(input_text: str, guardrail_config: list[dict], job_id: UUID): - payload = { - "request_id": str(job_id), - "input": input_text, - "validators": guardrail_config - } - - headers = { - "accept": "application/json", - "Authorization": f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}", - "Content-Type": "application/json", - } - - with httpx.Client(timeout=10.0) as client: - response = client.post( - GUARDRAILS_URL, - json=payload, - headers=headers, - ) - - response.raise_for_status() - return response.json() From 32e61b52c3bc64cf19b9309ebcf267fd0d7ca57c Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 14:50:54 +0530 Subject: [PATCH 3/8] resolved comments --- .env.example | 2 +- .env.test.example | 2 +- backend/app/services/llm/guardrail.py | 4 ++-- backend/app/services/llm/jobs.py | 21 ++++++++++++++------- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index b33546234..98ac7d108 100644 --- a/.env.example +++ b/.env.example @@ -83,4 +83,4 @@ CALLBACK_READ_TIMEOUT = 10 OPENAI_API_KEY="" KAAPI_GUARDRAILS_AUTH="" -KAAPI_GUARDRAILS_URL = "" +KAAPI_GUARDRAILS_URL="" diff --git a/.env.test.example b/.env.test.example index 0a4f17a82..f529015e5 100644 --- a/.env.test.example +++ b/.env.test.example @@ -36,5 +36,5 @@ CALLBACK_READ_TIMEOUT = 10 OPENAI_API_KEY="" KAAPI_GUARDRAILS_AUTH="" -KAAPI_GUARDRAILS_URL = "" +KAAPI_GUARDRAILS_URL="" diff --git a/backend/app/services/llm/guardrail.py b/backend/app/services/llm/guardrail.py index 3e6cbcae2..2385a0f44 100644 --- a/backend/app/services/llm/guardrail.py +++ b/backend/app/services/llm/guardrail.py @@ -46,9 +46,9 @@ def call_guardrails(input_text: str, guardrail_config: list[dict], job_id: UUID) response.raise_for_status() return response.json() - except Exception as e: + except Exception as e: logger.warning( - f"[guardrails] Service unavailable. Bypassing guardrails. job_id={job_id}. error={e}" + f"[call_guardrails] Service unavailable. Bypassing guardrails. job_id={job_id}. error={e}" ) return { diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index 441033009..39c287d43 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -142,7 +142,8 @@ def execute_job( config_blob: ConfigBlob | None = None logger.info( - f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " ) + f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " + ) try: if input_guardrails: @@ -152,13 +153,13 @@ def execute_job( f"[execute_job] Input guardrail validation | success={safe_input['success']}." ) - if safe_input["bypassed"] == True: + if safe_input.get("bypassed"): logger.info("[execute_job] Guardrails bypassed (service unavailable)") - elif safe_input["success"] == True: + elif safe_input["success"]: request.query.input = safe_input["data"]["safe_text"] - if safe_input["data"]["rephrase_needed"] == True: + if safe_input["data"]["rephrase_needed"]: callback_response = APIResponse.failure_response( error=request.query.input, metadata=request.request_metadata, @@ -175,7 +176,7 @@ def execute_job( ) return handle_job_error( job_id, request.callback_url, callback_response - ) + ) with Session(engine) as session: # Update job status to PROCESSING @@ -269,10 +270,10 @@ def execute_job( f"[execute_job] Output guardrail validation | success={safe_output['success']}." ) - if safe_output["bypassed"] == True: + if safe_output.get("bypassed"): logger.info("[execute_job] Guardrails bypassed (service unavailable)") - elif safe_output["success"] == True: + elif safe_output["success"]: response.response.output.text = safe_output["data"]["safe_text"] if safe_output["data"]["rephrase_needed"] == True: @@ -280,6 +281,9 @@ def execute_job( error=request.query.input, metadata=request.request_metadata, ) + return handle_job_error( + job_id, request.callback_url, callback_response + ) else: response.response.output.text = safe_output["error"] @@ -288,6 +292,9 @@ def execute_job( error=safe_output["error"], metadata=request.request_metadata, ) + return handle_job_error( + job_id, request.callback_url, callback_response + ) callback_response = APIResponse.success_response( data=response, metadata=request.request_metadata From 8c0b389dd196d5a63d88ca3d7533c449a71b498b Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 15:50:06 +0530 Subject: [PATCH 4/8] Added tests --- backend/app/tests/api/routes/test_llm.py | 109 +++++++++++- backend/app/tests/services/llm/test_jobs.py | 175 ++++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 9313750a0..a09fd9a2b 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -151,7 +151,7 @@ def test_llm_call_invalid_provider( "blob": { "completion": { "provider": "invalid-provider", - "params": {"model": "gpt-4"}, + "params": {"model": "gpt-4o"}, } } }, @@ -164,3 +164,110 @@ def test_llm_call_invalid_provider( ) assert response.status_code == 422 + +def test_llm_call_success_with_guardrails( + client: TestClient, + user_api_key_header: dict[str, str], +): + """Test successful LLM call when guardrails are enabled (no validators).""" + + with ( + patch("app.services.llm.jobs.start_high_priority_job") as mock_start_job, + patch("app.services.llm.guardrail.call_guardrails") as mock_guardrails, + ): + mock_start_job.return_value = "test-task-id" + + mock_guardrails.return_value = { + "success": True, + "bypassed": False, + "data": { + "safe_text": "What is the capital of France?", + "rephrase_needed": False, + }, + } + + payload = LLMCallRequest( + query=QueryParams(input="What is the capital of France?"), + config=LLMCallConfig( + blob=ConfigBlob( + completion=NativeCompletionConfig( + provider="openai-native", + params={ + "model": "gpt-4o", + "temperature": 0.7, + }, + ) + ) + ), + input_guardrails=[], + output_guardrails=[], + callback_url="https://example.com/callback", + ) + + response = client.post( + "/api/v1/llm/call", + json=payload.model_dump(mode="json"), + headers=user_api_key_header, + ) + + assert response.status_code == 200 + + body = response.json() + assert body["success"] is True + assert "response is being generated" in body["data"]["message"] + + mock_start_job.assert_called_once() + mock_guardrails.assert_not_called() + +def test_llm_call_guardrails_bypassed_still_succeeds( + client: TestClient, + user_api_key_header: dict[str, str], +): + """If guardrails service is unavailable (bypassed), request should still succeed.""" + + with ( + patch("app.services.llm.jobs.start_high_priority_job") as mock_start_job, + patch("app.services.llm.guardrail.call_guardrails") as mock_guardrails, + ): + mock_start_job.return_value = "test-task-id" + + mock_guardrails.return_value = { + "success": True, + "bypassed": True, + "data": { + "safe_text": "What is the capital of France?", + "rephrase_needed": False, + }, + } + + payload = LLMCallRequest( + query=QueryParams(input="What is the capital of France?"), + config=LLMCallConfig( + blob=ConfigBlob( + completion=NativeCompletionConfig( + provider="openai-native", + params={ + "model": "gpt-4", + "temperature": 0.7, + }, + ) + ) + ), + input_guardrails=[{"type": "pii_remover"}], + output_guardrails=[], + callback_url="https://example.com/callback", + ) + + response = client.post( + "/api/v1/llm/call", + json=payload.model_dump(mode="json"), + headers=user_api_key_header, + ) + + assert response.status_code == 200 + + body = response.json() + assert body["success"] is True + assert "response is being generated" in body["data"]["message"] + + mock_start_job.assert_called_once() diff --git a/backend/app/tests/services/llm/test_jobs.py b/backend/app/tests/services/llm/test_jobs.py index 0aa3ad1f0..95742bc71 100644 --- a/backend/app/tests/services/llm/test_jobs.py +++ b/backend/app/tests/services/llm/test_jobs.py @@ -719,6 +719,181 @@ def test_kaapi_config_warnings_merged_with_existing_metadata( assert "reasoning" in result["metadata"]["warnings"][0].lower() assert "does not support reasoning" in result["metadata"]["warnings"][0] + def test_guardrails_sanitize_input_before_provider(self, db, job_env, job_for_execution): + """ + Input guardrails should sanitize the text BEFORE provider.execute is called. + """ + + env = job_env + + env["provider"].execute.return_value = ( + env["mock_llm_response"], + None, + ) + + unsafe_input = "My credit card is 4111 1111 1111 1111" + + with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: + mock_guardrails.return_value = { + "success": True, + "bypassed": False, + "data": { + "safe_text": "My credit card is [REDACTED]", + "rephrase_needed": False, + }, + } + + request_data = { + "query": {"input": unsafe_input}, + "config": { + "blob": { + "completion": { + "provider": "openai-native", + "params": {"model": "gpt-4"}, + } + } + }, + "input_guardrails": [{"type": "pii_remover"}], + "output_guardrails": [], + "include_provider_raw_response": False, + "callback_url": None, + } + + result = self._execute_job(job_for_execution, db, request_data) + + provider_query = env["provider"].execute.call_args[0][1] + assert "[REDACTED]" in provider_query.input + assert "4111" not in provider_query.input + + assert result["success"] + + def test_guardrails_sanitize_output_after_provider(self, db, job_env, job_for_execution): + env = job_env + + env["mock_llm_response"].response.output.text = "Aadhar no 123-45-6789" + env["provider"].execute.return_value = (env["mock_llm_response"], None) + + with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: + mock_guardrails.return_value = { + "success": True, + "bypassed": False, + "data": { + "safe_text": "Aadhar [REDACTED]", + "rephrase_needed": False, + }, + } + + request_data = { + "query": {"input": "hello"}, + "config": { + "blob": { + "completion": { + "provider": "openai-native", + "params": {"model": "gpt-4"}, + } + } + }, + "input_guardrails": [], + "output_guardrails": [{"type": "pii_remover"}], + } + + result = self._execute_job(job_for_execution, db, request_data) + + assert "REDACTED" in result["data"]["response"]["output"]["text"] + + def test_guardrails_bypass_does_not_modify_input(self, db, job_env, job_for_execution): + env = job_env + + env["provider"].execute.return_value = (env["mock_llm_response"], None) + + unsafe_input = "4111 1111 1111 1111" + + with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: + mock_guardrails.return_value = { + "success": True, + "bypassed": True, + "data": { + "safe_text": unsafe_input, + "rephrase_needed": False, + }, + } + + request_data = { + "query": {"input": unsafe_input}, + "config": { + "blob": { + "completion": { + "provider": "openai-native", + "params": {"model": "gpt-4"}, + } + } + }, + "input_guardrails": [{"type": "pii_remover"}], + } + + self._execute_job(job_for_execution, db, request_data) + + provider_query = env["provider"].execute.call_args[0][1] + assert provider_query.input == unsafe_input + + def test_guardrails_validation_failure_blocks_job(self, db, job_env, job_for_execution): + env = job_env + + with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: + mock_guardrails.return_value = { + "success": False, + "error": "Unsafe content detected", + } + + request_data = { + "query": {"input": "bad input"}, + "config": { + "blob": { + "completion": { + "provider": "openai-native", + "params": {"model": "gpt-4"}, + } + } + }, + "input_guardrails": [{"type": "uli_slur_match"}], + } + + result = self._execute_job(job_for_execution, db, request_data) + + assert not result["success"] + assert "Unsafe content" in result["error"] + env["provider"].execute.assert_not_called() + + def test_guardrails_rephrase_needed_blocks_job(self, db, job_env, job_for_execution): + env = job_env + + with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: + mock_guardrails.return_value = { + "success": True, + "bypassed": False, + "data": { + "safe_text": "Rephrased text", + "rephrase_needed": True, + }, + } + + request_data = { + "query": {"input": "unsafe text"}, + "config": { + "blob": { + "completion": { + "provider": "openai-native", + "params": {"model": "gpt-4"}, + } + } + }, + "input_guardrails": [{"type": "policy"}], + } + + result = self._execute_job(job_for_execution, db, request_data) + + assert not result["success"] + env["provider"].execute.assert_not_called() class TestResolveConfigBlob: """Test suite for resolve_config_blob function.""" From c50d30ad15ea8d33827165a87337a84704e8fcf6 Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 16:00:32 +0530 Subject: [PATCH 5/8] applied pre-commit fixes --- .env.test.example | 1 - backend/app/services/llm/guardrail.py | 6 ++++-- backend/app/services/llm/jobs.py | 10 ++++----- backend/app/tests/api/routes/test_llm.py | 2 ++ backend/app/tests/services/llm/test_jobs.py | 23 +++++++++++++++------ 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.env.test.example b/.env.test.example index f529015e5..9b42d15d9 100644 --- a/.env.test.example +++ b/.env.test.example @@ -37,4 +37,3 @@ OPENAI_API_KEY="" KAAPI_GUARDRAILS_AUTH="" KAAPI_GUARDRAILS_URL="" - diff --git a/backend/app/services/llm/guardrail.py b/backend/app/services/llm/guardrail.py index 2385a0f44..7553573f9 100644 --- a/backend/app/services/llm/guardrail.py +++ b/backend/app/services/llm/guardrail.py @@ -9,7 +9,9 @@ logger = logging.getLogger(__name__) -def call_guardrails(input_text: str, guardrail_config: list[dict], job_id: UUID) -> dict[str, Any]: +def call_guardrails( + input_text: str, guardrail_config: list[dict], job_id: UUID +) -> dict[str, Any]: """ Call the Kaapi guardrails service to validate and process input text. @@ -27,7 +29,7 @@ def call_guardrails(input_text: str, guardrail_config: list[dict], job_id: UUID) payload = { "request_id": str(job_id), "input": input_text, - "validators": guardrail_config + "validators": guardrail_config, } headers = { diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index 39c287d43..8ee6e60b2 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -142,7 +142,7 @@ def execute_job( config_blob: ConfigBlob | None = None logger.info( - f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " + f"[execute_job] Starting LLM job execution | job_id={job_id}, task_id={task_id}, " ) try: @@ -174,9 +174,7 @@ def execute_job( error=safe_input["error"], metadata=request.request_metadata, ) - return handle_job_error( - job_id, request.callback_url, callback_response - ) + return handle_job_error(job_id, request.callback_url, callback_response) with Session(engine) as session: # Update job status to PROCESSING @@ -271,7 +269,9 @@ def execute_job( ) if safe_output.get("bypassed"): - logger.info("[execute_job] Guardrails bypassed (service unavailable)") + logger.info( + "[execute_job] Guardrails bypassed (service unavailable)" + ) elif safe_output["success"]: response.response.output.text = safe_output["data"]["safe_text"] diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index a09fd9a2b..3f66a96e1 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -165,6 +165,7 @@ def test_llm_call_invalid_provider( assert response.status_code == 422 + def test_llm_call_success_with_guardrails( client: TestClient, user_api_key_header: dict[str, str], @@ -219,6 +220,7 @@ def test_llm_call_success_with_guardrails( mock_start_job.assert_called_once() mock_guardrails.assert_not_called() + def test_llm_call_guardrails_bypassed_still_succeeds( client: TestClient, user_api_key_header: dict[str, str], diff --git a/backend/app/tests/services/llm/test_jobs.py b/backend/app/tests/services/llm/test_jobs.py index 95742bc71..bae15c26b 100644 --- a/backend/app/tests/services/llm/test_jobs.py +++ b/backend/app/tests/services/llm/test_jobs.py @@ -719,7 +719,9 @@ def test_kaapi_config_warnings_merged_with_existing_metadata( assert "reasoning" in result["metadata"]["warnings"][0].lower() assert "does not support reasoning" in result["metadata"]["warnings"][0] - def test_guardrails_sanitize_input_before_provider(self, db, job_env, job_for_execution): + def test_guardrails_sanitize_input_before_provider( + self, db, job_env, job_for_execution + ): """ Input guardrails should sanitize the text BEFORE provider.execute is called. """ @@ -767,7 +769,9 @@ def test_guardrails_sanitize_input_before_provider(self, db, job_env, job_for_ex assert result["success"] - def test_guardrails_sanitize_output_after_provider(self, db, job_env, job_for_execution): + def test_guardrails_sanitize_output_after_provider( + self, db, job_env, job_for_execution + ): env = job_env env["mock_llm_response"].response.output.text = "Aadhar no 123-45-6789" @@ -800,8 +804,10 @@ def test_guardrails_sanitize_output_after_provider(self, db, job_env, job_for_ex result = self._execute_job(job_for_execution, db, request_data) assert "REDACTED" in result["data"]["response"]["output"]["text"] - - def test_guardrails_bypass_does_not_modify_input(self, db, job_env, job_for_execution): + + def test_guardrails_bypass_does_not_modify_input( + self, db, job_env, job_for_execution + ): env = job_env env["provider"].execute.return_value = (env["mock_llm_response"], None) @@ -836,7 +842,9 @@ def test_guardrails_bypass_does_not_modify_input(self, db, job_env, job_for_exec provider_query = env["provider"].execute.call_args[0][1] assert provider_query.input == unsafe_input - def test_guardrails_validation_failure_blocks_job(self, db, job_env, job_for_execution): + def test_guardrails_validation_failure_blocks_job( + self, db, job_env, job_for_execution + ): env = job_env with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: @@ -864,7 +872,9 @@ def test_guardrails_validation_failure_blocks_job(self, db, job_env, job_for_exe assert "Unsafe content" in result["error"] env["provider"].execute.assert_not_called() - def test_guardrails_rephrase_needed_blocks_job(self, db, job_env, job_for_execution): + def test_guardrails_rephrase_needed_blocks_job( + self, db, job_env, job_for_execution + ): env = job_env with patch("app.services.llm.jobs.call_guardrails") as mock_guardrails: @@ -895,6 +905,7 @@ def test_guardrails_rephrase_needed_blocks_job(self, db, job_env, job_for_execut assert not result["success"] env["provider"].execute.assert_not_called() + class TestResolveConfigBlob: """Test suite for resolve_config_blob function.""" From 8a675ecbcd720db3d7f3e8165167fe8ce4fc7166 Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 17:45:49 +0530 Subject: [PATCH 6/8] Resolved comments --- .../llm/{guardrail.py => guardrails.py} | 3 - backend/app/services/llm/jobs.py | 2 +- backend/app/tests/api/routes/test_llm.py | 5 +- .../app/tests/services/llm/test_guardrails.py | 96 +++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) rename backend/app/services/llm/{guardrail.py => guardrails.py} (96%) create mode 100644 backend/app/tests/services/llm/test_guardrails.py diff --git a/backend/app/services/llm/guardrail.py b/backend/app/services/llm/guardrails.py similarity index 96% rename from backend/app/services/llm/guardrail.py rename to backend/app/services/llm/guardrails.py index 7553573f9..37f0d1ebf 100644 --- a/backend/app/services/llm/guardrail.py +++ b/backend/app/services/llm/guardrails.py @@ -22,9 +22,6 @@ def call_guardrails( Returns: JSON response from the guardrails service with validation results. - - Raises: - httpx.HTTPError: If the request fails. """ payload = { "request_id": str(job_id), diff --git a/backend/app/services/llm/jobs.py b/backend/app/services/llm/jobs.py index 8ee6e60b2..492c1dc2c 100644 --- a/backend/app/services/llm/jobs.py +++ b/backend/app/services/llm/jobs.py @@ -13,7 +13,7 @@ from app.crud.jobs import JobCrud from app.models import JobStatus, JobType, JobUpdate, LLMCallRequest from app.models.llm.request import ConfigBlob, LLMCallConfig, KaapiCompletionConfig -from app.services.llm.guardrail import call_guardrails +from app.services.llm.guardrails import call_guardrails from app.services.llm.providers.registry import get_llm_provider from app.services.llm.mappers import transform_kaapi_config_to_native from app.utils import APIResponse, send_callback diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 3f66a96e1..87667b07a 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -6,7 +6,6 @@ from app.models.llm.request import ( QueryParams, LLMCallConfig, - CompletionConfig, ConfigBlob, KaapiLLMParams, KaapiCompletionConfig, @@ -174,7 +173,7 @@ def test_llm_call_success_with_guardrails( with ( patch("app.services.llm.jobs.start_high_priority_job") as mock_start_job, - patch("app.services.llm.guardrail.call_guardrails") as mock_guardrails, + patch("app.services.llm.guardrails.call_guardrails") as mock_guardrails, ): mock_start_job.return_value = "test-task-id" @@ -229,7 +228,7 @@ def test_llm_call_guardrails_bypassed_still_succeeds( with ( patch("app.services.llm.jobs.start_high_priority_job") as mock_start_job, - patch("app.services.llm.guardrail.call_guardrails") as mock_guardrails, + patch("app.services.llm.guardrails.call_guardrails") as mock_guardrails, ): mock_start_job.return_value = "test-task-id" diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py new file mode 100644 index 000000000..3887ca387 --- /dev/null +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -0,0 +1,96 @@ +import uuid +from unittest.mock import MagicMock, patch + +import pytest +import httpx + +from app.services.llm.guardrails import call_guardrails +from app.core.config import settings + + +TEST_JOB_ID = uuid.uuid4() +TEST_TEXT = "hello world" +TEST_CONFIG = [{"type": "pii_remover"}] + + +@patch("app.services.llm.guardrails.httpx.Client") +def test_call_guardrails_success(mock_client_cls): + mock_response = MagicMock() + mock_response.json.return_value = {"success": True} + mock_response.raise_for_status.return_value = None + + mock_client = MagicMock() + mock_client.post.return_value = mock_response + mock_client_cls.return_value.__enter__.return_value = mock_client + + result = call_guardrails(TEST_TEXT, TEST_CONFIG, TEST_JOB_ID) + + assert result == {"success": True} + mock_client.post.assert_called_once() + + args, kwargs = mock_client.post.call_args + + assert kwargs["json"]["input"] == TEST_TEXT + assert kwargs["json"]["validators"] == TEST_CONFIG + assert kwargs["json"]["request_id"] == str(TEST_JOB_ID) + + assert kwargs["headers"]["Authorization"].startswith("Bearer ") + assert kwargs["headers"]["Content-Type"] == "application/json" + + +@patch("app.services.llm.guardrails.httpx.Client") +def test_call_guardrails_http_error_bypasses(mock_client_cls): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "bad", request=None, response=None + ) + + mock_client = MagicMock() + mock_client.post.return_value = mock_response + mock_client_cls.return_value.__enter__.return_value = mock_client + + result = call_guardrails(TEST_TEXT, TEST_CONFIG, TEST_JOB_ID) + + assert result["success"] is False + assert result["bypassed"] is True + assert result["data"]["safe_text"] == TEST_TEXT + + +@patch("app.services.llm.guardrails.httpx.Client") +def test_call_guardrails_network_failure_bypasses(mock_client_cls): + mock_client = MagicMock() + mock_client.post.side_effect = httpx.ConnectError("failed") + mock_client_cls.return_value.__enter__.return_value = mock_client + + result = call_guardrails(TEST_TEXT, TEST_CONFIG, TEST_JOB_ID) + + assert result["bypassed"] is True + assert result["data"]["safe_text"] == TEST_TEXT + + +@patch("app.services.llm.guardrails.httpx.Client") +def test_call_guardrails_timeout_bypasses(mock_client_cls): + mock_client = MagicMock() + mock_client.post.side_effect = httpx.TimeoutException("timeout") + mock_client_cls.return_value.__enter__.return_value = mock_client + + result = call_guardrails(TEST_TEXT, TEST_CONFIG, TEST_JOB_ID) + + assert result["bypassed"] is True + + +@patch("app.services.llm.guardrails.httpx.Client") +def test_call_guardrails_uses_settings(mock_client_cls): + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"ok": True} + + mock_client = MagicMock() + mock_client.post.return_value = mock_response + mock_client_cls.return_value.__enter__.return_value = mock_client + + call_guardrails(TEST_TEXT, TEST_CONFIG, TEST_JOB_ID) + + _, kwargs = mock_client.post.call_args + + assert kwargs["headers"]["Authorization"] == f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}" From 39d97e3b44607d97eda66d0d0e7fe3927a883aac Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 17:58:46 +0530 Subject: [PATCH 7/8] added precommit files --- backend/app/tests/services/llm/test_guardrails.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py index 3887ca387..9c1ffab8a 100644 --- a/backend/app/tests/services/llm/test_guardrails.py +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -93,4 +93,6 @@ def test_call_guardrails_uses_settings(mock_client_cls): _, kwargs = mock_client.post.call_args - assert kwargs["headers"]["Authorization"] == f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}" + assert ( + kwargs["headers"]["Authorization"] == f"Bearer {settings.KAAPI_GUARDRAILS_AUTH}" + ) From e09e0aca3d18ccc4efa000596ed40a9819b76a04 Mon Sep 17 00:00:00 2001 From: rkritika1508 Date: Thu, 5 Feb 2026 18:04:06 +0530 Subject: [PATCH 8/8] resolved comments --- backend/app/tests/api/routes/test_llm.py | 6 +++--- backend/app/tests/services/llm/test_guardrails.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/app/tests/api/routes/test_llm.py b/backend/app/tests/api/routes/test_llm.py index 87667b07a..279911d8b 100644 --- a/backend/app/tests/api/routes/test_llm.py +++ b/backend/app/tests/api/routes/test_llm.py @@ -150,7 +150,7 @@ def test_llm_call_invalid_provider( "blob": { "completion": { "provider": "invalid-provider", - "params": {"model": "gpt-4o"}, + "params": {"model": "gpt-4"}, } } }, @@ -168,7 +168,7 @@ def test_llm_call_invalid_provider( def test_llm_call_success_with_guardrails( client: TestClient, user_api_key_header: dict[str, str], -): +) -> None: """Test successful LLM call when guardrails are enabled (no validators).""" with ( @@ -223,7 +223,7 @@ def test_llm_call_success_with_guardrails( def test_llm_call_guardrails_bypassed_still_succeeds( client: TestClient, user_api_key_header: dict[str, str], -): +) -> None: """If guardrails service is unavailable (bypassed), request should still succeed.""" with ( diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py index 9c1ffab8a..4443aecad 100644 --- a/backend/app/tests/services/llm/test_guardrails.py +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -14,7 +14,7 @@ @patch("app.services.llm.guardrails.httpx.Client") -def test_call_guardrails_success(mock_client_cls): +def test_call_guardrails_success(mock_client_cls) -> None: mock_response = MagicMock() mock_response.json.return_value = {"success": True} mock_response.raise_for_status.return_value = None @@ -39,7 +39,7 @@ def test_call_guardrails_success(mock_client_cls): @patch("app.services.llm.guardrails.httpx.Client") -def test_call_guardrails_http_error_bypasses(mock_client_cls): +def test_call_guardrails_http_error_bypasses(mock_client_cls) -> None: mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "bad", request=None, response=None @@ -57,7 +57,7 @@ def test_call_guardrails_http_error_bypasses(mock_client_cls): @patch("app.services.llm.guardrails.httpx.Client") -def test_call_guardrails_network_failure_bypasses(mock_client_cls): +def test_call_guardrails_network_failure_bypasses(mock_client_cls) -> None: mock_client = MagicMock() mock_client.post.side_effect = httpx.ConnectError("failed") mock_client_cls.return_value.__enter__.return_value = mock_client @@ -69,7 +69,7 @@ def test_call_guardrails_network_failure_bypasses(mock_client_cls): @patch("app.services.llm.guardrails.httpx.Client") -def test_call_guardrails_timeout_bypasses(mock_client_cls): +def test_call_guardrails_timeout_bypasses(mock_client_cls) -> None: mock_client = MagicMock() mock_client.post.side_effect = httpx.TimeoutException("timeout") mock_client_cls.return_value.__enter__.return_value = mock_client @@ -80,7 +80,7 @@ def test_call_guardrails_timeout_bypasses(mock_client_cls): @patch("app.services.llm.guardrails.httpx.Client") -def test_call_guardrails_uses_settings(mock_client_cls): +def test_call_guardrails_uses_settings(mock_client_cls) -> None: mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_response.json.return_value = {"ok": True}