diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index a96fac32c2a..4f74c49eac4 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -159,7 +159,7 @@ class LangfuseOtelLogger(OpenTelemetry): safe_set_attribute( span, LangfuseSpanAttributes.OBSERVATION_OUTPUT.value, - safe_dumps(transformed_tool_calls), + safe_dumps(transformed_tool_calls, ensure_ascii=False), ) else: output_data: Final = {} @@ -171,7 +171,7 @@ class LangfuseOtelLogger(OpenTelemetry): safe_set_attribute( span, LangfuseSpanAttributes.OBSERVATION_OUTPUT.value, - safe_dumps(output_data), + safe_dumps(output_data, ensure_ascii=False), ) output: Final = response_obj.get("output", []) @@ -215,7 +215,7 @@ class LangfuseOtelLogger(OpenTelemetry): safe_set_attribute( span, LangfuseSpanAttributes.OBSERVATION_OUTPUT.value, - safe_dumps(output_items_data), + safe_dumps(output_items_data, ensure_ascii=False), ) @staticmethod @@ -250,7 +250,7 @@ class LangfuseOtelLogger(OpenTelemetry): safe_set_attribute( span, LangfuseSpanAttributes.OBSERVATION_INPUT.value, - safe_dumps(messages), + safe_dumps(messages, ensure_ascii=False), ) LangfuseOtelLogger._set_observation_output(span=span, response_obj=response_obj) diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index 63242a580e7..5cd892b26cc 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -88,6 +88,15 @@ def safe_dumps( data: object, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH, value_transform: Callable[[str | None, str], str] | None = None, + ensure_ascii: bool = True, ) -> str: - """Serialize data to JSON text through safe_json_structure.""" - return json.dumps(safe_json_structure(data, max_depth, value_transform), default=str) + """Serialize data to JSON text through safe_json_structure. + + ensure_ascii=False keeps non-ASCII characters as-is instead of \\uXXXX + escapes; the parsed value is identical either way. + """ + return json.dumps( + safe_json_structure(data, max_depth, value_transform), + default=str, + ensure_ascii=ensure_ascii, + ) diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index 0a9ce55fe16..1ce7a85c94e 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -362,6 +362,42 @@ class TestLangfuseOtelIntegration: actual == expect_output ), "Mismatch in observation input/output OTEL attributes." + def test_set_langfuse_specific_attributes_keeps_non_ascii_unescaped(self): + """Non-ASCII input/output is written as-is, not as \\uXXXX escapes.""" + from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes + from litellm.types.utils import Choices, ModelResponse + + response_obj = ModelResponse( + id="chatcmpl-test", + model="gpt-4o", + choices=[ + Choices( + finish_reason="stop", + message={"role": "assistant", "content": "В Токио солнечно. 晴れ"}, + ) + ], + ) + kwargs = {"messages": [{"role": "user", "content": "Какая погода в Токио?"}]} + + with patch( + "litellm.integrations.arize._utils.safe_set_attribute" + ) as mock_safe_set_attribute: + LangfuseOtelLogger._set_langfuse_specific_attributes( + MagicMock(), kwargs, response_obj + ) + + raw = { + call.args[1]: call.args[2] + for call in mock_safe_set_attribute.call_args_list + } + + input_raw = raw[LangfuseSpanAttributes.OBSERVATION_INPUT.value] + output_raw = raw[LangfuseSpanAttributes.OBSERVATION_OUTPUT.value] + assert "Какая погода в Токио?" in input_raw + assert "В Токио солнечно. 晴れ" in output_raw + assert "\\u" not in input_raw and "\\u" not in output_raw + assert json.loads(input_raw) == kwargs["messages"] + def test_set_langfuse_specific_attributes_with_tool_calls(self): """Test that _set_langfuse_specific_attributes correctly sets observation.output with tool calls in Langfuse format.""" from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes diff --git a/tests/unit/litellm_core_utils/test_safe_json_dumps.py b/tests/unit/litellm_core_utils/test_safe_json_dumps.py index 1f2664f33cb..8f9233f2626 100644 --- a/tests/unit/litellm_core_utils/test_safe_json_dumps.py +++ b/tests/unit/litellm_core_utils/test_safe_json_dumps.py @@ -236,3 +236,15 @@ def test_safe_json_structure_keeps_tuples_and_drops_non_string_keys(): assert structure == {"models": ("A", "B"), "tags": ["X", "Y"], "nested": {"deep": ("C",)}} assert type(structure["models"]) is tuple assert json.loads(safe_dumps(data)) == {"models": ["a", "b"], "tags": ["x", "y"], "nested": {"deep": ["c"]}} + + +def test_ensure_ascii_default_escapes_non_ascii(): + # Default stays json.dumps-compatible: non-ASCII is escaped + assert safe_dumps({"text": "Привет"}) == '{"text": "\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442"}' + + +def test_ensure_ascii_false_keeps_non_ascii(): + data = {"text": "Привет, 世界", "nested": ["ü", {"k": "é"}]} + result = safe_dumps(data, ensure_ascii=False) + assert result == '{"text": "Привет, 世界", "nested": ["ü", {"k": "é"}]}' + assert json.loads(result) == json.loads(safe_dumps(data))