Skip to content

Commit 572ea21

Browse files
Fix: LLM telemetry logging (#1028)
Co-authored-by: openhands <[email protected]>
1 parent aa954ce commit 572ea21

File tree

2 files changed

+54
-7
lines changed

2 files changed

+54
-7
lines changed

openhands-sdk/openhands/sdk/llm/utils/telemetry.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,17 @@ def log_llm_call(
303303

304304
def _safe_json(obj: Any) -> Any:
305305
# Centralized serializer for telemetry logs.
306-
# Today, responses are Pydantic ModelResponse or ResponsesAPIResponse; rely on it.
306+
# Prefer robust serialization for Pydantic models first to avoid cycles.
307+
# Typed LiteLLM responses
307308
if isinstance(obj, ModelResponse) or isinstance(obj, ResponsesAPIResponse):
308-
# Use mode='json' to ensure proper serialization of nested Pydantic models
309-
# and avoid PydanticSerializationUnexpectedValue warnings.
310-
# exclude_none=True reduces payload size by omitting None fields.
311309
return obj.model_dump(mode="json", exclude_none=True)
312310

313-
# Fallbacks for non-Pydantic objects used elsewhere in the log payload
311+
# Any Pydantic BaseModel (e.g., ToolDefinition, ChatCompletionToolParam, etc.)
312+
if isinstance(obj, BaseModel):
313+
# Use Pydantic's serializer which respects field exclusions (e.g., executors)
314+
return obj.model_dump(mode="json", exclude_none=True)
315+
316+
# Fallbacks for other non-serializable objects used elsewhere in the log payload
314317
try:
315318
return obj.__dict__
316319
except Exception:

tests/sdk/llm/test_llm_telemetry.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import pytest
99
from litellm.types.utils import ModelResponse, Usage
10-
from pydantic import ValidationError
10+
from pydantic import BaseModel, Field, ValidationError
1111

1212
from openhands.sdk.llm.utils.metrics import Metrics
1313
from openhands.sdk.llm.utils.telemetry import Telemetry, _safe_json
@@ -416,7 +416,51 @@ def test_log_completion_with_raw_response(self, mock_metrics, mock_response):
416416

417417
assert "raw_response" in data
418418

419-
def test_log_completion_model_name_sanitization(self, mock_metrics, mock_response):
419+
def test_log_completion_with_pydantic_objects_in_context(
420+
self, mock_metrics, mock_response
421+
):
422+
"""
423+
Ensure logging works when log_ctx contains Pydantic models with
424+
excluded fields. This simulates the remote-run case where tools
425+
(Pydantic models with excluded runtime-only fields like executors)
426+
are included in the log context. Using Pydantic's model_dump should
427+
avoid circular references.
428+
"""
429+
430+
class SelfReferencingModel(BaseModel):
431+
name: str
432+
# Simulate an executor-like field that should not be serialized
433+
executor: object | None = Field(default=None, exclude=True)
434+
435+
with tempfile.TemporaryDirectory() as temp_dir:
436+
telemetry = Telemetry(
437+
model_name="gpt-4o",
438+
log_enabled=True,
439+
log_dir=temp_dir,
440+
metrics=mock_metrics,
441+
)
442+
443+
# Create a self-referencing instance via an excluded field
444+
m = SelfReferencingModel(name="tool-like")
445+
m.executor = m # would create a cycle if serialized via __dict__
446+
447+
telemetry.on_request({"tools": [m]})
448+
449+
with warnings.catch_warnings(record=True) as w:
450+
warnings.simplefilter("always")
451+
telemetry.log_llm_call(mock_response, 0.25)
452+
453+
# Should not raise circular reference warnings
454+
msgs = [str(x.message) for x in w]
455+
assert not any("Circular reference detected" in s for s in msgs)
456+
457+
# Log file should be created and readable JSON
458+
files = os.listdir(temp_dir)
459+
assert len(files) == 1
460+
with open(os.path.join(temp_dir, files[0])) as f:
461+
data = json.loads(f.read())
462+
assert "response" in data
463+
420464
"""Test that model names with slashes are sanitized in filenames."""
421465
with tempfile.TemporaryDirectory() as temp_dir:
422466
telemetry = Telemetry(

0 commit comments

Comments
 (0)