Merge branch 'main' into litellm_compliance_checker_ui

This commit is contained in:
Ishaan Jaffer 2026-02-17 18:03:45 -08:00
commit 71a5f00e5d
21 changed files with 363 additions and 90 deletions

View file

@ -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

View file

@ -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": []
}

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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=(

View file

@ -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": []
}

View file

@ -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,
):

View file

@ -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)

View file

@ -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():

View file

@ -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()

View file

@ -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')

View file

@ -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()

View file

@ -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},
)

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -201,6 +201,11 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
{guardrail.definition.litellm_params.patterns.length} pattern(s)
</Tag>
)}
{guardrail.definition?.litellm_params?.categories && (
<Tag className="text-xs" color="orange">
{guardrail.definition.litellm_params.categories.length} category/categories
</Tag>
)}
</div>
</div>
</div>

View file

@ -507,20 +507,6 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
{/* Expanded details */}
{expanded && (
<div className="border-t border-gray-100 px-4 py-3">
{/* View Policy Configuration link */}
{entry.policy_template && (
<div className="flex justify-end mb-3">
<a
href="/ui/policies"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
>
View Policy Configuration
<ExternalLinkIcon />
</a>
</div>
)}
{/* Classification details for llm-judge */}
{entry.classification && (

View file

@ -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 && (
<Descriptions.Item label="Guardrail">
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} policyNames={guardrailPolicyNames} />
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
</Descriptions.Item>
)}
</Descriptions>
@ -225,7 +222,7 @@ function TagsSection({ tags }: { tags: Record<string, any> }) {
);
}
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
</Tag>
)}
{policyNames.map((name) => (
<Tag key={name} color="purple">{name}</Tag>
))}
</Space>
);
}
@ -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 (
<div style={{ textAlign: "left", marginBottom: 12 }}>
<div
onClick={handleClick}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 12px",
borderRadius: 16,
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
backgroundColor: allPassed ? "#f0fdf4" : "#fef2f2",
color: allPassed ? "#15803d" : "#b91c1c",
border: `1px solid ${allPassed ? "#bbf7d0" : "#fecaca"}`,
}}
>
{allPassed ? "\u2713" : "\u2717"} {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""} evaluated
<span style={{ fontSize: 11, opacity: 0.7 }}>{"\u2193"}</span>
</div>
</div>
);
}
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">

View file

@ -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({
</div>
<div className="flex-1 overflow-y-auto">
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
<div className="px-3 pt-2">
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
</div>
)}
{isSessionMode ? (
<div className="py-1">
{/* Child events — vertical tree line with horizontal connectors */}