From 27f10138064932edd93b8bf7a086c4b56294e628 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 26 Sep 2026 08:05:11 +0000 Subject: [PATCH] refactor(guardrails): keep agent 365 fail closed by default and make fail_open an explicit opt-in Restores the shared unreachable_fallback default and the sibling guardrail initializers, drops the Admin UI YAML preview that only existed for the per-guardrail default, and reworks the unit and integration tests so the default blocks with HTTP 503 while unreachable_fallback: fail_open lets availability failures through as Unscanned Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 40 +++----- .../guardrail_hooks/agent_365/__init__.py | 2 +- .../guardrail_hooks/agent_365/agent_365.py | 4 +- .../guardrail_hooks/akto/__init__.py | 2 +- .../guardrail_hooks/alice/__init__.py | 2 +- .../guardrail_hooks/conduct/__init__.py | 2 +- .../guardrail_hooks/deepkeep/__init__.py | 2 +- .../generic_guardrail_api/__init__.py | 2 +- .../guardrail_hooks/repelloai/__init__.py | 2 +- .../guardrail_hooks/typesafe/__init__.py | 4 +- litellm/types/guardrails.py | 14 +-- .../guardrails/guardrail_hooks/agent_365.py | 9 +- .../mcp/test_mcp_agent_365_guardrail.py | 96 +++++++++++-------- .../guardrail_hooks/test_agent_365.py | 78 +++++++-------- .../proxy/guardrails/test_init_guardrails.py | 28 +----- .../_components/TeamGuardrailsTab.tsx | 72 +++++++++++++- .../teamGuardrailConfigYaml.test.ts | 94 ------------------ .../_components/teamGuardrailConfigYaml.ts | 78 --------------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 12 ++- 19 files changed, 203 insertions(+), 340 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.ts diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 44b30386e07..5225f33ae92 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -10405,20 +10405,14 @@ "title": "Timeout" }, "unreachable_fallback": { - "anyOf": [ - { - "enum": [ - "fail_closed", - "fail_open" - ], - "type": "string" - }, - { - "type": "null" - } + "default": "fail_closed", + "description": "Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed.", + "enum": [ + "fail_closed", + "fail_open" ], - "description": "Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error. 'fail_open' logs a critical error and allows the request to proceed. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed.", - "title": "Unreachable Fallback" + "title": "Unreachable Fallback", + "type": "string" }, "violation_message_template": { "anyOf": [ @@ -13279,20 +13273,14 @@ "title": "Tracker Api Key" }, "unreachable_fallback": { - "anyOf": [ - { - "enum": [ - "fail_closed", - "fail_open" - ], - "type": "string" - }, - { - "type": "null" - } + "default": "fail_closed", + "description": "Behavior when the headroom compression service is unreachable or errors. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and forwards the request uncompressed instead of blocking it.", + "enum": [ + "fail_closed", + "fail_open" ], - "description": "Behavior when the guardrail endpoint is unreachable. 'fail_closed' blocks, 'fail_open' allows and logs. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed.", - "title": "Unreachable Fallback" + "title": "Unreachable Fallback", + "type": "string" }, "use_v2": { "anyOf": [ diff --git a/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py index 04bb00be7f1..68fdb14e6c7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py @@ -54,7 +54,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" agent_id=litellm_params.agent_id, authority_host=authority_host, request_timeout=litellm_params.timeout if litellm_params.timeout is not None else 10.0, - unreachable_fallback=litellm_params.unreachable_fallback or "fail_open", + unreachable_fallback=litellm_params.unreachable_fallback, event_hook=litellm_params.mode, default_on=litellm_params.default_on, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py b/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py index 1572bdbf5eb..3d057c329fc 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py +++ b/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py @@ -159,7 +159,7 @@ class Agent365Guardrail(CustomGuardrail): agent_id: str | None = None, authority_host: str = AGENT_365_DEFAULT_AUTHORITY_HOST, request_timeout: float = 10.0, - unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_open", + unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", async_handler: AsyncHTTPHandler | None = None, **kwargs, # noqa: ANN003 # kwargs-ok: forwarded verbatim to CustomGuardrail (event_hook, default_on) ) -> None: @@ -180,7 +180,7 @@ class Agent365Guardrail(CustomGuardrail): self.authority_host = authority if "://" in authority else f"https://{authority}" self.request_timeout = request_timeout self.unreachable_fallback: Literal["fail_closed", "fail_open"] = ( - "fail_closed" if unreachable_fallback == "fail_closed" else "fail_open" + "fail_open" if unreachable_fallback == "fail_open" else "fail_closed" ) self.async_handler = async_handler or get_async_httpx_client( llm_provider=httpxSpecialProvider.GuardrailCallback diff --git a/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py index 8c3f22d3767..1888b333748 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/akto/__init__.py @@ -16,7 +16,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" akto_api_key=getattr(litellm_params, "akto_api_key", None), akto_account_id=getattr(litellm_params, "akto_account_id", None), akto_vxlan_id=getattr(litellm_params, "akto_vxlan_id", None), - unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"), guardrail_timeout=getattr(litellm_params, "guardrail_timeout", None), guardrail_name=guardrail.get("guardrail_name", ""), event_hook=litellm_params.mode, diff --git a/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py index 7117dbb5b23..75ea16f7a88 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/alice/__init__.py @@ -14,7 +14,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" _alice_guardrail_callback: Final = AliceGuardrail( api_key=litellm_params.api_key, api_base=litellm_params.api_base, - unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"), guardrail_name=guardrail.get("guardrail_name", ""), event_hook=litellm_params.mode, default_on=litellm_params.default_on, diff --git a/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py index 07abc51672d..9eac143be88 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/conduct/__init__.py @@ -29,7 +29,7 @@ def initialize_guardrail( agent_token=litellm_params.api_key, workspace_id=extras.get("workspace_id"), tool_name=extras.get("tool_name", "llm_call"), - unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + unreachable_fallback=litellm_params.unreachable_fallback, timeout=DEFAULT_TIMEOUT_SECONDS if litellm_params.timeout is None else litellm_params.timeout, guardrail_name=guardrail.get("guardrail_name", ""), event_hook=litellm_params.mode, diff --git a/litellm/proxy/guardrails/guardrail_hooks/deepkeep/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/deepkeep/__init__.py index 6eae31c86e2..3b73883d290 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/deepkeep/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/deepkeep/__init__.py @@ -15,7 +15,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" api_base=litellm_params.api_base, api_key=litellm_params.api_key, firewall_id=getattr(litellm_params, "deepkeep_firewall_id", None), - unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"), extra_headers=getattr(litellm_params, "extra_headers", None), guardrail_name=guardrail.get("guardrail_name", ""), event_hook=litellm_params.mode, diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py index 7b02cf5b04f..e3511d46544 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/__init__.py @@ -30,7 +30,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" api_key=litellm_params.api_key, headers=getattr(litellm_params, "headers", None), additional_provider_specific_params=getattr(litellm_params, "additional_provider_specific_params", {}), - unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + unreachable_fallback=getattr(litellm_params, "unreachable_fallback", "fail_closed"), fail_on_error=getattr(litellm_params, "fail_on_error", True), extra_headers=getattr(litellm_params, "extra_headers", None), guardrail_name=guardrail.get("guardrail_name", ""), diff --git a/litellm/proxy/guardrails/guardrail_hooks/repelloai/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/repelloai/__init__.py index a20a3d8e1a9..37788b35ec7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/repelloai/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/repelloai/__init__.py @@ -30,7 +30,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" api_key=litellm_params.api_key, api_base=litellm_params.api_base, asset_id=litellm_params.asset_id, - unreachable_fallback=litellm_params.unreachable_fallback or "fail_closed", + unreachable_fallback=litellm_params.unreachable_fallback, event_hook=_event_hook_from_mode(litellm_params.mode), default_on=litellm_params.default_on or False, ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/typesafe/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/typesafe/__init__.py index b521c2e03a3..dcea75d3a98 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/typesafe/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/typesafe/__init__.py @@ -55,7 +55,9 @@ def initialize_guardrail(litellm_params: LitellmParams, guardrail: Guardrail) -> guardrail_name=guardrail["guardrail_name"], event_hook=_coerce_event_hook(litellm_params.mode), default_on=litellm_params.default_on or False, - unreachable_fallback=litellm_params.unreachable_fallback, + unreachable_fallback=( + litellm_params.unreachable_fallback if "unreachable_fallback" in litellm_params.model_fields_set else None + ), ) litellm.logging_callback_manager.add_litellm_callback( # pyright: ignore[reportUnknownMemberType] # callback manager is untyped _callback diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 4a79f41b030..579a3f6322f 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -1055,13 +1055,12 @@ class BaseLitellmParams(ContentFilterConfigModel): # works for new and patch up description="Additional provider-specific parameters for generic guardrail APIs", ) - unreachable_fallback: Literal["fail_closed", "fail_open"] | None = Field( - default=None, + unreachable_fallback: Literal["fail_closed", "fail_open"] = Field( + default="fail_closed", description=( "Behavior when a guardrail endpoint is unreachable due to network errors. " "Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. " - "'fail_closed' raises an error. 'fail_open' logs a critical error and allows the request to proceed. " - "Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed." + "'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed." ), ) @@ -1205,13 +1204,6 @@ class LitellmParams( # pyright: ignore[reportIncompatibleVariableOverride] # o mode: str | list[str] | Mode = Field( description="When to apply the guardrail (pre_call, post_call, during_call, logging_only)" ) - unreachable_fallback: Literal["fail_closed", "fail_open"] | None = Field( # pyright: ignore[reportIncompatibleVariableOverride] # mixins pin a default; unset defers to the guardrail's own - default=None, - description=( - "Behavior when the guardrail endpoint is unreachable. 'fail_closed' blocks, 'fail_open' allows and logs. " - "Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed." - ), - ) @field_validator("timeout", mode="before", check_fields=False) @classmethod diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py b/litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py index f8a1458fc64..d4d9452c380 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py @@ -72,13 +72,12 @@ class Agent365GuardrailConfigModel(GuardrailConfigModel): ) unreachable_fallback: Literal["fail_closed", "fail_open"] = Field( - default="fail_open", + default="fail_closed", description=( "Behavior when Agent 365 or Entra is unreachable, times out, returns 5xx, skips the evaluation, or " - "rejects the gateway's own client credentials. 'fail_open' (default) allows the tool call and records " - "it as Unscanned in the logs and OpenTelemetry. " - "'fail_closed' blocks it with HTTP 503. Policy blocks, 4xx rejections, throttling and a rejected " - "caller token always block." + "rejects the gateway's own client credentials. 'fail_closed' (default) blocks the tool call with HTTP 503. " + "'fail_open' allows it, logs an error and records it as Unscanned in the logs and OpenTelemetry. " + "Policy blocks, 4xx rejections, throttling and a rejected caller token always block." ), ) diff --git a/tests/integration/mcp/test_mcp_agent_365_guardrail.py b/tests/integration/mcp/test_mcp_agent_365_guardrail.py index 4761e500e17..995fdc27e45 100644 --- a/tests/integration/mcp/test_mcp_agent_365_guardrail.py +++ b/tests/integration/mcp/test_mcp_agent_365_guardrail.py @@ -265,14 +265,50 @@ def _chat(rig: Rig, model: str, marker: str) -> httpx.Response: ) -def test_default_lets_the_call_through_unscanned_when_agent_365_cannot_evaluate( +def test_default_blocks_with_503_and_never_reaches_upstream_when_agent_365_cannot_evaluate( gateway: Gateway, tmp_path: Path ) -> None: with _rig(gateway, tmp_path, fallback=None) as rig: + unavailable: Final = "could not authorize the tool call" outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1}) - assert outage.text == '{"a": 1}', f"Agent 365 down must fail open by default: {outage.raw}" + assert outage.error is not None and unavailable in outage.raw, ( + f"Agent 365 down must block by default: {outage.raw}" + ) skipped: Final = rig.caller.call(f"{rig.alias}-skipped", {"a": 2}) - assert skipped.text == '{"a": 2}', f"Defender skipping the call must fail open by default: {skipped.raw}" + assert skipped.error is not None and unavailable in skipped.raw, ( + f"Defender skip must block by default: {skipped.raw}" + ) + denied: Final = rig.caller.call(f"{rig.alias}-denied", {"a": 3}) + assert denied.error is not None and "Blocked by Microsoft Defender" in denied.raw, denied.raw + assert rig.caller.call(f"{rig.alias}-add", {"a": 4}).text == '{"a": 4}' + assert rig.upstream_tool_names() == ("add",) + statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 4, seconds=70) + assert statuses == { + "outage": "guardrail_failed_to_respond", + "skipped": "guardrail_failed_to_respond", + "denied": "guardrail_intervened", + "add": "success", + }, statuses + + +def test_explicit_fail_closed_matches_the_default(gateway: Gateway, tmp_path: Path) -> None: + with _rig(gateway, tmp_path, fallback="fail_closed") as rig: + outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1}) + assert outage.error is not None and "could not authorize the tool call" in outage.raw, outage.raw + assert rig.caller.call(f"{rig.alias}-add", {"a": 2}).text == '{"a": 2}' + assert rig.upstream_tool_names() == ("add",) + statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 2, seconds=70) + assert statuses == {"outage": "guardrail_failed_to_respond", "add": "success"}, statuses + + +def test_opted_in_fail_open_lets_the_call_through_unscanned_and_still_blocks_policy_denials( + gateway: Gateway, tmp_path: Path +) -> None: + with _rig(gateway, tmp_path, fallback="fail_open") as rig: + outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1}) + assert outage.text == '{"a": 1}', f"Agent 365 down must fail open once opted in: {outage.raw}" + skipped: Final = rig.caller.call(f"{rig.alias}-skipped", {"a": 2}) + assert skipped.text == '{"a": 2}', f"Defender skipping the call must fail open once opted in: {skipped.raw}" denied: Final = rig.caller.call(f"{rig.alias}-denied", {"a": 3}) assert denied.error is not None and "Blocked by Microsoft Defender" in denied.raw, denied.raw assert rig.upstream_tool_names() == ("outage", "skipped") @@ -284,32 +320,8 @@ def test_default_lets_the_call_through_unscanned_when_agent_365_cannot_evaluate( }, statuses -def test_explicit_fail_closed_blocks_with_503_and_never_reaches_upstream_when_agent_365_is_down( - gateway: Gateway, tmp_path: Path -) -> None: - with _rig(gateway, tmp_path, fallback="fail_closed") as rig: - outage: Final = rig.caller.call(f"{rig.alias}-outage", {"a": 1}) - assert outage.error is not None and "could not authorize the tool call" in outage.raw, outage.raw - assert rig.caller.call(f"{rig.alias}-add", {"a": 2}).text == '{"a": 2}' - assert rig.upstream_tool_names() == ("add",) - statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 2, seconds=70) - assert statuses == {"outage": "guardrail_failed_to_respond", "add": "success"}, statuses - - -def test_explicit_fail_open_matches_the_default_and_still_blocks_policy_denials( - gateway: Gateway, tmp_path: Path -) -> None: +def test_opted_in_fail_open_covers_malformed_or_stalled_agent_365_replies(gateway: Gateway, tmp_path: Path) -> None: with _rig(gateway, tmp_path, fallback="fail_open") as rig: - assert rig.caller.call(f"{rig.alias}-outage", {"a": 1}).text == '{"a": 1}' - denied: Final = rig.caller.call(f"{rig.alias}-denied", {"a": 2}) - assert denied.error is not None and "Blocked by Microsoft Defender" in denied.raw, denied.raw - assert rig.upstream_tool_names() == ("outage",) - statuses: Final = eventually(lambda: _guardrail_statuses(rig.key), lambda seen: len(seen) >= 2, seconds=70) - assert statuses == {"outage": "guardrail_failed_to_respond", "denied": "guardrail_intervened"}, statuses - - -def test_default_fails_open_on_malformed_or_stalled_agent_365_replies(gateway: Gateway, tmp_path: Path) -> None: - with _rig(gateway, tmp_path, fallback=None) as rig: for index, tool in enumerate(("nonjson", "nobool", "slow")): outcome: Final = rig.caller.call(f"{rig.alias}-{tool}", {"a": index}) assert outcome.text == json.dumps({"a": index}), f"{tool}: {outcome.raw}" @@ -318,10 +330,10 @@ def test_default_fails_open_on_malformed_or_stalled_agent_365_replies(gateway: G assert statuses == dict.fromkeys(("nonjson", "nobool", "slow"), "guardrail_failed_to_respond"), statuses -def test_default_fails_open_when_entra_is_down_stalled_malformed_or_refuses_the_gateway_credentials( +def test_opted_in_fail_open_covers_entra_down_stalled_malformed_or_refusing_the_gateway_credentials( gateway: Gateway, tmp_path: Path ) -> None: - with _rig(gateway, tmp_path, fallback=None) as rig: + with _rig(gateway, tmp_path, fallback="fail_open") as rig: cases: Final = ("entra-outage", "entra-slow", "entra-nonjson", "entra-misconfigured") for index, case in enumerate(cases): outcome: Final = rig.caller_for("mcp", _caller_token(case)).call(f"{rig.alias}-add", {"a": index}) @@ -339,10 +351,10 @@ def test_default_fails_open_when_entra_is_down_stalled_malformed_or_refuses_the_ assert [row["gi"][0]["guardrail_status"] for row in rows] == ["guardrail_failed_to_respond"] * len(cases) -def test_throttling_and_ordinary_4xx_from_agent_365_keep_blocking_under_the_default( +def test_throttling_and_ordinary_4xx_from_agent_365_keep_blocking_even_when_opted_in_to_fail_open( gateway: Gateway, tmp_path: Path ) -> None: - with _rig(gateway, tmp_path, fallback=None) as rig: + with _rig(gateway, tmp_path, fallback="fail_open") as rig: throttled: Final = rig.caller.call(f"{rig.alias}-throttled", {"a": 1}) assert throttled.error == "Error: Agent 365 guardrail could not authorize the tool call", throttled.raw rejected: Final = rig.caller.call(f"{rig.alias}-rejected", {"a": 2}) @@ -352,8 +364,10 @@ def test_throttling_and_ordinary_4xx_from_agent_365_keep_blocking_under_the_defa assert statuses == {"throttled": "guardrail_failed_to_respond", "rejected": "guardrail_intervened"}, statuses -def test_caller_authentication_failures_keep_blocking_under_the_default(gateway: Gateway, tmp_path: Path) -> None: - with _rig(gateway, tmp_path, fallback=None) as rig: +def test_caller_authentication_failures_keep_blocking_even_when_opted_in_to_fail_open( + gateway: Gateway, tmp_path: Path +) -> None: + with _rig(gateway, tmp_path, fallback="fail_open") as rig: rejected: Final = "Error: Agent 365 guardrail rejected the tool call" missing: Final = rig.call_without_bearer("add") assert missing.error == rejected, missing.raw @@ -372,8 +386,8 @@ def test_caller_authentication_failures_keep_blocking_under_the_default(gateway: assert [row["gi"][0]["guardrail_status"] for row in rows] == ["guardrail_intervened"] * 3 -def test_every_mcp_entry_point_fails_open_on_outage_and_blocks_denials(gateway: Gateway, tmp_path: Path) -> None: - with _rig(gateway, tmp_path, fallback=None) as rig: +def test_every_mcp_entry_point_honors_the_fail_open_opt_in_and_blocks_denials(gateway: Gateway, tmp_path: Path) -> None: + with _rig(gateway, tmp_path, fallback="fail_open") as rig: for entry in ENTRY_POINTS: caller: Final = rig.caller_for(entry) passed: Final = caller.call(f"{rig.alias}-outage", {"entry": entry}, server_id=rig.server_id) @@ -383,10 +397,10 @@ def test_every_mcp_entry_point_fails_open_on_outage_and_blocks_denials(gateway: assert rig.upstream_tool_names() == ("outage",) * len(ENTRY_POINTS) -def test_chat_completions_never_touch_agent_365_while_a_sibling_guardrail_keeps_its_fail_closed_default( +def test_agent_365_fail_open_opt_in_does_not_leak_to_a_sibling_guardrail_on_chat_completions( gateway: Gateway, tmp_path: Path ) -> None: - with _rig(gateway, tmp_path, fallback=None, sibling=True) as rig, rig.candidate.scenario() as scenario: + with _rig(gateway, tmp_path, fallback="fail_open", sibling=True) as rig, rig.candidate.scenario() as scenario: model: Final = scenario.model() chat: Final = _chat(rig, model, "sibling-" + uuid.uuid4().hex) assert chat.status_code == 500 and "Generic Guardrail API failed" in chat.text, chat.text @@ -433,7 +447,7 @@ def test_agent365_authority_host_env_wins_over_azure_authority_host_when_config_ def test_thirty_call_burst_against_a_flapping_agent_365_reaches_upstream_exactly_once_each_on_two_workers( gateway: Gateway, tmp_path: Path ) -> None: - with _rig(gateway, tmp_path, fallback=None, workers=2) as rig: + with _rig(gateway, tmp_path, fallback="fail_open", workers=2) as rig: markers: Final = tuple(("outage" if index % 2 else "add", uuid.uuid4().hex) for index in range(30)) with ThreadPoolExecutor(max_workers=10) as pool: outcomes: Final = tuple( @@ -449,10 +463,10 @@ def test_thirty_call_burst_against_a_flapping_agent_365_reaches_upstream_exactly assert rig.upstream_tool_names() == () -def test_default_survives_a_worker_kill_and_keeps_blocking_denials_on_two_workers( +def test_opted_in_fail_open_survives_a_worker_kill_and_keeps_blocking_denials_on_two_workers( gateway: Gateway, tmp_path: Path ) -> None: - with _rig(gateway, tmp_path, fallback=None, workers=2) as rig: + with _rig(gateway, tmp_path, fallback="fail_open", workers=2) as rig: before: Final = rig.every_worker_serves_the_catalog(2) victim: Final = min(before) os.kill(victim, signal.SIGKILL) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py index ce86361c2be..97d69e92aa3 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py @@ -234,31 +234,19 @@ class TestInitializeGuardrail: assert guardrail.client_secret == "env-secret" assert guardrail.api_base == "https://env.example.test" assert guardrail.resource_app_id == AGENT_365_PROD_RESOURCE_APP_ID - assert guardrail.unreachable_fallback == "fail_open" + assert guardrail.unreachable_fallback == "fail_closed" - def test_unset_fallback_survives_a_model_dump_round_trip(self): - stored: Final = LitellmParams( - guardrail="agent_365", mode="pre_mcp_call", tenant_id="t", client_id="c", client_secret="s" - ).model_dump() - assert stored["unreachable_fallback"] is None - guardrail: Final = initialize_guardrail(LitellmParams(**stored), {"guardrail_name": "a365-db"}) - assert guardrail.unreachable_fallback == "fail_open" - - def test_explicit_fail_closed_is_kept(self): + def test_fail_open_is_opt_in_through_litellm_params(self): params: Final = LitellmParams( guardrail="agent_365", mode="pre_mcp_call", tenant_id="t", client_id="c", client_secret="s", - unreachable_fallback="fail_closed", + unreachable_fallback="fail_open", ) - guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-closed"}) - assert guardrail.unreachable_fallback == "fail_closed" - - def test_agent_365_default_leaves_other_guardrails_unset(self): - assert LitellmParams(guardrail="generic_guardrail_api", mode="pre_call").unreachable_fallback is None - assert Agent365GuardrailConfigModel.model_fields["unreachable_fallback"].default == "fail_open" + guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-open"}) + assert guardrail.unreachable_fallback == "fail_open" def test_authority_host_defaults_to_public_entra(self, monkeypatch): monkeypatch.delenv("AGENT365_AUTHORITY_HOST", raising=False) @@ -569,45 +557,51 @@ class TestDefenderNotEvaluated: assert "rejected" in exc_info.value.detail["error"] -class TestFailOpenDefault: +AVAILABILITY_FAILURES: Final = ( + pytest.param([_token_response(), httpx.ReadTimeout("timed out")], id="evaluate-timeout"), + pytest.param([_token_response(), _response(502, text="bad gateway")], id="evaluate-5xx"), + pytest.param([_token_response(), _not_evaluated_response("Skipped")], id="evaluate-skipped"), + pytest.param([_response(503, text="entra down")], id="entra-5xx"), +) + + +class TestOptInFailOpen: @pytest.mark.asyncio - async def test_constructor_default_lets_timed_out_evaluation_through_unscanned(self, caplog): - handler: Final = FakeHandler([_token_response(), httpx.ReadTimeout("timed out")]) - guardrail: Final = _default_fallback_guardrail(handler) - assert guardrail.unreachable_fallback == "fail_open" + @pytest.mark.parametrize("responses", AVAILABILITY_FAILURES) + async def test_constructor_default_blocks_each_availability_failure_with_503(self, responses): + guardrail: Final = _default_fallback_guardrail(FakeHandler(responses)) + assert guardrail.unreachable_fallback == "fail_closed" + with pytest.raises(HTTPException) as exc_info: + await _run(guardrail, _mcp_data()) + assert exc_info.value.status_code == 503 + + @pytest.mark.asyncio + @pytest.mark.parametrize("responses", AVAILABILITY_FAILURES) + async def test_opted_in_fail_open_lets_each_availability_failure_through_as_failed_to_respond(self, responses): + guardrail: Final = _make_guardrail(FakeHandler(responses), unreachable_fallback="fail_open") data: Final = _mcp_data() - with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"): - assert await _run(guardrail, data) is data + assert await _run(guardrail, data) is data info: Final = _guardrail_info(data) assert info["guardrail_status"] == "guardrail_failed_to_respond" assert info["guardrail_response"]["verdict"] == "Unscanned" + + @pytest.mark.asyncio + async def test_opted_in_fail_open_logs_the_unscanned_call_at_error_level(self, caplog): + handler: Final = FakeHandler([_token_response(), httpx.ReadTimeout("timed out")]) + guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open") + with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"): + await _run(guardrail, _mcp_data()) fail_open_logs: Final = [r for r in caplog.records if "unreachable_fallback='fail_open'" in r.getMessage()] assert [r.levelno for r in fail_open_logs] == [logging.ERROR], caplog.text @pytest.mark.asyncio - async def test_constructor_default_still_blocks_a_policy_block(self): + async def test_opted_in_fail_open_still_blocks_a_policy_block(self): handler: Final = FakeHandler([_token_response(), _block_response()]) - guardrail: Final = _default_fallback_guardrail(handler) + guardrail: Final = _make_guardrail(handler, unreachable_fallback="fail_open") with pytest.raises(HTTPException) as exc_info: await _run(guardrail, _mcp_data()) assert exc_info.value.status_code == 400 - @pytest.mark.asyncio - @pytest.mark.parametrize( - "responses", - [ - [_token_response(), httpx.ReadTimeout("timed out")], - [_token_response(), _response(502, text="bad gateway")], - [_token_response(), _not_evaluated_response("Skipped")], - [_response(503, text="entra down")], - ], - ) - async def test_each_availability_failure_lets_the_call_through_as_failed_to_respond(self, responses): - guardrail: Final = _default_fallback_guardrail(FakeHandler(responses)) - data: Final = _mcp_data() - assert await _run(guardrail, data) is data - assert _guardrail_info(data)["guardrail_status"] == "guardrail_failed_to_respond" - class TestUnreachableFallback: @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/guardrails/test_init_guardrails.py b/tests/test_litellm/proxy/guardrails/test_init_guardrails.py index d2b4bc2e52e..fc2fb949143 100644 --- a/tests/test_litellm/proxy/guardrails/test_init_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_init_guardrails.py @@ -1,7 +1,9 @@ import json +from unittest.mock import MagicMock, patch import pytest + from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2 from litellm.types.guardrails import SupportedGuardrailIntegrations @@ -165,29 +167,6 @@ def test_initialize_guardrail_sets_run_in_parallel(config_value, expected): assert custom_guardrail.run_in_parallel is expected -@pytest.mark.parametrize( - "guardrail, provider_params, expected", - [ - ("agent_365", {"tenant_id": "t", "client_id": "c", "client_secret": "s", "mode": "pre_mcp_call"}, "fail_open"), - ("typesafe", {"api_key": "k"}, "fail_open"), - ("generic_guardrail_api", {"api_base": "http://127.0.0.1:1/guard"}, "fail_closed"), - ("akto", {"akto_base_url": "http://127.0.0.1:1", "akto_api_key": "k", "akto_account_id": "1"}, "fail_closed"), - ("alice", {"api_key": "k", "api_base": "http://127.0.0.1:1"}, "fail_closed"), - ("deepkeep", {"api_base": "http://127.0.0.1:1", "api_key": "k", "deepkeep_firewall_id": "f"}, "fail_closed"), - ("repelloai", {"api_key": "k", "api_base": "http://127.0.0.1:1", "asset_id": "a"}, "fail_closed"), - ], -) -def test_unset_unreachable_fallback_applies_each_guardrails_own_default(guardrail, provider_params, expected): - litellm_params = {"guardrail": guardrail, "mode": "pre_call", **provider_params} - guardrail_handler = InMemoryGuardrailHandler() - result = guardrail_handler.initialize_guardrail( - guardrail={"guardrail_name": f"default-fallback-{guardrail}", "litellm_params": litellm_params}, - ) - - custom_guardrail = guardrail_handler.guardrail_id_to_custom_guardrail[result["guardrail_id"]] - assert custom_guardrail.unreachable_fallback == expected, f"{guardrail} with unreachable_fallback unset" - - def test_initialize_presidio_forwards_analyze_chunk_size_bytes(): """Regression (LIT-4785): `presidio_analyze_chunk_size_bytes` set in config.yaml must reach the guardrail instance. The field lives on @@ -216,7 +195,8 @@ def test_initialize_presidio_forwards_analyze_chunk_size_bytes(): initialized = [ callback for callback in litellm.callbacks - if isinstance(callback, _OPTIONAL_PresidioPIIMasking) and callback.guardrail_name == "test_presidio_chunk_size" + if isinstance(callback, _OPTIONAL_PresidioPIIMasking) + and callback.guardrail_name == "test_presidio_chunk_size" ] assert initialized, "presidio guardrail was not registered as a callback" assert initialized[-1].presidio_analyze_chunk_size_bytes == 250_000 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index b6cd98d8152..d45cfc3fe7d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -38,7 +38,6 @@ import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { isValidUrl } from "@/lib/forms/urlValidation"; import { useZodForm } from "@/lib/forms/useZodForm"; -import { buildEquivalentConfigYaml, type TeamGuardrail, type TeamGuardrailStatus } from "./teamGuardrailConfigYaml"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; @@ -98,7 +97,32 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => ( ); -function mapStatus(apiStatus: string): TeamGuardrailStatus { +type GuardrailStatus = "active" | "pending" | "rejected"; + +type TeamGuardrail = { + id: string; + team: string; + name: string; + endpoint: string; + status: GuardrailStatus; + model: string; + forwardKey: boolean; + description: string; + method: "POST" | "GET"; + customHeaders: { + key: string; + value: string; + }[]; + extraHeaders: string[]; + submittedAt: string; + submittedBy: string; + mode?: string; + unreachable_fallback?: string; + additionalProviderParams?: Record; + guardrailType?: string; +}; + +function mapStatus(apiStatus: string): GuardrailStatus { if (apiStatus === "pending_review") return "pending"; if (apiStatus === "active" || apiStatus === "rejected") return apiStatus; return "active"; @@ -150,13 +174,13 @@ function submissionToTeamGuardrail(item: GuardrailSubmissionItem): TeamGuardrail submittedAt: formatSubmissionDate(item.submitted_at), submittedBy: item.submitted_by_email ?? item.submitted_by_user_id ?? "—", mode: params.mode as string | undefined, - unreachable_fallback: params.unreachable_fallback as string | null | undefined, + unreachable_fallback: params.unreachable_fallback as string | undefined, additionalProviderParams: params.additional_provider_specific_params as Record | undefined, guardrailType: params.guardrail as string | undefined, }; } -const STATUS_CONFIG: Record = { +const STATUS_CONFIG: Record = { active: { label: "Active", bg: "bg-success/10", @@ -186,6 +210,44 @@ const TEAM_COLORS: Record = { Finance: "bg-success/15 text-success", }; +function buildEquivalentConfigYaml(g: TeamGuardrail): string { + const lines: string[] = [ + "litellm_settings:", + " guardrails:", + ` - guardrail_name: "${g.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`, + " litellm_params:", + ` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`, + ` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`, + ` api_base: ${g.endpoint || "https://your-guardrail-api.com"}`, + " api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional", + ` unreachable_fallback: ${g.unreachable_fallback ?? "fail_closed"} # default: fail_closed. Set to fail_open to proceed if the guardrail endpoint is unreachable.`, + ` forward_api_key: ${g.forwardKey}`, + ]; + if (g.model && g.model !== "—") { + lines.push(` model: "${g.model}" # LLM model name sent to the guardrail for context`); + } + if (g.customHeaders.length > 0) { + lines.push(" headers: # static headers (sent with every request)"); + for (const h of g.customHeaders) { + lines.push(` ${h.key}: "${String(h.value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`); + } + } + if (g.extraHeaders.length > 0) { + lines.push(" extra_headers: # forward these client request headers to the guardrail"); + for (const name of g.extraHeaders) { + lines.push(` - ${name}`); + } + } + if (g.additionalProviderParams && Object.keys(g.additionalProviderParams).length > 0) { + lines.push(" additional_provider_specific_params:"); + for (const [k, v] of Object.entries(g.additionalProviderParams)) { + const val = typeof v === "string" ? `"${v}"` : String(v); + lines.push(` ${k}: ${val}`); + } + } + return lines.join("\n"); +} + function StatCard({ label, value, color }: { label: string; value: number; color: string }) { return (
@@ -763,7 +825,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) { }); const [search, setSearch] = useState(""); const [searchDebounced] = useDebouncedValue(search, { wait: DEBOUNCE_WAIT_MS }); - const [statusFilter, setStatusFilter] = useState<"all" | TeamGuardrailStatus>("all"); + const [statusFilter, setStatusFilter] = useState<"all" | GuardrailStatus>("all"); const [selectedId, setSelectedId] = useState(null); const [expandedHeaders, setExpandedHeaders] = useState>(new Set()); const [confirmAction, setConfirmAction] = useState<{ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.test.ts deleted file mode 100644 index 96987325419..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { buildEquivalentConfigYaml, defaultUnreachableFallback, type TeamGuardrail } from "./teamGuardrailConfigYaml"; - -function guardrail(overrides: Partial): TeamGuardrail { - return { - id: "g-1", - team: "Security", - name: "agent365-mcp", - endpoint: "", - status: "active", - model: "—", - forwardKey: false, - description: "", - method: "POST", - customHeaders: [], - extraHeaders: [], - submittedAt: "2026-01-01", - submittedBy: "ops@example.com", - ...overrides, - }; -} - -function fallbackLine(yaml: string): string { - const line = yaml.split("\n").find((l) => l.includes("unreachable_fallback:")); - if (line === undefined) throw new Error(`no unreachable_fallback line in:\n${yaml}`); - return line.trim().split(" #")[0]; -} - -describe("defaultUnreachableFallback", () => { - it("fails open for agent_365 and typesafe, closed for everything else including unknown", () => { - expect(defaultUnreachableFallback("agent_365")).toBe("fail_open"); - expect(defaultUnreachableFallback("typesafe")).toBe("fail_open"); - expect(defaultUnreachableFallback("generic_guardrail_api")).toBe("fail_closed"); - expect(defaultUnreachableFallback("akto")).toBe("fail_closed"); - expect(defaultUnreachableFallback(undefined)).toBe("fail_closed"); - }); -}); - -describe("buildEquivalentConfigYaml unreachable_fallback line", () => { - it("shows fail_open for an agent_365 guardrail with no explicit fallback", () => { - const yaml = buildEquivalentConfigYaml(guardrail({ guardrailType: "agent_365" })); - expect(fallbackLine(yaml)).toBe("unreachable_fallback: fail_open"); - expect(yaml).toContain(" guardrail: agent_365"); - }); - - it("shows fail_open for a typesafe guardrail with no explicit fallback", () => { - expect(fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "typesafe" })))).toBe( - "unreachable_fallback: fail_open", - ); - }); - - it("shows fail_closed for a generic guardrail and when the type is unknown", () => { - expect(fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "generic_guardrail_api" })))).toBe( - "unreachable_fallback: fail_closed", - ); - const untyped = buildEquivalentConfigYaml(guardrail({})); - expect(fallbackLine(untyped)).toBe("unreachable_fallback: fail_closed"); - expect(untyped).toContain(" guardrail: generic_guardrail_api"); - }); - - it("treats a null fallback from the API as unset and shows the per-guardrail default", () => { - expect( - fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "agent_365", unreachable_fallback: null }))), - ).toBe("unreachable_fallback: fail_open"); - expect( - fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "akto", unreachable_fallback: null }))), - ).toBe("unreachable_fallback: fail_closed"); - }); - - it("keeps an explicit override over the per-guardrail default", () => { - expect( - fallbackLine( - buildEquivalentConfigYaml(guardrail({ guardrailType: "agent_365", unreachable_fallback: "fail_closed" })), - ), - ).toBe("unreachable_fallback: fail_closed"); - expect( - fallbackLine(buildEquivalentConfigYaml(guardrail({ guardrailType: "akto", unreachable_fallback: "fail_open" }))), - ).toBe("unreachable_fallback: fail_open"); - }); - - it("explains the shown value is the guardrail's default only when nothing was set explicitly", () => { - const rawLine = (g: TeamGuardrail) => - buildEquivalentConfigYaml(g) - .split("\n") - .find((l) => l.includes("unreachable_fallback:")); - expect(rawLine(guardrail({ guardrailType: "agent_365" }))).toBe( - " unreachable_fallback: fail_open # fail_closed blocks, fail_open proceeds when the guardrail endpoint is unreachable. Shown value is this guardrail's default.", - ); - expect(rawLine(guardrail({ guardrailType: "agent_365", unreachable_fallback: "fail_closed" }))).toBe( - " unreachable_fallback: fail_closed # fail_closed blocks, fail_open proceeds when the guardrail endpoint is unreachable", - ); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.ts deleted file mode 100644 index c4ad6b61e8c..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/teamGuardrailConfigYaml.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type TeamGuardrailStatus = "active" | "pending" | "rejected"; - -export type TeamGuardrail = { - id: string; - team: string; - name: string; - endpoint: string; - status: TeamGuardrailStatus; - model: string; - forwardKey: boolean; - description: string; - method: "POST" | "GET"; - customHeaders: { - key: string; - value: string; - }[]; - extraHeaders: string[]; - submittedAt: string; - submittedBy: string; - mode?: string; - unreachable_fallback?: string | null; - additionalProviderParams?: Record; - guardrailType?: string; -}; - -const FAIL_OPEN_BY_DEFAULT_GUARDRAILS: ReadonlySet = new Set(["agent_365", "typesafe"]); - -export function defaultUnreachableFallback(guardrailType: string | undefined): "fail_open" | "fail_closed" { - return guardrailType !== undefined && FAIL_OPEN_BY_DEFAULT_GUARDRAILS.has(guardrailType) - ? "fail_open" - : "fail_closed"; -} - -function unreachableFallbackLine(g: TeamGuardrail): string { - const hint = "fail_closed blocks, fail_open proceeds when the guardrail endpoint is unreachable"; - if (g.unreachable_fallback) { - return ` unreachable_fallback: ${g.unreachable_fallback} # ${hint}`; - } - return ` unreachable_fallback: ${defaultUnreachableFallback(g.guardrailType)} # ${hint}. Shown value is this guardrail's default.`; -} - -export function buildEquivalentConfigYaml(g: TeamGuardrail): string { - const lines: string[] = [ - "litellm_settings:", - " guardrails:", - ` - guardrail_name: "${g.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`, - " litellm_params:", - ` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`, - ` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`, - ` api_base: ${g.endpoint || "https://your-guardrail-api.com"}`, - " api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional", - unreachableFallbackLine(g), - ` forward_api_key: ${g.forwardKey}`, - ]; - if (g.model && g.model !== "—") { - lines.push(` model: "${g.model}" # LLM model name sent to the guardrail for context`); - } - if (g.customHeaders.length > 0) { - lines.push(" headers: # static headers (sent with every request)"); - for (const h of g.customHeaders) { - lines.push(` ${h.key}: "${String(h.value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`); - } - } - if (g.extraHeaders.length > 0) { - lines.push(" extra_headers: # forward these client request headers to the guardrail"); - for (const name of g.extraHeaders) { - lines.push(` - ${name}`); - } - } - if (g.additionalProviderParams && Object.keys(g.additionalProviderParams).length > 0) { - lines.push(" additional_provider_specific_params:"); - for (const [k, v] of Object.entries(g.additionalProviderParams)) { - const val = typeof v === "string" ? `"${v}"` : String(v); - lines.push(` ${k}: ${val}`); - } - } - return lines.join("\n"); -} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 20e700b7938..500495b3797 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25570,9 +25570,11 @@ export interface components { timeout?: number | null; /** * Unreachable Fallback - * @description Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error. 'fail_open' logs a critical error and allows the request to proceed. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed. + * @description Behavior when a guardrail endpoint is unreachable due to network errors. Implemented by guardrail='generic_guardrail_api', 'agent_365', 'akto', 'vigil_guard', 'repelloai', 'headroom', 'compresr', and 'typesafe'. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed. + * @default fail_closed + * @enum {string} */ - unreachable_fallback?: ("fail_closed" | "fail_open") | null; + unreachable_fallback: "fail_closed" | "fail_open"; /** * Violation Message Template * @description Custom message when a guardrail blocks an action. Supports placeholders like {tool_name}, {rule_id}, and {default_message}. @@ -34794,9 +34796,11 @@ export interface components { tracker_api_key?: string | null; /** * Unreachable Fallback - * @description Behavior when the guardrail endpoint is unreachable. 'fail_closed' blocks, 'fail_open' allows and logs. Unset applies the guardrail's own default: 'agent_365' and 'typesafe' fail open, the others fail closed. + * @description Behavior when the headroom compression service is unreachable or errors. 'fail_closed' raises an error (default). 'fail_open' logs a critical error and forwards the request uncompressed instead of blocking it. + * @default fail_closed + * @enum {string} */ - unreachable_fallback?: ("fail_closed" | "fail_open") | null; + unreachable_fallback: "fail_closed" | "fail_open"; /** * Use V2 * @description If True and guardrail='noma', route to the new Noma v2 implementation instead of the legacy implementation.