fix: Handle circular references in spend tracking metadata JSON serialization (#12643)

* fix: Handle circular references in spend tracking metadata JSON serialization

- Fixes issue #12634 where circular references in metadata caused
  ValueError: Circular reference detected when logging spend data
- Adds _safe_json_dumps() function that detects and handles circular
  references by replacing them with placeholder strings
- Maintains full functionality for normal objects while preventing
  crashes from circular references
- Adds comprehensive tests for circular reference handling
- Critical fix for v1.74.3 stable release

* fix: Replace bare except clauses with specific Exception handling

- Fixes E722 linting errors in _safe_json_dumps function
- Maintains same error handling behavior while following best practices
- All tests continue to pass

* refactor: Use existing safe_dumps utility instead of custom implementation

- Replace custom _safe_json_dumps() with existing safe_dumps() from litellm_core_utils
- Remove duplicate code and leverage existing circular reference handling
- Update tests to use safe_dumps function
- Maintains same functionality while reducing code duplication
- All tests continue to pass
This commit is contained in:
Cole McIntosh
2025-07-16 07:24:41 -07:00
committed by GitHub
parent 4e9440ee85
commit 79e4d77bcf
2 changed files with 76 additions and 1 deletions
@@ -12,6 +12,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import REDACTED_BY_LITELM_STRING
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
from litellm.proxy.utils import PrismaClient, hash_token
from litellm.types.utils import (
@@ -305,7 +306,7 @@ def get_logging_payload( # noqa: PLR0915
model=kwargs.get("model", "") or "",
user=metadata.get("user_api_key_user_id", "") or "",
team_id=metadata.get("user_api_key_team_id", "") or "",
metadata=json.dumps(clean_metadata),
metadata=safe_dumps(clean_metadata),
cache_key=cache_key,
spend=kwargs.get("response_cost", 0),
total_tokens=usage.get("total_tokens", standard_logging_total_tokens),
@@ -17,6 +17,7 @@ from unittest.mock import MagicMock, patch
import litellm
from litellm.constants import REDACTED_BY_LITELM_STRING
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy.spend_tracking.spend_tracking_utils import (
_get_vector_store_request_for_spend_logs_payload,
_sanitize_request_body_for_spend_logs_payload,
@@ -175,3 +176,76 @@ def test_get_vector_store_request_for_spend_logs_payload_null_input(mock_should_
mock_should_store.return_value = False
result = _get_vector_store_request_for_spend_logs_payload(None)
assert result is None
def test_safe_dumps_handles_circular_references():
"""Test that safe_dumps can handle circular references without raising exceptions"""
# Create a circular reference
obj1 = {"name": "obj1"}
obj2 = {"name": "obj2", "ref": obj1}
obj1["ref"] = obj2 # This creates a circular reference
# This should not raise an exception
result = safe_dumps(obj1)
# Should be a valid JSON string
assert isinstance(result, str)
# Should contain placeholder for circular reference
assert "CircularReference Detected" in result
# Should be parseable as JSON
parsed = json.loads(result)
assert parsed["name"] == "obj1"
assert parsed["ref"]["name"] == "obj2"
def test_safe_dumps_normal_objects():
"""Test that safe_dumps works correctly with normal objects"""
normal_obj = {
"string": "test",
"number": 42,
"boolean": True,
"null": None,
"list": [1, 2, 3],
"nested": {"key": "value"}
}
result = safe_dumps(normal_obj)
# Should be a valid JSON string that can be parsed
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed == normal_obj
def test_safe_dumps_complex_metadata_like_object():
"""Test with a complex metadata-like object similar to what caused the issue"""
# Simulate a complex metadata object
metadata = {
"user_api_key": "test-key",
"model": "gpt-4",
"usage": {"total_tokens": 100},
"mcp_tool_call_metadata": {
"name": "test_tool",
"arguments": {"param": "value"}
}
}
# Add a potential circular reference
usage_detail = {"parent_metadata": metadata}
metadata["usage"]["detail"] = usage_detail
# This should not raise an exception
result = safe_dumps(metadata)
# Should be a valid JSON string
assert isinstance(result, str)
# Should be parseable as JSON
parsed = json.loads(result)
assert parsed["user_api_key"] == "test-key"
assert parsed["model"] == "gpt-4"