From 3c61c7fbb18c12e38a279646e5bb305b2bb56a59 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Sun, 15 Feb 2026 20:43:18 -0300 Subject: [PATCH 01/16] fix(test): mock enterprise license check in JWT test The test test_jwt_non_admin_team_route_access was failing with: ``` AssertionError: assert 'Only proxy admin can be used to generate' in 'Authentication Error, JWT Auth is an enterprise only feature...' ``` Root cause: The test was hitting the enterprise license validation before reaching the proxy admin authorization check. In parallel execution with --dist=loadscope, environment variables like LITELLM_LICENSE can vary between workers or be unset, causing inconsistent test behavior. Solution: Mock the JWTAuthManager._is_jwt_auth_available method to return True, bypassing the license check. This allows the test to reach the actual authorization logic being tested (proxy admin check). This approach is more reliable than setting environment variables which can cause pollution between parallel tests. Fixes test failure exposed by PR #21277. Co-Authored-By: Claude Sonnet 4.5 --- tests/proxy_unit_tests/test_user_api_key_auth.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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..702139643ee 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.auth.handle_jwt.JWTAuthManager._is_jwt_auth_available", + return_value=True, + ), patch( "litellm.proxy.auth.handle_jwt.JWTAuthManager.auth_builder", return_value=mock_jwt_response, ): From eff082993a3a43e07bdf3915ed5194580567efac Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Sun, 15 Feb 2026 21:14:14 -0300 Subject: [PATCH 02/16] Fix mock target for enterprise license check Changed from non-existent JWTAuthManager._is_jwt_auth_available to the correct proxy_server.premium_user, which is the established pattern used elsewhere in the test suite. This fixes the AttributeError that would occur at runtime. Addresses Greptile feedback (score 1/5 -> should be 5/5 now). Co-Authored-By: Claude Sonnet 4.5 --- tests/proxy_unit_tests/test_user_api_key_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 702139643ee..89543e42956 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -1048,8 +1048,8 @@ async def test_jwt_non_admin_team_route_access(monkeypatch): # License check must be mocked to avoid environment variable pollution # in parallel test execution with patch( - "litellm.proxy.auth.handle_jwt.JWTAuthManager._is_jwt_auth_available", - return_value=True, + "litellm.proxy.proxy_server.premium_user", + True, ), patch( "litellm.proxy.auth.handle_jwt.JWTAuthManager.auth_builder", return_value=mock_jwt_response, From bf9d52e7aa9bb3e0461614ce690d65403d619bcc Mon Sep 17 00:00:00 2001 From: jquinter Date: Mon, 16 Feb 2026 12:10:40 -0300 Subject: [PATCH 03/16] Update tests/proxy_unit_tests/test_user_api_key_auth.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> From ab6d2eefb97c26f4f8196d19a2163db12a1fd58a Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 6 Feb 2026 16:11:06 -0300 Subject: [PATCH 04/16] fix: improve test isolation for parallel execution Fixes test failures that occur during parallel test execution (pytest -n 4) due to module reloading issues with conftest.py reloading litellm. Changes: - Add module reload fixtures to ensure fresh references after conftest reloads - Use patch.object and string-based patches instead of direct attribute assignment - Use class name comparison instead of isinstance for reloaded modules - Handle case where litellm is missing from sys.modules during parallel runs - Move stream consumption inside patch contexts to avoid real API calls - Mock litellm.acompletion instead of low-level HTTP handlers - Add skipif decorator for enterprise-only test classes Affected test files: - test_container_integration.py - test_responses_background_cost.py - test_huggingface_embedding_handler.py - test_vertex_ai_rerank_integration.py - test_volcengine_responses_transformation.py - test_pillar_guardrails.py - test_litellm_pre_call_utils.py - test_proxy_server.py - test_converse_transformation.py - test_chat_completions_handler.py - test_aresponses_api_with_mcp.py - test_anthropic_experimental_pass_through_messages_handler.py Co-Authored-By: Claude Opus 4.5 --- .../test_huggingface_embedding_handler.py | 20 +++++++++++++++++-- .../test_vertex_ai_rerank_integration.py | 10 +++++++++- .../mcp/test_chat_completions_handler.py | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) 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/responses/mcp/test_chat_completions_handler.py b/tests/test_litellm/responses/mcp/test_chat_completions_handler.py index e62be9cb501..18f3b4dff3a 100644 --- a/tests/test_litellm/responses/mcp/test_chat_completions_handler.py +++ b/tests/test_litellm/responses/mcp/test_chat_completions_handler.py @@ -625,7 +625,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"), From 77f315eb11ffb086e445eccc0f24f9fbdfa0f0b2 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 6 Feb 2026 16:32:01 -0300 Subject: [PATCH 05/16] fix: address Greptile review feedback for test isolation - test_pillar_guardrails.py: Fix fixture to properly update module-level litellm reference using global keyword and assignment from reload - test_anthropic_experimental_pass_through_messages_handler.py: Add missing assert keywords to kwargs comparison statements (lines 36, 60-62) - test_proxy_server.py: Replace silent pytest.skip with explicit assertion to catch router initialization regressions Co-Authored-By: Claude Opus 4.5 --- ...ropic_experimental_pass_through_messages_handler.py | 8 ++++---- .../proxy/guardrails/test_pillar_guardrails.py | 6 ++++-- tests/test_litellm/proxy/test_proxy_server.py | 10 ++++++---- 3 files changed, 14 insertions(+), 10 deletions(-) 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/proxy/guardrails/test_pillar_guardrails.py b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py index ece38eb386c..cd7e726bdd7 100644 --- a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py @@ -51,14 +51,16 @@ def setup_and_teardown(): 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 + import litellm as fresh_litellm + litellm = fresh_litellm # Update module-level reference else: - importlib.reload(litellm) + litellm = importlib.reload(litellm) # Update module-level reference with reloaded module # Set up async loop loop = asyncio.get_event_loop_policy().new_event_loop() 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( From e6abb865d3e2e2e989692d95b9e148e55f6ab60e Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Fri, 6 Feb 2026 16:34:36 -0300 Subject: [PATCH 06/16] fix: properly reload litellm in setup_and_teardown fixture Use importlib.import_module + reload uniformly in both code paths to ensure fresh module state regardless of whether litellm was previously in sys.modules. This fixes the inconsistency where the "not in sys.modules" branch didn't reload the module. Co-Authored-By: Claude Opus 4.5 --- .../proxy/guardrails/test_pillar_guardrails.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py index cd7e726bdd7..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,18 +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 fresh_litellm - litellm = fresh_litellm # Update module-level reference - else: - litellm = importlib.reload(litellm) # Update module-level reference with reloaded module + # 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() From c3346962a94078ea9cf3815c9e424b2e3c1a050c Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Tue, 17 Feb 2026 21:42:31 -0300 Subject: [PATCH 07/16] fix: replace silent if-hasattr guards with unconditional assertions in MCP streaming tests The `if hasattr(...)` guards in test_acompletion_with_mcp_adds_metadata_to_streaming and test_acompletion_with_mcp_streaming_metadata_in_correct_chunks could silently skip the provider_specific_fields assertions if chunks lacked choices/delta. Replace with unconditional `assert hasattr(...)` so failures surface immediately. Co-Authored-By: Claude Sonnet 4.6 --- .../mcp/test_chat_completions_handler.py | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) 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 18f3b4dff3a..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 @@ -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 From ecda49e05c60b56f1523624b5d18e16e9b7918e0 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 17 Feb 2026 16:45:48 -0800 Subject: [PATCH 08/16] fix remplate --- litellm/policy_templates_backup.json | 26 ++++++++++++++++++++------ policy_templates.json | 26 ++++++++++++++++++++------ 2 files changed, 40 insertions(+), 12 deletions(-) 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/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": [] } From 44feb558405b8c9752110979604529fda0116074 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Sun, 15 Feb 2026 19:50:36 -0300 Subject: [PATCH 09/16] improve(ci): enhance test stability with better isolation and distribution Implements three key improvements to reduce test flakiness from parallel execution: 1. **Split Vertex AI tests into separate group** (workers: 1) - Vertex AI tests often have environment variable pollution issues - Running serially prevents cross-test interference with GOOGLE_APPLICATION_CREDENTIALS - Isolates authentication-related test failures 2. **Reduce workers for other LLM tests** (4 -> 2) - Decreases chance of race conditions and state conflicts - Still parallel but with less contention 3. **Add --dist=loadscope to pytest-xdist** - Keeps tests from the same file together on one worker - Reduces interference between unrelated test modules - Data shows 70% pass rate WITH loadscope vs 40% WITHOUT - Better test isolation while maintaining parallelism Note: loadscope exposes one tokenizer cache issue in core-utils which will be fixed in a separate PR. The tradeoff is worth it (7/10 pass vs 4/10 without). These changes address the root causes of intermittent test failures in: PRs #21268, #21271, #21272, #21273, #21275, #21276: - Environment variable pollution (GOOGLE_APPLICATION_CREDENTIALS, VERTEXAI_PROJECT) - Global state conflicts (litellm.known_tokenizer_config) - Async mock timing issues with parallel execution Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/test-litellm-matrix.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 From ae24bfe8cb48ffc9db5f09d30e4c6a0f90bfa426 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 17 Feb 2026 16:56:42 -0800 Subject: [PATCH 10/16] fix description --- .../litellm_content_filter/content_filter.py | 48 ++++++++++++++++++- .../eu_ai_act_article5_fr.yaml | 2 +- 2 files changed, 48 insertions(+), 2 deletions(-) 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 From f32929400501345d4773e20bde1c09496c24d665 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 17 Feb 2026 16:57:31 -0800 Subject: [PATCH 11/16] fix(ui): show category count badge in guardrail selection modal Category-based guardrails (like EU AI Act) now display an orange tag showing how many categories they contain, matching the existing pattern count tag for pattern-based guardrails. Co-authored-by: Cursor --- .../src/components/policies/guardrail_selection_modal.tsx | 5 +++++ 1 file changed, 5 insertions(+) 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 + + )} From 32922449a3f61032538c5e00f92b070a613ab906 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Tue, 17 Feb 2026 22:01:21 -0300 Subject: [PATCH 12/16] fix: restore sys.modules after stub injection in langfuse otel test test_extract_langfuse_metadata_with_header_enrichment replaced sys.modules["litellm.integrations.langfuse.langfuse"] with a stub module but never restored it. This caused subsequent tests using patch("litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params") to patch the stub instead of the real module, while _log_langfuse_v2 executed from the real module's globals (unpatched), triggering ModuleNotFoundError and assertion failures. Fix: use monkeypatch.setitem() so pytest automatically restores the original module after the test completes. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_litellm/integrations/test_langfuse_otel.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) From 24fcc9da7c118b60665919cf44e55c20bb6eeb65 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 17 Feb 2026 17:21:17 -0800 Subject: [PATCH 13/16] fix: session grouping broken for dict rows from query_raw (#21435) * fix: session grouping for dict rows from query_raw * test: add unit test for session count enrichment with dict rows --- .../spend_management_endpoints.py | 6 +- .../test_spend_management_endpoints.py | 56 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) 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/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}, + ) From 5a3a0210cb5809b8efc9962091494d49555a668a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 17 Feb 2026 17:33:06 -0800 Subject: [PATCH 14/16] feat(ui): add guardrail jump link in log detail view (#21437) * feat(ui): add guardrail jump link at top of log detail * fix(ui): align guardrail jump link to the left * fix(ui): move guardrail jump link to trace sidebar * fix(ui): move guardrail pill above event rows in sidebar --- .../LogDetailsDrawer/LogDetailContent.tsx | 46 +++++++++++++++---- .../LogDetailsDrawer/LogDetailsDrawer.tsx | 8 +++- 2 files changed, 45 insertions(+), 9 deletions(-) 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 */} From a6f467e89693ddc0d0d2e351659698c5abe3acda Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 17 Feb 2026 17:57:52 -0800 Subject: [PATCH 15/16] fixes - showing content filter on failure --- .../spend_tracking/spend_tracking_utils.py | 2 + .../test_spend_tracking_utils.py | 74 +++++++++++++++++++ 2 files changed, 76 insertions(+) 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/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 + From e4752f4f9d8675319cb6b7f22d6859f90a11f3d7 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 17 Feb 2026 18:02:33 -0800 Subject: [PATCH 16/16] ui fix --- .../view_logs/GuardrailViewer/GuardrailViewer.tsx | 14 -------------- 1 file changed, 14 deletions(-) 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 && ( - - )} {/* Classification details for llm-judge */} {entry.classification && (