diff --git a/langfuse/_utils/serializer.py b/langfuse/_utils/serializer.py index 135d1f625..7f308b9b7 100644 --- a/langfuse/_utils/serializer.py +++ b/langfuse/_utils/serializer.py @@ -136,7 +136,10 @@ def _default_inner(self, obj: Any) -> Any: if isinstance(raw := getattr(obj, "raw", None), BaseModel): raw.model_rebuild() - return obj.model_dump() + # model_dump() returns plain dicts/lists/scalars, so route it back + # through default() to keep nested non-finite floats (NaN/Inf) and + # other non-JSON-safe values sanitized like the dict branch does. + return self.default(obj.model_dump()) # if langchain is not available, the Serializable type is NoneType if Serializable is not type(None) and isinstance(obj, Serializable): # type: ignore diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py index 6605485de..65bf8343d 100644 --- a/tests/unit/test_serializer.py +++ b/tests/unit/test_serializer.py @@ -71,6 +71,26 @@ def test_pydantic_model(): assert json.loads(serializer.encode(model)) == {"field": "test"} +def test_pydantic_model_with_non_finite_float_serializes_to_valid_json(): + # model_dump() returns a plain dict; the previous BaseModel branch returned + # it directly, so nested NaN/Inf floats bypassed the sanitizer and produced + # bare NaN/Infinity tokens that strict JSON parsers reject. See #16048. + class Scores(BaseModel): + confidence: float + + serializer = EventSerializer() + encoded = serializer.encode(Scores(confidence=float("nan"))) + + # Strict parse must succeed and the NaN token must be a quoted string. + assert json.loads( + encoded, parse_constant=lambda t: (_ for _ in ()).throw(ValueError(t)) + ) == {"confidence": "NaN"} + assert ( + serializer.encode(Scores(confidence=float("inf"))) + == '{"confidence": "Infinity"}' + ) + + def test_langfuse_media_reference_serializes_to_reference_string(): # Resolved references must round-trip back to their original reference string # rather than falling through to asdict() and emitting an opaque dict.