Skip to content

Commit f00e2d3

Browse files
authored
feat(server): add /v1/guardrail/checks endpoint (#2013)
- Introduced /v1/guardrail/checks API endpoint for validating messages against guardrails - Added per-rail status reporting to track which guardrails are triggered Signed-off-by: m-misiura <mmisiura@redhat.com>
1 parent b6fbcf5 commit f00e2d3

3 files changed

Lines changed: 423 additions & 13 deletions

File tree

nemoguardrails/server/api.py

Lines changed: 91 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@
3535

3636
from nemoguardrails import LLMRails, RailsConfig, utils
3737
from nemoguardrails.rails.llm.config import Model
38-
from nemoguardrails.rails.llm.options import GenerationResponse
38+
from nemoguardrails.rails.llm.options import GenerationResponse, RailStatus
3939
from nemoguardrails.server.datastore.datastore import DataStore
4040
from nemoguardrails.server.schemas.openai import (
41+
GuardrailCheckRequest,
42+
GuardrailCheckResponse,
4143
GuardrailsChatCompletion,
4244
GuardrailsChatCompletionRequest,
4345
OpenAIModelsList,
@@ -328,6 +330,20 @@ def _update_models_in_config(config: RailsConfig, main_model: Model) -> RailsCon
328330
return config.model_copy(update={"models": models})
329331

330332

333+
def _inject_model(config: RailsConfig, model_name: str) -> RailsConfig:
334+
"""Inject the request's model into a RailsConfig using env-based engine/base_url."""
335+
engine = os.environ.get("MAIN_MODEL_ENGINE")
336+
if not engine:
337+
engine = "openai"
338+
log.warning("MAIN_MODEL_ENGINE not set, defaulting to 'openai'. ")
339+
parameters = {}
340+
base_url = os.environ.get("MAIN_MODEL_BASE_URL")
341+
if base_url:
342+
parameters["base_url"] = base_url
343+
main_model = Model(model=model_name, type="main", engine=engine, parameters=parameters)
344+
return _update_models_in_config(config, main_model)
345+
346+
331347
async def _get_rails(config_ids: List[str], model_name: Optional[str] = None) -> LLMRails:
332348
"""Returns the rails instance for the given config id and model.
333349
@@ -373,18 +389,7 @@ async def _get_rails(config_ids: List[str], model_name: Optional[str] = None) ->
373389
raise ValueError("No valid rails configuration found.")
374390

375391
if model_name:
376-
engine = os.environ.get("MAIN_MODEL_ENGINE")
377-
if not engine:
378-
engine = "openai"
379-
log.warning("MAIN_MODEL_ENGINE not set, defaulting to 'openai'. ")
380-
381-
parameters = {}
382-
base_url = os.environ.get("MAIN_MODEL_BASE_URL")
383-
if base_url:
384-
parameters["base_url"] = base_url
385-
386-
main_model = Model(model=model_name, type="main", engine=engine, parameters=parameters)
387-
full_llm_rails_config = _update_models_in_config(full_llm_rails_config, main_model)
392+
full_llm_rails_config = _inject_model(full_llm_rails_config, model_name)
388393

389394
llm_rails = LLMRails(config=full_llm_rails_config, verbose=True)
390395
llm_rails_instances[configs_cache_key] = llm_rails
@@ -643,6 +648,79 @@ async def chat_completion(body: GuardrailsChatCompletionRequest, request: Reques
643648
)
644649

645650

651+
def _map_rail_status(status: RailStatus) -> str:
652+
"""Map internal RailStatus to API status string."""
653+
return status.value
654+
655+
656+
@app.post(
657+
"/v1/checks",
658+
response_model=GuardrailCheckResponse,
659+
response_model_exclude_none=True,
660+
)
661+
async def guardrail_check(body: GuardrailCheckRequest, request: Request):
662+
"""Guardrail check request."""
663+
api_request_headers.set(request.headers)
664+
665+
if not body.messages:
666+
raise HTTPException(status_code=422, detail="messages must be non-empty")
667+
668+
config_ids = None
669+
config = body.guardrails.config
670+
671+
if isinstance(config, dict):
672+
try:
673+
rails_config = RailsConfig.from_content(config=config)
674+
if body.model:
675+
rails_config = _inject_model(rails_config, body.model)
676+
llm_rails = LLMRails(config=rails_config, verbose=True)
677+
except Exception as ex:
678+
log.exception(ex)
679+
raise HTTPException(status_code=422, detail=f"Invalid inline config: {ex}")
680+
else:
681+
if isinstance(config, str):
682+
config_ids = [config]
683+
elif body.guardrails.config_ids:
684+
config_ids = list(body.guardrails.config_ids)
685+
elif app.default_config_id:
686+
config_ids = [app.default_config_id]
687+
else:
688+
raise HTTPException(
689+
status_code=422,
690+
detail="No guardrails config_id provided and server has no default configuration",
691+
)
692+
try:
693+
llm_rails = await _get_rails(config_ids, model_name=body.model)
694+
except ValueError as ex:
695+
log.exception(ex)
696+
raise HTTPException(status_code=422, detail=str(ex))
697+
698+
if llm_rails.config.colang_version != "1.0":
699+
raise HTTPException(
700+
status_code=422,
701+
detail="check_async does not support Colang 2.0 configurations.",
702+
)
703+
704+
try:
705+
messages = list(body.messages)
706+
if body.guardrails.context:
707+
messages.insert(0, {"role": "context", "content": body.guardrails.context})
708+
709+
result = await llm_rails.check_async(messages=messages)
710+
711+
return GuardrailCheckResponse(
712+
status=_map_rail_status(result.status),
713+
content=result.content,
714+
rail=result.rail,
715+
)
716+
717+
except HTTPException:
718+
raise
719+
except Exception as ex:
720+
log.exception(ex)
721+
raise HTTPException(status_code=500, detail="Internal server error")
722+
723+
646724
# By default, there are no challenges
647725
challenges = []
648726

nemoguardrails/server/schemas/openai.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,37 @@ class OpenAIModelsList(BaseModel):
165165
"""Standard OpenAI models list response."""
166166

167167
data: list[OpenAIModel] = Field(..., description="List of OpenAI model objects.")
168+
169+
170+
class GuardrailCheckDataInput(GuardrailsDataInput):
171+
"""Guardrails input options specific to the checks endpoint."""
172+
173+
config: Optional[Union[str, dict]] = Field(
174+
default=None,
175+
description="The id of the configuration or its dict representation to be used.",
176+
)
177+
178+
@model_validator(mode="before")
179+
@classmethod
180+
def validate_config_exclusivity(cls, data: Any) -> Any:
181+
if isinstance(data, dict) and data.get("config") is not None:
182+
if data.get("config_id") is not None or data.get("config_ids") is not None:
183+
raise ValueError("config is mutually exclusive with config_id and config_ids")
184+
return data
185+
186+
187+
class GuardrailCheckRequest(OpenAIChatCompletionRequest):
188+
"""Request body for the /v1/checks endpoint."""
189+
190+
guardrails: GuardrailCheckDataInput = Field(
191+
default_factory=GuardrailCheckDataInput,
192+
description="Guardrails specific options for the request.",
193+
)
194+
195+
196+
class GuardrailCheckResponse(BaseModel):
197+
"""Response from the /v1/checks endpoint."""
198+
199+
status: str = Field(..., description="Overall check result: passed, modified, or blocked.")
200+
content: str = Field(..., description="Content after rails processing.")
201+
rail: Optional[str] = Field(default=None, description="Name of the blocking rail, if any.")

0 commit comments

Comments
 (0)