mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge branch 'main' into litellm_compliance_checker_ui
This commit is contained in:
commit
71a5f00e5d
21 changed files with 363 additions and 90 deletions
14
.github/workflows/test-litellm-matrix.yml
vendored
14
.github/workflows/test-litellm-matrix.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue