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 <noreply@anthropic.com>
This commit is contained in:
Stephen Sennett 2026-09-10 01:13:55 +10:00
parent b40a7b5b53
commit 19d8737998
3 changed files with 104 additions and 2 deletions

View file

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

View file

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

View file

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