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>
This commit is contained in:
yucheng 2026-09-27 00:39:19 +00:00
parent e6b63f93a3
commit 7ca548379e
17 changed files with 81 additions and 239 deletions

View file

@ -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": [

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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", ""),

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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."
),
)

View file

@ -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() == ()

View file

@ -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

View file

@ -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

View file

@ -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.