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 1/3] 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 From 24064e3b3181dd6d65afe10f5e2f51f3d58e0c19 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:29:43 -0700 Subject: [PATCH 2/3] fix(guardrails): treat the stored Javelin api_version default as unset for Azure Content Safety Guardrails created through POST /guardrails on older releases have api_version "v1" saved in the database, because the writer persists every default. Azure Content Safety never accepts that value, so those guardrails kept answering 404 after the default moved to None. The Azure base now resolves "v1" to 2024-09-01 the same way it resolves a missing value. Also restores the OpenAPI snapshot line that a Python 3.14 regeneration had dedented --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- .../guardrails/guardrail_hooks/azure/base.py | 9 ++++- .../azure/test_azure_text_moderation.py | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 3ddf0def821..4cfb2bf8c38 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19621,7 +19621,7 @@ } } }, - "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" + "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 " }, "500": { "content": { diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/base.py b/litellm/proxy/guardrails/guardrail_hooks/azure/base.py index 2338ed2e30d..d2aa11da7c9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/base.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/base.py @@ -21,6 +21,13 @@ AZURE_CONTENT_SAFETY_MAX_TEXT_LENGTH: Final = 10000 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: @@ -58,7 +65,7 @@ class AzureGuardrailBase: Returns: Parsed JSON response dict. """ - api_version: Final = self.api_version or AZURE_CONTENT_SAFETY_DEFAULT_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, 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 1798565f383..acb15b4d869 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 @@ -494,3 +494,38 @@ async def test_config_without_api_version_calls_documented_azure_api_version(): 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): + """Releases before the api_version default fix saved every guardrail created + through the API or dashboard with Javelin's "v1", which Azure always answers + with 404. A row like that must reach Azure at the documented default, while a + real Azure version an admin chose is sent as written.""" + 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}" + ) From e327a6ae7652a4e8c1949af5839abebfdc408e2d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:52:42 -0700 Subject: [PATCH 3/3] test(guardrails): drop regression docstrings from the api_version tests --- .../guardrail_hooks/azure/test_azure_prompt_shield.py | 6 ------ .../guardrail_hooks/azure/test_azure_text_moderation.py | 7 ------- .../proxy/guardrails/guardrail_hooks/test_javelin.py | 3 --- 3 files changed, 16 deletions(-) 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 0e7bf72706c..f4af4b5ead7 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 @@ -640,9 +640,6 @@ def test_update_in_memory_litellm_params_dead_env_credential_rejected_untouched( @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={ @@ -670,9 +667,6 @@ async def test_config_without_api_version_calls_documented_azure_api_version(): @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( 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 acb15b4d869..4fbc33edcd6 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 @@ -468,9 +468,6 @@ async def test_apply_guardrail_handles_missing_texts_key(): @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={ @@ -502,10 +499,6 @@ async def test_config_without_api_version_calls_documented_azure_api_version(): ) @pytest.mark.asyncio async def test_guardrail_loaded_with_stored_api_version_calls_azure_at(stored_api_version, expected_api_version): - """Releases before the api_version default fix saved every guardrail created - through the API or dashboard with Javelin's "v1", which Azure always answers - with 404. A row like that must reach Azure at the documented default, while a - real Azure version an admin chose is sent as written.""" handler = InMemoryGuardrailHandler() registered = handler.initialize_guardrail( guardrail={ diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py index b5283255eb2..dc58b67e3f8 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_javelin.py @@ -9,9 +9,6 @@ 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={