From 19d873799892a19627778e93bf84d4ca5c884e4f Mon Sep 17 00:00:00 2001 From: Stephen Sennett Date: Thu, 10 Sep 2026 01:13:55 +1000 Subject: [PATCH] fix(guardrails): stop sibling defaults leaking into Azure Content Safety LitellmParams mixes in every guardrail's config model, so dumping it with exclude_none handed the Azure guardrails 37 defaults belonging to other providers. One of them broke config-driven setups outright: Javelin's api_version of "v1" replaced the Content Safety default, and Azure answers api-version=v1 with 404 Resource not found Forward only the params the config actually set, the way the Prisma AIRS initializer already does. A config that omits api_version now gets the Content Safety default of 2024-09-01, and an explicit value still wins The Admin UI was unaffected, because its form submits the api_version it reads from the provider schema. Any other caller that omits api_version hits this, which is why the documented config.yaml example never worked Co-Authored-By: Claude Opus 5 --- .../guardrail_hooks/azure/__init__.py | 4 +- .../azure/test_azure_prompt_shield.py | 59 +++++++++++++++++++ .../azure/test_azure_text_moderation.py | 43 ++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/azure/__init__.py index 4f5b15df3d6..3e278e620b6 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/__init__.py @@ -32,7 +32,7 @@ def initialize_guardrail( ) = AzureContentSafetyPromptShieldGuardrail( guardrail_name=guardrail_name, **{ - **litellm_params.model_dump(exclude_none=True), + **litellm_params.model_dump(exclude_unset=True), "api_key": litellm_params.api_key, "api_base": litellm_params.api_base, "entra_token_provider": entra_token_provider, @@ -44,7 +44,7 @@ def initialize_guardrail( azure_content_safety_guardrail = AzureContentSafetyTextModerationGuardrail( guardrail_name=guardrail_name, **{ - **litellm_params.model_dump(exclude_none=True), + **litellm_params.model_dump(exclude_unset=True), "api_key": litellm_params.api_key, "api_base": litellm_params.api_base, "entra_token_provider": entra_token_provider, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_prompt_shield.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_prompt_shield.py index b421f52d3e0..c7eab7a80ba 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_prompt_shield.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_prompt_shield.py @@ -690,3 +690,62 @@ async def test_clearing_api_key_at_runtime_switches_to_entra(api_base, capturing assert "authorization" not in sent[0].headers assert sent[1].headers["Authorization"] == "Bearer entra-token" assert "ocp-apim-subscription-key" not in sent[1].headers + + +@pytest.mark.asyncio +async def test_config_without_api_version_uses_the_content_safety_default(api_base, capturing_handler): + """A config that omits api_version gets the Content Safety default, not another guardrail's.""" + handler, sent = capturing_handler + + guardrail = initialize_guardrail( + LitellmParams(guardrail="azure/prompt_shield", mode="pre_call", api_base=api_base), + {"guardrail_name": "azure-prompt-shield"}, + entra_token_provider=lambda: "entra-token", + ) + guardrail.async_handler = handler + + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert sent[0].url.params["api-version"] == "2024-09-01" + + +@pytest.mark.asyncio +async def test_config_api_version_is_honoured(api_base, capturing_handler): + """Pinning an older Content Safety version stays possible.""" + handler, sent = capturing_handler + + guardrail = initialize_guardrail( + LitellmParams(guardrail="azure/prompt_shield", mode="pre_call", api_base=api_base, api_version="2023-10-01"), + {"guardrail_name": "azure-prompt-shield"}, + entra_token_provider=lambda: "entra-token", + ) + guardrail.async_handler = handler + + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert sent[0].url.params["api-version"] == "2023-10-01" + + +@pytest.mark.asyncio +async def test_config_pricing_extras_survive_the_forwarded_params(api_base, capturing_handler): + """cost_tier and price_per_1000_text_records arrive as pydantic extras rather than declared + fields, so forwarding only the params the config set must still carry them through.""" + handler, _ = capturing_handler + + guardrail = initialize_guardrail( + LitellmParams( + guardrail="azure/prompt_shield", + mode="pre_call", + api_base=api_base, + cost_tier="paid", + price_per_1000_text_records=0.38, + ), + {"guardrail_name": "azure-prompt-shield"}, + entra_token_provider=lambda: "entra-token", + ) + guardrail.async_handler = handler + request_data = {"metadata": {}} + + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data=request_data, input_type="request") + + assert _recorded_guardrail_info(request_data)["guardrail_cost"] == pytest.approx(0.38 / 1000) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_text_moderation.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_text_moderation.py index 9df49db199d..e86c53ed1e4 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_text_moderation.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/azure/test_azure_text_moderation.py @@ -1,3 +1,4 @@ +import json from unittest.mock import Mock, patch import pytest @@ -486,3 +487,45 @@ async def test_initialize_guardrail_without_api_key_authenticates_with_entra(api await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") assert sent[0].headers["Authorization"] == "Bearer entra-token" + + +@pytest.mark.asyncio +async def test_config_without_api_version_uses_the_content_safety_default(api_base, capturing_handler): + """A config that omits api_version gets the Content Safety default, not another guardrail's.""" + handler, sent = capturing_handler + + guardrail = initialize_guardrail( + LitellmParams(guardrail="azure/text_moderations", mode="pre_call", api_base=api_base), + {"guardrail_name": "azure-text-moderation"}, + entra_token_provider=lambda: "entra-token", + ) + guardrail.async_handler = handler + + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + assert sent[0].url.params["api-version"] == "2024-09-01" + + +@pytest.mark.asyncio +async def test_config_moderation_options_still_reach_the_request(api_base, capturing_handler): + """Forwarding only params the config set must not drop the guardrail's own options.""" + handler, sent = capturing_handler + + guardrail = initialize_guardrail( + LitellmParams( + guardrail="azure/text_moderations", + mode="pre_call", + api_base=api_base, + outputType="EightSeverityLevels", + blocklistNames=["my-blocklist"], + ), + {"guardrail_name": "azure-text-moderation"}, + entra_token_provider=lambda: "entra-token", + ) + guardrail.async_handler = handler + + await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request") + + body = json.loads(sent[0].content) + assert body["outputType"] == "EightSeverityLevels" + assert body["blocklistNames"] == ["my-blocklist"]