From cb5464421f751afd58df39a8dfe1719e20feecc2 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Sat, 30 Aug 2025 16:11:39 -0700 Subject: [PATCH] fix(braintrust_logging.py): filter metadata before logging avoid unserializable json --- litellm/integrations/braintrust_logging.py | 13 +-- litellm/litellm_core_utils/safe_json_dumps.py | 92 +++++++++++++++++++ litellm/proxy/_new_secret_config.yaml | 10 +- 3 files changed, 100 insertions(+), 15 deletions(-) diff --git a/litellm/integrations/braintrust_logging.py b/litellm/integrations/braintrust_logging.py index 531da933fc..5238bfe1db 100644 --- a/litellm/integrations/braintrust_logging.py +++ b/litellm/integrations/braintrust_logging.py @@ -12,6 +12,7 @@ from pydantic import BaseModel import litellm from litellm import verbose_logger from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.safe_json_dumps import filter_json_serializable from litellm.llms.custom_httpx.http_handler import ( HTTPHandler, get_async_httpx_client, @@ -45,9 +46,9 @@ class BraintrustLogger(CustomLogger): "Authorization": "Bearer " + self.api_key, "Content-Type": "application/json", } - self._project_id_cache: Dict[ - str, str - ] = {} # Cache mapping project names to IDs + self._project_id_cache: Dict[str, str] = ( + {} + ) # Cache mapping project names to IDs self.global_braintrust_http_handler = get_async_httpx_client( llm_provider=httpxSpecialProvider.LoggingCallback ) @@ -276,7 +277,7 @@ class BraintrustLogger(CustomLogger): # Allow metadata override for span name span_name = metadata.get("span_name", "Chat Completion") - + request_data = { "id": litellm_call_id, "input": prompt["messages"], @@ -431,12 +432,12 @@ class BraintrustLogger(CustomLogger): # Allow metadata override for span name span_name = metadata.get("span_name", "Chat Completion") - + request_data = { "id": litellm_call_id, "input": prompt["messages"], "output": output, - "metadata": clean_metadata, + "metadata": filter_json_serializable(clean_metadata), "tags": tags, "span_attributes": {"name": span_name, "type": "llm"}, } diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index 7ad0038ecb..b3b1d7fb3d 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -1,5 +1,6 @@ import json from typing import Any, Union + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH @@ -49,3 +50,94 @@ def safe_dumps(data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH) -> str: safe_data = _serialize(data, set(), 0) return json.dumps(safe_data, default=str) + + +def filter_json_serializable( + data: Any, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH +) -> Any: + """ + Recursively filter data to only include JSON serializable items. + Non-serializable items are completely skipped (not included in the result). + """ + + def _is_json_serializable(obj: Any) -> bool: + """Test if an object is JSON serializable.""" + try: + json.dumps(obj) + return True + except (TypeError, ValueError): + return False + + def _filter(obj: Any, seen: set, depth: int) -> Any: + # Check for maximum depth. + if depth > max_depth: + return None + + # Base-case: if it is a primitive, test if it's serializable + if isinstance(obj, (str, int, float, bool, type(None))): + return obj if _is_json_serializable(obj) else None + + # Check for circular reference. + if id(obj) in seen: + return None + + seen.add(id(obj)) + + try: + if isinstance(obj, dict): + result = {} + for k, v in obj.items(): + # Only include keys that are strings and values that are serializable + if isinstance(k, str): + filtered_value = _filter(v, seen, depth + 1) + # Only add the key-value pair if the value is serializable + if filtered_value is not None or v is None: + if _is_json_serializable(filtered_value): + result[k] = filtered_value + seen.remove(id(obj)) + return result + + elif isinstance(obj, list): + result = [] + for item in obj: + filtered_item = _filter(item, seen, depth + 1) + # Only include items that are serializable + if filtered_item is not None or item is None: + if _is_json_serializable(filtered_item): + result.append(filtered_item) + seen.remove(id(obj)) + return result + + elif isinstance(obj, tuple): + filtered_items = [] + for item in obj: + filtered_item = _filter(item, seen, depth + 1) + # Only include items that are serializable + if filtered_item is not None or item is None: + if _is_json_serializable(filtered_item): + filtered_items.append(filtered_item) + seen.remove(id(obj)) + return tuple(filtered_items) + + elif isinstance(obj, set): + filtered_items = [] + for item in obj: + filtered_item = _filter(item, seen, depth + 1) + # Only include items that are serializable + if filtered_item is not None or item is None: + if _is_json_serializable(filtered_item): + filtered_items.append(filtered_item) + seen.remove(id(obj)) + return sorted(filtered_items) + + else: + # Test if the object is directly serializable + seen.remove(id(obj)) + return obj if _is_json_serializable(obj) else None + + except Exception: + if id(obj) in seen: + seen.remove(id(obj)) + return None + + return _filter(data, set(), 0) diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index b3653c3143..f4dc1fca71 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -19,12 +19,4 @@ router_settings: litellm_settings: callbacks: ["otel"] - cache: true - cache_params: - type: redis - ttl: 600 - supported_call_types: ["acompletion", "completion"] - - model_group_settings: - forward_client_headers_to_llm_api: - - fake-openai-endpoint \ No newline at end of file + success_callback: ["braintrust"] \ No newline at end of file