From 79e4d77bcfc6051d776f18aa4bb02a80340e6dce Mon Sep 17 00:00:00 2001 From: Cole McIntosh <82463175+colesmcintosh@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:24:41 -0600 Subject: [PATCH] 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 --- .../spend_tracking/spend_tracking_utils.py | 3 +- .../test_spend_tracking_utils.py | 74 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 9f2c7772e8..ad5cad29e6 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -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), 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 d466f76e88..26f41b76d3 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 @@ -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"