From 6f4d1c5911253c866586007026c7604bc6a95fb2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:41:35 -0700 Subject: [PATCH] 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. --- litellm/proxy/_lazy_openapi_snapshot.json | 3 +- .../guardrails/guardrail_hooks/azure/base.py | 7 ++- litellm/types/guardrails.py | 2 +- .../azure/test_azure_prompt_shield.py | 55 +++++++++++++++++++ .../azure/test_azure_text_moderation.py | 31 +++++++++++ .../guardrail_hooks/test_javelin.py | 42 ++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 3 +- 7 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 73ea8cf1991..3ddf0def821 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -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": { diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/base.py b/litellm/proxy/guardrails/guardrail_hooks/azure/base.py index 42f0220cc4d..2338ed2e30d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/base.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/base.py @@ -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", diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 400cadd69e7..172edf136fd 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -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") 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 17e7222fa44..0e7bf72706c 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 @@ -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" + ) 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 a43f95062f9..1798565f383 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 @@ -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" + ) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py new file mode 100644 index 00000000000..b5283255eb2 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py @@ -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" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a5ede63bf7f..2bb98fa4c66 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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