From a01bf766c2043d078c338a2f088d7908d9120fc6 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 16 Oct 2026 00:12:26 +0000 Subject: [PATCH 1/3] feat(tracing): forward agent filter to lens trace list Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/tracing_endpoints.py | 1 + litellm/tracing/generated/requests.py | 1 + litellm/tracing/receiver.py | 8 ++++-- litellm/tracing/remote.py | 3 ++- litellm/tracing/storage.py | 3 ++- .../schemas/requests/TraceListRequest.json | 8 ++++++ scripts/lens_assets/source.json | 2 +- tests/unit/proxy/test_tracing_endpoints.py | 27 ++++++++++++++++--- tests/unit/tracing/test_remote.py | 26 ++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 1 + 10 files changed, 71 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/tracing_endpoints.py b/litellm/proxy/tracing_endpoints.py index 95894e9c3b1..3a62aa87fdd 100644 --- a/litellm/proxy/tracing_endpoints.py +++ b/litellm/proxy/tracing_endpoints.py @@ -185,6 +185,7 @@ async def list_agent_traces( start_ms=request.start_ms if request.start_ms is not None else now_ms - MS_PER_DAY, end_ms=request.end_ms if request.end_ms is not None else now_ms, cursor=request.cursor, + agent=request.agent, ) except (TraceChanged, ValueError, OverflowError, RuntimeError) as error: raise read_failure(error) from error diff --git a/litellm/tracing/generated/requests.py b/litellm/tracing/generated/requests.py index f0c9cbc2d4e..49d724b63c5 100644 --- a/litellm/tracing/generated/requests.py +++ b/litellm/tracing/generated/requests.py @@ -35,6 +35,7 @@ class TraceListRequest(LiteLLMBaseModel): cursor: str | None = Field(None, max_length=512) end_ms: int | None = Field(None, description="Window end, unix ms. Default: now") + agent: str | None = Field(None, max_length=256) start_ms: int | None = Field(None, description="Window start, unix ms. Default: 24h ago") diff --git a/litellm/tracing/receiver.py b/litellm/tracing/receiver.py index 80cb9d7ba45..57e6e176de8 100644 --- a/litellm/tracing/receiver.py +++ b/litellm/tracing/receiver.py @@ -12,8 +12,12 @@ class TraceReceiver: def __init__(self, storage: LensTraceStorage) -> None: self.storage: Final = storage - async def list_traces(self, scope: TraceScope, start_ms: int, end_ms: int, cursor: str | None = None) -> TracePage: - return await self.storage.list_traces(scope, start_ms, end_ms, cursor, AGENT_TRACING_LIST_PAGE_SIZE) + async def list_traces( + self, scope: TraceScope, start_ms: int, end_ms: int, cursor: str | None = None, agent: str | None = None + ) -> TracePage: + return await self.storage.list_traces( + scope, start_ms, end_ms, cursor, AGENT_TRACING_LIST_PAGE_SIZE, agent + ) async def list_agents(self, scope: TraceScope, start_ms: int, end_ms: int) -> TraceAgentList: rows: Final = await self.storage.trace_agents( diff --git a/litellm/tracing/remote.py b/litellm/tracing/remote.py index f0ad1d834a3..8fd608ce82c 100644 --- a/litellm/tracing/remote.py +++ b/litellm/tracing/remote.py @@ -130,7 +130,7 @@ class RemoteTraceStore: response.raise_for_status() async def list_traces( - self, scope: TraceScope, start_ms: int, end_ms: int, cursor: str | None, limit: int + self, scope: TraceScope, start_ms: int, end_ms: int, cursor: str | None, limit: int, agent: str | None = None ) -> JsonValue: return await self._read( { @@ -140,6 +140,7 @@ class RemoteTraceStore: "end_ms": end_ms, "cursor": cursor, "limit": limit, + **({"agent": agent} if agent else {}), } ) diff --git a/litellm/tracing/storage.py b/litellm/tracing/storage.py index 1f1296364f4..5db46216f72 100644 --- a/litellm/tracing/storage.py +++ b/litellm/tracing/storage.py @@ -44,8 +44,9 @@ class LensTraceStorage: end_ms: int, cursor: str | None = None, limit: int = AGENT_TRACING_LIST_PAGE_SIZE, + agent: str | None = None, ) -> TracePage: - result: Final = await self._remote.list_traces(scope, start_ms, end_ms, cursor, limit) + result: Final = await self._remote.list_traces(scope, start_ms, end_ms, cursor, limit, agent) return _validate_query_response(_TRACE_PAGE, result) async def get_trace( diff --git a/scripts/lens_assets/schemas/requests/TraceListRequest.json b/scripts/lens_assets/schemas/requests/TraceListRequest.json index a19c3965c0b..1c40b3524b5 100644 --- a/scripts/lens_assets/schemas/requests/TraceListRequest.json +++ b/scripts/lens_assets/schemas/requests/TraceListRequest.json @@ -18,6 +18,14 @@ "null" ] }, + "agent": { + "default": null, + "maxLength": 256, + "type": [ + "string", + "null" + ] + }, "start_ms": { "default": null, "description": "Window start, unix ms. Default: 24h ago", diff --git a/scripts/lens_assets/source.json b/scripts/lens_assets/source.json index ce4a2c29685..f270699a5d6 100644 --- a/scripts/lens_assets/source.json +++ b/scripts/lens_assets/source.json @@ -14,7 +14,7 @@ "requests": { "schemas/requests/TraceDetailRequest.json": "4666e0cae8439c8f1726ea5418db8d4dbd613836759c7d0f1578f92077f9ff70", "schemas/requests/TraceErrorPageRequest.json": "f5d8695d0a2b004fb3baca8d2566e35e8b33e3b3e81fd5ea8ce6c03f2274ea4f", - "schemas/requests/TraceListRequest.json": "5cdb08384256257ed481ce27a4989b13e5dabc56bf063457f6fa97c0ce04f007", + "schemas/requests/TraceListRequest.json": "fe2c44f27e9821426bd3f2b0245c1764c20b436f1f6144c7d5f62b9b27b9fa11", "schemas/requests/TraceQueryRequest.json": "dd2e2d25d4d54a2021b0585dbd8391a1e91334a959328b68b73ed1fea3303930", "schemas/requests/TraceSpanRequest.json": "7009bc131f190c0863ea6eb808966f1613d6a1611e1dd88a884953b7b19d3224" }, diff --git a/tests/unit/proxy/test_tracing_endpoints.py b/tests/unit/proxy/test_tracing_endpoints.py index 62ca71b6cf6..2005a72e4f9 100644 --- a/tests/unit/proxy/test_tracing_endpoints.py +++ b/tests/unit/proxy/test_tracing_endpoints.py @@ -180,7 +180,9 @@ def test_trace_read_permissions_with_retired_uploads( if scope is None: receiver.list_traces.assert_not_awaited() else: - receiver.list_traces.assert_awaited_once_with(scope=scope, start_ms=1, end_ms=2, cursor=None) + receiver.list_traces.assert_awaited_once_with( + scope=scope, start_ms=1, end_ms=2, cursor=None, agent=None + ) write: Final = client.post("/v1/traces", json={}) assert write.status_code == 410 @@ -230,7 +232,7 @@ def test_gateway_uploads_return_setup_guidance_without_reading_the_body( def test_list_traces_passes_scope_window_and_cursor(client, receiver): - response = client.get("/v1/traces", params={"start_ms": 1, "end_ms": 2, "cursor": "abc"}) + response = client.get("/v1/traces", params={"start_ms": 1, "end_ms": 2, "cursor": "abc", "agent": "agent-a"}) assert response.status_code == 200 assert response.json() == {"data": [], "next_cursor": None} receiver.list_traces.assert_awaited_once_with( @@ -238,6 +240,7 @@ def test_list_traces_passes_scope_window_and_cursor(client, receiver): start_ms=1, end_ms=2, cursor="abc", + agent="agent-a", ) @@ -246,6 +249,7 @@ def test_list_traces_defaults_to_last_24h(client, receiver): kwargs = receiver.list_traces.call_args.kwargs assert kwargs["end_ms"] - kwargs["start_ms"] == tracing_endpoints.MS_PER_DAY assert kwargs["cursor"] is None + assert kwargs["agent"] is None @pytest.mark.parametrize( @@ -271,6 +275,7 @@ def test_list_traces_resolves_default_bounds_from_injected_clock( start_ms=expected_start_ms, end_ms=expected_end_ms, cursor=None, + agent=None, ) @@ -454,6 +459,7 @@ def test_list_traces_forwards_large_and_negative_bounds_unchanged(client: TestCl start_ms=2**63, end_ms=-1, cursor="next", + agent=None, ) @@ -526,6 +532,7 @@ def test_trace_read_routes_accept_and_forward_512_character_cursors(client: Test start_ms=NOW_MS - tracing_endpoints.MS_PER_DAY, end_ms=NOW_MS, cursor=cursor, + agent=None, ) receiver.get_trace.assert_awaited_once_with( "t1", {"all_teams": 0, "user_id": "user", "team_ids": ()}, "run-one", cursor, None @@ -571,8 +578,20 @@ def test_trace_read_routes_ignore_unknown_query_parameters(client: TestClient, r assert error_unknown_response.status_code == 200, error_unknown_response.text receiver.list_traces.assert_has_awaits( ( - call(scope={"all_teams": 0, "user_id": "user", "team_ids": ()}, start_ms=1, end_ms=2, cursor="list-cursor"), - call(scope={"all_teams": 0, "user_id": "user", "team_ids": ()}, start_ms=1, end_ms=2, cursor="list-cursor"), + call( + scope={"all_teams": 0, "user_id": "user", "team_ids": ()}, + start_ms=1, + end_ms=2, + cursor="list-cursor", + agent=None, + ), + call( + scope={"all_teams": 0, "user_id": "user", "team_ids": ()}, + start_ms=1, + end_ms=2, + cursor="list-cursor", + agent=None, + ), ) ) receiver.get_trace.assert_has_awaits( diff --git a/tests/unit/tracing/test_remote.py b/tests/unit/tracing/test_remote.py index e7438c3a4b7..95a746605dd 100644 --- a/tests/unit/tracing/test_remote.py +++ b/tests/unit/tracing/test_remote.py @@ -119,6 +119,32 @@ async def test_remote_reads_preserve_scope_and_pagination(operation: str) -> Non assert b"unused-local-secret" not in request.content +@pytest.mark.asyncio +@pytest.mark.parametrize(("agent", "expected"), (("research-agent", "research-agent"), ("", None), (None, None))) +async def test_remote_list_includes_only_a_non_empty_agent_filter(agent: str | None, expected: str | None) -> None: + requests: Final = asyncio.Queue[httpx.Request]() + + def accept(request: httpx.Request) -> httpx.Response: + requests.put_nowait(request) + return httpx.Response(200, json={"data": [], "next_cursor": None}) + + async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(accept)) as client: + await RemoteTraceStore(client).list_traces( + TraceScope(all_teams=1, user_id="", team_ids=()), 10, 20, None, 17, agent + ) + request: Final = requests.get_nowait() + body: Final = json.loads(request.content) + assert body == { + "operation": "list", + "scope": {"all_teams": 1, "user_id": "", "team_ids": []}, + "start_ms": 10, + "end_ms": 20, + "cursor": None, + "limit": 17, + **({"agent": expected} if expected else {}), + } + + @pytest.mark.asyncio @pytest.mark.parametrize( "status,error", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6f6eae1f98e..b8426921dca 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -81428,6 +81428,7 @@ export interface operations { cursor?: string | null; /** @description Window end, unix ms. Default: now */ end_ms?: number | null; + agent?: string | null; /** @description Window start, unix ms. Default: 24h ago */ start_ms?: number | null; }; From 4982b0a98d97caedcdb1948d3a9cf419788ef938 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 16 Oct 2026 00:27:51 +0000 Subject: [PATCH 2/3] style(tracing): format trace receiver Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/tracing/receiver.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/tracing/receiver.py b/litellm/tracing/receiver.py index 57e6e176de8..c416eb40e81 100644 --- a/litellm/tracing/receiver.py +++ b/litellm/tracing/receiver.py @@ -15,9 +15,7 @@ class TraceReceiver: async def list_traces( self, scope: TraceScope, start_ms: int, end_ms: int, cursor: str | None = None, agent: str | None = None ) -> TracePage: - return await self.storage.list_traces( - scope, start_ms, end_ms, cursor, AGENT_TRACING_LIST_PAGE_SIZE, agent - ) + return await self.storage.list_traces(scope, start_ms, end_ms, cursor, AGENT_TRACING_LIST_PAGE_SIZE, agent) async def list_agents(self, scope: TraceScope, start_ms: int, end_ms: int) -> TraceAgentList: rows: Final = await self.storage.trace_agents( From 8e509bf6dce336947ba7f23d15ff063323a32d64 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 16 Oct 2026 01:10:12 +0000 Subject: [PATCH 3/3] test(tracing): cover agent filter forwarding Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/unit/tracing/test_receiver.py | 35 +++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/unit/tracing/test_receiver.py b/tests/unit/tracing/test_receiver.py index bf0ae8425e0..6c9661dfec0 100644 --- a/tests/unit/tracing/test_receiver.py +++ b/tests/unit/tracing/test_receiver.py @@ -1,13 +1,17 @@ +import asyncio +import json from datetime import datetime, timezone from typing import Final, cast +import httpx import pytest -from litellm.constants import AGENT_TRACING_AGENT_LIST_LIMIT +from litellm.constants import AGENT_TRACING_AGENT_LIST_LIMIT, AGENT_TRACING_LIST_PAGE_SIZE +from litellm.tracing import TraceReceiver from litellm.tracing.generated.models import TraceAgentRow, TraceAgentsParams from litellm.tracing.generated.types import TraceScope +from litellm.tracing.remote import RemoteTraceStore from litellm.tracing.storage import LensTraceStorage -from litellm.tracing import TraceReceiver from litellm.tracing.types import TraceAgent @@ -69,3 +73,30 @@ async def test_list_agents_queries_the_reader_scope_and_shapes_rows( last_seen=datetime(1970, 1, 1, tzinfo=timezone.utc), ), ) + + +@pytest.mark.asyncio +async def test_list_traces_forwards_agent_filter_to_lens() -> None: + requests: Final = asyncio.Queue[httpx.Request]() + + def accept(request: httpx.Request) -> httpx.Response: + requests.put_nowait(request) + return httpx.Response(200, json={"data": [], "next_cursor": None}) + + scope: Final = TraceScope(all_teams=0, user_id="owner", team_ids=("team",)) + async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(accept)) as client: + receiver: Final = TraceReceiver(LensTraceStorage(RemoteTraceStore(client))) + page: Final = await receiver.list_traces(scope, start_ms=10, end_ms=20, agent="research-agent") + + request: Final = requests.get_nowait() + assert request.url.path == "/internal/read" + assert json.loads(request.content) == { + "operation": "list", + "scope": {"all_teams": 0, "user_id": "owner", "team_ids": ["team"]}, + "start_ms": 10, + "end_ms": 20, + "cursor": None, + "limit": AGENT_TRACING_LIST_PAGE_SIZE, + "agent": "research-agent", + } + assert page == {"data": (), "next_cursor": None}