feat: add LiteLLM Relay collector logs

This commit is contained in:
Ishaan Jaff 2026-07-09 16:55:14 -07:00
parent 68a4ca7247
commit 0fb55f0809
No known key found for this signature in database
18 changed files with 1248 additions and 40 deletions

View file

@ -118,6 +118,7 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
"/docs/oauth2-redirect",
"/redoc",
"/test",
"/collector/spend-logs",
}
)

View file

@ -1626,3 +1626,9 @@ ADVISOR_TOOL_DESCRIPTION: str = (
"want to verify your reasoning, or face a complex decision. "
"Describe your question or challenge clearly in the 'question' field."
)
########################### LiteLLM Relay Collector Constants ###########################
LITELLM_RELAY_CALL_TYPE = "litellm-relay"
MAX_COLLECTOR_SPEND_LOGS = 1000
MAX_COLLECTOR_SPEND_LOG_BYTES = 256 * 1024
MAX_COLLECTOR_SPEND_LOG_BATCH_BYTES = 10 * 1024 * 1024

View file

@ -0,0 +1 @@
"""Collector endpoints for ingesting external LiteLLM Relay logs."""

View file

@ -0,0 +1,396 @@
import hashlib
import hmac
import json
from datetime import datetime, timezone
from typing import Any, Optional, TypedDict
from fastapi import APIRouter, Depends, HTTPException, Request, status
from litellm.constants import (
LITELLM_ASYNCIO_QUEUE_MAXSIZE,
LITELLM_RELAY_CALL_TYPE,
MAX_COLLECTOR_SPEND_LOG_BATCH_BYTES,
MAX_COLLECTOR_SPEND_LOG_BYTES,
MAX_COLLECTOR_SPEND_LOGS,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
class CollectorSpendLogRow(TypedDict, total=False):
request_id: str
call_type: str
api_key: str
spend: float
total_tokens: int
prompt_tokens: int
completion_tokens: int
startTime: datetime
endTime: datetime
model: str
api_base: str
custom_llm_provider: str
user: Optional[str]
team_id: Optional[str]
organization_id: Optional[str]
metadata: dict[str, Any]
cache_hit: str
cache_key: str
request_tags: str
messages: Any
response: Any
proxy_server_request: Any
status: str
class CollectorSpendLogsIngestResponse(TypedDict):
enqueued: int
async def _enqueue_collector_spend_logs(
prisma_client: Any,
spend_logs: list[CollectorSpendLogRow],
) -> None:
async with prisma_client._spend_log_transactions_lock:
queued_spend_logs = len(prisma_client.spend_log_transactions)
if queued_spend_logs + len(spend_logs) > LITELLM_ASYNCIO_QUEUE_MAXSIZE:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"error": "Collector spend-log queue is full",
"queued": queued_spend_logs,
"limit": LITELLM_ASYNCIO_QUEUE_MAXSIZE,
},
)
prisma_client.spend_log_transactions.extend(spend_logs)
class CollectorSpendLogTransformer:
PASSTHROUGH_FIELDS = {
"total_tokens",
"prompt_tokens",
"completion_tokens",
"startTime",
"endTime",
"completionStartTime",
"model",
"model_id",
"model_group",
"mcp_namespaced_tool_name",
"agent_id",
"api_base",
"cache_hit",
"cache_key",
"end_user",
"requester_ip_address",
"messages",
"response",
"proxy_server_request",
"session_id",
"request_duration_ms",
"status",
}
@staticmethod
def transform_collector_events_to_spend_logs(
logs: list[dict[str, Any]],
user_api_key_dict: Any,
now: datetime,
) -> list[CollectorSpendLogRow]:
CollectorSpendLogTransformer._validate_raw_batch_size(logs)
spend_logs = [
CollectorSpendLogTransformer.transform_collector_event_to_spend_log(
log=log,
user_api_key_dict=user_api_key_dict,
now=now,
)
for log in logs
]
CollectorSpendLogTransformer._validate_normalized_batch_size(spend_logs)
return spend_logs
@staticmethod
def transform_collector_event_to_spend_log(
log: dict[str, Any],
user_api_key_dict: Any,
now: datetime,
) -> CollectorSpendLogRow:
collector_request_id = log.get("request_id")
if (
not isinstance(collector_request_id, str)
or not collector_request_id.strip()
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "request_id is required for collector spend-log ingestion"
},
)
key_hash = CollectorSpendLogTransformer._get_auth_key_hash(user_api_key_dict)
row: CollectorSpendLogRow = {
"request_id": CollectorSpendLogTransformer._collector_request_id_for(
key_hash,
collector_request_id,
),
"call_type": LITELLM_RELAY_CALL_TYPE,
"api_key": key_hash or LITELLM_RELAY_CALL_TYPE,
"spend": 0.0,
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"startTime": now,
"endTime": now,
"model": "local-ai-traffic",
"api_base": "",
"custom_llm_provider": "",
"user": getattr(user_api_key_dict, "user_id", None),
"team_id": getattr(user_api_key_dict, "team_id", None),
"organization_id": CollectorSpendLogTransformer._get_auth_organization_id(
user_api_key_dict
),
"metadata": CollectorSpendLogTransformer._normalize_metadata(
log.get("metadata"),
user_api_key_dict,
collector_request_id,
),
"cache_hit": "False",
"cache_key": "",
"request_tags": CollectorSpendLogTransformer._normalize_request_tags(
log.get("request_tags")
),
"messages": {},
"response": {},
"proxy_server_request": {},
"status": "success",
}
for key in CollectorSpendLogTransformer.PASSTHROUGH_FIELDS:
if key in log:
row[key] = log[key]
return CollectorSpendLogTransformer._sanitize_json_value(row)
@staticmethod
def _get_auth_organization_id(user_api_key_dict: Any) -> Optional[str]:
return getattr(user_api_key_dict, "organization_id", None) or getattr(
user_api_key_dict, "org_id", None
)
@staticmethod
def _get_auth_key_hash(user_api_key_dict: Any) -> Optional[str]:
return getattr(user_api_key_dict, "api_key", None) or getattr(
user_api_key_dict, "token", None
)
@staticmethod
def _get_auth_key_alias(user_api_key_dict: Any) -> str:
return (
getattr(user_api_key_dict, "key_alias", None)
or getattr(user_api_key_dict, "key_name", None)
or LITELLM_RELAY_CALL_TYPE
)
@staticmethod
def _get_auth_team_alias(user_api_key_dict: Any) -> Optional[str]:
return getattr(user_api_key_dict, "team_alias", None) or None
@staticmethod
def _collector_request_id_for(
key_hash: Optional[str], collector_request_id: str
) -> str:
digest = hmac.new(
(key_hash or LITELLM_RELAY_CALL_TYPE).encode(),
collector_request_id.encode(),
hashlib.sha256,
).hexdigest()
return f"collector-{digest[:32]}"
@staticmethod
def _normalize_metadata(
metadata: Any,
user_api_key_dict: Any,
collector_request_id: str,
) -> dict[str, Any]:
if isinstance(metadata, dict):
normalized = dict(metadata)
elif metadata is None:
normalized = {}
else:
normalized = {"relay_raw_metadata": metadata}
key_hash = CollectorSpendLogTransformer._get_auth_key_hash(user_api_key_dict)
normalized.update(
{
"source": LITELLM_RELAY_CALL_TYPE,
"collector_request_id": collector_request_id,
"relay_request_id": collector_request_id,
"user_api_key": key_hash,
"user_api_key_alias": CollectorSpendLogTransformer._get_auth_key_alias(
user_api_key_dict
),
"user_api_key_user_id": getattr(user_api_key_dict, "user_id", None),
"user_api_key_team_id": getattr(user_api_key_dict, "team_id", None),
"user_api_key_team_alias": CollectorSpendLogTransformer._get_auth_team_alias(
user_api_key_dict
),
"user_api_key_org_id": CollectorSpendLogTransformer._get_auth_organization_id(
user_api_key_dict
),
}
)
return normalized
@staticmethod
def _normalize_request_tags(value: Any) -> str:
if isinstance(value, str):
try:
parsed = json.loads(value)
if not isinstance(parsed, list):
parsed = [parsed]
except json.JSONDecodeError:
parsed = [value] if value.strip() else []
elif isinstance(value, list):
parsed = list(value)
elif value is None:
parsed = []
else:
parsed = [value]
if LITELLM_RELAY_CALL_TYPE not in parsed:
parsed.append(LITELLM_RELAY_CALL_TYPE)
return json.dumps(parsed, separators=(",", ":"))
@staticmethod
def _sanitize_json_value(value: Any) -> Any:
if isinstance(value, str):
return value.replace("\x00", "")
if not isinstance(value, (dict, list)):
return value
sanitized: Any = {} if isinstance(value, dict) else []
stack = [(value, sanitized)]
while stack:
source, target = stack.pop()
if isinstance(source, dict):
for key, item in source.items():
sanitized_key = str(key).replace("\x00", "")
if isinstance(item, str):
target[sanitized_key] = item.replace("\x00", "")
elif isinstance(item, dict):
child: dict[str, Any] = {}
target[sanitized_key] = child
stack.append((item, child))
elif isinstance(item, list):
child_list: list[Any] = []
target[sanitized_key] = child_list
stack.append((item, child_list))
else:
target[sanitized_key] = item
else:
for item in source:
if isinstance(item, str):
target.append(item.replace("\x00", ""))
elif isinstance(item, dict):
child = {}
target.append(child)
stack.append((item, child))
elif isinstance(item, list):
child_list = []
target.append(child_list)
stack.append((item, child_list))
else:
target.append(item)
return sanitized
@staticmethod
def _json_size_bytes(value: Any) -> int:
return len(json.dumps(value, default=str, separators=(",", ":")).encode())
@staticmethod
def _validate_log_size(log: dict[str, Any]) -> int:
encoded_size = CollectorSpendLogTransformer._json_size_bytes(log)
if encoded_size > MAX_COLLECTOR_SPEND_LOG_BYTES:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail={
"error": f"Collector spend-log entry exceeds {MAX_COLLECTOR_SPEND_LOG_BYTES} bytes"
},
)
return encoded_size
@staticmethod
def _validate_batch_size(total_bytes: int) -> None:
if total_bytes > MAX_COLLECTOR_SPEND_LOG_BATCH_BYTES:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail={
"error": f"Collector spend-log batch exceeds {MAX_COLLECTOR_SPEND_LOG_BATCH_BYTES} bytes"
},
)
@staticmethod
def _validate_raw_batch_size(logs: list[dict[str, Any]]) -> None:
total_bytes = 0
for log in logs:
total_bytes += CollectorSpendLogTransformer._json_size_bytes(log)
CollectorSpendLogTransformer._validate_batch_size(total_bytes)
@staticmethod
def _validate_normalized_batch_size(spend_logs: list[dict[str, Any]]) -> None:
total_bytes = 0
for spend_log in spend_logs:
total_bytes += CollectorSpendLogTransformer._validate_log_size(spend_log)
CollectorSpendLogTransformer._validate_batch_size(total_bytes)
router = APIRouter(include_in_schema=False)
@router.post(
"/collector/spend-logs",
tags=["Collector"],
)
async def ingest_collector_spend_logs(
payload: dict[str, list[dict[str, Any]]],
request: Request,
user_api_key_dict: Any = Depends(user_api_key_auth),
) -> CollectorSpendLogsIngestResponse:
"""
Ingest LiteLLM Relay captures into the existing spend-log batcher so they
appear in the Gateway Logs UI without replaying captured traffic.
"""
prisma_client = getattr(request.app.state, "prisma_client", None)
if prisma_client is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "Prisma Client is not initialized"},
)
if getattr(request.app.state, "proxy_logging_obj", None) is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "Proxy logging is not initialized"},
)
logs = payload.get("logs", [])
if len(logs) == 0:
return {"enqueued": 0}
if len(logs) > MAX_COLLECTOR_SPEND_LOGS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": f"Collector spend-log ingestion is limited to {MAX_COLLECTOR_SPEND_LOGS} rows"
},
)
spend_logs = CollectorSpendLogTransformer.transform_collector_events_to_spend_logs(
logs=logs,
user_api_key_dict=user_api_key_dict,
now=datetime.now(timezone.utc),
)
await _enqueue_collector_spend_logs(
prisma_client=prisma_client,
spend_logs=spend_logs,
)
return {"enqueued": len(spend_logs)}

View file

@ -468,6 +468,9 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
router as pass_through_router,
)
from litellm.proxy.collector_endpoints.spend_logs import (
router as collector_spend_logs_router,
)
from litellm.proxy.public_endpoints import router as public_endpoints_router
from litellm.proxy.rag_endpoints.endpoints import router as rag_router
from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router
@ -913,6 +916,8 @@ async def proxy_startup_event(app: FastAPI):
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
)
if app is not None:
app.state.prisma_client = prisma_client
if prisma_client is not None:
@ -1933,6 +1938,8 @@ store_model_in_db: bool = False
open_telemetry_logger: Optional[OpenTelemetry] = None
### INITIALIZE GLOBAL LOGGING OBJECT ###
proxy_logging_obj: ProxyLogging = ProxyLogging(user_api_key_cache=user_api_key_cache, premium_user=premium_user)
app.state.proxy_logging_obj = proxy_logging_obj
app.state.prisma_client = prisma_client
### REDIS QUEUE ###
async_result = None
celery_app_conn = None
@ -15792,6 +15799,7 @@ app.include_router(team_router)
app.include_router(ui_sso_router)
app.include_router(organization_router)
app.include_router(customer_router)
app.include_router(collector_spend_logs_router)
app.include_router(spend_management_router)
app.include_router(caching_router)
app.include_router(analytics_router)

View file

@ -86,6 +86,60 @@ def test_compliance_routes_open_to_non_admin_roles(role, route):
)
def test_collector_spend_logs_requires_explicit_allowed_route_for_non_admin_key():
"""Collector ingestion is a write path and must not be open to every key."""
user_obj = LiteLLM_UserTable(
user_id="relay_user",
user_email="relay@example.com",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
valid_token = UserAPIKeyAuth(
user_id="relay_user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
request = MagicMock(spec=Request)
request.query_params = {}
with pytest.raises(Exception) as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/collector/spend-logs",
request=request,
valid_token=valid_token,
request_data={},
)
assert "Only proxy admin" in str(exc_info.value)
assert "Route=/collector/spend-logs" in str(exc_info.value)
@pytest.mark.parametrize("allowed_route", ["/collector/spend-logs", "/collector/*"])
def test_collector_spend_logs_allows_explicit_allowed_route(allowed_route):
"""Relay keys can be scoped to only the collector ingest endpoint."""
user_obj = LiteLLM_UserTable(
user_id="relay_user",
user_email="relay@example.com",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
valid_token = UserAPIKeyAuth(
user_id="relay_user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
allowed_routes=[allowed_route],
)
request = MagicMock(spec=Request)
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/collector/spend-logs",
request=request,
valid_token=valid_token,
request_data={},
)
def test_proxy_admin_viewer_config_update_route_rejected():
"""Test that proxy admin viewer users are rejected when trying to call /config/update"""

View file

@ -0,0 +1,362 @@
import asyncio
import json
import pytest
from fastapi.testclient import TestClient
import litellm.proxy.proxy_server as ps
import litellm.proxy.collector_endpoints.spend_logs as collector_spend_logs
from litellm.constants import MAX_COLLECTOR_SPEND_LOG_BYTES
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.proxy_server import app
class MockPrismaClient:
def __init__(self):
self.spend_log_transactions = []
self._spend_log_transactions_lock = asyncio.Lock()
def _set_collector_runtime(monkeypatch, prisma_client):
monkeypatch.setattr(ps, "prisma_client", prisma_client)
monkeypatch.setattr(app.state, "prisma_client", prisma_client, raising=False)
def test_collector_spend_logs_enqueues_batcher_rows(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
api_key="hashed-admin-key",
team_id="team-1",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "relay-test-request",
"model": "notion-ai",
"metadata": {"app": "notion", "host": "www.notion.so"},
"proxy_server_request": {
"method": "POST",
"body_preview": "hi",
},
"response": {
"status_code": 200,
"body_preview": "hello",
},
"request_duration_ms": 42,
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert response.json() == {"enqueued": 1}
assert len(prisma_client.spend_log_transactions) == 1
row = prisma_client.spend_log_transactions[0]
assert row["request_id"].startswith("collector-")
assert row["request_id"] != "relay-test-request"
assert row["call_type"] == "litellm-relay"
assert row["model"] == "notion-ai"
assert row["spend"] == 0.0
assert row["team_id"] == "team-1"
assert row["metadata"]["source"] == "litellm-relay"
assert row["metadata"]["user_api_key_team_alias"] is None
assert row["metadata"]["collector_request_id"] == "relay-test-request"
assert row["metadata"]["app"] == "notion"
assert row["proxy_server_request"]["body_preview"] == "hi"
assert row["response"]["body_preview"] == "hello"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_attributes_valid_virtual_key(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="user_1",
api_key="hashed-virtual-key",
team_id="team_1",
team_alias="Relay Team",
key_alias="relay-key",
organization_id="org_1",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "relay-test-request",
"api_key": "client-supplied-key-is-ignored",
"spend": 10,
"team_id": "client-team-is-ignored",
"organization_id": "client-org-is-ignored",
"user": "client-user-is-ignored",
"custom_llm_provider": "client-provider-is-ignored",
"metadata": {
"source": "client-source-is-ignored",
"user_api_key": "client-key-is-ignored",
},
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
row = prisma_client.spend_log_transactions[0]
assert row["api_key"] == "hashed-virtual-key"
assert row["spend"] == 0.0
assert row["team_id"] == "team_1"
assert row["organization_id"] == "org_1"
assert row["user"] == "user_1"
assert row["custom_llm_provider"] == ""
assert row["metadata"]["source"] == "litellm-relay"
assert row["metadata"]["user_api_key"] == "hashed-virtual-key"
assert row["metadata"]["user_api_key_alias"] == "relay-key"
assert row["metadata"]["user_api_key_team_alias"] == "Relay Team"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_accepts_json_object_request_tags(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
api_key="hashed-admin-key",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "relay-json-tags-test",
"request_tags": '{"source":"notion"}',
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
row = prisma_client.spend_log_transactions[0]
assert json.loads(row["request_tags"]) == [
{"source": "notion"},
"litellm-relay",
]
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_strips_nul_bytes(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="user_1",
api_key="hashed-virtual-key",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "relay-nul-test",
"proxy_server_request": {"body_preview": "hello\u0000world"},
"response": {"body_preview": "ok\u0000"},
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
row_text = json.dumps(prisma_client.spend_log_transactions[0], default=str)
assert "\u0000" not in row_text
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_rejects_oversized_log(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
api_key="hashed-admin-key",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "large-relay-request",
"proxy_server_request": {
"body_preview": "x" * (MAX_COLLECTOR_SPEND_LOG_BYTES + 1)
},
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 413, response.text
assert prisma_client.spend_log_transactions == []
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_rejects_normalized_row_over_size_limit(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
api_key="hashed-admin-key",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "normalized-large-relay-request",
"proxy_server_request": {
"body_preview": "x" * (MAX_COLLECTOR_SPEND_LOG_BYTES - 50)
},
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 413, response.text
assert prisma_client.spend_log_transactions == []
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_rejects_oversized_batch(monkeypatch):
prisma_client = MockPrismaClient()
_set_collector_runtime(monkeypatch, prisma_client)
monkeypatch.setattr(
collector_spend_logs,
"MAX_COLLECTOR_SPEND_LOG_BATCH_BYTES",
900,
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
api_key="hashed-admin-key",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": f"batch-relay-request-{idx}",
"proxy_server_request": {"body_preview": "x" * 200},
}
for idx in range(5)
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 413, response.text
assert prisma_client.spend_log_transactions == []
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_rejects_when_queue_is_full(monkeypatch):
prisma_client = MockPrismaClient()
prisma_client.spend_log_transactions = [{} for _ in range(3)]
_set_collector_runtime(monkeypatch, prisma_client)
monkeypatch.setattr(
collector_spend_logs,
"LITELLM_ASYNCIO_QUEUE_MAXSIZE",
3,
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user",
api_key="hashed-admin-key",
)
try:
response = TestClient(app).post(
"/collector/spend-logs",
json={
"logs": [
{
"request_id": "queued-relay-request",
}
]
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 429, response.text
assert response.json()["detail"] == {
"error": "Collector spend-log queue is full",
"queued": 3,
"limit": 3,
}
assert len(prisma_client.spend_log_transactions) == 3
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
def test_collector_spend_logs_enqueue_is_capacity_checked_under_lock(monkeypatch):
prisma_client = MockPrismaClient()
monkeypatch.setattr(
collector_spend_logs,
"LITELLM_ASYNCIO_QUEUE_MAXSIZE",
3,
)
async def enqueue_two_batches():
await collector_spend_logs._enqueue_collector_spend_logs(
prisma_client=prisma_client,
spend_logs=[{"request_id": "one"}, {"request_id": "two"}],
)
with pytest.raises(collector_spend_logs.HTTPException) as exc_info:
await collector_spend_logs._enqueue_collector_spend_logs(
prisma_client=prisma_client,
spend_logs=[{"request_id": "three"}, {"request_id": "four"}],
)
return exc_info.value
error = asyncio.run(enqueue_two_batches())
assert error.status_code == 429
assert error.detail == {
"error": "Collector spend-log queue is full",
"queued": 2,
"limit": 3,
}
assert [row["request_id"] for row in prisma_client.spend_log_transactions] == [
"one",
"two",
]

View file

@ -26,6 +26,8 @@ const python = (process.env.LITELLM_PYTHON ?? "python3").split(" ");
// The dashboard calls internal UI routes that the public /openapi.json hides via
// include_in_schema=False. Force them in so they get typed here; this mutates a
// throwaway interpreter, so the spec the proxy actually serves is unchanged.
// Collector ingestion routes are machine-to-machine write APIs and should stay
// out of dashboard client types even though other hidden UI routes are included.
// Python 3.13 strips a docstring's common leading indentation at compile time
// while 3.12 keeps it, so the same model yields differently-indented descriptions
// depending on the interpreter — enough to make this output non-reproducible
@ -37,7 +39,7 @@ const dumpSpec = [
"from fastapi.routing import APIRoute",
"for route in app.routes:",
" if isinstance(route, APIRoute):",
" route.include_in_schema = True",
" route.include_in_schema = not route.path.startswith('/collector/')",
"app.openapi_schema = None",
"def normalize(node):",
" if isinstance(node, dict):",

View file

@ -56,6 +56,22 @@ describe("LogDetailContent", () => {
expect(screen.getByText("completion")).toBeInTheDocument();
});
it("should display relay source with logo in Request Details", () => {
render(
<LogDetailContent
logEntry={createLogEntry({
model: "notion-ai",
call_type: "litellm-relay",
metadata: { status: "success", app: "notion", source: "litellm-relay" },
})}
/>,
);
expect(screen.getByText("Source")).toBeInTheDocument();
expect(screen.getByLabelText("Notion logo")).toBeInTheDocument();
expect(screen.getByText("Notion")).toBeInTheDocument();
});
it("should display error alert when request has failed", () => {
render(
<LogDetailContent

View file

@ -28,14 +28,25 @@ import {
TAB_RESPONSE,
FONT_SIZE_SMALL,
FONT_FAMILY_MONO,
COLOR_BG_LIGHT,
SPACING_LARGE,
SPACING_XLARGE,
SPACING_MEDIUM,
} from "./constants";
import { ToolsSection } from "../ToolsSection";
import { PrettyMessagesView } from "./PrettyMessagesView";
import { RelaySourceBadge, getRelaySource } from "../TypeBadges";
const { Text } = Typography;
const getObjectRequestTags = (requestTags: LogEntry["request_tags"]): Record<string, unknown> | undefined => {
if (!requestTags || Array.isArray(requestTags) || typeof requestTags !== "object") {
return undefined;
}
return requestTags;
};
export interface LogDetailContentProps {
logEntry: LogEntry;
/** When true, log details (messages/response) are still being lazy-loaded. */
@ -53,8 +64,11 @@ export interface LogDetailContentProps {
*/
export function LogDetailContent({ logEntry, isLoadingDetails = false, accessToken }: LogDetailContentProps) {
const metadata = logEntry.metadata || {};
const requestTags = getObjectRequestTags(logEntry.request_tags);
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
const isRelayCapture = logEntry.call_type === "litellm-relay";
const relaySource = getRelaySource(logEntry);
const hasMessages = checkHasMessages(logEntry.messages);
const hasResponse = checkHasResponse(logEntry.response);
@ -107,9 +121,7 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
)}
{/* Tags */}
{logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && (
<TagsSection tags={logEntry.request_tags} />
)}
{requestTags && Object.keys(requestTags).length > 0 && <TagsSection tags={requestTags} />}
{/* Request Details */}
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
@ -118,6 +130,11 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
{isRelayCapture && (
<Descriptions.Item label="Source">
<RelaySourceBadge source={relaySource} />
</Descriptions.Item>
)}
<Descriptions.Item label="Model ID">
<TruncatedValue value={logEntry.model_id} />
</Descriptions.Item>
@ -232,7 +249,7 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) {
);
}
function TagsSection({ tags }: { tags: Record<string, any> }) {
function TagsSection({ tags }: { tags: Record<string, unknown> }) {
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden p-4 mb-6">
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
@ -418,6 +435,7 @@ function RequestResponseSection({
: totalTokens > 0
? (totalSpend * completionTokens) / totalTokens
: 0;
const isRelayCapture = logEntry.call_type === "litellm-relay";
return (
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
@ -448,6 +466,7 @@ function RequestResponseSection({
),
children: (
<div>
{isRelayCapture && <RelayPayloadPreview request={getRawRequest()} response={getFormattedResponse()} />}
{viewMode === "pretty" ? (
<PrettyMessagesView
request={getRawRequest()}
@ -509,6 +528,61 @@ function RequestResponseSection({
);
}
function getPreviewText(payload: any): string {
if (!payload) return "";
if (typeof payload === "string") return payload;
if (typeof payload.body === "string") return payload.body;
if (typeof payload.body_preview === "string") return payload.body_preview;
return JSON.stringify(payload, null, 2);
}
function PayloadCodeBlock({ value }: { value: string }) {
return (
<pre
className="whitespace-pre-wrap break-words text-xs"
style={{
margin: 0,
maxHeight: 220,
overflow: "auto",
padding: SPACING_LARGE,
background: COLOR_BG_LIGHT,
border: "1px solid #e5e7eb",
borderRadius: 6,
fontFamily: FONT_FAMILY_MONO,
}}
>
{value || "No body captured"}
</pre>
);
}
function RelayPayloadPreview({ request, response }: { request: any; response: any }) {
const requestBody = getPreviewText(request);
const responseBody = getPreviewText(response);
return (
<div className="mb-4">
<Alert
type="info"
showIcon
message="HTTP payload captured by LiteLLM Relay"
description="This is the intercepted request and response body stored on the LiteLLM spend log."
className="mb-3"
/>
<div className="grid grid-cols-1 gap-3">
<Card
size="small"
title={`Request${request?.method ? ` · ${request.method}` : ""}${request?.path ? ` ${request.path}` : ""}`}
>
<PayloadCodeBlock value={requestBody} />
</Card>
<Card size="small" title={`Response${response?.status_code ? ` · ${response.status_code}` : ""}`}>
<PayloadCodeBlock value={responseBody} />
</Card>
</div>
</div>
);
}
export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) {
const allPassed = guardrailEntries.every((e) => {
const status = e?.guardrail_status || e?.status;

View file

@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Drawer, Segmented } from "antd";
import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Bot, Sparkles, Wrench } from "lucide-react";
import { Bot, Cable, Sparkles, Wrench } from "lucide-react";
import { LogEntry } from "../columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES, RELAY_CALL_TYPES } from "../constants";
import { getEventDisplayName } from "../utils";
import { DrawerHeader } from "./DrawerHeader";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
@ -49,12 +49,22 @@ interface TraceEventRowProps {
function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const isAgent = AGENT_CALL_TYPES.includes(row.call_type);
const durationValue =
row.request_duration_ms != null
? (row.request_duration_ms / 1000).toFixed(3)
: row.startTime && row.endTime
? ((Date.parse(row.endTime) - Date.parse(row.startTime)) / 1000).toFixed(3)
: "-";
const isRelay = RELAY_CALL_TYPES.includes(row.call_type);
let durationValue = "-";
if (row.request_duration_ms != null) {
durationValue = (row.request_duration_ms / 1000).toFixed(3);
} else if (row.startTime && row.endTime) {
durationValue = ((Date.parse(row.endTime) - Date.parse(row.startTime)) / 1000).toFixed(3);
}
let eventIcon = <Sparkles size={12} className="text-slate-500 shrink-0" />;
if (isMcp) {
eventIcon = <Wrench size={12} className="text-slate-500 shrink-0" />;
} else if (isAgent) {
eventIcon = <Bot size={12} className="text-slate-500 shrink-0" />;
} else if (isRelay) {
eventIcon = <Cable size={12} className="text-slate-500 shrink-0" />;
}
return (
<button
@ -65,13 +75,7 @@ function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
onClick={onClick}
>
<div className="flex items-center gap-1">
{isMcp ? (
<Wrench size={12} className="text-slate-500 shrink-0" />
) : isAgent ? (
<Bot size={12} className="text-slate-500 shrink-0" />
) : (
<Sparkles size={12} className="text-slate-500 shrink-0" />
)}
{eventIcon}
<span className="text-xs font-medium text-slate-900 truncate">
{getEventDisplayName(row.call_type, row.model)}
</span>
@ -274,7 +278,12 @@ export function LogDetailsDrawer({
).length;
const agentCount = sessionLogs.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length;
const mcpCount = sessionLogs.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length;
const logsForList = isSessionMode ? sessionLogs : currentLog ? [currentLog] : [];
let logsForList: LogEntry[] = [];
if (isSessionMode) {
logsForList = sessionLogs;
} else if (currentLog) {
logsForList = [currentLog];
}
const leftPanelId = isSessionMode ? sessionId || "" : currentLog?.request_id || "";
const leftPanelDisplayId = leftPanelId.length > 14 ? `${leftPanelId.slice(0, 11)}...` : leftPanelId;

View file

@ -1,6 +1,14 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LlmBadge, McpBadge, AgentBadge } from "./TypeBadges";
import {
LlmBadge,
McpBadge,
AgentBadge,
RelayBadge,
RelaySourceBadge,
RelayTypeBadge,
getRelaySource,
} from "./TypeBadges";
describe("TypeBadges", () => {
describe("LlmBadge", () => {
@ -43,4 +51,43 @@ describe("TypeBadges", () => {
expect(screen.getByText("12")).toBeInTheDocument();
});
});
describe("RelayBadge", () => {
it("should render with default 'litellm-relay' text when no count is provided", () => {
render(<RelayBadge />);
expect(screen.getByText("litellm-relay")).toBeInTheDocument();
});
});
describe("RelaySourceBadge", () => {
it("should render Notion source with logo", () => {
render(<RelaySourceBadge source="notion" />);
expect(screen.getByRole("img", { name: "Notion logo" })).toBeInTheDocument();
expect(screen.getByText("Notion")).toBeInTheDocument();
});
it("should render Codex source with logo", () => {
render(<RelaySourceBadge source="codex" />);
expect(screen.getByRole("img", { name: "Codex logo" })).toBeInTheDocument();
expect(screen.getByText("Codex")).toBeInTheDocument();
});
it("should derive source from relay metadata app", () => {
expect(getRelaySource({ metadata: { app: "notion" }, model: "local-ai" })).toBe("notion");
});
it("should derive source from relay model when metadata is missing", () => {
expect(getRelaySource({ model: "codex-ai" })).toBe("codex");
});
});
describe("RelayTypeBadge", () => {
it("should render relay type next to the captured app", () => {
render(<RelayTypeBadge source="notion" />);
expect(screen.getByText("litellm-relay")).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Notion logo" })).toBeInTheDocument();
expect(screen.getByText("Notion")).toBeInTheDocument();
});
});
});

View file

@ -1,7 +1,9 @@
/**
* Compact type-indicator badges for LLM, Agent, and MCP log entries.
* Compact type-indicator badges for LLM, Agent, MCP, and Relay log entries.
* Used in the request logs table and session type column.
*/
import { Cable, Monitor } from "lucide-react";
import { resolveLogoSrc } from "@/lib/assetPaths";
export const SparkleIcon = ({ size = 12 }: { size?: number }) => (
<svg
@ -57,6 +59,8 @@ export const AgentIcon = ({ size = 12 }: { size?: number }) => (
</svg>
);
export const RelayIcon = ({ size = 12 }: { size?: number }) => <Cable size={size} className="flex-shrink-0" />;
export const LlmBadge = ({ count }: { count?: number }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<SparkleIcon />
@ -77,3 +81,97 @@ export const AgentBadge = ({ count }: { count?: number }) => (
{count != null ? count : "Agent"}
</span>
);
export const RelayBadge = ({ count }: { count?: number }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<RelayIcon />
{count != null ? count : "litellm-relay"}
</span>
);
type RelaySourceLike = {
metadata?: Record<string, any>;
model?: string;
request_tags?: Record<string, any> | string[] | string;
};
const RELAY_SOURCE_LABELS: Record<string, string> = {
notion: "Notion",
codex: "Codex",
};
const RELAY_SOURCE_LOGOS: Record<string, string> = {
notion: "/ui/assets/logos/notion.svg",
codex: "/ui/assets/logos/openai_small.svg",
};
const normalizeRelaySource = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (normalized === "litellm-relay") return undefined;
if (normalized.includes("notion")) return "notion";
if (normalized.includes("codex")) return "codex";
return normalized.replace(/-ai$/, "");
};
const getTagSource = (requestTags: RelaySourceLike["request_tags"]): string | undefined => {
if (Array.isArray(requestTags)) {
return requestTags.map(normalizeRelaySource).find(Boolean);
}
if (typeof requestTags === "string") {
try {
const parsed = JSON.parse(requestTags);
return getTagSource(parsed);
} catch {
return normalizeRelaySource(requestTags);
}
}
return undefined;
};
export const getRelaySource = (entry: RelaySourceLike): string => {
return (
normalizeRelaySource(entry.metadata?.app) ||
normalizeRelaySource(entry.metadata?.relay_app) ||
normalizeRelaySource(entry.metadata?.shadow_source) ||
normalizeRelaySource(entry.metadata?.host) ||
getTagSource(entry.request_tags) ||
normalizeRelaySource(entry.model) ||
"unknown"
);
};
export const getRelaySourceLabel = (source: string) => {
return RELAY_SOURCE_LABELS[source] || source.charAt(0).toUpperCase() + source.slice(1);
};
export const RelaySourceLogo = ({ source, size = 16 }: { source: string; size?: number }) => {
const logo = RELAY_SOURCE_LOGOS[source];
if (logo) {
return (
<span aria-label={`${getRelaySourceLabel(source)} logo`} role="img" className="inline-flex">
<img src={resolveLogoSrc(logo)} alt="" className="flex-shrink-0" style={{ width: size, height: size }} />
</span>
);
}
return (
<span aria-label={`${getRelaySourceLabel(source)} logo`} role="img" className="inline-flex">
<Monitor size={size} className="flex-shrink-0 text-slate-500" aria-hidden="true" />
</span>
);
};
export const RelaySourceBadge = ({ source }: { source: string }) => (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 bg-slate-50 text-slate-700 border border-slate-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<RelaySourceLogo source={source} />
{getRelaySourceLabel(source)}
</span>
);
export const RelayTypeBadge = ({ source }: { source: string }) => (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
<RelayBadge />
<RelaySourceBadge source={source} />
</span>
);

View file

@ -54,3 +54,66 @@ describe("Cost column", () => {
expect(await screen.findByText("$0.00012345678")).toBeInTheDocument();
});
});
describe("view logs columns", () => {
const relayLog = logEntry({
request_id: "req-relay",
api_key: "hashed-relay-key",
team_id: "",
model: "notion-ai",
api_base: "https://www.notion.so",
call_type: "litellm-relay",
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
metadata: {
app: "notion",
status: "success",
status_code: 200,
user_api_key: null,
user_api_key_alias: "relay-key",
user_api_key_team_alias: null,
},
request_tags: { source: "notion" },
proxy_server_request: {},
status: "success",
});
it("should render relay type with captured app logo and name", () => {
render(<DataTable data={[relayLog]} columns={createColumns()} getRowId={(row) => row.request_id} />);
expect(screen.getByText("litellm-relay")).toBeInTheDocument();
expect(screen.getAllByText("Notion").length).toBeGreaterThan(0);
expect(screen.getAllByRole("img", { name: "Notion logo" }).length).toBeGreaterThan(0);
expect(screen.queryByRole("columnheader", { name: "Source" })).not.toBeInTheDocument();
expect(screen.queryByText("LLM")).not.toBeInTheDocument();
});
it("should render collector rows with relay metadata as relay logs", () => {
const legacyCollectorLog = {
...relayLog,
request_id: "collector-01f159da",
call_type: "completion",
metadata: {
...relayLog.metadata,
app: "codex",
source: "litellm-relay",
},
model: "codex-ai",
request_tags: ["litellm-relay", "codex"],
};
render(<DataTable data={[legacyCollectorLog]} columns={createColumns()} getRowId={(row) => row.request_id} />);
expect(screen.getByText("litellm-relay")).toBeInTheDocument();
expect(screen.getAllByText("Codex").length).toBeGreaterThan(0);
expect(screen.getAllByRole("img", { name: "Codex logo" }).length).toBeGreaterThan(0);
expect(screen.queryByText("LLM")).not.toBeInTheDocument();
});
it("should fall back to row api_key when relay metadata does not include a key hash", () => {
render(<DataTable data={[relayLog]} columns={createColumns()} getRowId={(row) => row.request_id} />);
expect(screen.getByText("hashed-relay-key")).toBeInTheDocument();
});
});

View file

@ -5,8 +5,18 @@ import { Tooltip } from "antd";
import React from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES, RELAY_CALL_TYPES } from "./constants";
import {
AgentBadge,
AgentIcon,
LlmBadge,
McpBadge,
RelayIcon,
RelayTypeBadge,
SparkleIcon,
WrenchIcon,
getRelaySource,
} from "./TypeBadges";
/** API sort field mapping for /spend/logs/ui endpoint */
export const LOGS_SORT_FIELD_MAP = {
@ -56,7 +66,7 @@ export type LogEntry = {
metadata?: Record<string, any>;
cache_hit: string;
cache_key?: string;
request_tags?: Record<string, any>;
request_tags?: Record<string, any> | string[] | string;
requester_ip_address?: string;
messages: string | any[] | Record<string, any>;
response: string | any[] | Record<string, any>;
@ -104,6 +114,32 @@ const SortableHeader = ({
</div>
);
const requestTagsIncludeRelay = (requestTags: LogEntry["request_tags"]): boolean => {
if (!requestTags) return false;
if (Array.isArray(requestTags)) {
return requestTags.some((tag) => String(tag).toLowerCase() === "litellm-relay");
}
if (typeof requestTags === "string") {
try {
return requestTagsIncludeRelay(JSON.parse(requestTags));
} catch {
return requestTags.toLowerCase().includes("litellm-relay");
}
}
return Object.entries(requestTags).some(
([key, value]) => key.toLowerCase().includes("litellm-relay") || String(value).toLowerCase().includes("litellm-relay"),
);
};
const isRelayLog = (row: LogEntry): boolean => {
return (
RELAY_CALL_TYPES.includes(row.call_type) ||
row.metadata?.source === "litellm-relay" ||
requestTagsIncludeRelay(row.request_tags) ||
(row.request_id?.startsWith("collector-") && getRelaySource(row) !== "unknown")
);
};
export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[] => [
{
header: sortProps
@ -124,17 +160,19 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
{
header: "Type",
id: "type",
size: 90,
size: 240,
cell: (info: any) => {
const row = info.row.original;
const sessionCount = row.session_total_count || 1;
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const isAgent = AGENT_CALL_TYPES.includes(row.call_type);
const sessionLlmCount = row.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount);
const isRelay = isRelayLog(row);
const sessionLlmCount = row.session_llm_count ?? (isMcp || isAgent || isRelay ? 0 : sessionCount);
const sessionAgentCount = row.session_agent_count ?? (isAgent ? sessionCount : 0);
const sessionMcpCount = row.session_mcp_count ?? (isMcp ? sessionCount : 0);
if (isMcp) return <McpBadge />;
if (isRelay) return <RelayTypeBadge source={getRelaySource(row)} />;
if (isAgent && sessionCount <= 1) return <AgentBadge />;
if (sessionCount <= 1) return <LlmBadge />;
@ -155,6 +193,12 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
<WrenchIcon />
</>
)}
{isRelay && (
<>
<span className="text-blue-300">·</span>
<RelayIcon size={10} />
</>
)}
</span>
);
@ -162,6 +206,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
sessionLlmCount > 0 && `${sessionLlmCount} LLM`,
sessionAgentCount > 0 && `${sessionAgentCount} Agent`,
sessionMcpCount > 0 && `${sessionMcpCount} MCP`,
isRelay && "litellm-relay",
].filter(Boolean);
return <Tooltip title={tooltipParts.join(" • ")}>{sessionTypeBadge}</Tooltip>;
},
@ -284,17 +329,25 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[]
header: "Team Name",
accessorKey: "metadata.user_api_key_team_alias",
size: 150,
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "-")}>
<span className="max-w-[15ch] truncate block">{String(info.getValue() || "-")}</span>
</Tooltip>
),
cell: (info: any) => {
const row = info.row.original;
const value =
info.getValue() || row.metadata?.user_api_key_team_id || row.team_id || (row.api_key ? "No team" : "-");
return (
<Tooltip title={String(value)}>
<span className="max-w-[15ch] truncate block">{String(value)}</span>
</Tooltip>
);
},
},
{
header: "Key Hash",
accessorKey: "metadata.user_api_key",
size: 110,
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" onClick={info.row.original.onKeyHashClick} />,
cell: (info: any) => {
const row = info.row.original;
return <IdCell value={info.getValue() || row.api_key || "-"} variant="plain" onClick={row.onKeyHashClick} />;
},
},
{
header: "Key Alias",

View file

@ -18,6 +18,9 @@ export const MCP_CALL_TYPES = ["call_mcp_tool", "list_mcp_tools"];
/** Call types that represent agent/A2A requests (e.g. asend_message). */
export const AGENT_CALL_TYPES = ["asend_message"];
/** Call types that represent local collector captures from LiteLLM Relay. */
export const RELAY_CALL_TYPES = ["litellm-relay"];
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
{ label: "Last Minute", value: 1, unit: "minutes" },
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },

View file

@ -10,7 +10,7 @@ import { keyInfoV1Call } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogs from "./audit_logs";
import { createColumns, LogEntry, type LogsSortField } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES, RELAY_CALL_TYPES } from "./constants";
import { getLogFilterOptions } from "./filter_options";
import { useLogFilterLogic, defaultFilters, type LogFilterState } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
@ -154,6 +154,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
acc[log.session_id].mcp += 1;
} else if (AGENT_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].agent += 1;
} else if (RELAY_CALL_TYPES.includes(log.call_type)) {
// Relay captures are not LLM calls; they get their own Type badge.
} else {
acc[log.session_id].llm += 1;
}
@ -163,14 +165,22 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
);
// Build a single-pass map of session_id → representative request_id.
// Prefers an LLM row over an MCP row as the representative.
const sessionRepresentativeMap = new Map<string, { requestId: string; isMcp: boolean }>();
// Prefer higher-signal execution rows over collector/tool rows.
const sessionRepresentativeMap = new Map<string, { requestId: string; priority: number }>();
for (const log of searchedLogs) {
if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
const isRelay = RELAY_CALL_TYPES.includes(log.call_type);
const isAgent = AGENT_CALL_TYPES.includes(log.call_type);
let priority = 2;
if (isRelay || isMcp) {
priority = 0;
} else if (isAgent) {
priority = 1;
}
const existing = sessionRepresentativeMap.get(log.session_id);
if (!existing || (existing.isMcp && !isMcp)) {
sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
if (!existing || priority > existing.priority) {
sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, priority });
}
}

View file

@ -1,4 +1,4 @@
import { MCP_CALL_TYPES } from "./constants";
import { MCP_CALL_TYPES, RELAY_CALL_TYPES } from "./constants";
/**
* Derive a short, human-readable display name for a log entry.
@ -7,6 +7,7 @@ import { MCP_CALL_TYPES } from "./constants";
export function getEventDisplayName(callType: string, model: string): string {
const raw = (model || "").trim();
const isMcp = MCP_CALL_TYPES.includes(callType);
const isRelay = RELAY_CALL_TYPES.includes(callType);
if (isMcp) {
return (
@ -19,6 +20,10 @@ export function getEventDisplayName(callType: string, model: string): string {
);
}
if (isRelay) {
return raw || "litellm-relay";
}
const lastSegment = raw.split("/").pop() || raw;
const noSuffix = lastSegment.replace(/-20\d{6}.*$/i, "").replace(/:.*$/, "");
const claudeMatch = noSuffix.match(/claude-[a-z0-9-]+/i);