Merge pull request #41941 from BerriAI/litellm_azure_content_safety_api_version_default

fix(guardrails): stop the Javelin api_version default leaking into Azure Content Safety
This commit is contained in:
Mateo Wang 2026-09-19 03:09:38 -07:00 committed by GitHub
commit 385932b4e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 161 additions and 6 deletions

View file

@ -11155,7 +11155,6 @@
"type": "null"
}
],
"default": "v1",
"description": "API version for Javelin service",
"title": "Api Version"
},

View file

@ -20,6 +20,15 @@ AZURE_CONTENT_SAFETY_MAX_TEXT_LENGTH: Final = 10000
# chunk of N characters consumes ceil(N / 1000) text records.
AZURE_CONTENT_SAFETY_TEXT_RECORD_LENGTH: Final = 1000
AZURE_CONTENT_SAFETY_DEFAULT_API_VERSION: Final = "2024-09-01"
JAVELIN_API_VERSION_STORED_BY_OLDER_RELEASES: Final = "v1"
def resolve_content_safety_api_version(configured: str | None) -> str:
if not configured or configured == JAVELIN_API_VERSION_STORED_BY_OLDER_RELEASES:
return AZURE_CONTENT_SAFETY_DEFAULT_API_VERSION
return configured
class AzureGuardrailBase:
"""
@ -43,7 +52,7 @@ class AzureGuardrailBase:
self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
self.api_key = api_key
self.api_base = api_base
self.api_version: str = kwargs.get("api_version") or "2024-09-01"
self.api_version: str | None = kwargs.get("api_version")
async def _post_to_content_safety(self, endpoint_path: str, request_body: dict[str, object]) -> dict[str, Any]:
"""POST to an Azure Content Safety endpoint with standard auth headers.
@ -56,7 +65,8 @@ class AzureGuardrailBase:
Returns:
Parsed JSON response dict.
"""
url: Final = f"{self.api_base}/contentsafety/{endpoint_path}?api-version={self.api_version}"
api_version: Final = resolve_content_safety_api_version(self.api_version)
url: Final = f"{self.api_base}/contentsafety/{endpoint_path}?api-version={api_version}"
headers: Final = {
"Ocp-Apim-Subscription-Key": self.api_key,
"Content-Type": "application/json",

View file

@ -819,7 +819,7 @@ class JavelinGuardrailConfigModel(BaseModel):
"""Configuration parameters for the Javelin guardrail"""
guard_name: str | None = Field(default=None, description="Name of the Javelin guard to use")
api_version: str | None = Field(default="v1", description="API version for Javelin service")
api_version: str | None = Field(default=None, description="API version for Javelin service")
metadata: dict | None = Field(default=None, description="Additional metadata to send with requests")
application: str | None = Field(default=None, description="Application name for Javelin service")
config: dict | None = Field(default=None, description="Additional configuration for the guardrail")

View file

@ -7,6 +7,7 @@ from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_hooks.azure.prompt_shield import (
AzureContentSafetyPromptShieldGuardrail,
)
from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler
from litellm.types.guardrails import LitellmParams
@ -635,3 +636,51 @@ def test_update_in_memory_litellm_params_dead_env_credential_rejected_untouched(
assert guardrail.api_key == "azure_prompt_shield_api_key"
assert guardrail.price_per_1000_text_records == 0.38
@pytest.mark.asyncio
async def test_config_without_api_version_calls_documented_azure_api_version():
handler = InMemoryGuardrailHandler()
registered = handler.initialize_guardrail(
guardrail={
"guardrail_name": "azure-prompt-shield-no-api-version",
"litellm_params": {
"guardrail": "azure/prompt_shield",
"mode": "pre_call",
"api_key": "azure_prompt_shield_api_key",
"api_base": "https://example.cognitiveservices.azure.com",
},
}
)
assert registered is not None
guardrail = handler.guardrail_id_to_custom_guardrail[registered["guardrail_id"]]
assert isinstance(guardrail, AzureContentSafetyPromptShieldGuardrail)
with patch.object(guardrail.async_handler, "post", return_value=_shield_response(False)) as mock_post:
result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
assert result == {"texts": ["hello"]}
assert mock_post.call_args.kwargs["url"] == (
"https://example.cognitiveservices.azure.com/contentsafety/text:shieldPrompt?api-version=2024-09-01"
)
@pytest.mark.asyncio
async def test_update_without_api_version_keeps_documented_azure_api_version():
guardrail = _shield_guardrail()
guardrail.update_in_memory_litellm_params(
LitellmParams(
guardrail="azure/prompt_shield",
mode="pre_call",
api_key="azure_prompt_shield_api_key",
api_base="https://example.cognitiveservices.azure.com",
)
)
with patch.object(guardrail.async_handler, "post", return_value=_shield_response(False)) as mock_post:
result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
assert result == {"texts": ["hello"]}
assert mock_post.call_args.kwargs["url"] == (
"https://example.cognitiveservices.azure.com/contentsafety/text:shieldPrompt?api-version=2024-09-01"
)

View file

@ -4,6 +4,7 @@ import pytest
from fastapi import HTTPException
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler
from litellm.proxy.guardrails.guardrail_hooks.azure.text_moderation import (
AzureContentSafetyTextModerationGuardrail,
)
@ -463,3 +464,61 @@ async def test_apply_guardrail_handles_missing_texts_key():
mock_post.assert_not_called()
assert result == {"images": ["x"]}
@pytest.mark.asyncio
async def test_config_without_api_version_calls_documented_azure_api_version():
handler = InMemoryGuardrailHandler()
registered = handler.initialize_guardrail(
guardrail={
"guardrail_name": "azure-text-moderation-no-api-version",
"litellm_params": {
"guardrail": "azure/text_moderations",
"mode": "pre_call",
"api_key": "azure_text_moderation_api_key",
"api_base": "https://example.cognitiveservices.azure.com",
},
}
)
assert registered is not None
guardrail = handler.guardrail_id_to_custom_guardrail[registered["guardrail_id"]]
assert isinstance(guardrail, AzureContentSafetyTextModerationGuardrail)
with patch.object(guardrail.async_handler, "post", return_value=_moderation_response(0)) as mock_post:
result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
assert result == {"texts": ["hello"]}
assert mock_post.call_args.kwargs["url"] == (
"https://example.cognitiveservices.azure.com/contentsafety/text:analyze?api-version=2024-09-01"
)
@pytest.mark.parametrize(
("stored_api_version", "expected_api_version"),
[("v1", "2024-09-01"), ("2023-10-01", "2023-10-01")],
)
@pytest.mark.asyncio
async def test_guardrail_loaded_with_stored_api_version_calls_azure_at(stored_api_version, expected_api_version):
handler = InMemoryGuardrailHandler()
registered = handler.initialize_guardrail(
guardrail={
"guardrail_name": f"azure-text-moderation-stored-{stored_api_version}",
"litellm_params": {
"guardrail": "azure/text_moderations",
"mode": "pre_call",
"api_key": "azure_text_moderation_api_key",
"api_base": "https://example.cognitiveservices.azure.com",
"api_version": stored_api_version,
},
}
)
assert registered is not None
guardrail = handler.guardrail_id_to_custom_guardrail[registered["guardrail_id"]]
with patch.object(guardrail.async_handler, "post", return_value=_moderation_response(0)) as mock_post:
result = await guardrail.apply_guardrail(inputs={"texts": ["hello"]}, request_data={}, input_type="request")
assert result == {"texts": ["hello"]}
assert mock_post.call_args.kwargs["url"] == (
f"https://example.cognitiveservices.azure.com/contentsafety/text:analyze?api-version={expected_api_version}"
)

View file

@ -0,0 +1,39 @@
from unittest.mock import Mock, patch
import pytest
from litellm.proxy.guardrails.guardrail_hooks.javelin.javelin import JavelinGuardrail
from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler
from litellm.types.guardrails import GuardrailEventHooks
@pytest.mark.asyncio
async def test_config_without_api_version_calls_javelin_v1():
handler = InMemoryGuardrailHandler()
registered = handler.initialize_guardrail(
guardrail={
"guardrail_name": "javelin-no-api-version",
"litellm_params": {
"guardrail": "javelin",
"mode": "pre_call",
"api_key": "javelin_api_key",
"api_base": "https://javelin.example",
"guard_name": "trustsafety",
},
}
)
assert registered is not None
guardrail = handler.guardrail_id_to_custom_guardrail[registered["guardrail_id"]]
assert isinstance(guardrail, JavelinGuardrail)
assessments = [{"trustsafety": {"request_reject": False}}]
response = Mock()
response.json.return_value = {"assessments": assessments}
with patch.object(guardrail.async_handler, "post", return_value=response) as mock_post:
result = await guardrail.call_javelin_guard(
request={"input": {"text": "hello"}, "config": None, "metadata": None},
event_type=GuardrailEventHooks.pre_call,
)
assert result == {"assessments": assessments}
assert mock_post.call_args.kwargs["url"] == "https://javelin.example/v1/guardrail/trustsafety/apply"

View file

@ -31795,9 +31795,8 @@ export interface components {
/**
* Api Version
* @description API version for Javelin service
* @default v1
*/
api_version: string | null;
api_version?: string | null;
/**
* Application
* @description Application name for Javelin service