mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
* feat(ui): redesign agent trace run view with chat-style detail pane
Tree with connector lines, typed icon tiles and provider logos, hover
cards with timing, and Input/Output sections rendered as message cards.
* fix(tracing): show text for block-list message content and split normalizers per convention
OpenAI responses-style content (reasoning + text blocks) rendered as raw
JSON in the trace view. Keep the text blocks and drop opaque reasoning.
Move each convention into litellm/tracing/normalizers with an ordered
registry so new frameworks plug in without touching OTLP decoding.
* fix(tracing): keep long message histories as valid JSON and parse function_call blocks
* feat(ui): open agent traces in a resizable side drawer with a devtool-style tree
Clicking a run opens it in a drawer over the list instead of a full page.
j/k and the header arrows switch runs, Esc closes. The tree gets dashed
connectors, per-span waterfall bars, mono tool names and real provider
logos. AI messages with reasoning/function_call blocks render as text.
* fix(trace-ui): address review: valid JSON trimming, drawer keys, reduced motion, narrow screens
* feat(tracing): serve span content in a standard LiteLLM UI format
GET /v1/traces/{trace_id}/spans/{span_id} now also returns input_ui and
output_ui, a tagged union of messages, fields or text built server side by
litellm/tracing/ui_format.py. The trace UI renders from those fields and only
falls back to client-side parsing when talking to an older proxy. The raw
input and output strings are unchanged, and so is storage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(tracing): fall back to an elision marker when shortened messages still exceed the size limit
* fix(tracing): keep both messages when tool_calls are oversized and keep failed-tool styling
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
4.1 KiB
Python
119 lines
4.1 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from litellm.tracing.ui_format import to_ui_content
|
|
|
|
|
|
def test_message_array_maps_roles_and_keeps_order():
|
|
raw = json.dumps(
|
|
[
|
|
{"role": "system", "content": "be brief"},
|
|
{"role": "human", "content": "hi"},
|
|
{"role": "tool", "name": "lookup", "content": "42"},
|
|
{"role": "narrator", "content": "aside"},
|
|
]
|
|
)
|
|
assert to_ui_content(raw) == {
|
|
"kind": "messages",
|
|
"messages": (
|
|
{"role": "system", "content": "be brief"},
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "tool", "content": "42", "name": "lookup"},
|
|
{"role": "user", "content": "aside"},
|
|
),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"call",
|
|
[
|
|
{"name": "get_plan", "args": {"customer_id": "c-1"}},
|
|
{"name": "get_plan", "arguments": '{"customer_id": "c-1"}'},
|
|
{"id": "call_1", "type": "function", "function": {"name": "get_plan", "arguments": '{"customer_id": "c-1"}'}},
|
|
],
|
|
)
|
|
def test_single_assistant_message_with_tool_call(call: dict[str, object]):
|
|
content = to_ui_content(json.dumps({"role": "assistant", "content": None, "tool_calls": [call]}))
|
|
assert content["kind"] == "messages"
|
|
(message,) = content["messages"]
|
|
assert message["role"] == "assistant"
|
|
assert message["content"] == ""
|
|
calls = message.get("tool_calls")
|
|
assert calls is not None and len(calls) == 1
|
|
assert calls[0]["name"] == "get_plan"
|
|
assert json.loads(calls[0]["arguments"]) == {"customer_id": "c-1"}
|
|
|
|
|
|
def test_unknown_role_with_tool_calls_is_assistant():
|
|
content = to_ui_content(json.dumps({"role": "model", "content": "", "tool_calls": [{"name": "f", "args": None}]}))
|
|
assert content == {
|
|
"kind": "messages",
|
|
"messages": ({"role": "assistant", "content": "", "tool_calls": ({"name": "f", "arguments": "{}"},)},),
|
|
}
|
|
|
|
|
|
def test_block_list_content_keeps_text_and_drops_reasoning():
|
|
raw = json.dumps(
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "reasoning", "encrypted_content": "opaque"},
|
|
{"type": "thinking", "thinking": "hidden chain"},
|
|
{"type": "text", "text": "first"},
|
|
{"type": "text", "text": "second"},
|
|
],
|
|
}
|
|
)
|
|
assert to_ui_content(raw) == {
|
|
"kind": "messages",
|
|
"messages": ({"role": "assistant", "content": "first\n\nsecond"},),
|
|
}
|
|
|
|
|
|
def test_langchain_kwargs_shape():
|
|
raw = json.dumps(
|
|
[
|
|
{"lc": 1, "type": "constructor", "kwargs": {"type": "human", "content": "question"}},
|
|
{"kwargs": {"type": "ai", "content": "", "tool_calls": [{"name": "search", "args": {"q": "x"}}]}},
|
|
]
|
|
)
|
|
content = to_ui_content(raw)
|
|
assert content["kind"] == "messages"
|
|
human, ai = content["messages"]
|
|
assert human == {"role": "user", "content": "question"}
|
|
assert ai["role"] == "assistant"
|
|
assert ai.get("tool_calls") == ({"name": "search", "arguments": '{"q": "x"}'},)
|
|
|
|
|
|
def test_plain_object_becomes_fields_in_key_order():
|
|
raw = json.dumps({"zeta": "plain", "alpha": {"nested": [1, 2]}, "count": 3, "missing": None})
|
|
assert to_ui_content(raw) == {
|
|
"kind": "fields",
|
|
"fields": (
|
|
{"key": "zeta", "value": "plain"},
|
|
{"key": "alpha", "value": '{"nested": [1, 2]}'},
|
|
{"key": "count", "value": "3"},
|
|
{"key": "missing", "value": "null"},
|
|
),
|
|
}
|
|
|
|
|
|
def test_object_with_role_but_no_content_is_fields():
|
|
assert to_ui_content('{"role": "admin", "user_id": "u1"}')["kind"] == "fields"
|
|
|
|
|
|
def test_json_string_becomes_its_text():
|
|
assert to_ui_content(json.dumps('line one\n"quoted"')) == {"kind": "text", "text": 'line one\n"quoted"'}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
['[{"role": "user", "content": "cut of', "plain words", "42", "[1, 2]", "[]"],
|
|
)
|
|
def test_non_message_non_object_payloads_keep_the_raw_string(raw: str):
|
|
assert to_ui_content(raw) == {"kind": "text", "text": raw}
|
|
|
|
|
|
def test_empty_is_empty_text():
|
|
assert to_ui_content("") == {"kind": "text", "text": ""}
|