mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 02:34:29 +00:00
(Feat) - Allow viewing Request/Response Logs stored in GCS Bucket (#8449)
* BaseRequestResponseFetchFromCustomLogger * get_active_base_request_response_fetch_from_custom_logger * get_request_response_payload * ui_view_request_response_for_request_id * fix uiSpendLogDetailsCall * fix get_request_response_payload * ui fix RequestViewer * use 1 class AdditionalLoggingUtils * ui_view_request_response_for_request_id * cache the prefetch logs details * refactor prefetch * test view request/resp logs * fix code quality * fix get_request_response_payload * uninstall posthog prevent it from being added in ci/cd * fix posthog * fix traceloop test * fix linting error
This commit is contained in:
parent
64a4229606
commit
00c596a852
13 changed files with 706 additions and 201 deletions
36
litellm/integrations/additional_logging_utils.py
Normal file
36
litellm/integrations/additional_logging_utils.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
Base class for Additional Logging Utils for CustomLoggers
|
||||
|
||||
- Health Check for the logging util
|
||||
- Get Request / Response Payload for the logging util
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
|
||||
|
||||
|
||||
class AdditionalLoggingUtils(ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@abstractmethod
|
||||
async def async_health_check(self) -> IntegrationHealthCheckStatus:
|
||||
"""
|
||||
Check if the service is healthy
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_request_response_payload(
|
||||
self,
|
||||
request_id: str,
|
||||
start_time_utc: Optional[datetime],
|
||||
end_time_utc: Optional[datetime],
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Get the request and response payload for a given `request_id`
|
||||
"""
|
||||
return None
|
|
@ -1,19 +0,0 @@
|
|||
"""
|
||||
Base class for health check integrations
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
|
||||
|
||||
|
||||
class HealthCheckIntegration(ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@abstractmethod
|
||||
async def async_health_check(self) -> IntegrationHealthCheckStatus:
|
||||
"""
|
||||
Check if the service is healthy
|
||||
"""
|
||||
pass
|
|
@ -38,14 +38,14 @@ from litellm.types.integrations.datadog import *
|
|||
from litellm.types.services import ServiceLoggerPayload
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
from ..base_health_check import HealthCheckIntegration
|
||||
from ..additional_logging_utils import AdditionalLoggingUtils
|
||||
|
||||
DD_MAX_BATCH_SIZE = 1000 # max number of logs DD API can accept
|
||||
|
||||
|
||||
class DataDogLogger(
|
||||
CustomBatchLogger,
|
||||
HealthCheckIntegration,
|
||||
AdditionalLoggingUtils,
|
||||
):
|
||||
# Class variables or attributes
|
||||
def __init__(
|
||||
|
@ -543,3 +543,13 @@ class DataDogLogger(
|
|||
status="unhealthy",
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
async def get_request_response_payload(
|
||||
self,
|
||||
request_id: str,
|
||||
start_time_utc: Optional[datetimeObj],
|
||||
end_time_utc: Optional[datetimeObj],
|
||||
) -> Optional[dict]:
|
||||
raise NotImplementedError(
|
||||
"Datdog Integration for getting request/response payloads not implemented as yet"
|
||||
)
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.additional_logging_utils import AdditionalLoggingUtils
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase
|
||||
from litellm.proxy._types import CommonProxyErrors
|
||||
from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus
|
||||
from litellm.types.integrations.gcs_bucket import *
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
@ -20,7 +24,7 @@ GCS_DEFAULT_BATCH_SIZE = 2048
|
|||
GCS_DEFAULT_FLUSH_INTERVAL_SECONDS = 20
|
||||
|
||||
|
||||
class GCSBucketLogger(GCSBucketBase):
|
||||
class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
|
||||
def __init__(self, bucket_name: Optional[str] = None) -> None:
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
|
@ -39,6 +43,7 @@ class GCSBucketLogger(GCSBucketBase):
|
|||
batch_size=self.batch_size,
|
||||
flush_interval=self.flush_interval,
|
||||
)
|
||||
AdditionalLoggingUtils.__init__(self)
|
||||
|
||||
if premium_user is not True:
|
||||
raise ValueError(
|
||||
|
@ -150,11 +155,16 @@ class GCSBucketLogger(GCSBucketBase):
|
|||
"""
|
||||
Get the object name to use for the current payload
|
||||
"""
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
current_date = self._get_object_date_from_datetime(datetime.now(timezone.utc))
|
||||
if logging_payload.get("error_str", None) is not None:
|
||||
object_name = f"{current_date}/failure-{uuid.uuid4().hex}"
|
||||
object_name = self._generate_failure_object_name(
|
||||
request_date_str=current_date,
|
||||
)
|
||||
else:
|
||||
object_name = f"{current_date}/{response_obj.get('id', '')}"
|
||||
object_name = self._generate_success_object_name(
|
||||
request_date_str=current_date,
|
||||
response_id=response_obj.get("id", ""),
|
||||
)
|
||||
|
||||
# used for testing
|
||||
_litellm_params = kwargs.get("litellm_params", None) or {}
|
||||
|
@ -163,3 +173,65 @@ class GCSBucketLogger(GCSBucketBase):
|
|||
object_name = _metadata["gcs_log_id"]
|
||||
|
||||
return object_name
|
||||
|
||||
async def get_request_response_payload(
|
||||
self,
|
||||
request_id: str,
|
||||
start_time_utc: Optional[datetime],
|
||||
end_time_utc: Optional[datetime],
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Get the request and response payload for a given `request_id`
|
||||
Tries current day, next day, and previous day until it finds the payload
|
||||
"""
|
||||
if start_time_utc is None:
|
||||
raise ValueError(
|
||||
"start_time_utc is required for getting a payload from GCS Bucket"
|
||||
)
|
||||
|
||||
# Try current day, next day, and previous day
|
||||
dates_to_try = [
|
||||
start_time_utc,
|
||||
start_time_utc + timedelta(days=1),
|
||||
start_time_utc - timedelta(days=1),
|
||||
]
|
||||
date_str = None
|
||||
for date in dates_to_try:
|
||||
try:
|
||||
date_str = self._get_object_date_from_datetime(datetime_obj=date)
|
||||
object_name = self._generate_success_object_name(
|
||||
request_date_str=date_str,
|
||||
response_id=request_id,
|
||||
)
|
||||
encoded_object_name = quote(object_name, safe="")
|
||||
response = await self.download_gcs_object(encoded_object_name)
|
||||
|
||||
if response is not None:
|
||||
loaded_response = json.loads(response)
|
||||
return loaded_response
|
||||
except Exception as e:
|
||||
verbose_logger.debug(
|
||||
f"Failed to fetch payload for date {date_str}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _generate_success_object_name(
|
||||
self,
|
||||
request_date_str: str,
|
||||
response_id: str,
|
||||
) -> str:
|
||||
return f"{request_date_str}/{response_id}"
|
||||
|
||||
def _generate_failure_object_name(
|
||||
self,
|
||||
request_date_str: str,
|
||||
) -> str:
|
||||
return f"{request_date_str}/failure-{uuid.uuid4().hex}"
|
||||
|
||||
def _get_object_date_from_datetime(self, datetime_obj: datetime) -> str:
|
||||
return datetime_obj.strftime("%Y-%m-%d")
|
||||
|
||||
async def async_health_check(self) -> IntegrationHealthCheckStatus:
|
||||
raise NotImplementedError("GCS Bucket does not support health check")
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
from typing import Callable, List, Union
|
||||
from typing import Callable, List, Set, Union
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.additional_logging_utils import AdditionalLoggingUtils
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
|
||||
|
@ -220,3 +221,36 @@ class LoggingCallbackManager:
|
|||
litellm._async_success_callback = []
|
||||
litellm._async_failure_callback = []
|
||||
litellm.callbacks = []
|
||||
|
||||
def _get_all_callbacks(self) -> List[Union[CustomLogger, Callable, str]]:
|
||||
"""
|
||||
Get all callbacks from litellm.callbacks, litellm.success_callback, litellm.failure_callback, litellm._async_success_callback, litellm._async_failure_callback
|
||||
"""
|
||||
return (
|
||||
litellm.callbacks
|
||||
+ litellm.success_callback
|
||||
+ litellm.failure_callback
|
||||
+ litellm._async_success_callback
|
||||
+ litellm._async_failure_callback
|
||||
)
|
||||
|
||||
def get_active_additional_logging_utils_from_custom_logger(
|
||||
self,
|
||||
) -> Set[AdditionalLoggingUtils]:
|
||||
"""
|
||||
Get all custom loggers that are instances of the given class type
|
||||
|
||||
Args:
|
||||
class_type: The class type to match against (e.g., AdditionalLoggingUtils)
|
||||
|
||||
Returns:
|
||||
Set[CustomLogger]: Set of custom loggers that are instances of the given class type
|
||||
"""
|
||||
all_callbacks = self._get_all_callbacks()
|
||||
matched_callbacks: Set[AdditionalLoggingUtils] = set()
|
||||
for callback in all_callbacks:
|
||||
if isinstance(callback, CustomLogger) and isinstance(
|
||||
callback, AdditionalLoggingUtils
|
||||
):
|
||||
matched_callbacks.add(callback)
|
||||
return matched_callbacks
|
||||
|
|
|
@ -5,8 +5,11 @@ model_list:
|
|||
api_key: my-fake-key
|
||||
api_base: https://exampleopenaiendpoint-production.up.railway.app/
|
||||
|
||||
litellm_settings:
|
||||
cache: True
|
||||
cache_params:
|
||||
type: redis
|
||||
|
||||
general_settings:
|
||||
store_model_in_db: true
|
||||
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["gcs_bucket"]
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import collections
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
|
||||
import fastapi
|
||||
|
@ -1759,6 +1760,56 @@ async def ui_view_spend_logs( # noqa: PLR0915
|
|||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
@router.get(
|
||||
"/spend/logs/ui/{request_id}",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def ui_view_request_response_for_request_id(
|
||||
request_id: str,
|
||||
start_date: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Time from which to start viewing key spend",
|
||||
),
|
||||
end_date: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Time till which to view key spend",
|
||||
),
|
||||
):
|
||||
"""
|
||||
View request / response for a specific request_id
|
||||
|
||||
- goes through all callbacks, checks if any of them have a @property -> has_request_response_payload
|
||||
- if so, it will return the request and response payload
|
||||
"""
|
||||
custom_loggers = (
|
||||
litellm.logging_callback_manager.get_active_additional_logging_utils_from_custom_logger()
|
||||
)
|
||||
start_date_obj: Optional[datetime] = None
|
||||
end_date_obj: Optional[datetime] = None
|
||||
if start_date is not None:
|
||||
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
if end_date is not None:
|
||||
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
for custom_logger in custom_loggers:
|
||||
payload = await custom_logger.get_request_response_payload(
|
||||
request_id=request_id,
|
||||
start_time_utc=start_date_obj,
|
||||
end_time_utc=end_date_obj,
|
||||
)
|
||||
if payload is not None:
|
||||
return payload
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spend/logs",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
|
|
|
@ -11,6 +11,7 @@ sys.path.insert(0, os.path.abspath("../.."))
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.mark.skip(reason="Traceloop use `otel` integration instead")
|
||||
def exporter():
|
||||
from traceloop.sdk import Traceloop
|
||||
|
||||
|
@ -27,6 +28,7 @@ def exporter():
|
|||
|
||||
|
||||
@pytest.mark.parametrize("model", ["claude-3-5-haiku-20241022", "gpt-3.5-turbo"])
|
||||
@pytest.mark.skip(reason="Traceloop use `otel` integration instead")
|
||||
def test_traceloop_logging(exporter, model):
|
||||
litellm.completion(
|
||||
model=model,
|
||||
|
|
208
tests/logging_callback_tests/test_view_request_resp_logs.py
Normal file
208
tests/logging_callback_tests/test_view_request_resp_logs.py
Normal file
|
@ -0,0 +1,208 @@
|
|||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm import completion
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.gcs_bucket.gcs_bucket import (
|
||||
GCSBucketLogger,
|
||||
StandardLoggingPayload,
|
||||
)
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
|
||||
# This is the response payload that GCS would return.
|
||||
mock_response_data = {
|
||||
"id": "chatcmpl-9870a859d6df402795f75dc5fca5b2e0",
|
||||
"trace_id": None,
|
||||
"call_type": "acompletion",
|
||||
"cache_hit": None,
|
||||
"stream": True,
|
||||
"status": "success",
|
||||
"custom_llm_provider": "openai",
|
||||
"saved_cache_cost": 0.0,
|
||||
"startTime": 1739235379.683053,
|
||||
"endTime": 1739235379.84533,
|
||||
"completionStartTime": 1739235379.84533,
|
||||
"response_time": 0.1622769832611084,
|
||||
"model": "my-fake-model",
|
||||
"metadata": {
|
||||
"user_api_key_hash": "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b",
|
||||
"user_api_key_alias": None,
|
||||
"user_api_key_team_id": None,
|
||||
"user_api_key_org_id": None,
|
||||
"user_api_key_user_id": "default_user_id",
|
||||
"user_api_key_team_alias": None,
|
||||
"spend_logs_metadata": None,
|
||||
"requester_ip_address": "127.0.0.1",
|
||||
"requester_metadata": {},
|
||||
"user_api_key_end_user_id": None,
|
||||
"prompt_management_metadata": None,
|
||||
},
|
||||
"cache_key": None,
|
||||
"response_cost": 3.7500000000000003e-05,
|
||||
"total_tokens": 21,
|
||||
"prompt_tokens": 9,
|
||||
"completion_tokens": 12,
|
||||
"request_tags": [],
|
||||
"end_user": "",
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app",
|
||||
"model_group": "fake-openai-endpoint",
|
||||
"model_id": "b68d56d76b0c24ac9462ab69541e90886342508212210116e300441155f37865",
|
||||
"requester_ip_address": "127.0.0.1",
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "very gm to u"}]}
|
||||
],
|
||||
"response": {
|
||||
"id": "chatcmpl-9870a859d6df402795f75dc5fca5b2e0",
|
||||
"created": 1677652288,
|
||||
"model": "gpt-3.5-turbo-0301",
|
||||
"object": "chat.completion",
|
||||
"system_fingerprint": "fp_44709d6fcb",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "\n\nHello there, how may I assist you today?",
|
||||
"role": "assistant",
|
||||
"tool_calls": None,
|
||||
"function_call": None,
|
||||
"refusal": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"completion_tokens": 12,
|
||||
"prompt_tokens": 9,
|
||||
"total_tokens": 21,
|
||||
"completion_tokens_details": None,
|
||||
"prompt_tokens_details": None,
|
||||
},
|
||||
"service_tier": None,
|
||||
},
|
||||
"model_parameters": {"stream": False, "max_retries": 0, "extra_body": {}},
|
||||
"hidden_params": {
|
||||
"model_id": "b68d56d76b0c24ac9462ab69541e90886342508212210116e300441155f37865",
|
||||
"cache_key": None,
|
||||
"api_base": "https://exampleopenaiendpoint-production.up.railway.app/",
|
||||
"response_cost": 3.7500000000000003e-05,
|
||||
"additional_headers": {},
|
||||
"litellm_overhead_time_ms": 2.126,
|
||||
},
|
||||
"model_map_information": {
|
||||
"model_map_key": "gpt-3.5-turbo-0301",
|
||||
"model_map_value": {},
|
||||
},
|
||||
"error_str": None,
|
||||
"error_information": {"error_code": "", "error_class": "", "llm_provider": ""},
|
||||
"response_cost_failure_debug_info": None,
|
||||
"guardrail_information": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_payload_current_day():
|
||||
"""
|
||||
Verify that the payload is returned when it is found on the current day.
|
||||
"""
|
||||
gcs_logger = GCSBucketLogger()
|
||||
# Use January 1, 2024 as the current day
|
||||
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
request_id = mock_response_data["id"]
|
||||
|
||||
async def fake_download(object_name: str, **kwargs) -> bytes | None:
|
||||
if "2024-01-01" in object_name:
|
||||
return json.dumps(mock_response_data).encode("utf-8")
|
||||
return None
|
||||
|
||||
gcs_logger.download_gcs_object = fake_download
|
||||
|
||||
payload = await gcs_logger.get_request_response_payload(
|
||||
request_id, start_time, None
|
||||
)
|
||||
assert payload is not None
|
||||
assert payload["id"] == request_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_payload_next_day():
|
||||
"""
|
||||
Verify that if the payload is not found on the current day,
|
||||
but is available on the next day, it is returned.
|
||||
"""
|
||||
gcs_logger = GCSBucketLogger()
|
||||
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
request_id = mock_response_data["id"]
|
||||
|
||||
async def fake_download(object_name: str, **kwargs) -> bytes | None:
|
||||
if "2024-01-02" in object_name:
|
||||
return json.dumps(mock_response_data).encode("utf-8")
|
||||
return None
|
||||
|
||||
gcs_logger.download_gcs_object = fake_download
|
||||
|
||||
payload = await gcs_logger.get_request_response_payload(
|
||||
request_id, start_time, None
|
||||
)
|
||||
assert payload is not None
|
||||
assert payload["id"] == request_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_payload_previous_day():
|
||||
"""
|
||||
Verify that if the payload is not found on the current or next day,
|
||||
but is available on the previous day, it is returned.
|
||||
"""
|
||||
gcs_logger = GCSBucketLogger()
|
||||
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
request_id = mock_response_data["id"]
|
||||
|
||||
async def fake_download(object_name: str, **kwargs) -> bytes | None:
|
||||
if "2023-12-31" in object_name:
|
||||
return json.dumps(mock_response_data).encode("utf-8")
|
||||
return None
|
||||
|
||||
gcs_logger.download_gcs_object = fake_download
|
||||
|
||||
payload = await gcs_logger.get_request_response_payload(
|
||||
request_id, start_time, None
|
||||
)
|
||||
assert payload is not None
|
||||
assert payload["id"] == request_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_payload_not_found():
|
||||
"""
|
||||
Verify that if none of the three days contain the payload, None is returned.
|
||||
"""
|
||||
gcs_logger = GCSBucketLogger()
|
||||
start_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
request_id = mock_response_data["id"]
|
||||
|
||||
async def fake_download(object_name: str, **kwargs) -> bytes | None:
|
||||
return None
|
||||
|
||||
gcs_logger.download_gcs_object = fake_download
|
||||
|
||||
payload = await gcs_logger.get_request_response_payload(
|
||||
request_id, start_time, None
|
||||
)
|
||||
assert payload is None
|
|
@ -3221,3 +3221,41 @@ export const getGuardrailsList = async (accessToken: String) => {
|
|||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const uiSpendLogDetailsCall = async (
|
||||
accessToken: string,
|
||||
logId: string,
|
||||
start_date: string
|
||||
) => {
|
||||
try {
|
||||
// Construct base URL
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/spend/logs/ui/${logId}?start_date=${encodeURIComponent(start_date)}`
|
||||
: `/spend/logs/ui/${logId}?start_date=${encodeURIComponent(start_date)}`;
|
||||
|
||||
console.log("Fetching log details from:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
handleError(errorData);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Fetched log details:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch log details:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import moment from "moment";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
import { uiSpendLogsCall } from "../networking";
|
||||
import { uiSpendLogsCall, uiSpendLogDetailsCall } from "../networking";
|
||||
import { DataTable } from "./table";
|
||||
import { columns, LogEntry } from "./columns";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { RequestViewer } from "./request_viewer";
|
||||
import { prefetchLogDetails } from "./prefetch";
|
||||
|
||||
interface SpendLogsTableProps {
|
||||
accessToken: string | null;
|
||||
|
@ -54,6 +55,8 @@ export default function SpendLogsTable({
|
|||
const [selectedKeyHash, setSelectedKeyHash] = useState("");
|
||||
const [selectedFilter, setSelectedFilter] = useState("Team ID");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
@ -82,6 +85,7 @@ export default function SpendLogsTable({
|
|||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Update the logs query to use the imported prefetchLogDetails
|
||||
const logs = useQuery<PaginatedResponse>({
|
||||
queryKey: [
|
||||
"logs",
|
||||
|
@ -105,13 +109,21 @@ export default function SpendLogsTable({
|
|||
};
|
||||
}
|
||||
|
||||
// Convert times to UTC before formatting
|
||||
console.log("Fetching logs with params:", {
|
||||
startTime,
|
||||
endTime,
|
||||
selectedTeamId,
|
||||
selectedKeyHash,
|
||||
currentPage,
|
||||
pageSize
|
||||
});
|
||||
|
||||
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const formattedEndTime = isCustomDate
|
||||
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
|
||||
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
return await uiSpendLogsCall(
|
||||
const response = await uiSpendLogsCall(
|
||||
accessToken,
|
||||
selectedKeyHash || undefined,
|
||||
selectedTeamId || undefined,
|
||||
|
@ -121,30 +133,62 @@ export default function SpendLogsTable({
|
|||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
|
||||
console.log("Received logs response:", response);
|
||||
|
||||
// Update prefetchLogDetails call with new parameters
|
||||
prefetchLogDetails(response.data, formattedStartTime, accessToken, queryClient);
|
||||
|
||||
return response;
|
||||
},
|
||||
enabled: !!accessToken && !!token && !!userRole && !!userID,
|
||||
refetchInterval: 5000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Move useQueries before the early return
|
||||
const logDetailsQueries = useQueries({
|
||||
queries: logs.data?.data?.map((log) => ({
|
||||
queryKey: ["logDetails", log.request_id, moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")],
|
||||
queryFn: () => uiSpendLogDetailsCall(accessToken!, log.request_id, moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
cacheTime: 10 * 60 * 1000,
|
||||
enabled: !!log.request_id,
|
||||
})) || []
|
||||
});
|
||||
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
console.log(
|
||||
"got None values for one of accessToken, token, userRole, userID",
|
||||
);
|
||||
console.log("got None values for one of accessToken, token, userRole, userID");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Consolidate log details from queries
|
||||
const logDetails: Record<string, any> = {};
|
||||
logDetailsQueries.forEach((q, index) => {
|
||||
const log = logs.data?.data[index];
|
||||
if (log && q.data) {
|
||||
logDetails[log.request_id] = q.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Modify the filtered data to include log details
|
||||
const filteredData =
|
||||
logs.data?.data?.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
log.request_id.includes(searchTerm) ||
|
||||
log.model.includes(searchTerm) ||
|
||||
(log.user && log.user.includes(searchTerm));
|
||||
|
||||
// No need for additional filtering since we're now handling this in the API call
|
||||
return matchesSearch;
|
||||
}) || [];
|
||||
logs.data?.data
|
||||
?.filter((log) => {
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
log.request_id.includes(searchTerm) ||
|
||||
log.model.includes(searchTerm) ||
|
||||
(log.user && log.user.includes(searchTerm));
|
||||
|
||||
return matchesSearch;
|
||||
})
|
||||
.map(log => ({
|
||||
...log,
|
||||
// Include messages/response from cached details
|
||||
messages: logDetails[log.request_id]?.messages || [],
|
||||
response: logDetails[log.request_id]?.response || {},
|
||||
})) || [];
|
||||
|
||||
// Add this function to handle manual refresh
|
||||
const handleRefresh = () => {
|
||||
|
@ -529,155 +573,3 @@ export default function SpendLogsTable({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
const formatData = (input: any) => {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 space-y-6">
|
||||
{/* Combined Info Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="text-lg font-medium ">Request Details</h3>
|
||||
</div>
|
||||
<div className="space-y-2 p-4 ">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Request ID:</span>
|
||||
<span>{row.original.request_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Api Key:</span>
|
||||
<span>{row.original.api_key}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Team ID:</span>
|
||||
<span>{row.original.team_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Model:</span>
|
||||
<span>{row.original.model}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Api Base:</span>
|
||||
<span>{row.original.api_base}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Call Type:</span>
|
||||
<span>{row.original.call_type}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Spend:</span>
|
||||
<span>{row.original.spend}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Total Tokens:</span>
|
||||
<span>{row.original.total_tokens}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Prompt Tokens:</span>
|
||||
<span>{row.original.prompt_tokens}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Completion Tokens:</span>
|
||||
<span>{row.original.completion_tokens}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{row.original.startTime}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{row.original.endTime}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Cache Hit:</span>
|
||||
<span>{row.original.cache_hit}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Cache Key:</span>
|
||||
<span>{row.original.cache_key}</span>
|
||||
</div>
|
||||
{row?.original?.requester_ip_address && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Request IP Address:</span>
|
||||
<span>{row?.original?.requester_ip_address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Request Tags</h3>
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(row.original.request_tags), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Request Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Request</h3>
|
||||
{/* <div>
|
||||
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
Expand
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
JSON
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(row.original.messages), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Response Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Response</h3>
|
||||
<div>
|
||||
{/* <button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
Expand
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
JSON
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(row.original.response), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Metadata Card */}
|
||||
{row.original.metadata &&
|
||||
Object.keys(row.original.metadata).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Metadata</h3>
|
||||
{/* <div>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
JSON
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm ">
|
||||
{JSON.stringify(row.original.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
23
ui/litellm-dashboard/src/components/view_logs/prefetch.ts
Normal file
23
ui/litellm-dashboard/src/components/view_logs/prefetch.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { uiSpendLogDetailsCall } from "../networking";
|
||||
import { LogEntry } from "./columns";
|
||||
|
||||
export const prefetchLogDetails = (
|
||||
logs: LogEntry[],
|
||||
formattedStartTime: string,
|
||||
accessToken: string,
|
||||
queryClient: QueryClient
|
||||
) => {
|
||||
logs.forEach((log) => {
|
||||
if (log.request_id) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["logDetails", log.request_id, formattedStartTime],
|
||||
queryFn: () => uiSpendLogDetailsCall(accessToken, log.request_id, formattedStartTime),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
}).catch((error) => {
|
||||
console.error(`Failed to prefetch details for log: ${log.request_id}`, error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
155
ui/litellm-dashboard/src/components/view_logs/request_viewer.tsx
Normal file
155
ui/litellm-dashboard/src/components/view_logs/request_viewer.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import { Row } from "@tanstack/react-table";
|
||||
import { LogEntry } from "./columns";
|
||||
|
||||
export function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
const formatData = (input: any) => {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 space-y-6">
|
||||
{/* Combined Info Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="text-lg font-medium ">Request Details</h3>
|
||||
</div>
|
||||
<div className="space-y-2 p-4 ">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Request ID:</span>
|
||||
<span>{row.original.request_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Api Key:</span>
|
||||
<span>{row.original.api_key}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Team ID:</span>
|
||||
<span>{row.original.team_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Model:</span>
|
||||
<span>{row.original.model}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Api Base:</span>
|
||||
<span>{row.original.api_base}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Call Type:</span>
|
||||
<span>{row.original.call_type}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Spend:</span>
|
||||
<span>{row.original.spend}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Total Tokens:</span>
|
||||
<span>{row.original.total_tokens}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Prompt Tokens:</span>
|
||||
<span>{row.original.prompt_tokens}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Completion Tokens:</span>
|
||||
<span>{row.original.completion_tokens}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{row.original.startTime}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{row.original.endTime}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Cache Hit:</span>
|
||||
<span>{row.original.cache_hit}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Cache Key:</span>
|
||||
<span>{row.original.cache_key}</span>
|
||||
</div>
|
||||
{row?.original?.requester_ip_address && (
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Request IP Address:</span>
|
||||
<span>{row?.original?.requester_ip_address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Request Tags</h3>
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(row.original.request_tags), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Request Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Request</h3>
|
||||
{/* <div>
|
||||
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
Expand
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
JSON
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(row.original.messages), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Response Card */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Response</h3>
|
||||
<div>
|
||||
{/* <button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
Expand
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
JSON
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm">
|
||||
{JSON.stringify(formatData(row.original.response), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Metadata Card */}
|
||||
{row.original.metadata &&
|
||||
Object.keys(row.original.metadata).length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-medium">Metadata</h3>
|
||||
{/* <div>
|
||||
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">
|
||||
JSON
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
<pre className="p-4 text-wrap overflow-auto text-sm ">
|
||||
{JSON.stringify(row.original.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue