From c3ca8a5140ab6e4060b3751e1208f5d4ba3c7f67 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Mon, 12 Oct 2026 22:18:10 -0700 Subject: [PATCH] feat(guardrails): add Wingback guardrail integration (#46136) * feat(guardrails): add Wingback guardrail integration Mirrored from #38685 at d73b66544d (net diff against its merge base, applied on main) * fix(guardrails): dispatch Wingback through the unified guardrail The proxy only runs a unified guardrail when apply_guardrail is defined on the guardrail's own class, so WingbackGuardrail now binds the parent's decorated method in its class body. Chat, messages and responses pre, post and during call checks reach Wingback again, and each check logs once. Also types the initializer and config model for the lint gates and uses monkeypatch.setenv in the unit test * chore(guardrails): drop the Wingback dispatch comment The unit and integration hook tests already fail when the apply_guardrail binding is removed, so the comment is redundant under the repo comment rule --------- Co-authored-by: bsk-wb Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 12 + .../guardrail_hooks/wingback/__init__.py | 43 +++ .../guardrail_hooks/wingback/wingback.py | 68 +++++ litellm/types/guardrails.py | 5 + .../guardrails/guardrail_hooks/wingback.py | 48 ++++ .../test_guardrail_timeout_all_providers.py | 1 + .../observability/test_wingback_guardrail.py | 157 +++++++++++ .../guardrail_hooks/test_wingback.py | 265 ++++++++++++++++++ .../public/assets/logos/wingback.png | Bin 0 -> 14286 bytes .../_components/guardrail_garden_configs.ts | 6 + .../_components/guardrail_garden_data.test.ts | 1 + .../_components/guardrail_garden_data.ts | 10 + .../_components/guardrail_info_helpers.tsx | 2 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 + 14 files changed, 623 insertions(+) create mode 100644 litellm/proxy/guardrails/guardrail_hooks/wingback/__init__.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/wingback/wingback.py create mode 100644 litellm/types/proxy/guardrails/guardrail_hooks/wingback.py create mode 100644 tests/integration/observability/test_wingback_guardrail.py create mode 100644 tests/unit/proxy/guardrails/guardrail_hooks/test_wingback.py create mode 100644 ui/litellm-dashboard/public/assets/logos/wingback.png diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 1b8f9484aea..ee49644f442 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -17248,6 +17248,18 @@ "description": "Custom message when a guardrail blocks an action. Supports placeholders like {tool_name}, {rule_id}, and {default_message}.", "title": "Violation Message Template" }, + "wingback_app_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Wingback external gateway integration name for request attribution (sent as additional_provider_specific_params.wingback_app_id).", + "title": "Wingback App Id" + }, "xecguard_model": { "anyOf": [ { diff --git a/litellm/proxy/guardrails/guardrail_hooks/wingback/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/wingback/__init__.py new file mode 100644 index 00000000000..bf6cf20bed6 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/wingback/__init__.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from litellm.types.guardrails import SupportedGuardrailIntegrations + +from .wingback import WingbackGuardrail + +if TYPE_CHECKING: + from litellm.types.guardrails import Guardrail, LitellmParams + + +def initialize_guardrail( + litellm_params: LitellmParams, + guardrail: Guardrail, +) -> WingbackGuardrail: + import litellm + + _wingback_callback: Final = WingbackGuardrail( + api_base=litellm_params.api_base, + api_key=litellm_params.api_key, + wingback_app_id=litellm_params.wingback_app_id, + additional_provider_specific_params=litellm_params.additional_provider_specific_params, + unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + fail_on_error=litellm_params.fail_on_error, + extra_headers=litellm_params.extra_headers, + guardrail_name=guardrail["guardrail_name"], + event_hook=litellm_params.mode, + default_on=litellm_params.default_on, + timeout=litellm_params.timeout, + ) + + litellm.logging_callback_manager.add_litellm_callback(_wingback_callback) + return _wingback_callback + + +guardrail_initializer_registry: Final = { + SupportedGuardrailIntegrations.WINGBACK.value: initialize_guardrail, +} + +guardrail_class_registry: Final = { + SupportedGuardrailIntegrations.WINGBACK.value: WingbackGuardrail, +} diff --git a/litellm/proxy/guardrails/guardrail_hooks/wingback/wingback.py b/litellm/proxy/guardrails/guardrail_hooks/wingback/wingback.py new file mode 100644 index 00000000000..02c5fed1e8d --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/wingback/wingback.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence +from typing import Final, Literal + +from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api.generic_guardrail_api import ( + GenericGuardrailAPI, +) +from litellm.types.guardrails import Mode +from litellm.types.proxy.guardrails.guardrail_hooks.wingback import ( + WingbackGuardrailConfigModel, +) + +GUARDRAIL_NAME: Final = "wingback" +DEFAULT_WINGBACK_API_BASE: Final = "https://api.wingback.ai/connectors" + + +class WingbackGuardrail(GenericGuardrailAPI): + """Wingback runtime security via the LiteLLM Generic Guardrail API contract.""" + + apply_guardrail = GenericGuardrailAPI.apply_guardrail # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # parent annotates request_data as a bare dict + + def __init__( + self, + api_base: str | None = None, + api_key: str | None = None, + wingback_app_id: str | None = None, + additional_provider_specific_params: Mapping[str, object] | None = None, + unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", + fail_on_error: bool | None = True, + extra_headers: Sequence[str] | None = None, + guardrail_name: str | None = None, + event_hook: str | Sequence[str] | Mode | None = None, + default_on: bool | None = False, + timeout: float | None = None, + ) -> None: + resolved_api_base: Final = api_base or os.environ.get("WINGBACK_API_BASE") or DEFAULT_WINGBACK_API_BASE + resolved_api_key: Final = api_key or os.environ.get("WINGBACK_INTEGRATION_API_KEY") + + existing_params: Final = additional_provider_specific_params + additional_params: Final = ( + { + **existing_params, + "wingback_app_id": wingback_app_id, + } + if wingback_app_id and existing_params is not None and "wingback_app_id" not in existing_params + else ( + {"wingback_app_id": wingback_app_id} if wingback_app_id and existing_params is None else existing_params + ) + ) + + super().__init__( # pyright: ignore[reportUnknownMemberType] # parent takes untyped extra_headers and **kwargs + api_base=resolved_api_base, + api_key=resolved_api_key, + additional_provider_specific_params=additional_params, + unreachable_fallback=unreachable_fallback, + fail_on_error=fail_on_error, + extra_headers=list(extra_headers) if extra_headers is not None else None, + guardrail_name=guardrail_name or GUARDRAIL_NAME, + event_hook=event_hook, + default_on=default_on, + timeout=timeout, + ) + + @staticmethod + def get_config_model() -> type[WingbackGuardrailConfigModel] | None: + return WingbackGuardrailConfigModel diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index b3b57d53bdf..e65a0dabd6d 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -73,6 +73,9 @@ from litellm.types.proxy.guardrails.guardrail_hooks.typesafe import ( from litellm.types.proxy.guardrails.guardrail_hooks.vigil_guard import ( VigilGuardGuardrailConfigModel, ) +from litellm.types.proxy.guardrails.guardrail_hooks.wingback import ( + WingbackGuardrailConfigModel, +) from litellm.types.proxy.guardrails.guardrail_hooks.xecguard import ( XecGuardConfigModel, ) @@ -152,6 +155,7 @@ class SupportedGuardrailIntegrations(Enum): AGENT_365 = "agent_365" LLM_SHIELD_PROXY = "llm_shield_proxy" CONDUCT = "conduct" + WINGBACK = "wingback" DECISION_MODEL = "decision_model" @@ -1347,6 +1351,7 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o VigilGuardGuardrailConfigModel, SingulrGuardrailConfigModel, Agent365GuardrailConfigModel, + WingbackGuardrailConfigModel, ): guardrail: str = Field(description="The type of guardrail integration to use") decision_model: str | None = Field( diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/wingback.py b/litellm/types/proxy/guardrails/guardrail_hooks/wingback.py new file mode 100644 index 00000000000..11066691e95 --- /dev/null +++ b/litellm/types/proxy/guardrails/guardrail_hooks/wingback.py @@ -0,0 +1,48 @@ +from typing import Literal + +from pydantic import Field + +from .base import GuardrailConfigModel + + +class WingbackGuardrailConfigModel(GuardrailConfigModel): # pyright: ignore[reportMissingTypeArgument] # parameterizing adds an optional_params field to the UI form + """Configuration parameters for the Wingback guardrail.""" + + api_key: str | None = Field( + default=None, + description=( + "Wingback external gateway integration API key (wbk_eg_*). " + "If not provided, the WINGBACK_INTEGRATION_API_KEY environment variable is checked." + ), + ) + api_base: str | None = Field( + default=None, + description=( + "Wingback connectors service base URL. LiteLLM appends " + "/beta/litellm_basic_guardrail_api. Defaults to https://api.wingback.ai/connectors " + "or WINGBACK_API_BASE when unset." + ), + ) + wingback_app_id: str | None = Field( + default=None, + description=( + "Wingback external gateway integration name for request attribution " + "(sent as additional_provider_specific_params.wingback_app_id)." + ), + ) + unreachable_fallback: Literal["fail_closed", "fail_open"] = Field( + default="fail_closed", + description=( + "Behavior when the Wingback connectors service is unreachable. " + "fail_closed blocks traffic when Wingback is unavailable (default, matches generic_guardrail_api). " + "fail_open allows traffic for monitor-only rollouts." + ), + ) + fail_on_error: bool | None = Field( + default=True, + description=("If False, allow requests when the guardrail returns an error other than an explicit block."), + ) + + @staticmethod + def ui_friendly_name() -> str: + return "Wingback" diff --git a/tests/integration/observability/test_guardrail_timeout_all_providers.py b/tests/integration/observability/test_guardrail_timeout_all_providers.py index 8f6c098a3d4..859d17497ed 100644 --- a/tests/integration/observability/test_guardrail_timeout_all_providers.py +++ b/tests/integration/observability/test_guardrail_timeout_all_providers.py @@ -149,6 +149,7 @@ PROVIDERS: Final = ( pytest.param("pillar", "pillar", {}, "pre_call", False, id="pillar"), pytest.param("cisco", "cisco_ai_defense", {}, "pre_call", False, id="cisco-ai-defense"), pytest.param("vigil", "vigil_guard", {}, "pre_call", False, id="vigil-guard"), + pytest.param("wingback", "wingback", {}, "pre_call", False, id="wingback"), pytest.param("singulr", "singulr", {}, "pre_call", False, id="singulr"), pytest.param("headroom", "headroom", {}, "pre_call", True, id="headroom"), pytest.param("onyx", "onyx", {}, "post_call", False, id="onyx"), diff --git a/tests/integration/observability/test_wingback_guardrail.py b/tests/integration/observability/test_wingback_guardrail.py new file mode 100644 index 00000000000..e93d873345c --- /dev/null +++ b/tests/integration/observability/test_wingback_guardrail.py @@ -0,0 +1,157 @@ +import json +import uuid +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.client import Gateway, eventually, gateway_from_environment, object_value +from integration._support.database import read_rows +from integration._support.process import owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server + +API_KEY: Final = "wbk_eg_synthetic" +APP_ID: Final = "synthetic-app" +DENIED: Final = "synthetic wingback denied marker" +DENIAL: Final = "synthetic wingback denial" +PRE_CALL: Final = "wingback-pre" +POST_CALL: Final = "wingback-post" + + +def _verdict(request: Request) -> Reply: + texts: Final = json.loads(request.body).get("texts") or [] + denied: Final = any(DENIED in text for text in texts) + result: Final = {"action": "BLOCKED", "blocked_reason": DENIAL} if denied else {"action": "NONE"} + return Reply(body=json.dumps(result).encode()) + + +def _echo_provider(request: Request) -> Reply: + prompt: Final = json.loads(request.body)["messages"][-1]["content"] + return Reply( + body=json.dumps( + { + "id": "chatcmpl-" + uuid.uuid4().hex, + "object": "chat.completion", + "created": 1, + "model": "gpt-4o-mini", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": f"echo {prompt}"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + ).encode() + ) + + +def _wingback(name: str, mode: str, policy: str) -> dict[str, object]: + return { + "guardrail_name": name, + "litellm_params": { + "guardrail": "wingback", + "mode": mode, + "default_on": False, + "api_base": policy, + "api_key": API_KEY, + "wingback_app_id": APP_ID, + }, + } + + +@dataclass(frozen=True, slots=True) +class Rig: + proxy: Gateway + policy: Wire + provider: Wire + model: str + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + root: Final = tmp_path_factory.mktemp("wingback") + with ( + gateway_from_environment() as gateway, + wire_server(_verdict) as policy, + wire_server(_echo_provider) as provider, + ): + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["litellm_settings"]["cache"] = False + config["guardrails"] = [ + _wingback(PRE_CALL, "pre_call", policy.url), + _wingback(POST_CALL, "post_call", policy.url), + ] + path: Final = root / "wingback.yaml" + path.write_text(yaml.safe_dump(config)) + with ( + owned_proxy_process(gateway, root, {}, config=path, workers=2) as owned, + owned.gateway.scenario() as scenario, + ): + model: Final = scenario.model( + model="openai/gpt-4o-mini", api_base=provider.url + "/v1", api_key="synthetic-openai-key" + ) + yield Rig(owned.gateway, policy, provider, model) + + +def _chat(rig: Rig, guardrail: str, text: str) -> httpx.Response: + rig.policy.drain() + rig.provider.drain() + return rig.proxy.request( + "POST", + "/v1/chat/completions", + {"model": rig.model, "messages": [{"role": "user", "content": text}], "guardrails": [guardrail]}, + ) + + +def _assert_wingback_request(request: Request, input_type: str, text: str) -> None: + assert request.target == "/beta/litellm_basic_guardrail_api", request.target + assert request.headers.get("x-api-key") == API_KEY + body: Final = json.loads(request.body) + assert body["input_type"] == input_type, body + assert any(text in sent for sent in body["texts"]), body + assert body["additional_provider_specific_params"] == {"wingback_app_id": APP_ID}, body + + +def test_pre_call_denial_never_reaches_the_provider(rig: Rig) -> None: + response: Final = _chat(rig, PRE_CALL, f"{DENIED} {uuid.uuid4().hex}") + + assert response.status_code == 400 and DENIAL in response.text, response.text + assert rig.provider.drain() == () + checks: Final = rig.policy.drain() + assert len(checks) == 1, checks + _assert_wingback_request(checks[0], "request", DENIED) + + +def test_post_call_denial_blocks_the_provider_response(rig: Rig) -> None: + response: Final = _chat(rig, POST_CALL, f"{DENIED} {uuid.uuid4().hex}") + + assert response.status_code == 400 and DENIAL in response.text, response.text + assert len(rig.provider.drain()) == 1 + checks: Final = rig.policy.drain() + assert len(checks) == 1, checks + _assert_wingback_request(checks[0], "response", f"echo {DENIED}") + + +def test_allowed_call_is_checked_once_and_logged_once(rig: Rig) -> None: + prompt: Final = "synthetic wingback allowed prompt " + uuid.uuid4().hex + response: Final = _chat(rig, PRE_CALL, prompt) + + assert response.status_code == 200, response.text + assert response.json()["choices"][0]["message"]["content"] == f"echo {prompt}" + assert len(rig.provider.drain()) == 1 + assert response.headers["x-litellm-applied-guardrails"] == PRE_CALL, dict(response.headers) + checks: Final = rig.policy.drain() + assert len(checks) == 1, checks + _assert_wingback_request(checks[0], "request", prompt) + rows: Final = eventually( + lambda: read_rows('SELECT metadata FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (response.json()["id"],)), + lambda values: len(values) == 1, + seconds=70, + ) + entries: Final = object_value(rows[0]["metadata"])["guardrail_information"] + assert isinstance(entries, list) and len(entries) == 1, entries + entry: Final = object_value(entries[0]) + assert entry["guardrail_name"] == PRE_CALL, entry + assert entry["guardrail_mode"] == "pre_call", entry + assert entry["guardrail_status"] == "success", entry diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_wingback.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_wingback.py new file mode 100644 index 00000000000..c9e331de4a2 --- /dev/null +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_wingback.py @@ -0,0 +1,265 @@ +import json +import os +from collections.abc import Iterator, Mapping +from typing import Final +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +import litellm +from litellm.caching.dual_cache import DualCache +from litellm.exceptions import GuardrailRaisedException +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.guardrails.guardrail_hooks.wingback import ( + guardrail_class_registry, + guardrail_initializer_registry, + initialize_guardrail, +) +from litellm.proxy.guardrails.guardrail_hooks.wingback.wingback import ( + DEFAULT_WINGBACK_API_BASE, + WingbackGuardrail, +) +from litellm.types.guardrails import SupportedGuardrailIntegrations +from litellm.types.proxy.guardrails.guardrail_hooks.wingback import ( + WingbackGuardrailConfigModel, +) +from litellm.proxy.utils import ProxyLogging +from litellm.types.utils import Choices, GenericGuardrailAPIInputs, Message, ModelResponse + + +def test_wingback_guard_registry(): + assert SupportedGuardrailIntegrations.WINGBACK.value in guardrail_initializer_registry + assert SupportedGuardrailIntegrations.WINGBACK.value in guardrail_class_registry + assert guardrail_class_registry["wingback"].get_config_model().ui_friendly_name() == "Wingback" + + +def test_wingback_config_model_defaults(): + assert WingbackGuardrailConfigModel.ui_friendly_name() == "Wingback" + assert WingbackGuardrailConfigModel.model_fields["unreachable_fallback"].default == "fail_closed" + + +def test_initialize_guardrail_passes_extra_headers(): + litellm_params = MagicMock() + litellm_params.api_base = "http://localhost:8101" + litellm_params.api_key = "wbk_eg_test" + litellm_params.wingback_app_id = None + litellm_params.additional_provider_specific_params = None + litellm_params.unreachable_fallback = "fail_closed" + litellm_params.fail_on_error = True + litellm_params.mode = "pre_call" + litellm_params.default_on = True + litellm_params.extra_headers = ["x-request-id"] + litellm_params.timeout = 12.5 + + with patch("litellm.logging_callback_manager.add_litellm_callback"): + instance = initialize_guardrail( + litellm_params, + {"guardrail_name": "wingback-runtime-security"}, + ) + + assert instance.extra_headers == ["x-request-id"] + assert instance.timeout == 12.5 + + +class TestWingbackGuardrail: + def setup_method(self): + for key in ["WINGBACK_INTEGRATION_API_KEY", "WINGBACK_API_BASE"]: + if key in os.environ: + del os.environ[key] + + def teardown_method(self): + for key in ["WINGBACK_INTEGRATION_API_KEY", "WINGBACK_API_BASE"]: + if key in os.environ: + del os.environ[key] + + def test_initialization_with_defaults(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("WINGBACK_INTEGRATION_API_KEY", "wbk_eg_test_key") + + guardrail = WingbackGuardrail( + guardrail_name="wingback-runtime-security", + event_hook="pre_call", + default_on=True, + ) + + assert guardrail.api_base == f"{DEFAULT_WINGBACK_API_BASE}/beta/litellm_basic_guardrail_api" + assert guardrail.headers["x-api-key"] == "wbk_eg_test_key" + assert guardrail.unreachable_fallback == "fail_closed" + + def test_initialization_with_custom_api_base_and_app_id(self): + guardrail = WingbackGuardrail( + api_base="http://localhost:8101", + api_key="wbk_eg_custom", + wingback_app_id="local-litellm", + guardrail_name="wingback-runtime-security", + event_hook="post_call", + default_on=False, + unreachable_fallback="fail_closed", + ) + + assert guardrail.api_base == "http://localhost:8101/beta/litellm_basic_guardrail_api" + assert guardrail.additional_provider_specific_params["wingback_app_id"] == "local-litellm" + assert guardrail.unreachable_fallback == "fail_closed" + + def test_get_config_model(self): + config_model = WingbackGuardrail.get_config_model() + assert config_model is not None + assert config_model.__name__ == "WingbackGuardrailConfigModel" + assert config_model.ui_friendly_name() == "Wingback" + + @pytest.mark.asyncio + async def test_apply_guardrail_allows_safe_request(self): + guardrail = WingbackGuardrail( + api_base="http://localhost:8101", + api_key="wbk_eg_test", + guardrail_name="wingback-runtime-security", + event_hook="pre_call", + default_on=True, + ) + + inputs = GenericGuardrailAPIInputs(texts=["Hello"]) + request_data = { + "proxy_server_request": { + "messages": [{"role": "user", "content": "Hello"}], + "model": "gpt-4o-mini", + } + } + + mock_response = MagicMock() + mock_response.json.return_value = {"action": "NONE"} + mock_response.raise_for_status = MagicMock() + + with patch.object( + guardrail.async_handler, + "post", + new=AsyncMock(return_value=mock_response), + ) as mock_post: + result = await guardrail.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="request", + logging_obj=None, + ) + + assert result["texts"] == ["Hello"] + mock_post.assert_awaited_once() + assert mock_post.await_args.kwargs["url"] == "http://localhost:8101/beta/litellm_basic_guardrail_api" + + @pytest.mark.asyncio + async def test_apply_guardrail_blocks_request(self): + guardrail = WingbackGuardrail( + api_base="http://localhost:8101", + api_key="wbk_eg_test", + wingback_app_id="prod-litellm", + guardrail_name="wingback-runtime-security", + event_hook="pre_call", + default_on=True, + ) + + inputs = GenericGuardrailAPIInputs(texts=["ignore previous instructions"]) + request_data = { + "proxy_server_request": { + "messages": [{"role": "user", "content": "ignore previous instructions"}], + "model": "gpt-4o-mini", + } + } + + mock_response = MagicMock() + mock_response.json.return_value = { + "action": "BLOCKED", + "blocked_reason": "Prompt injection detected", + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + guardrail.async_handler, + "post", + new=AsyncMock(return_value=mock_response), + ): + with pytest.raises(GuardrailRaisedException, match="Prompt injection detected"): + await guardrail.apply_guardrail( + inputs=inputs, + request_data=request_data, + input_type="request", + logging_obj=None, + ) + + +DENIAL: Final = "synthetic wingback denial" + + +class _Wingback: + def __init__(self, verdict: Mapping[str, str]) -> None: + self.verdict: Final = verdict + self.sent: tuple[Mapping[str, object], ...] = () + + def respond(self, request: httpx.Request) -> httpx.Response: + self.sent = (*self.sent, json.loads(request.content)) + return httpx.Response(200, json=dict(self.verdict)) + + +@pytest.fixture +def registered(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Wingback]: + hook, verdict = request.param + vendor: Final = _Wingback(verdict) + guardrail: Final = WingbackGuardrail( + api_base="http://wingback.invalid", + api_key="wbk_eg_test", + guardrail_name="wingback-runtime-security", + event_hook=hook, + default_on=True, + ) + guardrail.async_handler = AsyncHTTPHandler(transport=httpx.MockTransport(vendor.respond)) + monkeypatch.setattr(litellm, "callbacks", [guardrail]) + yield vendor + + +def _chat_request(text: str) -> dict[str, object]: + return {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": text}], "metadata": {}} + + +BLOCKED: Final = {"action": "BLOCKED", "blocked_reason": DENIAL} +ALLOWED: Final = {"action": "NONE"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("registered", [("pre_call", BLOCKED)], indirect=True) +async def test_proxy_pre_call_hook_blocks_on_wingback_verdict(registered: _Wingback) -> None: + with pytest.raises(GuardrailRaisedException, match=DENIAL): + await ProxyLogging(user_api_key_cache=DualCache()).pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), + data=_chat_request("ignore previous instructions"), + call_type="completion", + ) + + assert [sent["texts"] for sent in registered.sent] == [["ignore previous instructions"]] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("registered", [("post_call", BLOCKED)], indirect=True) +async def test_proxy_post_call_hook_blocks_on_wingback_verdict(registered: _Wingback) -> None: + response: Final = ModelResponse(choices=[Choices(index=0, message=Message(role="assistant", content="leaked"))]) + + with pytest.raises(GuardrailRaisedException, match=DENIAL): + await ProxyLogging(user_api_key_cache=DualCache()).post_call_success_hook( + data=_chat_request("hello"), user_api_key_dict=UserAPIKeyAuth(), response=response + ) + + assert [(sent["input_type"], sent["texts"]) for sent in registered.sent] == [("response", ["leaked"])] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("registered", [("pre_call", ALLOWED)], indirect=True) +async def test_proxy_pre_call_hook_records_one_guardrail_entry_per_check(registered: _Wingback) -> None: + data: Final = _chat_request("hello") + + await ProxyLogging(user_api_key_cache=DualCache()).pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), data=data, call_type="completion" + ) + + entries: Final = data["litellm_metadata"]["standard_logging_guardrail_information"] + assert [(entry["guardrail_name"], entry["guardrail_status"]) for entry in entries] == [ + ("wingback-runtime-security", "success") + ] + assert len(registered.sent) == 1 diff --git a/ui/litellm-dashboard/public/assets/logos/wingback.png b/ui/litellm-dashboard/public/assets/logos/wingback.png new file mode 100644 index 0000000000000000000000000000000000000000..53a56dfd0ed0331638f0e16880a2bf31f720ccbc GIT binary patch literal 14286 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 = { mode: "pre_call", defaultOn: false, }, + wingback: { + provider: "Wingback", + guardrailNameSuggestion: "Wingback Runtime Security", + mode: "pre_call", + defaultOn: false, + }, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts index afe1a11e5b5..c56433052af 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.test.ts @@ -37,6 +37,7 @@ const EXPECTED_PARTNER_LOGO_FILES: Record = { agent_365: "microsoft_azure.svg", llm_shield_proxy: "llm_shield_proxy.svg", conduct: "conduct.png", + wingback: "wingback.png", }; describe("guardrail_garden_data logos", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts index 2b662c3dbbc..569f78a0bde 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden_data.ts @@ -557,6 +557,16 @@ export const PARTNER_GUARDRAIL_CARDS: GuardrailCardInfo[] = [ tags: ["Security", "Prompt Injection", "PII", "Policy"], providerKey: "Conduct", }, + { + id: "wingback", + name: "Wingback", + description: + "Wingback runtime security for LiteLLM — PII detection, prompt injection, toxic content, and policy enforcement via the Wingback connectors service.", + category: "partner", + logo: guardrailLogoMap["Wingback"], + tags: ["Security", "PII", "Prompt Injection", "Runtime"], + providerKey: "Wingback", + }, ]; export const ALL_CARDS = [...LITELLM_CONTENT_FILTER_CARDS, ...DECISION_MODEL_CARDS, ...PARTNER_GUARDRAIL_CARDS]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx index c3bfde7b81f..32f7c0c056d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx @@ -26,6 +26,7 @@ import promptguardLogo from "../../../../../public/assets/logos/promptguard.svg" import qohashLogo from "../../../../../public/assets/logos/qohash.jpg"; import repelloAiLogo from "../../../../../public/assets/logos/repelloai.png"; import straikerLogo from "../../../../../public/assets/logos/straiker.svg"; +import wingbackLogo from "../../../../../public/assets/logos/wingback.png"; import xecguardLogo from "../../../../../public/assets/logos/xecguard.svg"; import zscalerLogo from "../../../../../public/assets/logos/zscaler.svg"; @@ -323,6 +324,7 @@ export const guardrailLogoMap = { "Microsoft Agent 365": microsoftAzureLogo.src, "LLM Shield Proxy": llmShieldProxyLogo.src, "Conduct Guard": conductLogo.src, + Wingback: wingbackLogo.src, "Decision Model": litellmLogo.src, } satisfies Record; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3a509fdd395..7343c13b409 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -36624,6 +36624,11 @@ export interface components { * @description Custom message when a guardrail blocks an action. Supports placeholders like {tool_name}, {rule_id}, and {default_message}. */ violation_message_template?: string | null; + /** + * Wingback App Id + * @description Wingback external gateway integration name for request attribution (sent as additional_provider_specific_params.wingback_app_id). + */ + wingback_app_id?: string | null; /** * Xecguard Model * @description XecGuard scanning model identifier. Defaults to 'xecguard_v2'.