Skip to content

Commit fff1554

Browse files
authored
(UI + Proxy) Cache Health Check Page - Cleanup/Improvements (BerriAI#8665)
* fixes for redis cache ping serialization * fix cache ping check * fix cache health check ui * working error details on ui * ui expand / collapse error * move cache health check to diff file * fix displaying error from cache health check * ui allow copying errors * ui cache health fixes * show redis details * clean up cache health page * ui polish fixes * fix error handling on cache health page * fix redis_cache_params on cache ping response * error handling * cache health ping response * fx error response from cache ping * parsedLitellmParams * fix cache health check * fix cache health page * cache safely handle json dumps issues * test caching routes * test_primitive_types * fix caching routes * litellm_mapped_tests * fix pytest-mock * fix _serialize * fix linting on safe dumps * test_default_max_depth * pip install "pytest-mock==3.12.0" * litellm_mapped_tests_coverage * add readme on new litellm test dir
1 parent 39db314 commit fff1554

File tree

16 files changed

+807
-58
lines changed

16 files changed

+807
-58
lines changed

.circleci/config.yml

+51
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,49 @@ jobs:
678678
paths:
679679
- llm_translation_coverage.xml
680680
- llm_translation_coverage
681+
litellm_mapped_tests:
682+
docker:
683+
- image: cimg/python:3.11
684+
auth:
685+
username: ${DOCKERHUB_USERNAME}
686+
password: ${DOCKERHUB_PASSWORD}
687+
working_directory: ~/project
688+
689+
steps:
690+
- checkout
691+
- run:
692+
name: Install Dependencies
693+
command: |
694+
python -m pip install --upgrade pip
695+
python -m pip install -r requirements.txt
696+
pip install "pytest-mock==3.12.0"
697+
pip install "pytest==7.3.1"
698+
pip install "pytest-retry==1.6.3"
699+
pip install "pytest-cov==5.0.0"
700+
pip install "pytest-asyncio==0.21.1"
701+
pip install "respx==0.21.1"
702+
# Run pytest and generate JUnit XML report
703+
- run:
704+
name: Run tests
705+
command: |
706+
pwd
707+
ls
708+
python -m pytest -vv tests/litellm --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5
709+
no_output_timeout: 120m
710+
- run:
711+
name: Rename the coverage files
712+
command: |
713+
mv coverage.xml litellm_mapped_tests_coverage.xml
714+
mv .coverage litellm_mapped_tests_coverage
715+
716+
# Store test results
717+
- store_test_results:
718+
path: test-results
719+
- persist_to_workspace:
720+
root: .
721+
paths:
722+
- litellm_mapped_tests_coverage.xml
723+
- litellm_mapped_tests_coverage
681724
batches_testing:
682725
docker:
683726
- image: cimg/python:3.11
@@ -2316,6 +2359,12 @@ workflows:
23162359
only:
23172360
- main
23182361
- /litellm_.*/
2362+
- litellm_mapped_tests:
2363+
filters:
2364+
branches:
2365+
only:
2366+
- main
2367+
- /litellm_.*/
23192368
- batches_testing:
23202369
filters:
23212370
branches:
@@ -2349,6 +2398,7 @@ workflows:
23492398
- upload-coverage:
23502399
requires:
23512400
- llm_translation_testing
2401+
- litellm_mapped_tests
23522402
- batches_testing
23532403
- litellm_utils_testing
23542404
- pass_through_unit_testing
@@ -2406,6 +2456,7 @@ workflows:
24062456
- load_testing
24072457
- test_bad_database_url
24082458
- llm_translation_testing
2459+
- litellm_mapped_tests
24092460
- batches_testing
24102461
- litellm_utils_testing
24112462
- pass_through_unit_testing

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
rev: 7.0.0 # The version of flake8 to use
2323
hooks:
2424
- id: flake8
25-
exclude: ^litellm/tests/|^litellm/proxy/tests/
25+
exclude: ^litellm/tests/|^litellm/proxy/tests/|^litellm/tests/litellm/|^tests/litellm/
2626
additional_dependencies: [flake8-print]
2727
files: litellm/.*\.py
2828
# - id: flake8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import json
2+
from typing import Any, Union
3+
4+
5+
def safe_dumps(data: Any, max_depth: int = 10) -> str:
6+
"""
7+
Recursively serialize data while detecting circular references.
8+
If a circular reference is detected then a marker string is returned.
9+
"""
10+
11+
def _serialize(obj: Any, seen: set, depth: int) -> Any:
12+
# Check for maximum depth.
13+
if depth > max_depth:
14+
return "MaxDepthExceeded"
15+
# Base-case: if it is a primitive, simply return it.
16+
if isinstance(obj, (str, int, float, bool, type(None))):
17+
return obj
18+
# Check for circular reference.
19+
if id(obj) in seen:
20+
return "CircularReference Detected"
21+
seen.add(id(obj))
22+
result: Union[dict, list, tuple, set, str]
23+
if isinstance(obj, dict):
24+
result = {}
25+
for k, v in obj.items():
26+
result[k] = _serialize(v, seen, depth + 1)
27+
seen.remove(id(obj))
28+
return result
29+
elif isinstance(obj, list):
30+
result = [_serialize(item, seen, depth + 1) for item in obj]
31+
seen.remove(id(obj))
32+
return result
33+
elif isinstance(obj, tuple):
34+
result = tuple(_serialize(item, seen, depth + 1) for item in obj)
35+
seen.remove(id(obj))
36+
return result
37+
elif isinstance(obj, set):
38+
result = sorted([_serialize(item, seen, depth + 1) for item in obj])
39+
seen.remove(id(obj))
40+
return result
41+
else:
42+
# Fall back to string conversion for non-serializable objects.
43+
try:
44+
return str(obj)
45+
except Exception:
46+
return "Unserializable Object"
47+
48+
safe_data = _serialize(data, set(), 0)
49+
return json.dumps(safe_data, default=str)

litellm/proxy/_experimental/out/onboarding.html

-1
This file was deleted.

litellm/proxy/_types.py

+1
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,7 @@ class ProxyErrorTypes(str, enum.Enum):
20002000
bad_request_error = "bad_request_error"
20012001
not_found_error = "not_found_error"
20022002
validation_error = "bad_request_error"
2003+
cache_ping_error = "cache_ping_error"
20032004

20042005

20052006
DB_CONNECTION_ERROR_TYPES = (httpx.ConnectError, httpx.ReadError, httpx.ReadTimeout)

litellm/proxy/caching_routes.py

+38-33
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import litellm
66
from litellm._logging import verbose_proxy_logger
77
from litellm.caching.caching import RedisCache
8+
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
89
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
10+
from litellm.proxy._types import ProxyErrorTypes, ProxyException
911
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
12+
from litellm.types.caching import CachePingResponse
1013

1114
masker = SensitiveDataMasker()
1215

@@ -18,6 +21,7 @@
1821

1922
@router.get(
2023
"/ping",
24+
response_model=CachePingResponse,
2125
dependencies=[Depends(user_api_key_auth)],
2226
)
2327
async def cache_ping():
@@ -27,27 +31,17 @@ async def cache_ping():
2731
litellm_cache_params: Dict[str, Any] = {}
2832
specific_cache_params: Dict[str, Any] = {}
2933
try:
30-
3134
if litellm.cache is None:
3235
raise HTTPException(
3336
status_code=503, detail="Cache not initialized. litellm.cache is None"
3437
)
35-
litellm_cache_params = {}
36-
specific_cache_params = {}
37-
for k, v in vars(litellm.cache).items():
38-
try:
39-
if k == "cache":
40-
continue
41-
litellm_cache_params[k] = v
42-
except Exception:
43-
litellm_cache_params[k] = "<unable to copy or convert>"
44-
for k, v in vars(litellm.cache.cache).items():
45-
try:
46-
specific_cache_params[k] = v
47-
except Exception:
48-
specific_cache_params[k] = "<unable to copy or convert>"
49-
litellm_cache_params = masker.mask_dict(litellm_cache_params)
50-
specific_cache_params = masker.mask_dict(specific_cache_params)
38+
litellm_cache_params = masker.mask_dict(vars(litellm.cache))
39+
# remove field that might reference itself
40+
litellm_cache_params.pop("cache", None)
41+
specific_cache_params = (
42+
masker.mask_dict(vars(litellm.cache.cache)) if litellm.cache else {}
43+
)
44+
5145
if litellm.cache.type == "redis":
5246
# ping the redis cache
5347
ping_response = await litellm.cache.ping()
@@ -63,24 +57,35 @@ async def cache_ping():
6357
)
6458
verbose_proxy_logger.debug("/cache/ping: done with set_cache()")
6559

66-
return {
67-
"status": "healthy",
68-
"cache_type": litellm.cache.type,
69-
"ping_response": True,
70-
"set_cache_response": "success",
71-
"litellm_cache_params": litellm_cache_params,
72-
"redis_cache_params": specific_cache_params,
73-
}
60+
return CachePingResponse(
61+
status="healthy",
62+
cache_type=str(litellm.cache.type),
63+
ping_response=True,
64+
set_cache_response="success",
65+
litellm_cache_params=safe_dumps(litellm_cache_params),
66+
redis_cache_params=safe_dumps(specific_cache_params),
67+
)
7468
else:
75-
return {
76-
"status": "healthy",
77-
"cache_type": litellm.cache.type,
78-
"litellm_cache_params": litellm_cache_params,
79-
}
69+
return CachePingResponse(
70+
status="healthy",
71+
cache_type=str(litellm.cache.type),
72+
litellm_cache_params=safe_dumps(litellm_cache_params),
73+
)
8074
except Exception as e:
81-
raise HTTPException(
82-
status_code=503,
83-
detail=f"Service Unhealthy ({str(e)}).Cache parameters: {litellm_cache_params}.specific_cache_params: {specific_cache_params}",
75+
import traceback
76+
77+
traceback.print_exc()
78+
error_message = {
79+
"message": f"Service Unhealthy ({str(e)})",
80+
"litellm_cache_params": safe_dumps(litellm_cache_params),
81+
"redis_cache_params": safe_dumps(specific_cache_params),
82+
"traceback": traceback.format_exc(),
83+
}
84+
raise ProxyException(
85+
message=safe_dumps(error_message),
86+
type=ProxyErrorTypes.cache_ping_error,
87+
param="cache_ping",
88+
code=503,
8489
)
8590

8691

litellm/proxy/proxy_config.yaml

+4-2
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@ general_settings:
1111

1212

1313
litellm_settings:
14-
callbacks: ["gcs_bucket"]
15-
14+
cache: true
15+
cache_params:
16+
type: redis
17+
ttl: 600

litellm/types/caching.py

+11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from enum import Enum
22
from typing import Literal, Optional, TypedDict
33

4+
from pydantic import BaseModel
5+
46

57
class LiteLLMCacheType(str, Enum):
68
LOCAL = "local"
@@ -51,3 +53,12 @@ class RedisPipelineIncrementOperation(TypedDict):
5153
"no-store": Optional[bool],
5254
},
5355
)
56+
57+
58+
class CachePingResponse(BaseModel):
59+
status: str
60+
cache_type: str
61+
ping_response: Optional[bool] = None
62+
set_cache_response: Optional[str] = None
63+
litellm_cache_params: Optional[str] = None
64+
redis_cache_params: Optional[str] = None

tests/code_coverage_tests/recursive_detector.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"strip_field",
1515
"_transform_prompt",
1616
"mask_dict",
17+
"_serialize", # we now set a max depth for this
1718
]
1819

1920

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import json
2+
import os
3+
import sys
4+
5+
import pytest
6+
7+
sys.path.insert(
8+
0, os.path.abspath("../../..")
9+
) # Adds the parent directory to the system path
10+
11+
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
12+
13+
14+
def test_primitive_types():
15+
# Test basic primitive types
16+
assert safe_dumps("test") == '"test"'
17+
assert safe_dumps(123) == "123"
18+
assert safe_dumps(3.14) == "3.14"
19+
assert safe_dumps(True) == "true"
20+
assert safe_dumps(None) == "null"
21+
22+
23+
def test_nested_structures():
24+
# Test nested dictionaries and lists
25+
data = {"name": "test", "numbers": [1, 2, 3], "nested": {"a": 1, "b": 2}}
26+
result = json.loads(safe_dumps(data))
27+
assert result["name"] == "test"
28+
assert result["numbers"] == [1, 2, 3]
29+
assert result["nested"] == {"a": 1, "b": 2}
30+
31+
32+
def test_circular_reference():
33+
# Test circular reference detection
34+
d = {}
35+
d["self"] = d
36+
result = json.loads(safe_dumps(d))
37+
assert result["self"] == "CircularReference Detected"
38+
39+
40+
def test_max_depth():
41+
# Test maximum depth handling
42+
deep_dict = {}
43+
current = deep_dict
44+
for i in range(15):
45+
current["deeper"] = {}
46+
current = current["deeper"]
47+
48+
result = json.loads(safe_dumps(deep_dict, max_depth=5))
49+
assert "MaxDepthExceeded" in str(result)
50+
51+
52+
def test_default_max_depth():
53+
# Test that default max depth still prevents infinite recursion
54+
deep_dict = {}
55+
current = deep_dict
56+
for i in range(1000): # Create a very deep dictionary
57+
current["deeper"] = {}
58+
current = current["deeper"]
59+
60+
result = json.loads(safe_dumps(deep_dict)) # No max_depth parameter provided
61+
assert "MaxDepthExceeded" in str(result)
62+
63+
64+
def test_complex_types():
65+
# Test handling of sets and tuples
66+
data = {"set": {1, 2, 3}, "tuple": (4, 5, 6)}
67+
result = json.loads(safe_dumps(data))
68+
assert result["set"] == [1, 2, 3] # Sets are converted to sorted lists
69+
assert result["tuple"] == [4, 5, 6] # Tuples are converted to lists
70+
71+
72+
def test_unserializable_object():
73+
# Test handling of unserializable objects
74+
class TestClass:
75+
def __str__(self):
76+
raise Exception("Cannot convert to string")
77+
78+
obj = TestClass()
79+
result = json.loads(safe_dumps(obj))
80+
assert result == "Unserializable Object"

0 commit comments

Comments
 (0)