diff --git a/litellm/integrations/additional_logging_utils.py b/litellm/integrations/additional_logging_utils.py new file mode 100644 index 0000000000..795afd81d4 --- /dev/null +++ b/litellm/integrations/additional_logging_utils.py @@ -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 diff --git a/litellm/integrations/base_health_check.py b/litellm/integrations/base_health_check.py deleted file mode 100644 index 35b390692b..0000000000 --- a/litellm/integrations/base_health_check.py +++ /dev/null @@ -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 diff --git a/litellm/integrations/datadog/datadog.py b/litellm/integrations/datadog/datadog.py index 89928840e9..04364d3a7f 100644 --- a/litellm/integrations/datadog/datadog.py +++ b/litellm/integrations/datadog/datadog.py @@ -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" + ) diff --git a/litellm/integrations/gcs_bucket/gcs_bucket.py b/litellm/integrations/gcs_bucket/gcs_bucket.py index d6a9c316b3..187ab779c0 100644 --- a/litellm/integrations/gcs_bucket/gcs_bucket.py +++ b/litellm/integrations/gcs_bucket/gcs_bucket.py @@ -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") diff --git a/litellm/litellm_core_utils/logging_callback_manager.py b/litellm/litellm_core_utils/logging_callback_manager.py index e55df44474..a20e826c43 100644 --- a/litellm/litellm_core_utils/logging_callback_manager.py +++ b/litellm/litellm_core_utils/logging_callback_manager.py @@ -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 diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index f83160a7a2..0d4b9b5f0a 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -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"] diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 00497c5f45..de540e1226 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -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"], diff --git a/tests/local_testing/test_traceloop.py b/tests/local_testing/test_traceloop.py index 5cab8dd59c..1127845a5d 100644 --- a/tests/local_testing/test_traceloop.py +++ b/tests/local_testing/test_traceloop.py @@ -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, diff --git a/tests/logging_callback_tests/test_view_request_resp_logs.py b/tests/logging_callback_tests/test_view_request_resp_logs.py new file mode 100644 index 0000000000..a8018b62ee --- /dev/null +++ b/tests/logging_callback_tests/test_view_request_resp_logs.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 1df3f228f7..a59cf14600 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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; + } +}; + diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 161cc7be8b..ed9b878854 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -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({ 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 = {}; + 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({ ); } - -function RequestViewer({ row }: { row: Row }) { - const formatData = (input: any) => { - if (typeof input === "string") { - try { - return JSON.parse(input); - } catch { - return input; - } - } - return input; - }; - - return ( -
- {/* Combined Info Card */} -
-
-

Request Details

-
-
-
- Request ID: - {row.original.request_id} -
-
- Api Key: - {row.original.api_key} -
-
- Team ID: - {row.original.team_id} -
-
- Model: - {row.original.model} -
-
- Api Base: - {row.original.api_base} -
-
- Call Type: - {row.original.call_type} -
-
- Spend: - {row.original.spend} -
-
- Total Tokens: - {row.original.total_tokens} -
-
- Prompt Tokens: - {row.original.prompt_tokens} -
-
- Completion Tokens: - {row.original.completion_tokens} -
-
- Start Time: - {row.original.startTime} -
-
- End Time: - {row.original.endTime} -
-
- Cache Hit: - {row.original.cache_hit} -
-
- Cache Key: - {row.original.cache_key} -
- {row?.original?.requester_ip_address && ( -
- Request IP Address: - {row?.original?.requester_ip_address} -
- )} -
-
- - {/* Request Card */} -
-
-

Request Tags

-
-
-          {JSON.stringify(formatData(row.original.request_tags), null, 2)}
-        
-
- - {/* Request Card */} -
-
-

Request

- {/*
- - -
*/} -
-
-          {JSON.stringify(formatData(row.original.messages), null, 2)}
-        
-
- - {/* Response Card */} -
-
-

Response

-
- {/* - */} -
-
-
-          {JSON.stringify(formatData(row.original.response), null, 2)}
-        
-
- - {/* Metadata Card */} - {row.original.metadata && - Object.keys(row.original.metadata).length > 0 && ( -
-
-

Metadata

- {/*
- -
*/} -
-
-              {JSON.stringify(row.original.metadata, null, 2)}
-            
-
- )} -
- ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/prefetch.ts b/ui/litellm-dashboard/src/components/view_logs/prefetch.ts new file mode 100644 index 0000000000..fa0d388720 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/prefetch.ts @@ -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); + }); + } + }); +}; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/view_logs/request_viewer.tsx b/ui/litellm-dashboard/src/components/view_logs/request_viewer.tsx new file mode 100644 index 0000000000..63af184300 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/request_viewer.tsx @@ -0,0 +1,155 @@ +import { Row } from "@tanstack/react-table"; +import { LogEntry } from "./columns"; + +export function RequestViewer({ row }: { row: Row }) { + const formatData = (input: any) => { + if (typeof input === "string") { + try { + return JSON.parse(input); + } catch { + return input; + } + } + return input; + }; + + return ( +
+ {/* Combined Info Card */} +
+
+

Request Details

+
+
+
+ Request ID: + {row.original.request_id} +
+
+ Api Key: + {row.original.api_key} +
+
+ Team ID: + {row.original.team_id} +
+
+ Model: + {row.original.model} +
+
+ Api Base: + {row.original.api_base} +
+
+ Call Type: + {row.original.call_type} +
+
+ Spend: + {row.original.spend} +
+
+ Total Tokens: + {row.original.total_tokens} +
+
+ Prompt Tokens: + {row.original.prompt_tokens} +
+
+ Completion Tokens: + {row.original.completion_tokens} +
+
+ Start Time: + {row.original.startTime} +
+
+ End Time: + {row.original.endTime} +
+
+ Cache Hit: + {row.original.cache_hit} +
+
+ Cache Key: + {row.original.cache_key} +
+ {row?.original?.requester_ip_address && ( +
+ Request IP Address: + {row?.original?.requester_ip_address} +
+ )} +
+
+ + {/* Request Card */} +
+
+

Request Tags

+
+
+            {JSON.stringify(formatData(row.original.request_tags), null, 2)}
+          
+
+ + {/* Request Card */} +
+
+

Request

+ {/*
+ + +
*/} +
+
+            {JSON.stringify(formatData(row.original.messages), null, 2)}
+          
+
+ + {/* Response Card */} +
+
+

Response

+
+ {/* + */} +
+
+
+            {JSON.stringify(formatData(row.original.response), null, 2)}
+          
+
+ + {/* Metadata Card */} + {row.original.metadata && + Object.keys(row.original.metadata).length > 0 && ( +
+
+

Metadata

+ {/*
+ +
*/} +
+
+                {JSON.stringify(row.original.metadata, null, 2)}
+              
+
+ )} +
+ ); + } + \ No newline at end of file