diff --git a/.github/workflows/test-litellm-matrix.yml b/.github/workflows/test-litellm-matrix.yml index 5590662f5ae..1672a193161 100644 --- a/.github/workflows/test-litellm-matrix.yml +++ b/.github/workflows/test-litellm-matrix.yml @@ -18,10 +18,15 @@ jobs: matrix: test-group: # tests/test_litellm split by subdirectory (~560 files total) - - name: "llms" - path: "tests/test_litellm/llms" - workers: 2 # Reduced from 4 to 2 to avoid race conditions - reruns: 2 # Retry flaky tests twice + # Vertex AI tests separated for better isolation (prevent auth/env pollution) + - name: "llms-vertex" + path: "tests/test_litellm/llms/vertex_ai" + workers: 1 + reruns: 2 + - name: "llms-other" + path: "tests/test_litellm/llms --ignore=tests/test_litellm/llms/vertex_ai" + workers: 2 + reruns: 2 # tests/test_litellm/proxy split by subdirectory (~180 files total) - name: "proxy-guardrails" path: "tests/test_litellm/proxy/guardrails tests/test_litellm/proxy/management_endpoints tests/test_litellm/proxy/management_helpers" @@ -105,4 +110,5 @@ jobs: -n ${{ matrix.test-group.workers }} \ --reruns ${{ matrix.test-group.reruns }} \ --reruns-delay 1 \ + --dist=loadscope \ --durations=20 diff --git a/litellm/policy_templates_backup.json b/litellm/policy_templates_backup.json index ac3d63ed909..5b20d86943b 100644 --- a/litellm/policy_templates_backup.json +++ b/litellm/policy_templates_backup.json @@ -772,13 +772,14 @@ { "id": "eu-ai-act-article5", "title": "EU AI Act Article 5 — Prohibited Practices", - "description": "EU AI Act Article 5 compliance for prohibited AI practices. Blocks requests related to social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation. Uses conditional matching (identifier word + context word).", + "description": "EU AI Act Article 5 compliance for prohibited AI practices. Blocks requests related to social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation. Includes both English and French keyword detection. Uses conditional matching (identifier word + context word).", "region": "EU", "icon": "ShieldExclamationIcon", "iconColor": "text-red-500", "iconBg": "bg-red-50", "guardrails": [ - "eu-ai-act-prohibited-practices" + "eu-ai-act-prohibited-practices", + "eu-ai-act-prohibited-practices-fr" ], "complexity": "High", "guardrailDefinitions": [ @@ -794,7 +795,19 @@ "enabled": true, "action": "BLOCK", "severity_threshold": "medium" - }, + } + ] + }, + "guardrail_info": { + "description": "Blocks EU AI Act Article 5 prohibited practices in English: social scoring systems, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation" + } + }, + { + "guardrail_name": "eu-ai-act-prohibited-practices-fr", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ { "category": "eu_ai_act_article5_prohibited_practices_fr", "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml", @@ -805,15 +818,16 @@ ] }, "guardrail_info": { - "description": "Blocks EU AI Act Article 5 prohibited practices: social scoring systems, emotion recognition in workplace/education, biometric categorization for sensitive attributes, predictive profiling, manipulation, and vulnerability exploitation" + "description": "Blocks EU AI Act Article 5 prohibited practices in French: detects and blocks French-language keywords related to social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation" } } ], "templateData": { "policy_name": "eu-ai-act-article5", - "description": "EU AI Act Article 5 compliance policy for prohibited AI practices. Blocks social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation.", + "description": "EU AI Act Article 5 compliance policy for prohibited AI practices. Blocks social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation. Includes English and French detection.", "guardrails_add": [ - "eu-ai-act-prohibited-practices" + "eu-ai-act-prohibited-practices", + "eu-ai-act-prohibited-practices-fr" ], "guardrails_remove": [] } diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 4ae3f6b35fe..c4ade2f1a85 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -264,6 +264,52 @@ class ContentFilterGuardrail(CustomGuardrail): f"{len(self.category_keywords)} keywords" ) + @staticmethod + def _resolve_category_file_path(file_path: str) -> str: + """ + Resolve a category file path that may be relative. + + Paths in policy templates (e.g. category_file) are often stored as + relative paths like "litellm/proxy/.../policy_templates/file.yaml". + These only work when the CWD is the project root. In production + (Docker, installed packages, etc.) the CWD is different, so the + file isn't found. + + Resolution order: + 1. Return as-is if absolute or already exists. + 2. Try joining the full path relative to this module's directory. + 3. Progressively strip leading path components and try each suffix + relative to this module's directory (handles paths like + "litellm/proxy/.../policy_templates/file.yaml" by finding the + "policy_templates/file.yaml" suffix that exists). + + Args: + file_path: The file path to resolve (absolute or relative). + + Returns: + The resolved absolute-ish path, or the original path if + resolution fails (caller should check existence). + """ + if os.path.isabs(file_path) or os.path.exists(file_path): + return file_path + + module_dir = os.path.dirname(__file__) + + # Try the full relative path joined to the module directory + candidate = os.path.join(module_dir, file_path) + if os.path.exists(candidate): + return candidate + + # Progressively strip leading components to find a matching suffix + parts = file_path.split("/") + for i in range(1, len(parts)): + suffix = os.path.join(*parts[i:]) + candidate = os.path.join(module_dir, suffix) + if os.path.exists(candidate): + return candidate + + return file_path + def _load_categories(self, categories: List[ContentFilterCategoryConfig]) -> None: """ Load content categories from configuration. @@ -302,7 +348,7 @@ class ContentFilterGuardrail(CustomGuardrail): # Load category file (custom or default) if custom_file: - category_file_path = custom_file + category_file_path = self._resolve_category_file_path(custom_file) else: # Try .yaml first, then .json (e.g. harm_toxic_abuse.json) yaml_path = os.path.join(categories_dir, f"{category_name}.yaml") diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml index 7c7c8aab4bf..88f07d1e576 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml @@ -2,7 +2,7 @@ # Utilise une logique conditionnelle : BLOQUER si un mot identificateur + un mot de blocage apparaissent ensemble # Référence : https://artificialintelligenceact.eu/article/5/ category_name: "eu_ai_act_article5_prohibited_practices_fr" -description: "Détecte les pratiques interdites de l'Article 5 de la loi sur l'IA de l'UE (français)" +description: "Detects EU AI Act Article 5 prohibited practices using conditional keyword matching in French" default_action: "BLOCK" # MOTS IDENTIFICATEURS - Actions qui pourraient créer des systèmes interdits diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 08aaa851691..b4aeda62004 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3278,7 +3278,11 @@ async def _build_ui_spend_logs_response( count_map: dict[str, int] = {} if enrich_session_counts: session_ids = list( - {row.session_id for row in data if getattr(row, "session_id", None)} + { + (row.get("session_id") if isinstance(row, dict) else getattr(row, "session_id", None)) + for row in data + if (row.get("session_id") if isinstance(row, dict) else getattr(row, "session_id", None)) + } ) if session_ids: # NOTE: This GROUP BY runs on every v1/UI page load. The IN clause diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 2f392c48e9d..8224425a135 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -352,6 +352,8 @@ def get_logging_payload( # noqa: PLR0915 guardrail_information=( standard_logging_payload.get("guardrail_information", None) if standard_logging_payload is not None + else metadata.get("standard_logging_guardrail_information", None) + if metadata is not None else None ), cold_storage_object_key=( diff --git a/policy_templates.json b/policy_templates.json index ac3d63ed909..5b20d86943b 100644 --- a/policy_templates.json +++ b/policy_templates.json @@ -772,13 +772,14 @@ { "id": "eu-ai-act-article5", "title": "EU AI Act Article 5 — Prohibited Practices", - "description": "EU AI Act Article 5 compliance for prohibited AI practices. Blocks requests related to social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation. Uses conditional matching (identifier word + context word).", + "description": "EU AI Act Article 5 compliance for prohibited AI practices. Blocks requests related to social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation. Includes both English and French keyword detection. Uses conditional matching (identifier word + context word).", "region": "EU", "icon": "ShieldExclamationIcon", "iconColor": "text-red-500", "iconBg": "bg-red-50", "guardrails": [ - "eu-ai-act-prohibited-practices" + "eu-ai-act-prohibited-practices", + "eu-ai-act-prohibited-practices-fr" ], "complexity": "High", "guardrailDefinitions": [ @@ -794,7 +795,19 @@ "enabled": true, "action": "BLOCK", "severity_threshold": "medium" - }, + } + ] + }, + "guardrail_info": { + "description": "Blocks EU AI Act Article 5 prohibited practices in English: social scoring systems, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation" + } + }, + { + "guardrail_name": "eu-ai-act-prohibited-practices-fr", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ { "category": "eu_ai_act_article5_prohibited_practices_fr", "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/eu_ai_act_article5_fr.yaml", @@ -805,15 +818,16 @@ ] }, "guardrail_info": { - "description": "Blocks EU AI Act Article 5 prohibited practices: social scoring systems, emotion recognition in workplace/education, biometric categorization for sensitive attributes, predictive profiling, manipulation, and vulnerability exploitation" + "description": "Blocks EU AI Act Article 5 prohibited practices in French: detects and blocks French-language keywords related to social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation" } } ], "templateData": { "policy_name": "eu-ai-act-article5", - "description": "EU AI Act Article 5 compliance policy for prohibited AI practices. Blocks social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation.", + "description": "EU AI Act Article 5 compliance policy for prohibited AI practices. Blocks social scoring, emotion recognition in workplace/education, biometric categorization, predictive profiling, manipulation, and vulnerability exploitation. Includes English and French detection.", "guardrails_add": [ - "eu-ai-act-prohibited-practices" + "eu-ai-act-prohibited-practices", + "eu-ai-act-prohibited-practices-fr" ], "guardrails_remove": [] } diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index 72d13aadad3..89543e42956 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -1044,8 +1044,13 @@ async def test_jwt_non_admin_team_route_access(monkeypatch): litellm.proxy.proxy_server, "general_settings", {"enable_jwt_auth": True} ) - # Mock JWTAuthManager.auth_builder + # Mock enterprise license check and JWTAuthManager.auth_builder + # License check must be mocked to avoid environment variable pollution + # in parallel test execution with patch( + "litellm.proxy.proxy_server.premium_user", + True, + ), patch( "litellm.proxy.auth.handle_jwt.JWTAuthManager.auth_builder", return_value=mock_jwt_response, ): diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index ba4a096be24..62851b8f99f 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -157,8 +157,12 @@ class TestLangfuseOtelIntegration: stub_module.LangFuseLogger = StubLFLogger # type: ignore - # Register stub in sys.modules so import inside method succeeds - sys.modules["litellm.integrations.langfuse.langfuse"] = stub_module # type: ignore + # Register stub in sys.modules so import inside method succeeds. + # Use monkeypatch so the real module is restored after the test runs, + # preventing sys.modules corruption that would break patch() targets in + # later tests (the patch would hit the stub while the real module's + # globals remain unpatch-ed). + monkeypatch.setitem(sys.modules, "litellm.integrations.langfuse.langfuse", stub_module) # type: ignore kwargs = {"litellm_params": {"metadata": {"foo": "bar"}}} extracted = LangfuseOtelLogger._extract_langfuse_metadata(kwargs) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 376d14416a3..77c74a7847e 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -33,7 +33,7 @@ def test_anthropic_experimental_pass_through_messages_handler(): except Exception as e: print(f"Error: {e}") mock_completion.assert_called_once() - mock_completion.call_args.kwargs["api_key"] == "test-api-key" + assert mock_completion.call_args.kwargs["api_key"] == "test-api-key" def test_anthropic_experimental_pass_through_messages_handler_dynamic_api_key_and_api_base_and_custom_values(): @@ -57,9 +57,9 @@ def test_anthropic_experimental_pass_through_messages_handler_dynamic_api_key_an except Exception as e: print(f"Error: {e}") mock_completion.assert_called_once() - mock_completion.call_args.kwargs["api_key"] == "test-api-key" - mock_completion.call_args.kwargs["api_base"] == "test-api-base" - mock_completion.call_args.kwargs["custom_key"] == "custom_value" + assert mock_completion.call_args.kwargs["api_key"] == "test-api-key" + assert mock_completion.call_args.kwargs["api_base"] == "test-api-base" + assert mock_completion.call_args.kwargs["custom_key"] == "custom_value" def test_anthropic_experimental_pass_through_messages_handler_custom_llm_provider(): diff --git a/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py b/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py index f6bc983df01..090792d4f0b 100644 --- a/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py +++ b/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py @@ -1,3 +1,4 @@ +import importlib import json import os import sys @@ -15,7 +16,22 @@ MOCK_EMBEDDING_RESPONSE = [[0.1, 0.2, 0.3, 0.4, 0.5]] @pytest.fixture -def mock_embedding_http_handler(): +def reload_huggingface_modules(): + """ + Reload modules to ensure fresh references after conftest reloads litellm. + This ensures the HTTPHandler class being patched is the same one used by + the embedding handler during parallel test execution. + """ + import litellm.llms.custom_httpx.http_handler as http_handler_module + import litellm.llms.huggingface.embedding.handler as hf_embedding_handler_module + + importlib.reload(http_handler_module) + importlib.reload(hf_embedding_handler_module) + yield + + +@pytest.fixture +def mock_embedding_http_handler(reload_huggingface_modules): """Fixture to mock the HTTP handler for embedding tests""" with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post: mock_response = MagicMock() @@ -27,7 +43,7 @@ def mock_embedding_http_handler(): @pytest.fixture -def mock_embedding_async_http_handler(): +def mock_embedding_async_http_handler(reload_huggingface_modules): """Fixture to mock the async HTTP handler for embedding tests""" with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock) as mock_post: mock_response = MagicMock() diff --git a/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py b/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py index 1acdadf541a..dd0a3e36e46 100644 --- a/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py +++ b/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py @@ -2,6 +2,7 @@ Integration tests for Vertex AI rerank functionality. These tests demonstrate end-to-end usage of the Vertex AI rerank feature. """ +import importlib import os from unittest.mock import MagicMock, patch @@ -13,7 +14,14 @@ from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig class TestVertexAIRerankIntegration: def setup_method(self): - self.config = VertexAIRerankConfig() + # Reload modules to ensure fresh references after conftest reloads litellm. + # This ensures the class being patched is the same one used by the tests. + import litellm.llms.vertex_ai.rerank.transformation as rerank_transformation_module + importlib.reload(rerank_transformation_module) + + # Re-import after reload to get the fresh class + from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig as FreshConfig + self.config = FreshConfig() self.model = "semantic-ranker-default@latest" @patch('litellm.llms.vertex_ai.rerank.transformation.VertexAIRerankConfig._ensure_access_token') diff --git a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py index ece38eb386c..c33203c0c14 100644 --- a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py @@ -6,6 +6,7 @@ and following LiteLLM testing patterns and best practices. """ # Standard library imports +import importlib import os import sys from typing import Dict @@ -49,16 +50,14 @@ def setup_and_teardown(): to speed up testing by removing callbacks being chained. """ import asyncio - import importlib - import sys + global litellm - # Reload litellm to ensure clean state - # During parallel test execution, another worker might have removed litellm from sys.modules - # so we need to ensure it's imported before reloading - if "litellm" not in sys.modules: - import litellm as _litellm - else: - importlib.reload(litellm) + # Always import then reload to ensure fresh state + # This handles both cases uniformly: + # 1. litellm not in sys.modules (parallel worker removed it) + # 2. litellm already imported (normal case) + _module = importlib.import_module("litellm") + litellm = importlib.reload(_module) # Set up async loop loop = asyncio.get_event_loop_policy().new_event_loop() diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index aaa14ebd1e9..eabaec8c206 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -15,7 +15,6 @@ sys.path.insert( from unittest.mock import AsyncMock, MagicMock, patch import litellm - import litellm.proxy.proxy_server as ps @@ -2150,3 +2149,58 @@ async def test_ui_view_spend_logs_with_error_code_and_key_alias(client): assert metadata["user_api_key_alias"] == "test-key-1" assert "error_information" in metadata assert metadata["error_information"]["error_code"] == "500" + + +@pytest.mark.asyncio +async def test_build_ui_spend_logs_response_dict_rows_session_counts(): + """ + Regression test: _build_ui_spend_logs_response must enrich session_total_count + even when rows are plain dicts (as returned by query_raw) rather than Prisma + model instances. Previously getattr(dict, "session_id", None) silently + returned None, so every row got session_total_count=1 and the UI never + grouped session rows. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _build_ui_spend_logs_response, + ) + + session_id = "sess-abc-123" + dict_rows = [ + {"request_id": "req-1", "session_id": session_id, "call_type": "completion"}, + {"request_id": "req-2", "session_id": session_id, "call_type": "mcp_tool_call"}, + {"request_id": "req-3", "session_id": None, "call_type": "completion"}, + ] + + mock_prisma = MagicMock() + mock_prisma.db.litellm_spendlogs.group_by = AsyncMock( + return_value=[ + {"session_id": session_id, "_count": {"session_id": 2}}, + ] + ) + + result = await _build_ui_spend_logs_response( + prisma_client=mock_prisma, + data=dict_rows, + total_records=3, + page=1, + page_size=50, + total_pages=1, + enrich_session_counts=True, + ) + + rows = result["data"] + assert len(rows) == 3 + + # Rows with the shared session_id should have session_total_count=2 + assert rows[0]["session_total_count"] == 2 + assert rows[1]["session_total_count"] == 2 + + # Row without a session_id defaults to 1 + assert rows[2]["session_total_count"] == 1 + + # group_by should have been called with the session_id + mock_prisma.db.litellm_spendlogs.group_by.assert_called_once_with( + by=["session_id"], + where={"session_id": {"in": [session_id]}}, + count={"session_id": True}, + ) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 1972103c3d2..db877b714ec 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -21,6 +21,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy.spend_tracking.spend_tracking_utils import ( _get_proxy_server_request_for_spend_logs_payload, _get_response_for_spend_logs_payload, + _get_spend_logs_metadata, _get_vector_store_request_for_spend_logs_payload, _sanitize_request_body_for_spend_logs_payload, _should_store_prompts_and_responses_in_spend_logs, @@ -957,3 +958,76 @@ def test_should_store_prompts_and_responses_in_spend_logs_case_insensitive_strin result = _should_store_prompts_and_responses_in_spend_logs() assert result is False, "Expected False (from env var) when key missing, got True" + +def test_get_spend_logs_metadata_guardrail_info_fallback_from_metadata(): + """ + When standard_logging_payload is None (e.g. guardrail blocks before LLM call), + guardrail_information should fall back to reading from metadata's + standard_logging_guardrail_information field. + """ + guardrail_info = [ + { + "guardrail_name": "content_filter", + "guardrail_provider": "litellm", + "guardrail_mode": "pre_call", + "guardrail_status": "guardrail_intervened", + "guardrail_response": "Content blocked", + } + ] + metadata = { + "user_api_key": "test-key", + "standard_logging_guardrail_information": guardrail_info, + } + + result = _get_spend_logs_metadata( + metadata=metadata, + guardrail_information=None, + ) + # When guardrail_information param is None, should NOT fall back + # (the caller is responsible for passing it) + assert result["guardrail_information"] is None + + +def test_get_logging_payload_guardrail_info_when_no_standard_logging_payload(): + """ + When a guardrail blocks a request before the LLM call, the standard_logging_object + is not set on request_data. In this case, get_logging_payload should still include + guardrail_information from the metadata. + + This is the bug fix for: guardrail failures not showing GuardrailViewer in the UI. + """ + guardrail_info = [ + { + "guardrail_name": "content_filter", + "guardrail_provider": "litellm", + "guardrail_mode": "pre_call", + "guardrail_status": "guardrail_intervened", + "guardrail_response": "Content blocked", + } + ] + # Simulate request_data as it looks when a guardrail blocks before LLM call + kwargs = { + "model": "gpt-4", + "litellm_call_id": "test-call-id", + "litellm_params": { + "metadata": { + "user_api_key": "test-key", + "standard_logging_guardrail_information": guardrail_info, + }, + "proxy_server_request": {}, + }, + # No "standard_logging_object" key - this is the failure case + } + + with patch("litellm.proxy.proxy_server.master_key", "sk-master"): + with patch("litellm.proxy.proxy_server.general_settings", {}): + payload = get_logging_payload( + kwargs=kwargs, + response_obj={}, + start_time=datetime.datetime.now(tz=timezone.utc), + end_time=datetime.datetime.now(tz=timezone.utc), + ) + + metadata_result = json.loads(payload["metadata"]) + assert metadata_result["guardrail_information"] == guardrail_info + diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index aefd19ef3c3..2696867d017 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -681,10 +681,12 @@ def test_embedding_input_array_of_tokens(client_no_auth): """ from litellm.proxy import proxy_server - # Apply the mock AFTER client_no_auth fixture has initialized the router - # This avoids issues with llm_router being None during parallel test execution - if proxy_server.llm_router is None: - pytest.skip("llm_router not initialized - skipping test") + # The client_no_auth fixture should initialize the router + # Assert this to catch any router initialization regressions + assert proxy_server.llm_router is not None, ( + "llm_router is None after client_no_auth fixture initialized. " + "This indicates a router initialization issue that should be investigated." + ) try: with mock.patch.object( diff --git a/tests/test_litellm/responses/mcp/test_chat_completions_handler.py b/tests/test_litellm/responses/mcp/test_chat_completions_handler.py index e62be9cb501..a238531d2e0 100644 --- a/tests/test_litellm/responses/mcp/test_chat_completions_handler.py +++ b/tests/test_litellm/responses/mcp/test_chat_completions_handler.py @@ -427,16 +427,14 @@ async def test_acompletion_with_mcp_adds_metadata_to_streaming(monkeypatch): assert len(all_chunks) > 0 # Verify mcp_list_tools is in the first chunk - first_chunk = all_chunks[0] if all_chunks else None - assert first_chunk is not None, "Should have a first chunk" - if hasattr(first_chunk, "choices") and first_chunk.choices: - choice = first_chunk.choices[0] - if hasattr(choice, "delta") and choice.delta: - provider_fields = getattr(choice.delta, "provider_specific_fields", None) - # mcp_list_tools should be added to the first chunk - assert provider_fields is not None, f"First chunk should have provider_specific_fields. Delta: {choice.delta}" - assert "mcp_list_tools" in provider_fields, f"First chunk should have mcp_list_tools. Fields: {provider_fields}" - assert provider_fields["mcp_list_tools"] == openai_tools + first_chunk = all_chunks[0] + assert hasattr(first_chunk, "choices") and first_chunk.choices, "First chunk must have choices" + choice = first_chunk.choices[0] + assert hasattr(choice, "delta") and choice.delta, "First choice must have delta" + provider_fields = getattr(choice.delta, "provider_specific_fields", None) + assert provider_fields is not None, f"First chunk should have provider_specific_fields. Delta: {choice.delta}" + assert "mcp_list_tools" in provider_fields, f"First chunk should have mcp_list_tools. Fields: {provider_fields}" + assert provider_fields["mcp_list_tools"] == openai_tools @pytest.mark.asyncio @@ -625,7 +623,7 @@ async def test_acompletion_with_mcp_streaming_metadata_in_correct_chunks(monkeyp ], ), # Final chunk with tool_calls ] - + follow_up_chunks = [ create_chunk("Hello"), create_chunk(" world", finish_reason="stop"), @@ -785,21 +783,21 @@ async def test_acompletion_with_mcp_streaming_metadata_in_correct_chunks(monkeyp assert initial_final_chunk is not None, "Should have a final chunk from initial response" # Verify mcp_list_tools is in the first chunk - if hasattr(first_chunk, "choices") and first_chunk.choices: - choice = first_chunk.choices[0] - if hasattr(choice, "delta") and choice.delta: - provider_fields = getattr(choice.delta, "provider_specific_fields", None) - assert provider_fields is not None, "First chunk should have provider_specific_fields" - assert "mcp_list_tools" in provider_fields, "First chunk should have mcp_list_tools" + assert hasattr(first_chunk, "choices") and first_chunk.choices, "First chunk must have choices" + first_choice = first_chunk.choices[0] + assert hasattr(first_choice, "delta") and first_choice.delta, "First choice must have delta" + first_provider_fields = getattr(first_choice.delta, "provider_specific_fields", None) + assert first_provider_fields is not None, "First chunk should have provider_specific_fields" + assert "mcp_list_tools" in first_provider_fields, "First chunk should have mcp_list_tools" # Verify mcp_tool_calls and mcp_call_results are in the final chunk of initial response - if hasattr(initial_final_chunk, "choices") and initial_final_chunk.choices: - choice = initial_final_chunk.choices[0] - if hasattr(choice, "delta") and choice.delta: - provider_fields = getattr(choice.delta, "provider_specific_fields", None) - assert provider_fields is not None, "Final chunk should have provider_specific_fields" - assert "mcp_tool_calls" in provider_fields, "Should have mcp_tool_calls" - assert "mcp_call_results" in provider_fields, "Should have mcp_call_results" + assert hasattr(initial_final_chunk, "choices") and initial_final_chunk.choices, "Final chunk must have choices" + final_choice = initial_final_chunk.choices[0] + assert hasattr(final_choice, "delta") and final_choice.delta, "Final choice must have delta" + final_provider_fields = getattr(final_choice.delta, "provider_specific_fields", None) + assert final_provider_fields is not None, "Final chunk should have provider_specific_fields" + assert "mcp_tool_calls" in final_provider_fields, "Should have mcp_tool_calls" + assert "mcp_call_results" in final_provider_fields, "Should have mcp_call_results" @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.tsx b/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.tsx index 973c8722bba..2984478ba5a 100644 --- a/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.tsx +++ b/ui/litellm-dashboard/src/components/policies/guardrail_selection_modal.tsx @@ -201,6 +201,11 @@ const GuardrailSelectionModal: React.FC = ({ {guardrail.definition.litellm_params.patterns.length} pattern(s) )} + {guardrail.definition?.litellm_params?.categories && ( + + {guardrail.definition.litellm_params.categories.length} category/categories + + )} diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx index 35828922012..c916818a0ee 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -507,20 +507,6 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { {/* Expanded details */} {expanded && (
- {/* View Policy Configuration link */} - {entry.policy_template && ( -
- - View Policy Configuration - - -
- )} {/* Classification details for llm-judge */} {entry.classification && ( diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index f0f4041531c..0552b1837db 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -66,9 +66,6 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = const hasGuardrailData = guardrailEntries.length > 0; const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries); const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries); - const guardrailPolicyNames = Array.from( - new Set(guardrailEntries.map((e: any) => e?.policy_template).filter(Boolean)) - ) as string[]; // Vector store data const hasVectorStoreData = checkHasVectorStoreData(metadata); @@ -127,7 +124,7 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = )} {hasGuardrailData && ( - + )} @@ -225,7 +222,7 @@ function TagsSection({ tags }: { tags: Record }) { ); } -function GuardrailLabel({ label, maskedCount, policyNames }: { label: string; maskedCount: number; policyNames: string[] }) { +function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) { const handleClick = () => { const el = document.getElementById("guardrail-section"); if (el) el.scrollIntoView({ behavior: "smooth" }); @@ -239,9 +236,6 @@ function GuardrailLabel({ label, maskedCount, policyNames }: { label: string; ma {maskedCount} masked )} - {policyNames.map((name) => ( - {name} - ))} ); } @@ -424,6 +418,42 @@ function RequestResponseSection({ ); } +export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) { + const allPassed = guardrailEntries.every((e) => { + const status = e?.guardrail_status || e?.status; + return status === "pass" || status === "passed" || status === "success"; + }); + + const handleClick = () => { + const el = document.getElementById("guardrail-section"); + if (el) el.scrollIntoView({ behavior: "smooth" }); + }; + + return ( +
+
+ {allPassed ? "\u2713" : "\u2717"} {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""} evaluated + {"\u2193"} +
+
+ ); +} + function MetadataSection({ metadata }: { metadata: Record }) { return (
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index b6feff77a1c..fb761918531 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -12,10 +12,11 @@ import { MCP_CALL_TYPES } from "../constants"; import { getEventDisplayName } from "../utils"; import { DrawerHeader } from "./DrawerHeader"; import { useKeyboardNavigation } from "./useKeyboardNavigation"; -import { LogDetailContent } from "./LogDetailContent"; +import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent"; import { sessionSpendLogsCall } from "../../networking"; import { useQuery } from "@tanstack/react-query"; import { getSpendString } from "@/utils/dataUtils"; +import { normalizeGuardrailEntries } from "./utils"; import { DRAWER_WIDTH } from "./constants"; import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails"; @@ -323,6 +324,11 @@ export function LogDetailsDrawer({
+ {normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && ( +
+ +
+ )} {isSessionMode ? (
{/* Child events — vertical tree line with horizontal connectors */}