diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 75dce43c84a..bb063f8f77a 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -12133,6 +12133,18 @@ "description": "Whether to fail the request if the guardrail encounters an error. Implemented by guardrail='model_armor', 'generic_guardrail_api' and 'crowdstrike_aidr'. True (default) raises the error. False logs a critical error and lets the request proceed, so only a valid guardrail response can block or modify it.", "title": "Fail On Error" }, + "gateway_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "noma_v2 only: name of this gateway, used as the gateway_host label on Noma scans", + "title": "Gateway Name" + }, "grounding_check": { "anyOf": [ { diff --git a/litellm/proxy/guardrails/guardrail_hooks/noma/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/noma/__init__.py index 0375e9f2bce..f82aaab4c0d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/noma/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/noma/__init__.py @@ -42,6 +42,7 @@ def initialize_guardrail_v2(litellm_params: "LitellmParams", guardrail: "Guardra api_key=litellm_params.api_key, api_base=litellm_params.api_base, application_id=litellm_params.application_id, + gateway_name=litellm_params.gateway_name, monitor_mode=litellm_params.monitor_mode, block_failures=litellm_params.block_failures, event_hook=litellm_params.mode, diff --git a/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py b/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py index 292f395053b..8b1fcda7f47 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py +++ b/litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py @@ -62,6 +62,7 @@ class NomaV2Guardrail(CustomGuardrail): application_id: str | None = None, monitor_mode: bool | None = None, block_failures: bool | None = None, + gateway_name: str | None = None, **kwargs: Any, ) -> None: self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) @@ -69,6 +70,9 @@ class NomaV2Guardrail(CustomGuardrail): self.api_key = api_key or os.environ.get("NOMA_API_KEY") self.api_base = (api_base or os.environ.get("NOMA_API_BASE") or _DEFAULT_API_BASE).rstrip("/") self.application_id = application_id or os.environ.get("NOMA_APPLICATION_ID") + self.gateway_name = self._get_non_empty_str(gateway_name) or self._get_non_empty_str( + os.environ.get("NOMA_GATEWAY_NAME") + ) if monitor_mode is None: self.monitor_mode = os.environ.get("NOMA_MONITOR_MODE", "false").lower() == "true" else: @@ -166,6 +170,8 @@ class NomaV2Guardrail(CustomGuardrail): } if application_id: payload["application_id"] = application_id + if self.gateway_name: + payload["gateway_name"] = self.gateway_name return payload @staticmethod diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 579a3f6322f..46026c12d24 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -783,6 +783,10 @@ class NomaGuardrailConfigModel(BaseModel): default=None, description="Application ID for Noma Security. Defaults to 'litellm' if not provided", ) + gateway_name: str | None = Field( + default=None, + description="noma_v2 only: name of this gateway, used as the gateway_host label on Noma scans", + ) monitor_mode: bool | None = Field( default=None, description="If True, logs violations without blocking. Defaults to False if not provided", diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/noma.py b/litellm/types/proxy/guardrails/guardrail_hooks/noma.py index 880a9beb333..ef22f73810f 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/noma.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/noma.py @@ -39,6 +39,10 @@ class NomaV2GuardrailConfigModel(GuardrailConfigModel): default=None, description="The Noma Application ID. Reads from NOMA_APPLICATION_ID env var if None.", ) + gateway_name: str | None = Field( + default=None, + description="Gateway name, used as the gateway_host label on Noma scans. Falls back to NOMA_GATEWAY_NAME.", + ) monitor_mode: bool | None = Field( default=None, description="When true, run guardrail checks in monitor mode.", diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py index 2533cf0e8c8..180cdbe5bb5 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py @@ -39,6 +39,7 @@ class TestNomaV2Configuration: assert "api_key" in noma_v2_params assert "api_base" in noma_v2_params assert "application_id" in noma_v2_params + assert "gateway_name" in noma_v2_params assert "monitor_mode" in noma_v2_params assert "block_failures" in noma_v2_params diff --git a/tests/unit/proxy/guardrails/__init__.py b/tests/unit/proxy/guardrails/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/__init__.py b/tests/unit/proxy/guardrails/guardrail_hooks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/noma/__init__.py b/tests/unit/proxy/guardrails/guardrail_hooks/noma/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/noma/test_noma_v2.py b/tests/unit/proxy/guardrails/guardrail_hooks/noma/test_noma_v2.py new file mode 100644 index 00000000000..6e536f95251 --- /dev/null +++ b/tests/unit/proxy/guardrails/guardrail_hooks/noma/test_noma_v2.py @@ -0,0 +1,99 @@ +import json + +import httpx +import pytest +import respx + +import litellm +from litellm.proxy.guardrails.guardrail_hooks.noma import ( + NomaV2Guardrail, + guardrail_initializer_registry, +) +from litellm.types.guardrails import LitellmParams + +_API_BASE = "https://noma.example.test" + + +@pytest.fixture(autouse=True) +def _fresh_httpx_client(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", None) + monkeypatch.delenv("NOMA_GATEWAY_NAME", raising=False) + + +def _guardrail(gateway_name: str | None) -> NomaV2Guardrail: + return NomaV2Guardrail( + api_base=_API_BASE, + gateway_name=gateway_name, + guardrail_name="noma-guard", + event_hook="pre_call", + default_on=True, + ) + + +async def _scan_body(guardrail: NomaV2Guardrail, respx_mock: respx.MockRouter) -> dict[str, object]: + route = respx_mock.post(f"{_API_BASE}/litellm/guardrail").respond(json={"action": "NONE"}) + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={"metadata": {}}, input_type="request") + assert route.call_count == 1 + return json.loads(route.calls.last.request.content) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("guardrail_type", "extra_params"), [("noma_v2", {}), ("noma", {"use_v2": True})]) +async def test_gateway_name_from_guardrail_config_reaches_noma( + guardrail_type: str, extra_params: dict[str, bool], respx_mock: respx.MockRouter +) -> None: + litellm_params = LitellmParams( + guardrail=guardrail_type, + mode="pre_call", + api_base=_API_BASE, + gateway_name="prod-us-east", + **extra_params, + ) + guardrail = guardrail_initializer_registry[guardrail_type](litellm_params, {"guardrail_name": "noma-guard"}) + + assert (await _scan_body(guardrail, respx_mock))["gateway_name"] == "prod-us-east" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("configured", "env_value", "expected"), + [ + (None, "env-gateway", "env-gateway"), + ("config-gateway", "env-gateway", "config-gateway"), + (" config-gateway ", None, "config-gateway"), + ], +) +async def test_gateway_name_resolution( + configured: str | None, + env_value: str | None, + expected: str, + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + if env_value is not None: + monkeypatch.setenv("NOMA_GATEWAY_NAME", env_value) + + assert (await _scan_body(_guardrail(configured), respx_mock))["gateway_name"] == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("configured", [None, "", " "]) +async def test_unset_or_blank_gateway_name_is_left_out(configured: str | None, respx_mock: respx.MockRouter) -> None: + assert "gateway_name" not in await _scan_body(_guardrail(configured), respx_mock) + + +@pytest.mark.asyncio +async def test_positional_args_keep_their_meaning_after_gateway_name_was_added(respx_mock: respx.MockRouter) -> None: + guardrail = NomaV2Guardrail("test-api-key", _API_BASE, "test-app", False, True) + + body = await _scan_body(guardrail, respx_mock) + + assert body["monitor_mode"] is False + assert body["application_id"] == "test-app" + assert "gateway_name" not in body + respx_mock.post(f"{_API_BASE}/litellm/guardrail").respond(status_code=503) + with pytest.raises(httpx.HTTPStatusError): + await guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, request_data={"metadata": {}}, input_type="request" + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 93f4718a586..077e6a6ecee 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -34348,6 +34348,11 @@ export interface components { * @default true */ fail_on_error: boolean | null; + /** + * Gateway Name + * @description noma_v2 only: name of this gateway, used as the gateway_host label on Noma scans + */ + gateway_name?: string | null; /** * Grounding Check * @description Enable grounding verification to ensure output is grounded in provided context.