fix(guardrails): stop the Javelin api_version default leaking into Azure Content Safety

LitellmParams mixes every provider config model into one class, so the
Javelin api_version default of "v1" reached the Azure Content Safety
guardrails whenever config.yaml omitted api_version and Azure answered 404.
The shared field now defaults to None, Javelin keeps filling in "v1" itself,
and the Azure guardrails fall back to the documented 2024-09-01 at request
time so a DB update that omits api_version stays on the default too.
This commit is contained in:
mateo-berri 2026-09-19 01:41:35 -07:00
parent 5f1268c056
commit 6f4d1c5911
7 changed files with 136 additions and 7 deletions

View file

@ -11155,7 +11155,6 @@
"type": "null"
}
],
"default": "v1",
"description": "API version for Javelin service",
"title": "Api Version"
},
@ -19622,7 +19621,7 @@
}
}
},
"description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n "
"description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n"
},
"500": {
"content": {

View file

@ -20,6 +20,8 @@ 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"
class AzureGuardrailBase:
"""
@ -43,7 +45,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 +58,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 = self.api_version or AZURE_CONTENT_SAFETY_DEFAULT_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,57 @@ 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():
"""A config.yaml entry that omits api_version must reach Azure at the documented
default. LitellmParams inherits every provider's config model, so a sibling
provider's api_version default used to leak into the Azure URL and 404."""
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():
"""The DB update path copies every LitellmParams attribute onto the live
instance, api_version included, so an update that omits it must still leave
the request on the documented default rather than a None or leaked value."""
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,33 @@ 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():
"""A config.yaml entry that omits api_version must reach Azure at the documented
default. LitellmParams inherits every provider's config model, so a sibling
provider's api_version default used to leak into the Azure URL and 404."""
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"
)

View file

@ -0,0 +1,42 @@
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():
"""Javelin's v1 default no longer lives in the shared LitellmParams model (it
leaked into every other provider), so the Javelin initializer has to supply
it itself when the config omits api_version."""
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