diff --git a/litellm/proxy/_experimental/out/404/index.html b/litellm/proxy/_experimental/out/404.html similarity index 100% rename from litellm/proxy/_experimental/out/404/index.html rename to litellm/proxy/_experimental/out/404.html diff --git a/litellm/proxy/_experimental/out/_not-found/index.html b/litellm/proxy/_experimental/out/_not-found.html similarity index 100% rename from litellm/proxy/_experimental/out/_not-found/index.html rename to litellm/proxy/_experimental/out/_not-found.html diff --git a/litellm/proxy/_experimental/out/api-reference/index.html b/litellm/proxy/_experimental/out/api-reference.html similarity index 100% rename from litellm/proxy/_experimental/out/api-reference/index.html rename to litellm/proxy/_experimental/out/api-reference.html diff --git a/litellm/proxy/_experimental/out/chat/index.html b/litellm/proxy/_experimental/out/chat.html similarity index 100% rename from litellm/proxy/_experimental/out/chat/index.html rename to litellm/proxy/_experimental/out/chat.html diff --git a/litellm/proxy/_experimental/out/experimental/api-playground/index.html b/litellm/proxy/_experimental/out/experimental/api-playground.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/api-playground/index.html rename to litellm/proxy/_experimental/out/experimental/api-playground.html diff --git a/litellm/proxy/_experimental/out/experimental/budgets/index.html b/litellm/proxy/_experimental/out/experimental/budgets.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/budgets/index.html rename to litellm/proxy/_experimental/out/experimental/budgets.html diff --git a/litellm/proxy/_experimental/out/experimental/caching/index.html b/litellm/proxy/_experimental/out/experimental/caching.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/caching/index.html rename to litellm/proxy/_experimental/out/experimental/caching.html diff --git a/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html b/litellm/proxy/_experimental/out/experimental/claude-code-plugins.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html rename to litellm/proxy/_experimental/out/experimental/claude-code-plugins.html diff --git a/litellm/proxy/_experimental/out/experimental/old-usage/index.html b/litellm/proxy/_experimental/out/experimental/old-usage.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/old-usage/index.html rename to litellm/proxy/_experimental/out/experimental/old-usage.html diff --git a/litellm/proxy/_experimental/out/experimental/prompts/index.html b/litellm/proxy/_experimental/out/experimental/prompts.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/prompts/index.html rename to litellm/proxy/_experimental/out/experimental/prompts.html diff --git a/litellm/proxy/_experimental/out/experimental/tag-management/index.html b/litellm/proxy/_experimental/out/experimental/tag-management.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/tag-management/index.html rename to litellm/proxy/_experimental/out/experimental/tag-management.html diff --git a/litellm/proxy/_experimental/out/guardrails/index.html b/litellm/proxy/_experimental/out/guardrails.html similarity index 100% rename from litellm/proxy/_experimental/out/guardrails/index.html rename to litellm/proxy/_experimental/out/guardrails.html diff --git a/litellm/proxy/_experimental/out/login/index.html b/litellm/proxy/_experimental/out/login.html similarity index 100% rename from litellm/proxy/_experimental/out/login/index.html rename to litellm/proxy/_experimental/out/login.html diff --git a/litellm/proxy/_experimental/out/logs/index.html b/litellm/proxy/_experimental/out/logs.html similarity index 100% rename from litellm/proxy/_experimental/out/logs/index.html rename to litellm/proxy/_experimental/out/logs.html diff --git a/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html b/litellm/proxy/_experimental/out/mcp/oauth/callback.html similarity index 100% rename from litellm/proxy/_experimental/out/mcp/oauth/callback/index.html rename to litellm/proxy/_experimental/out/mcp/oauth/callback.html diff --git a/litellm/proxy/_experimental/out/model-hub/index.html b/litellm/proxy/_experimental/out/model-hub.html similarity index 100% rename from litellm/proxy/_experimental/out/model-hub/index.html rename to litellm/proxy/_experimental/out/model-hub.html diff --git a/litellm/proxy/_experimental/out/model_hub/index.html b/litellm/proxy/_experimental/out/model_hub.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub/index.html rename to litellm/proxy/_experimental/out/model_hub.html diff --git a/litellm/proxy/_experimental/out/model_hub_table/index.html b/litellm/proxy/_experimental/out/model_hub_table.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table/index.html rename to litellm/proxy/_experimental/out/model_hub_table.html diff --git a/litellm/proxy/_experimental/out/models-and-endpoints/index.html b/litellm/proxy/_experimental/out/models-and-endpoints.html similarity index 100% rename from litellm/proxy/_experimental/out/models-and-endpoints/index.html rename to litellm/proxy/_experimental/out/models-and-endpoints.html diff --git a/litellm/proxy/_experimental/out/onboarding/index.html b/litellm/proxy/_experimental/out/onboarding.html similarity index 100% rename from litellm/proxy/_experimental/out/onboarding/index.html rename to litellm/proxy/_experimental/out/onboarding.html diff --git a/litellm/proxy/_experimental/out/organizations/index.html b/litellm/proxy/_experimental/out/organizations.html similarity index 100% rename from litellm/proxy/_experimental/out/organizations/index.html rename to litellm/proxy/_experimental/out/organizations.html diff --git a/litellm/proxy/_experimental/out/playground/index.html b/litellm/proxy/_experimental/out/playground.html similarity index 100% rename from litellm/proxy/_experimental/out/playground/index.html rename to litellm/proxy/_experimental/out/playground.html diff --git a/litellm/proxy/_experimental/out/policies/index.html b/litellm/proxy/_experimental/out/policies.html similarity index 100% rename from litellm/proxy/_experimental/out/policies/index.html rename to litellm/proxy/_experimental/out/policies.html diff --git a/litellm/proxy/_experimental/out/settings/admin-settings/index.html b/litellm/proxy/_experimental/out/settings/admin-settings.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/admin-settings/index.html rename to litellm/proxy/_experimental/out/settings/admin-settings.html diff --git a/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html b/litellm/proxy/_experimental/out/settings/logging-and-alerts.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html rename to litellm/proxy/_experimental/out/settings/logging-and-alerts.html diff --git a/litellm/proxy/_experimental/out/settings/router-settings/index.html b/litellm/proxy/_experimental/out/settings/router-settings.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/router-settings/index.html rename to litellm/proxy/_experimental/out/settings/router-settings.html diff --git a/litellm/proxy/_experimental/out/settings/ui-theme/index.html b/litellm/proxy/_experimental/out/settings/ui-theme.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/ui-theme/index.html rename to litellm/proxy/_experimental/out/settings/ui-theme.html diff --git a/litellm/proxy/_experimental/out/skills/index.html b/litellm/proxy/_experimental/out/skills.html similarity index 100% rename from litellm/proxy/_experimental/out/skills/index.html rename to litellm/proxy/_experimental/out/skills.html diff --git a/litellm/proxy/_experimental/out/teams/index.html b/litellm/proxy/_experimental/out/teams.html similarity index 100% rename from litellm/proxy/_experimental/out/teams/index.html rename to litellm/proxy/_experimental/out/teams.html diff --git a/litellm/proxy/_experimental/out/test-key/index.html b/litellm/proxy/_experimental/out/test-key.html similarity index 100% rename from litellm/proxy/_experimental/out/test-key/index.html rename to litellm/proxy/_experimental/out/test-key.html diff --git a/litellm/proxy/_experimental/out/tools/mcp-servers/index.html b/litellm/proxy/_experimental/out/tools/mcp-servers.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/mcp-servers/index.html rename to litellm/proxy/_experimental/out/tools/mcp-servers.html diff --git a/litellm/proxy/_experimental/out/tools/vector-stores/index.html b/litellm/proxy/_experimental/out/tools/vector-stores.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/vector-stores/index.html rename to litellm/proxy/_experimental/out/tools/vector-stores.html diff --git a/litellm/proxy/_experimental/out/usage/index.html b/litellm/proxy/_experimental/out/usage.html similarity index 100% rename from litellm/proxy/_experimental/out/usage/index.html rename to litellm/proxy/_experimental/out/usage.html diff --git a/litellm/proxy/_experimental/out/users/index.html b/litellm/proxy/_experimental/out/users.html similarity index 100% rename from litellm/proxy/_experimental/out/users/index.html rename to litellm/proxy/_experimental/out/users.html diff --git a/litellm/proxy/_experimental/out/virtual-keys/index.html b/litellm/proxy/_experimental/out/virtual-keys.html similarity index 100% rename from litellm/proxy/_experimental/out/virtual-keys/index.html rename to litellm/proxy/_experimental/out/virtual-keys.html diff --git a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py index 972d09f491c..b9dd11bb709 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py +++ b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/base.py @@ -293,6 +293,10 @@ class PurviewGuardrailBase: return trusted metadata = data.get("metadata") or data.get("litellm_metadata") or {} + uid = metadata.get("user_api_key_user_id") + if uid: + return str(uid) + uid = metadata.get(self.user_id_field) if uid: return str(uid) @@ -313,27 +317,19 @@ class PurviewGuardrailBase: ) -> Optional[str]: """Resolve user ID from trusted (proxy-authenticated) sources only. - Identical trust order to ``_resolve_user_id`` levels 1-3, but intentionally - omits level 4 (``metadata[user_id_field]``) because that value is supplied - by the caller and can be forged. Use this in blocking modes so that - content is always evaluated against the actual authenticated user's Purview - policy, not one chosen by the caller. + Uses only ``UserAPIKeyAuth.user_id`` and ``UserAPIKeyAuth.end_user_id``. + Intentionally omits ``metadata[user_id_field]`` and + ``metadata["user_api_key_user_id"]`` because those can be supplied or + spoofed by the caller when the API key has no bound user. - Returns ``None`` when no authenticated identity is available, even if a - caller-supplied value exists. Callers should then decide whether to fail - open (skip) or fail closed (block). + Returns ``None`` when no authenticated identity is available. Blocking + hooks must fail closed rather than skip the DLP check. """ - metadata = data.get("metadata") or data.get("litellm_metadata") or {} - if hasattr(user_api_key_dict, "user_id") and user_api_key_dict.user_id: return str(user_api_key_dict.user_id) if hasattr(user_api_key_dict, "end_user_id") and user_api_key_dict.end_user_id: return str(user_api_key_dict.end_user_id) - uid = metadata.get("user_api_key_user_id") - if uid: - return str(uid) - return None def _resolve_user_id_from_logging_kwargs( diff --git a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py index 95fa014cf23..27bbe91f045 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py +++ b/litellm/proxy/guardrails/guardrail_hooks/microsoft_purview/purview_dlp.py @@ -241,20 +241,12 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail): ) return self.get_prompt_text_for_dlp(cast(List[Any], messages)) except Exception: - verbose_proxy_logger.debug( + verbose_proxy_logger.warning( "Purview DLP: failed to transform responses API input", exc_info=True, ) if raise_on_failure: - raise HTTPException( - status_code=400, - detail={ - "error": ( - "Microsoft Purview DLP: Responses API input could " - "not be transformed for DLP scanning in blocking mode" - ), - }, - ) + raise return None # ------------------------------------------------------------------ @@ -272,7 +264,8 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail): Caller-supplied ``metadata[user_id_field]`` is rejected (fail closed) because it can impersonate another Entra user's Purview policy. - Returns ``None`` when no trusted identity exists — hooks skip the DLP check. + Raises ``HTTPException`` when no trusted identity exists or when only + caller-supplied metadata is available (fail closed). """ trusted_id = self._resolve_trusted_user_id(data, user_api_key_dict) if trusted_id: @@ -290,10 +283,15 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail): }, ) - verbose_proxy_logger.warning( - "Purview DLP: no trusted user_id resolved; skipping DLP check" + raise HTTPException( + status_code=400, + detail={ + "error": ( + "Microsoft Purview DLP: No proxy-authenticated user identity; " + "bind user_id to the API key for blocking DLP" + ), + }, ) - return None # ------------------------------------------------------------------ # Pre-call hook — DLP on prompts @@ -404,18 +402,12 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail): assembled_response = stream_chunk_builder(chunks=all_chunks) - if not isinstance(assembled_response, ModelResponse): - # Non-chat response (e.g. embeddings) — pass through unchanged. - for chunk in all_chunks: - yield chunk - return - user_id = self._resolve_user_id_for_blocking(request_data, user_api_key_dict) - if user_id: + + if isinstance(assembled_response, TextCompletionResponse): parts = self._completion_response_text_parts(assembled_response) if parts: combined = "\n\n---\n\n".join(parts) - # Raises HTTPException(400) on violation — no chunks are yielded. await self._check_content( user_id=user_id, text=combined, @@ -423,8 +415,29 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail): request_data=request_data, block_on_violation=True, ) + for chunk in all_chunks: + yield chunk + return - # DLP passed (or skipped) — re-yield chunks from the assembled response. + if not isinstance(assembled_response, ModelResponse): + # Non-chat response (e.g. embeddings) — pass through unchanged. + for chunk in all_chunks: + yield chunk + return + + parts = self._completion_response_text_parts(assembled_response) + if parts: + combined = "\n\n---\n\n".join(parts) + # Raises HTTPException(400) on violation — no chunks are yielded. + await self._check_content( + user_id=user_id, + text=combined, + activity="downloadText", + request_data=request_data, + block_on_violation=True, + ) + + # DLP passed — re-yield chunks from the assembled chat response. mock_response = MockResponseIterator(model_response=assembled_response) async for chunk in mock_response: yield chunk diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py index 2c002b4b1c2..5f83632cfa9 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_microsoft_purview.py @@ -290,22 +290,22 @@ class TestPreCallHook: assert exc_info.value.status_code == 400 @pytest.mark.asyncio - async def test_pre_call_no_user_id_skips(self): + async def test_pre_call_no_user_id_raises(self): guardrail = _make_guardrail() with patch.object( guardrail, "_check_content", new_callable=AsyncMock ) as mock_check: - result = await guardrail.async_pre_call_hook( - user_api_key_dict=UserAPIKeyAuth(api_key="test"), - cache=None, - data={"messages": [{"role": "user", "content": "Hello"}]}, - call_type="completion", - ) + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test"), + cache=None, + data={"messages": [{"role": "user", "content": "Hello"}]}, + call_type="completion", + ) mock_check.assert_not_called() - # Returns data when skipping - assert result is not None + assert exc_info.value.status_code == 400 @pytest.mark.asyncio async def test_pre_call_no_messages_skips(self): @@ -427,7 +427,7 @@ class TestPostCallHook: assert exc_info.value.status_code == 400 @pytest.mark.asyncio - async def test_post_call_no_user_id_skips(self): + async def test_post_call_no_user_id_raises(self): from litellm.types.utils import Choices, Message, ModelResponse guardrail = _make_guardrail() @@ -440,14 +440,15 @@ class TestPostCallHook: with patch.object( guardrail, "_check_content", new_callable=AsyncMock ) as mock_check: - result = await guardrail.async_post_call_success_hook( - data={}, - user_api_key_dict=UserAPIKeyAuth(api_key="test"), - response=response, - ) + with pytest.raises(HTTPException) as exc_info: + await guardrail.async_post_call_success_hook( + data={}, + user_api_key_dict=UserAPIKeyAuth(api_key="test"), + response=response, + ) mock_check.assert_not_called() - assert result is response + assert exc_info.value.status_code == 400 @pytest.mark.asyncio async def test_post_call_scans_all_choices(self): @@ -1807,11 +1808,12 @@ class TestResolveTrustedUserId: auth = UserAPIKeyAuth(api_key="test", end_user_id="end-user-222") assert guardrail._resolve_trusted_user_id({}, auth) == "end-user-222" - def test_trusted_user_id_from_proxy_injected_metadata(self): + def test_metadata_user_api_key_user_id_not_trusted_without_auth(self): + """Metadata user_api_key_user_id is not trusted when the key has no user_id.""" guardrail = _make_guardrail() auth = UserAPIKeyAuth(api_key="test") data = {"metadata": {"user_api_key_user_id": "proxy-user-333"}} - assert guardrail._resolve_trusted_user_id(data, auth) == "proxy-user-333" + assert guardrail._resolve_trusted_user_id(data, auth) is None def test_trusted_user_id_returns_none_for_caller_supplied_only(self): """Caller-supplied metadata must NOT be returned by _resolve_trusted_user_id.""" @@ -1896,14 +1898,13 @@ class TestResolveUserIdForBlocking: assert exc_info.value.status_code == 400 assert "proxy-authenticated" in str(exc_info.value.detail) - def test_no_id_returns_none(self, caplog): - import logging - + def test_no_id_raises_http_exception(self): guardrail = _make_guardrail() auth = UserAPIKeyAuth(api_key="test") - with caplog.at_level(logging.WARNING): - result = guardrail._resolve_user_id_for_blocking({}, auth) - assert result is None + with pytest.raises(HTTPException) as exc_info: + guardrail._resolve_user_id_for_blocking({}, auth) + assert exc_info.value.status_code == 400 + assert "bind user_id" in str(exc_info.value.detail) # --------------------------------------------------------------- @@ -2077,8 +2078,8 @@ class TestStreamingIteratorHook: assert len(chunks) == 0 # No chunks yielded before the block @pytest.mark.asyncio - async def test_streaming_no_user_id_passes_through(self): - """No resolvable user_id → stream passes through without DLP scan.""" + async def test_streaming_no_user_id_raises_before_yield(self): + """No resolvable user_id → fail closed before any chunk is yielded.""" from litellm.types.utils import Choices, Message, ModelResponse guardrail = _make_guardrail() @@ -2092,33 +2093,60 @@ class TestStreamingIteratorHook: ] ) + async def fake_response_stream(): + yield assembled_response + + with patch( + "litellm.main.stream_chunk_builder", return_value=assembled_response + ): + chunks = [] + with pytest.raises(HTTPException) as exc_info: + async for chunk in guardrail.async_post_call_streaming_iterator_hook( + user_api_key_dict=UserAPIKeyAuth(api_key="test"), # no user_id + response=fake_response_stream(), + request_data={}, + ): + chunks.append(chunk) + + assert exc_info.value.status_code == 400 + assert len(chunks) == 0 + + @pytest.mark.asyncio + async def test_streaming_text_completion_scans_before_yield(self): + """Streamed /v1/completions must be DLP-scanned via TextCompletionResponse.""" + from litellm.types.utils import TextChoices, TextCompletionResponse + + guardrail = _make_guardrail() + + assembled_response = TextCompletionResponse( + model="gpt-3.5-turbo-instruct", + choices=[TextChoices(text="completion body", index=0)], + ) + async def fake_response_stream(): yield assembled_response with ( - patch("litellm.main.stream_chunk_builder", return_value=assembled_response), patch( - "litellm.llms.base_llm.base_model_iterator.MockResponseIterator" - ) as mock_iterator_cls, + "litellm.main.stream_chunk_builder", return_value=assembled_response + ), patch.object( guardrail, "_check_content", new_callable=AsyncMock ) as mock_check, ): - - async def _iter_chunks(): - yield assembled_response - - mock_iterator_cls.return_value.__aiter__ = lambda s: _iter_chunks() + mock_check.return_value = {"policyActions": []} chunks = [] async for chunk in guardrail.async_post_call_streaming_iterator_hook( - user_api_key_dict=UserAPIKeyAuth(api_key="test"), # no user_id + user_api_key_dict=UserAPIKeyAuth(api_key="test", user_id="user-123"), response=fake_response_stream(), request_data={}, ): chunks.append(chunk) - mock_check.assert_not_called() + mock_check.assert_called_once() + assert mock_check.call_args.kwargs["text"] == "completion body" + assert len(chunks) > 0 # ---------------------------------------------------------------