mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge 2bbff986fd into b781d157d7
This commit is contained in:
commit
57c7523d54
4 changed files with 205 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,21 @@ 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. The result of an
|
||||
ensure_ascii=False call is always encodable as UTF-8: if the text holds
|
||||
something that is not (e.g. an unpaired surrogate such as "\\ud800"), the
|
||||
escaped ensure_ascii=True output is returned instead.
|
||||
"""
|
||||
structure: Final = safe_json_structure(data, max_depth, value_transform)
|
||||
text: Final = json.dumps(structure, default=str, ensure_ascii=ensure_ascii)
|
||||
if not ensure_ascii:
|
||||
try:
|
||||
text.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
return json.dumps(structure, default=str, ensure_ascii=True)
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -362,6 +362,155 @@ 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"]
|
||||
|
||||
@staticmethod
|
||||
def _raw_attributes(kwargs, response_obj):
|
||||
"""Run the attribute setter and return the raw {attribute: value} it wrote."""
|
||||
with patch(
|
||||
"litellm.integrations.arize._utils.safe_set_attribute"
|
||||
) as mock_safe_set_attribute:
|
||||
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
||||
MagicMock(), kwargs, response_obj
|
||||
)
|
||||
return {
|
||||
call.args[1]: call.args[2]
|
||||
for call in mock_safe_set_attribute.call_args_list
|
||||
}
|
||||
|
||||
def test_set_langfuse_specific_attributes_tool_calls_keep_non_ascii_unescaped(self):
|
||||
"""Tool call arguments with non-ASCII text are written as-is, not as \\uXXXX escapes."""
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionMessageToolCall,
|
||||
Choices,
|
||||
Function,
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
response_obj = ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
model="gpt-4o",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="tool_calls",
|
||||
message={
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
ChatCompletionMessageToolCall(
|
||||
function=Function(
|
||||
arguments='{"greeting": "Привет, мир", "city": "東京"}',
|
||||
name="say_hello",
|
||||
),
|
||||
id="call_123",
|
||||
type="function",
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
raw = self._raw_attributes({}, response_obj)
|
||||
|
||||
output_raw = raw[LangfuseSpanAttributes.OBSERVATION_OUTPUT.value]
|
||||
assert "Привет, мир" in output_raw and "東京" in output_raw
|
||||
assert "\\u" not in output_raw
|
||||
assert json.loads(output_raw) == [
|
||||
{
|
||||
"id": "chatcmpl-test",
|
||||
"name": "say_hello",
|
||||
"arguments": {"greeting": "Привет, мир", "city": "東京"},
|
||||
"call_id": "call_123",
|
||||
"type": "function_call",
|
||||
}
|
||||
]
|
||||
|
||||
def test_set_langfuse_specific_attributes_output_items_keep_non_ascii_unescaped(
|
||||
self,
|
||||
):
|
||||
"""Responses API output items with non-ASCII text are written as-is, not as \\uXXXX escapes."""
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
||||
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
||||
|
||||
response_obj = ResponsesAPIResponse(
|
||||
id="response-unicode",
|
||||
created_at=1625247600,
|
||||
output=[
|
||||
ResponseOutputMessage(
|
||||
id="msg-001",
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[
|
||||
ResponseOutputText(
|
||||
annotations=[],
|
||||
text="こんにちは, Ünïcödé",
|
||||
type="output_text",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
raw = self._raw_attributes({"call_type": "responses"}, response_obj)
|
||||
|
||||
output_raw = raw[LangfuseSpanAttributes.OBSERVATION_OUTPUT.value]
|
||||
assert "こんにちは, Ünïcödé" in output_raw
|
||||
assert "\\u" not in output_raw
|
||||
assert json.loads(output_raw) == [
|
||||
{"role": "assistant", "content": "こんにちは, Ünïcödé"}
|
||||
]
|
||||
|
||||
def test_set_langfuse_specific_attributes_unpaired_surrogate_stays_utf8_encodable(
|
||||
self,
|
||||
):
|
||||
"""An unpaired surrogate must not yield a string that cannot be UTF-8 encoded (OTLP export)."""
|
||||
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
||||
|
||||
kwargs = {"messages": [{"role": "user", "content": "Привет \ud800 мир"}]}
|
||||
|
||||
raw = self._raw_attributes(kwargs, None)
|
||||
|
||||
input_raw = raw[LangfuseSpanAttributes.OBSERVATION_INPUT.value]
|
||||
input_raw.encode("utf-8")
|
||||
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
|
||||
|
|
|
|||
|
|
@ -236,3 +236,38 @@ 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))
|
||||
|
||||
|
||||
def test_ensure_ascii_false_unpaired_surrogate_falls_back_to_escaped_json():
|
||||
data = {"t": "a\ud800b"}
|
||||
result = safe_dumps(data, ensure_ascii=False)
|
||||
result.encode("utf-8")
|
||||
assert result == '{"t": "a\\ud800b"}'
|
||||
assert json.loads(result) == data
|
||||
|
||||
|
||||
def test_ensure_ascii_false_surrogate_fallback_escapes_all_non_ascii():
|
||||
# The fallback is the previous escaped output as a whole, not a partial repair
|
||||
data = {"t": "Привет \ud800", "u": "こんにちは"}
|
||||
result = safe_dumps(data, ensure_ascii=False)
|
||||
result.encode("utf-8")
|
||||
assert result == safe_dumps(data)
|
||||
assert json.loads(result) == data
|
||||
|
||||
|
||||
def test_ensure_ascii_false_valid_non_ascii_not_escaped_alongside_ascii_only_default():
|
||||
result = safe_dumps({"t": "Ünïcödé"}, ensure_ascii=False)
|
||||
assert result == '{"t": "Ünïcödé"}'
|
||||
result.encode("utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue