mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
feat: add LiteLLM Relay collector logs
This commit is contained in:
parent
68a4ca7247
commit
0fb55f0809
18 changed files with 1248 additions and 40 deletions
|
|
@ -118,6 +118,7 @@ GATEWAY_EXACT_PATHS: frozenset[str] = frozenset(
|
|||
"/docs/oauth2-redirect",
|
||||
"/redoc",
|
||||
"/test",
|
||||
"/collector/spend-logs",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
litellm/proxy/collector_endpoints/__init__.py
Normal file
1
litellm/proxy/collector_endpoints/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Collector endpoints for ingesting external LiteLLM Relay logs."""
|
||||
396
litellm/proxy/collector_endpoints/spend_logs.py
Normal file
396
litellm/proxy/collector_endpoints/spend_logs.py
Normal 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)}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
362
tests/test_litellm/proxy/collector_endpoints/test_spend_logs.py
Normal file
362
tests/test_litellm/proxy/collector_endpoints/test_spend_logs.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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):",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue