From 7ca548379e784e6a63c70be6958ad286fa07ff9c Mon Sep 17 00:00:00 2001 From: yucheng Date: Sun, 27 Sep 2026 00:39:19 +0000 Subject: [PATCH] refactor(guardrails): fix agent 365 to the production endpoint and keep fail_closed as the default Remove api_base, resource_app_id and agent_id from the Agent 365 config model, their AGENT365_* env fallbacks and the _is_ui_hidden helper: the evaluation URL and the Agent Tools app id are fixed production constants and the agent identity is always the caller's key alias. Revert the fail_open default; unreachable_fallback: fail_open stays an explicit opt-in. Restore the shared unreachable_fallback field, the sibling guardrail initializers and typesafe to main. Move the Entra dependent cells from the subprocess integration suite to unit tests with an injected HTTP handler. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 66 ++++----------- .../proxy/guardrails/guardrail_endpoints.py | 8 -- .../guardrail_hooks/agent_365/__init__.py | 11 +-- .../guardrail_hooks/agent_365/agent_365.py | 23 +++--- .../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 | 37 +-------- .../mcp/test_mcp_agent_365_guardrail.py | 20 ++--- .../guardrail_hooks/test_agent_365.py | 80 +++++++------------ .../proxy/guardrails/test_init_guardrails.py | 23 ------ ui/litellm-dashboard/src/lib/http/schema.d.ts | 22 ++--- 17 files changed, 81 insertions(+), 239 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index d0e3cf79bc5..db57aa4f046 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": [ @@ -11374,19 +11368,6 @@ "description": "Custom advisory message template used when on_flagged='inject_system_message'. Must contain a {reason} placeholder. Defaults to a generic advisory message if unset.", "title": "Advisory System Message" }, - "agent_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Agent identity reported to Agent 365 with every tool evaluation. When unset, the caller's key alias is used.", - "title": "Agent Id", - "ui_hidden": true - }, "akto_account_id": { "anyOf": [ { @@ -12958,19 +12939,6 @@ "description": "The message the bot speaks aloud when a /v1/realtime guardrail fires. Falls back to violation_message_template if not set.", "title": "Realtime Violation Message" }, - "resource_app_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Application id of the Agent 365 resource the OBO token is minted for. Defaults to the production resource ea9ffc3e-8a23-4a7d-836d-234d7c7565c1; the Test and PreProd environments use a different id. Falls back to the AGENT365_RESOURCE_APP_ID environment variable.", - "title": "Resource App Id", - "ui_hidden": true - }, "rules": { "anyOf": [ { @@ -13269,20 +13237,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_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 57f46d04162..6053ab26726 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -1846,11 +1846,6 @@ def _build_field_dict( return field_dict -def _is_ui_hidden(field: "FieldInfo") -> bool: - field_json_schema_extra: Final = field.json_schema_extra - return isinstance(field_json_schema_extra, Mapping) and bool(field_json_schema_extra.get("ui_hidden")) - - def _extract_fields_recursive( model: type[BaseModel], depth: int = 0, @@ -1871,9 +1866,6 @@ def _extract_fields_recursive( if _should_skip_optional_params(field_name=field_name, field_annotation=field_annotation): continue - if _is_ui_hidden(field): - continue - # Handle Optional types and get the actual type if field_annotation is None: continue diff --git a/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py index ef4b46e3dfd..b39a4f0e684 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/agent_365/__init__.py @@ -1,10 +1,6 @@ from typing import TYPE_CHECKING, Final from litellm.types.guardrails import SupportedGuardrailIntegrations -from litellm.types.proxy.guardrails.guardrail_hooks.agent_365 import ( - AGENT_365_PROD_API_BASE, - AGENT_365_PROD_RESOURCE_APP_ID, -) from .agent_365 import Agent365Guardrail @@ -21,8 +17,6 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" client_secret: Final = ( litellm_params.client_secret or litellm_params.api_key or get_secret_str("AGENT365_CLIENT_SECRET") ) - api_base: Final = litellm_params.api_base or get_secret_str("AGENT365_API_BASE") - resource_app_id: Final = litellm_params.resource_app_id or get_secret_str("AGENT365_RESOURCE_APP_ID") if not tenant_id: raise ValueError("Microsoft Agent 365: tenant_id is required") @@ -42,11 +36,8 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, - api_base=api_base or AGENT_365_PROD_API_BASE, - resource_app_id=resource_app_id or AGENT_365_PROD_RESOURCE_APP_ID, - agent_id=litellm_params.agent_id, 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 fd1b8e821da..52f3eeb4ce7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py +++ b/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py @@ -39,7 +39,6 @@ from litellm.types.proxy.guardrails.guardrail_hooks.agent_365 import ( AGENT_365_PROD_API_BASE, AGENT_365_PROD_RESOURCE_APP_ID, AGENT_365_SCOPE_NAME, - AGENT_365_TOKEN_URL_TEMPLATE, Agent365GuardrailConfigModel, ) @@ -49,7 +48,9 @@ if TYPE_CHECKING: from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel from litellm.types.utils import GuardrailStatus -EVALUATE_PATH: Final = "/agents/tool-evaluation/evaluate" +TOKEN_ENDPOINT_TEMPLATE: Final = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" +EVALUATE_URL: Final = f"{AGENT_365_PROD_API_BASE}/agents/tool-evaluation/evaluate" +OBO_SCOPE: Final = f"{AGENT_365_PROD_RESOURCE_APP_ID}/{AGENT_365_SCOPE_NAME}" MCP_SESSION_ID_HEADER: Final = "mcp-session-id" DEFENDER_STATUS_EVALUATED: Final = "Evaluated" _GATEWAY_OWNED_TOKEN_ERRORS: Final = frozenset( @@ -153,11 +154,8 @@ class Agent365Guardrail(CustomGuardrail): tenant_id: str, client_id: str, client_secret: str, - api_base: str = AGENT_365_PROD_API_BASE, - resource_app_id: str = AGENT_365_PROD_RESOURCE_APP_ID, - agent_id: str | None = None, 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: @@ -171,9 +169,6 @@ class Agent365Guardrail(CustomGuardrail): self.tenant_id = tenant_id self.client_id = client_id self.client_secret = client_secret - self.api_base = api_base.rstrip("/") - self.resource_app_id = resource_app_id - self.agent_id = agent_id self.request_timeout = request_timeout self.unreachable_fallback: Literal["fail_closed", "fail_open"] = ( "fail_open" if unreachable_fallback == "fail_open" else "fail_closed" @@ -230,7 +225,7 @@ class Agent365Guardrail(CustomGuardrail): tool_name=tool_name, reason=( f"Entra rejected the gateway's own Agent 365 credentials ({exc.error_code}); " - "check the guardrail's client_id, client_secret and resource_app_id" + "check the guardrail's client_id and client_secret" ), ) self._handle_caller_fault( @@ -262,7 +257,7 @@ class Agent365Guardrail(CustomGuardrail): start: Final = time.perf_counter() try: response: Final = await self._post_allowing_error_status( - url=f"{self.api_base}{EVALUATE_PATH}", + url=EVALUATE_URL, json=self._build_evaluate_payload(data=data, user_api_key_dict=user_api_key_dict), headers={"Authorization": f"Bearer {obo_token}"}, # mutable-ok: httpx header dict ) @@ -396,7 +391,7 @@ class Agent365Guardrail(CustomGuardrail): tool_name: Final = str(data.get("mcp_tool_name") or "") arguments: Final = data.get("mcp_arguments") server_name: Final = str(data.get("mcp_server_name") or "litellm") - agent_id: Final = self.agent_id or user_api_key_dict.key_alias + agent_id: Final = user_api_key_dict.key_alias payload: Final[dict[str, object]] = { # mutable-ok: JSON body with optional fields added below "tool": {"name": tool_name}, "serverName": server_name, @@ -448,13 +443,13 @@ class Agent365Guardrail(CustomGuardrail): return cached[0] response: Final = await self._post_allowing_error_status( - url=AGENT_365_TOKEN_URL_TEMPLATE.format(tenant_id=self.tenant_id), + url=TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=self.tenant_id), data={ # mutable-ok: OAuth form body; AsyncHTTPHandler.post requires dict "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "client_id": self.client_id, "client_secret": self.client_secret, "assertion": assertion, - "scope": f"{self.resource_app_id}/{AGENT_365_SCOPE_NAME}", + "scope": OBO_SCOPE, "requested_token_use": "on_behalf_of", }, headers={"Content-Type": "application/x-www-form-urlencoded"}, # mutable-ok: httpx header dict 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 a1ca3cf268d..70ac10ae082 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/agent_365.py @@ -7,7 +7,6 @@ from .base import GuardrailConfigModel AGENT_365_PROD_API_BASE: Final = "https://agent365.svc.cloud.microsoft" AGENT_365_PROD_RESOURCE_APP_ID: Final = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" AGENT_365_SCOPE_NAME: Final = "ThreatProtection.Evaluate.All" -AGENT_365_TOKEN_URL_TEMPLATE: Final = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" class Agent365GuardrailConfigModel(GuardrailConfigModel): @@ -35,42 +34,12 @@ class Agent365GuardrailConfigModel(GuardrailConfigModel): ), ) - api_base: str | None = Field( - default=None, - description=( - "Base URL of the Microsoft Agent 365 tool-evaluation endpoint. " - f"Defaults to the production endpoint {AGENT_365_PROD_API_BASE}. " - "Falls back to the AGENT365_API_BASE environment variable." - ), - json_schema_extra={"ui_hidden": True}, - ) - - resource_app_id: str | None = Field( - default=None, - description=( - "Application id of the Agent 365 resource the OBO token is minted for. " - f"Defaults to the production resource {AGENT_365_PROD_RESOURCE_APP_ID}; " - "the Test and PreProd environments use a different id. " - "Falls back to the AGENT365_RESOURCE_APP_ID environment variable." - ), - json_schema_extra={"ui_hidden": True}, - ) - - agent_id: str | None = Field( - default=None, - description=( - "Agent identity reported to Agent 365 with every tool evaluation. " - "When unset, the caller's key alias is used." - ), - json_schema_extra={"ui_hidden": True}, - ) - 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, logs an error " - "and records it as Unscanned in the logs and OpenTelemetry. 'fail_closed' blocks it with HTTP 503. " + "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 d2309cf4d5d..5842d3ce4a7 100644 --- a/tests/integration/mcp/test_mcp_agent_365_guardrail.py +++ b/tests/integration/mcp/test_mcp_agent_365_guardrail.py @@ -1,4 +1,4 @@ -"""Agent 365 guardrail paths that end before the OBO exchange: no Entra, so no real tenant is ever contacted.""" +"""Agent 365 guardrail paths that end before the OBO exchange, so neither Entra nor Agent 365 is ever contacted.""" import json import uuid @@ -25,7 +25,7 @@ from integration._support.mcp import ( tool_calls, ) from integration._support.process import owned_proxy_process -from integration._support.wire import Reply, Request, Wire, wire_server +from integration._support.wire import Reply, Request, wire_server TENANT: Final = "00000000-0000-4000-8000-0000000a3650" REJECTED: Final = "Agent 365 guardrail rejected the tool call" @@ -36,16 +36,12 @@ GUARDRAIL_ROWS: Final = ( FALLBACKS: Final = (None, "fail_open", "fail_closed") -def _agent_365_never_reached(request: Request) -> Reply: - return Reply(status=500, body=json.dumps({"error": f"unexpected evaluation request {request.target}"}).encode()) - - def _generic_guardrail_outage(request: Request) -> Reply: assert request.target == "/beta/litellm_basic_guardrail_api", request.target return Reply(status=503, body=json.dumps({"error": "synthetic sibling guardrail outage"}).encode()) -def _config(tmp_path: Path, name: str, agent_365_url: str, fallback: str | None, sibling_url: str | None) -> Path: +def _config(tmp_path: Path, name: str, fallback: str | None, sibling_url: str | None) -> Path: config: dict = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) config["guardrails"] = [ { @@ -57,7 +53,6 @@ def _config(tmp_path: Path, name: str, agent_365_url: str, fallback: str | None, "tenant_id": TENANT, "client_id": "synthetic-client-id", "client_secret": "synthetic-client-secret", - "api_base": agent_365_url, **({"unreachable_fallback": fallback} if fallback else {}), }, }, @@ -89,7 +84,6 @@ class Rig: key: str alias: str peer: McpPeer - agent_365: Wire server_id: str def caller(self, entry: EntryPoint = "mcp", bearer: str | None = None) -> McpCaller: @@ -112,21 +106,20 @@ class Rig: def _rig(gateway: Gateway, tmp_path: Path, fallback: str | None, *, sibling: bool = False) -> Iterator[Rig]: alias: Final = "a365" + uuid.uuid4().hex[:8] with ( - wire_server(_agent_365_never_reached) as agent_365, wire_server(_generic_guardrail_outage) as sibling_outage, scripted_peer(echo_tool("add")) as peer, owned_proxy_process( gateway, tmp_path, {"PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": "2"}, - config=_config(tmp_path, alias, agent_365.url, fallback, sibling_outage.url if sibling else None), + config=_config(tmp_path, alias, fallback, sibling_outage.url if sibling else None), ) as owned, owned.gateway.scenario() as scenario, ): identity: Final = register_mcp(scenario, peer, alias) key: Final = scenario.key(object_permission={"mcp_servers": [identity]}) peer.drain() - yield Rig(owned.gateway, key, alias, peer, agent_365, identity) + yield Rig(owned.gateway, key, alias, peer, identity) def _chat(rig: Rig, model: str, marker: str) -> httpx.Response: @@ -149,7 +142,6 @@ def test_a_missing_or_malformed_caller_bearer_blocks_on_every_entry_point_whatev ) assert malformed.error is not None and REJECTED in malformed.raw, f"{entry} opaque bearer: {malformed.raw}" assert rig.upstream_tool_names() == () - assert rig.agent_365.drain() == (), "a rejected caller never produces an evaluation request" expected: Final = 2 * len(ENTRY_POINTS) assert rig.guardrail_statuses("call_mcp_tool", expected) == ["guardrail_intervened"] * expected @@ -159,7 +151,6 @@ def test_chat_completions_are_unaffected_by_the_mcp_guardrail(gateway: Gateway, model: Final = scenario.model() chat: Final = _chat(rig, model, "unaffected-" + uuid.uuid4().hex) assert chat.status_code == 200, chat.text - assert rig.agent_365.drain() == () assert rig.guardrail_statuses("acompletion", 1) == ["none"] @@ -170,4 +161,3 @@ def test_a_sibling_guardrail_keeps_its_own_fail_closed_default_next_to_agent_365 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 - assert rig.agent_365.drain() == () 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 e18b9aea979..eed6143c4e7 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 @@ -122,16 +122,12 @@ def _make_guardrail( handler: FakeHandler, *, unreachable_fallback: str = "fail_closed", - agent_id: str | None = None, - api_base: str = AGENT_365_PROD_API_BASE, ) -> Agent365Guardrail: return Agent365Guardrail( guardrail_name="agent-365-guard", tenant_id="tenant-abc", client_id="client-xyz", client_secret="secret-123", - api_base=api_base, - agent_id=agent_id, unreachable_fallback=unreachable_fallback, async_handler=handler, event_hook="pre_mcp_call", @@ -219,46 +215,37 @@ class TestInitializeGuardrail: assert redact_string(str(exc_info.value)) == str(exc_info.value) def test_env_var_fallbacks(self, monkeypatch): - monkeypatch.delenv("AGENT365_RESOURCE_APP_ID", raising=False) monkeypatch.setenv("AGENT365_TENANT_ID", "env-tenant") monkeypatch.setenv("AGENT365_CLIENT_ID", "env-client") monkeypatch.setenv("AGENT365_CLIENT_SECRET", "env-secret") - monkeypatch.setenv("AGENT365_API_BASE", "https://env.example.test") params: Final = LitellmParams(guardrail="agent_365", mode="pre_mcp_call") guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-env"}) assert guardrail.tenant_id == "env-tenant" assert guardrail.client_id == "env-client" 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_fail_closed_is_opt_in_through_litellm_params(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_unreachable_fallback_round_trips_through_a_stored_litellm_params_dump(self): - stored: Final = LitellmParams( - guardrail="agent_365", mode="pre_mcp_call", tenant_id="t", client_id="c", client_secret="s" - ).model_dump() - guardrail: Final = initialize_guardrail(LitellmParams(**stored), {"guardrail_name": "a365-stored"}) + guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-open"}) assert guardrail.unreachable_fallback == "fail_open" - def test_ui_form_hides_the_production_default_overrides(self): + def test_ui_form_offers_only_the_credentials_and_the_fallback(self): from litellm.proxy.guardrails.guardrail_endpoints import _get_fields_from_model fields: Final = _get_fields_from_model(Agent365GuardrailConfigModel) - assert {"tenant_id", "client_id", "client_secret", "unreachable_fallback"} <= set(fields) - assert not {"api_base", "resource_app_id", "agent_id"} & set(fields) - assert fields["unreachable_fallback"]["default_value"] == "fail_open" + assert set(fields) == {"tenant_id", "client_id", "client_secret", "unreachable_fallback"} + assert fields["unreachable_fallback"]["default_value"] == "fail_closed" + + def test_config_model_has_no_endpoint_or_identity_overrides(self): + assert not {"api_base", "resource_app_id", "agent_id"} & set(Agent365GuardrailConfigModel.model_fields) def test_explicit_params_win(self, monkeypatch): monkeypatch.setenv("AGENT365_TENANT_ID", "env-tenant") @@ -268,15 +255,13 @@ class TestInitializeGuardrail: tenant_id="param-tenant", client_id="client-xyz", client_secret="param-secret", - agent_id="agent-007", - unreachable_fallback="fail_closed", + unreachable_fallback="fail_open", timeout=5, ) guardrail: Final = initialize_guardrail(params, {"guardrail_name": "a365-params"}) assert guardrail.tenant_id == "param-tenant" assert guardrail.client_secret == "param-secret" - assert guardrail.agent_id == "agent-007" - assert guardrail.unreachable_fallback == "fail_closed" + assert guardrail.unreachable_fallback == "fail_open" assert guardrail.request_timeout == 5.0 def test_wrong_mode_rejected(self): @@ -329,7 +314,7 @@ class TestAllowFlow: @pytest.mark.asyncio async def test_evaluate_payload(self): handler: Final = FakeHandler([_token_response(), _allow_response()]) - guardrail: Final = _make_guardrail(handler, agent_id="agent-007") + guardrail: Final = _make_guardrail(handler) await _run(guardrail, _mcp_data()) evaluate_call: Final = handler.calls[1] assert evaluate_call.url == EVALUATE_URL @@ -338,14 +323,7 @@ class TestAllowFlow: assert evaluate_call.json["serverName"] == "outlook_mcp" assert evaluate_call.json["arguments"] == {"to": "user@example.com", "body": "hello"} assert evaluate_call.json["conversationId"] == "sess-123" - assert evaluate_call.json["agentId"] == "agent-007" - - @pytest.mark.asyncio - async def test_agent_id_falls_back_to_key_alias(self): - handler: Final = FakeHandler([_token_response(), _allow_response()]) - guardrail: Final = _make_guardrail(handler) - await _run(guardrail, _mcp_data()) - assert handler.calls[1].json["agentId"] == "my-agent-key" + assert evaluate_call.json["agentId"] == "my-agent-key" @pytest.mark.asyncio async def test_non_mcp_call_type_skipped(self): @@ -519,12 +497,21 @@ AVAILABILITY_FAILURES: Final = ( ) -class TestFailOpenDefault: +class TestFailOpenOptIn: @pytest.mark.asyncio @pytest.mark.parametrize("responses", AVAILABILITY_FAILURES) - async def test_constructor_default_lets_each_availability_failure_through_as_failed_to_respond(self, responses): + 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_open" + 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 + assert "fail_closed" in exc_info.value.detail["message"] + + @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() assert await _run(guardrail, data) is data info: Final = _guardrail_info(data) @@ -532,16 +519,7 @@ class TestFailOpenDefault: assert info["guardrail_response"]["verdict"] == "Unscanned" @pytest.mark.asyncio - @pytest.mark.parametrize("responses", AVAILABILITY_FAILURES) - async def test_opted_in_fail_closed_blocks_each_availability_failure_with_503(self, responses): - guardrail: Final = _make_guardrail(FakeHandler(responses), unreachable_fallback="fail_closed") - with pytest.raises(HTTPException) as exc_info: - await _run(guardrail, _mcp_data()) - assert exc_info.value.status_code == 503 - assert "fail_closed" in exc_info.value.detail["message"] - - @pytest.mark.asyncio - async def test_default_fail_open_logs_the_unscanned_call_at_error_level(self, caplog): + 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"): @@ -550,9 +528,9 @@ class TestFailOpenDefault: assert [r.levelno for r in fail_open_logs] == [logging.ERROR], caplog.text @pytest.mark.asyncio - async def test_default_fail_open_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 diff --git a/tests/test_litellm/proxy/guardrails/test_init_guardrails.py b/tests/test_litellm/proxy/guardrails/test_init_guardrails.py index c0d578a5ad9..39f9f9458b7 100644 --- a/tests/test_litellm/proxy/guardrails/test_init_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_init_guardrails.py @@ -168,29 +168,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 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bfef15db01a..b879f8a4da8 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}. @@ -34028,11 +34030,6 @@ export interface components { * @description Custom advisory message template used when on_flagged='inject_system_message'. Must contain a {reason} placeholder. Defaults to a generic advisory message if unset. */ advisory_system_message?: string | null; - /** - * Agent Id - * @description Agent identity reported to Agent 365 with every tool evaluation. When unset, the caller's key alias is used. - */ - agent_id?: string | null; /** * Akto Account Id * @description Akto account ID for multi-tenant deployments. Env: AKTO_ACCOUNT_ID. Default: '1000000'. @@ -34682,11 +34679,6 @@ export interface components { * @description The message the bot speaks aloud when a /v1/realtime guardrail fires. Falls back to violation_message_template if not set. */ realtime_violation_message?: string | null; - /** - * Resource App Id - * @description Application id of the Agent 365 resource the OBO token is minted for. Defaults to the production resource ea9ffc3e-8a23-4a7d-836d-234d7c7565c1; the Test and PreProd environments use a different id. Falls back to the AGENT365_RESOURCE_APP_ID environment variable. - */ - resource_app_id?: string | null; /** * Rules * @description Ordered allow/deny rules. Patterns use regex for tool names/types and optional regex constraints on tool arguments. @@ -34815,9 +34807,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.