mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-14 07:05:10 +00:00
[Feat] Responses API - Add session management support for non-openai models (#10321)
* add session id in spendLogs * fix log proxy server request as independant field * use trace id for SpendLogs * add _ENTERPRISE_ResponsesSessionHandler * use _ENTERPRISE_ResponsesSessionHandler * working session_ids * working session management * working session_ids * test_async_gcs_pub_sub_v1 * test_spend_logs_payload_e2e * working session_ids * test_get_standard_logging_payload_trace_id * test_get_standard_logging_payload_trace_id * test_gcs_pub_sub.py * fix all linting errors * test_spend_logs_payload_with_prompts_enabled * _ENTERPRISE_ResponsesSessionHandler * _ENTERPRISE_ResponsesSessionHandler * expose session id on ui * get spend logs by session * add sessionSpendLogsCall * add session handling * session logs * ui session details * fix on rowExpandDetails * ui working sessions
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
from litellm.proxy._types import SpendLogsPayload
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from typing import Optional, List, Union
|
||||
import json
|
||||
from litellm.types.utils import ModelResponse, Message
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionResponseMessage,
|
||||
GenericChatCompletionMessage,
|
||||
ResponseInputParam,
|
||||
)
|
||||
from litellm.types.utils import ChatCompletionMessageToolCall
|
||||
|
||||
from litellm.responses.utils import ResponsesAPIRequestUtils
|
||||
from typing import TypedDict
|
||||
|
||||
class ChatCompletionSession(TypedDict, total=False):
|
||||
messages: List[Union[AllMessageValues, GenericChatCompletionMessage, ChatCompletionMessageToolCall, ChatCompletionResponseMessage, Message]]
|
||||
litellm_session_id: Optional[str]
|
||||
|
||||
class _ENTERPRISE_ResponsesSessionHandler:
|
||||
@staticmethod
|
||||
async def get_chat_completion_message_history_for_previous_response_id(
|
||||
previous_response_id: str,
|
||||
) -> ChatCompletionSession:
|
||||
"""
|
||||
Return the chat completion message history for a previous response id
|
||||
"""
|
||||
from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig
|
||||
all_spend_logs: List[SpendLogsPayload] = await _ENTERPRISE_ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id(previous_response_id)
|
||||
|
||||
litellm_session_id: Optional[str] = None
|
||||
if len(all_spend_logs) > 0:
|
||||
litellm_session_id = all_spend_logs[0].get("session_id")
|
||||
|
||||
chat_completion_message_history: List[
|
||||
Union[
|
||||
AllMessageValues,
|
||||
GenericChatCompletionMessage,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionResponseMessage,
|
||||
Message,
|
||||
]
|
||||
] = []
|
||||
for spend_log in all_spend_logs:
|
||||
proxy_server_request: Union[str, dict] = spend_log.get("proxy_server_request") or "{}"
|
||||
proxy_server_request_dict: Optional[dict] = None
|
||||
response_input_param: Optional[Union[str, ResponseInputParam]] = None
|
||||
if isinstance(proxy_server_request, dict):
|
||||
proxy_server_request_dict = proxy_server_request
|
||||
else:
|
||||
proxy_server_request_dict = json.loads(proxy_server_request)
|
||||
|
||||
############################################################
|
||||
# Add Input messages for this Spend Log
|
||||
############################################################
|
||||
if proxy_server_request_dict:
|
||||
_response_input_param = proxy_server_request_dict.get("input", None)
|
||||
if isinstance(_response_input_param, str):
|
||||
response_input_param = _response_input_param
|
||||
elif isinstance(_response_input_param, dict):
|
||||
response_input_param = ResponseInputParam(**_response_input_param)
|
||||
|
||||
if response_input_param:
|
||||
chat_completion_messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=response_input_param,
|
||||
responses_api_request=proxy_server_request_dict or {}
|
||||
)
|
||||
chat_completion_message_history.extend(chat_completion_messages)
|
||||
|
||||
############################################################
|
||||
# Add Output messages for this Spend Log
|
||||
############################################################
|
||||
_response_output = spend_log.get("response", "{}")
|
||||
if isinstance(_response_output, dict):
|
||||
# transform `ChatCompletion Response` to `ResponsesAPIResponse`
|
||||
model_response = ModelResponse(**_response_output)
|
||||
for choice in model_response.choices:
|
||||
if hasattr(choice, "message"):
|
||||
chat_completion_message_history.append(choice.message)
|
||||
|
||||
verbose_proxy_logger.debug("chat_completion_message_history %s", json.dumps(chat_completion_message_history, indent=4, default=str))
|
||||
return ChatCompletionSession(
|
||||
messages=chat_completion_message_history,
|
||||
litellm_session_id=litellm_session_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_all_spend_logs_for_previous_response_id(
|
||||
previous_response_id: str
|
||||
) -> List[SpendLogsPayload]:
|
||||
"""
|
||||
Get all spend logs for a previous response id
|
||||
|
||||
|
||||
SQL query
|
||||
|
||||
SELECT session_id FROM spend_logs WHERE response_id = previous_response_id, SELECT * FROM spend_logs WHERE session_id = session_id
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
decoded_response_id = ResponsesAPIRequestUtils._decode_responses_api_response_id(previous_response_id)
|
||||
previous_response_id = decoded_response_id.get("response_id", previous_response_id)
|
||||
if prisma_client is None:
|
||||
return []
|
||||
|
||||
query = """
|
||||
WITH matching_session AS (
|
||||
SELECT session_id
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE request_id = $1
|
||||
)
|
||||
SELECT *
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE session_id IN (SELECT session_id FROM matching_session)
|
||||
ORDER BY "endTime" ASC;
|
||||
"""
|
||||
|
||||
spend_logs = await prisma_client.db.query_raw(
|
||||
query,
|
||||
previous_response_id
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"Found the following spend logs for previous response id %s: %s",
|
||||
previous_response_id,
|
||||
json.dumps(spend_logs, indent=4, default=str)
|
||||
)
|
||||
|
||||
|
||||
return spend_logs
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "proxy_server_request" JSONB DEFAULT '{}',
|
||||
ADD COLUMN "session_id" TEXT;
|
||||
|
||||
@@ -226,6 +226,8 @@ model LiteLLM_SpendLogs {
|
||||
requester_ip_address String?
|
||||
messages Json? @default("{}")
|
||||
response Json? @default("{}")
|
||||
session_id String?
|
||||
proxy_server_request Json? @default("{}")
|
||||
@@index([startTime])
|
||||
@@index([end_user])
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ from litellm._logging import _is_debugging_on, verbose_logger
|
||||
from litellm.batches.batch_utils import _handle_completed_batch
|
||||
from litellm.caching.caching import DualCache, InMemoryCache
|
||||
from litellm.caching.caching_handler import LLMCachingHandler
|
||||
|
||||
from litellm.constants import (
|
||||
DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT,
|
||||
DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT,
|
||||
@@ -249,7 +248,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||
self.start_time = start_time # log the call start time
|
||||
self.call_type = call_type
|
||||
self.litellm_call_id = litellm_call_id
|
||||
self.litellm_trace_id = litellm_trace_id
|
||||
self.litellm_trace_id: str = litellm_trace_id or str(uuid.uuid4())
|
||||
self.function_id = function_id
|
||||
self.streaming_chunks: List[Any] = [] # for generating complete stream response
|
||||
self.sync_streaming_chunks: List[Any] = (
|
||||
@@ -3500,6 +3499,21 @@ class StandardLoggingPayloadSetup:
|
||||
else:
|
||||
return end_time_float - start_time_float
|
||||
|
||||
@staticmethod
|
||||
def _get_standard_logging_payload_trace_id(
|
||||
logging_obj: Logging,
|
||||
litellm_params: dict,
|
||||
) -> str:
|
||||
"""
|
||||
Returns the `litellm_trace_id` for this request
|
||||
|
||||
This helps link sessions when multiple requests are made in a single session
|
||||
"""
|
||||
dynamic_trace_id = litellm_params.get("litellm_trace_id")
|
||||
if dynamic_trace_id:
|
||||
return str(dynamic_trace_id)
|
||||
return logging_obj.litellm_trace_id
|
||||
|
||||
|
||||
def get_standard_logging_object_payload(
|
||||
kwargs: Optional[dict],
|
||||
@@ -3652,7 +3666,10 @@ def get_standard_logging_object_payload(
|
||||
|
||||
payload: StandardLoggingPayload = StandardLoggingPayload(
|
||||
id=str(id),
|
||||
trace_id=kwargs.get("litellm_trace_id"), # type: ignore
|
||||
trace_id=StandardLoggingPayloadSetup._get_standard_logging_payload_trace_id(
|
||||
logging_obj=logging_obj,
|
||||
litellm_params=litellm_params,
|
||||
),
|
||||
call_type=call_type or "",
|
||||
cache_hit=cache_hit,
|
||||
stream=stream,
|
||||
|
||||
+35
-33
@@ -654,9 +654,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
|
||||
allowed_cache_controls: Optional[list] = []
|
||||
config: Optional[dict] = {}
|
||||
permissions: Optional[dict] = {}
|
||||
model_max_budget: Optional[
|
||||
dict
|
||||
] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
|
||||
model_max_budget: Optional[dict] = (
|
||||
{}
|
||||
) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
model_rpm_limit: Optional[dict] = None
|
||||
@@ -918,12 +918,12 @@ class NewCustomerRequest(BudgetNewRequest):
|
||||
alias: Optional[str] = None # human-friendly alias
|
||||
blocked: bool = False # allow/disallow requests for this end-user
|
||||
budget_id: Optional[str] = None # give either a budget_id or max_budget
|
||||
allowed_model_region: Optional[
|
||||
AllowedModelRegion
|
||||
] = None # require all user requests to use models in this specific region
|
||||
default_model: Optional[
|
||||
str
|
||||
] = None # if no equivalent model in allowed region - default all requests to this model
|
||||
allowed_model_region: Optional[AllowedModelRegion] = (
|
||||
None # require all user requests to use models in this specific region
|
||||
)
|
||||
default_model: Optional[str] = (
|
||||
None # if no equivalent model in allowed region - default all requests to this model
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -945,12 +945,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
|
||||
blocked: bool = False # allow/disallow requests for this end-user
|
||||
max_budget: Optional[float] = None
|
||||
budget_id: Optional[str] = None # give either a budget_id or max_budget
|
||||
allowed_model_region: Optional[
|
||||
AllowedModelRegion
|
||||
] = None # require all user requests to use models in this specific region
|
||||
default_model: Optional[
|
||||
str
|
||||
] = None # if no equivalent model in allowed region - default all requests to this model
|
||||
allowed_model_region: Optional[AllowedModelRegion] = (
|
||||
None # require all user requests to use models in this specific region
|
||||
)
|
||||
default_model: Optional[str] = (
|
||||
None # if no equivalent model in allowed region - default all requests to this model
|
||||
)
|
||||
|
||||
|
||||
class DeleteCustomerRequest(LiteLLMPydanticObjectBase):
|
||||
@@ -1086,9 +1086,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase):
|
||||
|
||||
class AddTeamCallback(LiteLLMPydanticObjectBase):
|
||||
callback_name: str
|
||||
callback_type: Optional[
|
||||
Literal["success", "failure", "success_and_failure"]
|
||||
] = "success_and_failure"
|
||||
callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = (
|
||||
"success_and_failure"
|
||||
)
|
||||
callback_vars: Dict[str, str]
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -1346,9 +1346,9 @@ class ConfigList(LiteLLMPydanticObjectBase):
|
||||
stored_in_db: Optional[bool]
|
||||
field_default_value: Any
|
||||
premium_field: bool = False
|
||||
nested_fields: Optional[
|
||||
List[FieldDetail]
|
||||
] = None # For nested dictionary or Pydantic fields
|
||||
nested_fields: Optional[List[FieldDetail]] = (
|
||||
None # For nested dictionary or Pydantic fields
|
||||
)
|
||||
|
||||
|
||||
class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
||||
@@ -1616,9 +1616,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase):
|
||||
budget_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user: Optional[
|
||||
Any
|
||||
] = None # You might want to replace 'Any' with a more specific type if available
|
||||
user: Optional[Any] = (
|
||||
None # You might want to replace 'Any' with a more specific type if available
|
||||
)
|
||||
litellm_budget_table: Optional[LiteLLM_BudgetTable] = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
@@ -2015,6 +2015,8 @@ class SpendLogsPayload(TypedDict):
|
||||
custom_llm_provider: Optional[str]
|
||||
messages: Optional[Union[str, list, dict]]
|
||||
response: Optional[Union[str, list, dict]]
|
||||
proxy_server_request: Optional[str]
|
||||
session_id: Optional[str]
|
||||
|
||||
|
||||
class SpanAttributes(str, enum.Enum):
|
||||
@@ -2366,9 +2368,9 @@ class TeamModelDeleteRequest(BaseModel):
|
||||
# Organization Member Requests
|
||||
class OrganizationMemberAddRequest(OrgMemberAddRequest):
|
||||
organization_id: str
|
||||
max_budget_in_organization: Optional[
|
||||
float
|
||||
] = None # Users max budget within the organization
|
||||
max_budget_in_organization: Optional[float] = (
|
||||
None # Users max budget within the organization
|
||||
)
|
||||
|
||||
|
||||
class OrganizationMemberDeleteRequest(MemberDeleteRequest):
|
||||
@@ -2557,9 +2559,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase):
|
||||
Maps provider names to their budget configs.
|
||||
"""
|
||||
|
||||
providers: Dict[
|
||||
str, ProviderBudgetResponseObject
|
||||
] = {} # Dictionary mapping provider names to their budget configurations
|
||||
providers: Dict[str, ProviderBudgetResponseObject] = (
|
||||
{}
|
||||
) # Dictionary mapping provider names to their budget configurations
|
||||
|
||||
|
||||
class ProxyStateVariables(TypedDict):
|
||||
@@ -2687,9 +2689,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
||||
enforce_rbac: bool = False
|
||||
roles_jwt_field: Optional[str] = None # v2 on role mappings
|
||||
role_mappings: Optional[List[RoleMapping]] = None
|
||||
object_id_jwt_field: Optional[
|
||||
str
|
||||
] = None # can be either user / team, inferred from the role mapping
|
||||
object_id_jwt_field: Optional[str] = (
|
||||
None # can be either user / team, inferred from the role mapping
|
||||
)
|
||||
scope_mappings: Optional[List[ScopeMapping]] = None
|
||||
enforce_scope_based_access: bool = False
|
||||
enforce_team_based_model_access: bool = False
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
model_list:
|
||||
- model_name: openai/*
|
||||
- model_name: anthropic/*
|
||||
litellm_params:
|
||||
model: openai/*
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
router_settings:
|
||||
optional_pre_call_checks: ["responses_api_deployment_check"]
|
||||
model: anthropic/*
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
general_settings:
|
||||
store_prompts_in_spend_logs: true
|
||||
|
||||
@@ -226,6 +226,8 @@ model LiteLLM_SpendLogs {
|
||||
requester_ip_address String?
|
||||
messages Json? @default("{}")
|
||||
response Json? @default("{}")
|
||||
session_id String?
|
||||
proxy_server_request Json? @default("{}")
|
||||
@@index([startTime])
|
||||
@@index([end_user])
|
||||
}
|
||||
|
||||
@@ -2858,3 +2858,47 @@ async def ui_get_spend_by_tags(
|
||||
)
|
||||
|
||||
return {"spend_per_tag": ui_tags}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spend/logs/session/ui",
|
||||
tags=["Budget & Spend Tracking"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
include_in_schema=False,
|
||||
responses={
|
||||
200: {"model": List[LiteLLM_SpendLogs]},
|
||||
},
|
||||
)
|
||||
async def ui_view_session_spend_logs(
|
||||
session_id: str = fastapi.Query(
|
||||
description="Get all spend logs for a particular session",
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Get all spend logs for a particular session
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
try:
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Database not connected",
|
||||
)
|
||||
|
||||
# Build query conditions
|
||||
where_conditions = {"session_id": session_id}
|
||||
# Query the database
|
||||
result = await prisma_client.db.litellm_spendlogs.find_many(
|
||||
where=where_conditions, order={"startTime": "asc"}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
@@ -137,9 +137,6 @@ def get_logging_payload( # noqa: PLR0915
|
||||
# standardize this function to be used across, s3, dynamoDB, langfuse logging
|
||||
litellm_params = kwargs.get("litellm_params", {})
|
||||
metadata = get_litellm_metadata_from_kwargs(kwargs)
|
||||
metadata = _add_proxy_server_request_to_metadata(
|
||||
metadata=metadata, litellm_params=litellm_params
|
||||
)
|
||||
completion_start_time = kwargs.get("completion_start_time", end_time)
|
||||
call_type = kwargs.get("call_type")
|
||||
cache_hit = kwargs.get("cache_hit", False)
|
||||
@@ -290,6 +287,14 @@ def get_logging_payload( # noqa: PLR0915
|
||||
standard_logging_payload=standard_logging_payload, metadata=metadata
|
||||
),
|
||||
response=_get_response_for_spend_logs_payload(standard_logging_payload),
|
||||
proxy_server_request=_get_proxy_server_request_for_spend_logs_payload(
|
||||
metadata=metadata, litellm_params=litellm_params
|
||||
),
|
||||
session_id=(
|
||||
standard_logging_payload.get("trace_id")
|
||||
if standard_logging_payload is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
@@ -427,10 +432,10 @@ def _sanitize_request_body_for_spend_logs_payload(
|
||||
return {k: _sanitize_value(v) for k, v in request_body.items()}
|
||||
|
||||
|
||||
def _add_proxy_server_request_to_metadata(
|
||||
def _get_proxy_server_request_for_spend_logs_payload(
|
||||
metadata: dict,
|
||||
litellm_params: dict,
|
||||
) -> dict:
|
||||
) -> str:
|
||||
"""
|
||||
Only store if _should_store_prompts_and_responses_in_spend_logs() is True
|
||||
"""
|
||||
@@ -442,8 +447,8 @@ def _add_proxy_server_request_to_metadata(
|
||||
_request_body = _proxy_server_request.get("body", {}) or {}
|
||||
_request_body = _sanitize_request_body_for_spend_logs_payload(_request_body)
|
||||
_request_body_json_str = json.dumps(_request_body, default=str)
|
||||
metadata["proxy_server_request"] = _request_body_json_str
|
||||
return metadata
|
||||
return _request_body_json_str
|
||||
return "{}"
|
||||
|
||||
|
||||
def _get_response_for_spend_logs_payload(
|
||||
|
||||
@@ -89,6 +89,16 @@ class LiteLLMCompletionTransformationHandler:
|
||||
responses_api_request: ResponsesAPIOptionalRequestParams,
|
||||
**kwargs,
|
||||
) -> Union[ResponsesAPIResponse, BaseResponsesAPIStreamingIterator]:
|
||||
|
||||
previous_response_id: Optional[str] = responses_api_request.get(
|
||||
"previous_response_id"
|
||||
)
|
||||
if previous_response_id:
|
||||
litellm_completion_request = await LiteLLMCompletionResponsesConfig.async_responses_api_session_handler(
|
||||
previous_response_id=previous_response_id,
|
||||
litellm_completion_request=litellm_completion_request,
|
||||
)
|
||||
|
||||
litellm_completion_response: Union[
|
||||
ModelResponse, litellm.CustomStreamWrapper
|
||||
] = await litellm.acompletion(
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
Responses API has previous_response_id, which is the id of the previous response.
|
||||
|
||||
LiteLLM needs to maintain a cache of the previous response input, output, previous_response_id, and model.
|
||||
|
||||
This class handles that cache.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm.caching import InMemoryCache
|
||||
from litellm.types.llms.openai import ResponseInputParam, ResponsesAPIResponse
|
||||
|
||||
RESPONSES_API_PREVIOUS_RESPONSES_CACHE = InMemoryCache()
|
||||
MAX_PREV_SESSION_INPUTS = 50
|
||||
|
||||
|
||||
class ResponsesAPISessionElement(TypedDict, total=False):
|
||||
input: Union[str, ResponseInputParam]
|
||||
output: ResponsesAPIResponse
|
||||
response_id: str
|
||||
previous_response_id: Optional[str]
|
||||
|
||||
|
||||
class SessionHandler:
|
||||
|
||||
def add_completed_response_to_cache(
|
||||
self, response_id: str, session_element: ResponsesAPISessionElement
|
||||
):
|
||||
RESPONSES_API_PREVIOUS_RESPONSES_CACHE.set_cache(
|
||||
key=response_id, value=session_element
|
||||
)
|
||||
|
||||
def get_chain_of_previous_input_output_pairs(
|
||||
self, previous_response_id: str
|
||||
) -> List[Tuple[ResponseInputParam, ResponsesAPIResponse]]:
|
||||
response_api_inputs: List[Tuple[ResponseInputParam, ResponsesAPIResponse]] = []
|
||||
current_previous_response_id = previous_response_id
|
||||
|
||||
count_session_elements = 0
|
||||
while current_previous_response_id:
|
||||
if count_session_elements > MAX_PREV_SESSION_INPUTS:
|
||||
break
|
||||
session_element = RESPONSES_API_PREVIOUS_RESPONSES_CACHE.get_cache(
|
||||
key=current_previous_response_id
|
||||
)
|
||||
if session_element:
|
||||
response_api_inputs.append(
|
||||
(session_element.get("input"), session_element.get("output"))
|
||||
)
|
||||
current_previous_response_id = session_element.get(
|
||||
"previous_response_id"
|
||||
)
|
||||
else:
|
||||
break
|
||||
count_session_elements += 1
|
||||
return response_api_inputs
|
||||
@@ -6,12 +6,12 @@ from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from openai.types.responses.tool_param import FunctionToolParam
|
||||
|
||||
from enterprise.enterprise_hooks.session_handler import (
|
||||
ChatCompletionSession,
|
||||
_ENTERPRISE_ResponsesSessionHandler,
|
||||
)
|
||||
from litellm.caching import InMemoryCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.responses.litellm_completion_transformation.session_handler import (
|
||||
ResponsesAPISessionElement,
|
||||
SessionHandler,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionResponseMessage,
|
||||
@@ -48,7 +48,6 @@ from litellm.types.utils import (
|
||||
|
||||
########### Initialize Classes used for Responses API ###########
|
||||
TOOL_CALLS_CACHE = InMemoryCache()
|
||||
RESPONSES_API_SESSION_HANDLER = SessionHandler()
|
||||
########### End of Initialize Classes used for Responses API ###########
|
||||
|
||||
|
||||
@@ -90,7 +89,6 @@ class LiteLLMCompletionResponsesConfig:
|
||||
"messages": LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
|
||||
input=input,
|
||||
responses_api_request=responses_api_request,
|
||||
previous_response_id=responses_api_request.get("previous_response_id"),
|
||||
),
|
||||
"model": model,
|
||||
"tool_choice": responses_api_request.get("tool_choice"),
|
||||
@@ -131,14 +129,14 @@ class LiteLLMCompletionResponsesConfig:
|
||||
@staticmethod
|
||||
def transform_responses_api_input_to_messages(
|
||||
input: Union[str, ResponseInputParam],
|
||||
responses_api_request: ResponsesAPIOptionalRequestParams,
|
||||
previous_response_id: Optional[str] = None,
|
||||
responses_api_request: Union[ResponsesAPIOptionalRequestParams, dict],
|
||||
) -> List[
|
||||
Union[
|
||||
AllMessageValues,
|
||||
GenericChatCompletionMessage,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionResponseMessage,
|
||||
Message,
|
||||
]
|
||||
]:
|
||||
"""
|
||||
@@ -150,6 +148,7 @@ class LiteLLMCompletionResponsesConfig:
|
||||
GenericChatCompletionMessage,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionResponseMessage,
|
||||
Message,
|
||||
]
|
||||
] = []
|
||||
if responses_api_request.get("instructions"):
|
||||
@@ -159,24 +158,6 @@ class LiteLLMCompletionResponsesConfig:
|
||||
)
|
||||
)
|
||||
|
||||
if previous_response_id:
|
||||
previous_response_pairs = (
|
||||
RESPONSES_API_SESSION_HANDLER.get_chain_of_previous_input_output_pairs(
|
||||
previous_response_id=previous_response_id
|
||||
)
|
||||
)
|
||||
if previous_response_pairs:
|
||||
for previous_response_pair in previous_response_pairs:
|
||||
chat_completion_input_messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
|
||||
input=previous_response_pair[0],
|
||||
)
|
||||
chat_completion_output_messages = LiteLLMCompletionResponsesConfig._transform_responses_api_outputs_to_chat_completion_messages(
|
||||
responses_api_output=previous_response_pair[1],
|
||||
)
|
||||
|
||||
messages.extend(chat_completion_input_messages)
|
||||
messages.extend(chat_completion_output_messages)
|
||||
|
||||
messages.extend(
|
||||
LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
|
||||
input=input,
|
||||
@@ -185,6 +166,29 @@ class LiteLLMCompletionResponsesConfig:
|
||||
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
async def async_responses_api_session_handler(
|
||||
previous_response_id: str,
|
||||
litellm_completion_request: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Async hook to get the chain of previous input and output pairs and return a list of Chat Completion messages
|
||||
"""
|
||||
chat_completion_session: ChatCompletionSession = ChatCompletionSession(
|
||||
messages=[], litellm_session_id=None
|
||||
)
|
||||
if previous_response_id:
|
||||
chat_completion_session = await _ENTERPRISE_ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id(
|
||||
previous_response_id=previous_response_id
|
||||
)
|
||||
_messages = litellm_completion_request.get("messages") or []
|
||||
session_messages = chat_completion_session.get("messages") or []
|
||||
litellm_completion_request["messages"] = session_messages + _messages
|
||||
litellm_completion_request["litellm_trace_id"] = chat_completion_session.get(
|
||||
"litellm_session_id"
|
||||
)
|
||||
return litellm_completion_request
|
||||
|
||||
@staticmethod
|
||||
def _transform_response_input_param_to_chat_completion_message(
|
||||
input: Union[str, ResponseInputParam],
|
||||
@@ -471,11 +475,13 @@ class LiteLLMCompletionResponsesConfig:
|
||||
def transform_chat_completion_response_to_responses_api_response(
|
||||
request_input: Union[str, ResponseInputParam],
|
||||
responses_api_request: ResponsesAPIOptionalRequestParams,
|
||||
chat_completion_response: ModelResponse,
|
||||
chat_completion_response: Union[ModelResponse, dict],
|
||||
) -> ResponsesAPIResponse:
|
||||
"""
|
||||
Transform a Chat Completion response into a Responses API response
|
||||
"""
|
||||
if isinstance(chat_completion_response, dict):
|
||||
chat_completion_response = ModelResponse(**chat_completion_response)
|
||||
responses_api_response: ResponsesAPIResponse = ResponsesAPIResponse(
|
||||
id=chat_completion_response.id,
|
||||
created_at=chat_completion_response.created,
|
||||
@@ -513,16 +519,6 @@ class LiteLLMCompletionResponsesConfig:
|
||||
),
|
||||
user=getattr(chat_completion_response, "user", None),
|
||||
)
|
||||
|
||||
RESPONSES_API_SESSION_HANDLER.add_completed_response_to_cache(
|
||||
response_id=responses_api_response.id,
|
||||
session_element=ResponsesAPISessionElement(
|
||||
input=request_input,
|
||||
output=responses_api_response,
|
||||
response_id=responses_api_response.id,
|
||||
previous_response_id=responses_api_request.get("previous_response_id"),
|
||||
),
|
||||
)
|
||||
return responses_api_response
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -226,6 +226,8 @@ model LiteLLM_SpendLogs {
|
||||
requester_ip_address String?
|
||||
messages Json? @default("{}")
|
||||
response Json? @default("{}")
|
||||
session_id String?
|
||||
proxy_server_request Json? @default("{}")
|
||||
@@index([startTime])
|
||||
@@index([end_user])
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from litellm.router import Router
|
||||
|
||||
ignored_keys = [
|
||||
"request_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"completionStartTime",
|
||||
@@ -452,6 +453,7 @@ class TestSpendLogsPayload:
|
||||
expected_payload = SpendLogsPayload(
|
||||
**{
|
||||
"request_id": "chatcmpl-34df56d5-4807-45c1-bb99-61e52586b802",
|
||||
"session_id": "1234567890",
|
||||
"call_type": "acompletion",
|
||||
"api_key": "",
|
||||
"cache_hit": "None",
|
||||
@@ -482,6 +484,7 @@ class TestSpendLogsPayload:
|
||||
"custom_llm_provider": "openai",
|
||||
"messages": "{}",
|
||||
"response": "{}",
|
||||
"proxy_server_request": "{}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -572,6 +575,7 @@ class TestSpendLogsPayload:
|
||||
"custom_llm_provider": "anthropic",
|
||||
"messages": "{}",
|
||||
"response": "{}",
|
||||
"proxy_server_request": "{}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -660,6 +664,7 @@ class TestSpendLogsPayload:
|
||||
"custom_llm_provider": "anthropic",
|
||||
"messages": "{}",
|
||||
"response": "{}",
|
||||
"proxy_server_request": "{}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"request_id": "chatcmpl-2283081b-dc89-41f6-93e6-d4f914774027",
|
||||
"session_id": "1234567890",
|
||||
"call_type": "acompletion",
|
||||
"api_key": "",
|
||||
"cache_hit": "None",
|
||||
@@ -23,5 +24,6 @@
|
||||
"requester_ip_address": null,
|
||||
"custom_llm_provider": "openai",
|
||||
"messages": "{}",
|
||||
"response": "{}"
|
||||
"response": "{}",
|
||||
"proxy_server_request": "{}"
|
||||
}
|
||||
@@ -31,6 +31,7 @@ verbose_logger.setLevel(logging.DEBUG)
|
||||
|
||||
ignored_keys = [
|
||||
"request_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"completionStartTime",
|
||||
@@ -170,7 +171,7 @@ def assert_gcs_pubsub_request_matches_expected_standard_logging_payload(
|
||||
"response_time",
|
||||
"completion_tokens",
|
||||
"prompt_tokens",
|
||||
"total_tokens",
|
||||
"total_tokens"
|
||||
]
|
||||
|
||||
for field in FIELDS_EXISTENCE_CHECKS:
|
||||
|
||||
@@ -385,10 +385,9 @@ def test_spend_logs_payload_with_prompts_enabled(monkeypatch):
|
||||
assert payload["response"] == json.dumps(
|
||||
{"role": "assistant", "content": "Hi there!"}
|
||||
)
|
||||
parsed_metadata = json.loads(payload["metadata"])
|
||||
assert parsed_metadata["proxy_server_request"] == json.dumps(
|
||||
{"model": "gpt-4", "messages": [{"role": "user", "content": "Hello!"}]}
|
||||
)
|
||||
proxy_server_request = json.loads(payload["proxy_server_request"] or "{}")
|
||||
assert proxy_server_request["model"] == "gpt-4"
|
||||
assert proxy_server_request["messages"] == [{"role": "user", "content": "Hello!"}]
|
||||
|
||||
# Clean up - reset general_settings
|
||||
general_settings["store_prompts_in_spend_logs"] = False
|
||||
|
||||
@@ -328,6 +328,48 @@ def test_get_final_response_obj():
|
||||
litellm.turn_off_message_logging = False
|
||||
|
||||
|
||||
def test_get_standard_logging_payload_trace_id():
|
||||
"""Test _get_standard_logging_payload_trace_id with different input scenarios"""
|
||||
# Test case 1: When litellm_trace_id is provided in litellm_params
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Create a mock Logging object
|
||||
mock_logging_obj = MagicMock()
|
||||
mock_logging_obj.litellm_trace_id = "default-trace-id"
|
||||
|
||||
# Test when litellm_trace_id is in litellm_params
|
||||
litellm_params = {"litellm_trace_id": "dynamic-trace-id"}
|
||||
result = StandardLoggingPayloadSetup._get_standard_logging_payload_trace_id(
|
||||
logging_obj=mock_logging_obj,
|
||||
litellm_params=litellm_params
|
||||
)
|
||||
assert result == "dynamic-trace-id"
|
||||
|
||||
# Test case 2: When litellm_trace_id is not provided in litellm_params
|
||||
litellm_params = {}
|
||||
result = StandardLoggingPayloadSetup._get_standard_logging_payload_trace_id(
|
||||
logging_obj=mock_logging_obj,
|
||||
litellm_params=litellm_params
|
||||
)
|
||||
assert result == "default-trace-id"
|
||||
|
||||
# Test case 3: When litellm_params is None
|
||||
result = StandardLoggingPayloadSetup._get_standard_logging_payload_trace_id(
|
||||
logging_obj=mock_logging_obj,
|
||||
litellm_params={}
|
||||
)
|
||||
assert result == "default-trace-id"
|
||||
|
||||
# Test case 4: When litellm_trace_id in params is not a string
|
||||
litellm_params = {"litellm_trace_id": 12345}
|
||||
result = StandardLoggingPayloadSetup._get_standard_logging_payload_trace_id(
|
||||
logging_obj=mock_logging_obj,
|
||||
litellm_params=litellm_params
|
||||
)
|
||||
assert result == "12345"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_truncate_standard_logging_payload():
|
||||
"""
|
||||
1. original messages, response, and error_str should NOT BE MODIFIED, since these are from kwargs
|
||||
|
||||
@@ -4746,4 +4746,38 @@ export const teamPermissionsUpdateCall = async (
|
||||
console.error("Failed to update team permissions:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all spend logs for a particular session
|
||||
*/
|
||||
export const sessionSpendLogsCall = async (
|
||||
accessToken: string,
|
||||
session_id: string
|
||||
) => {
|
||||
try {
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/spend/logs/session/ui?session_id=${encodeURIComponent(session_id)}`
|
||||
: `/spend/logs/session/ui?session_id=${encodeURIComponent(session_id)}`;
|
||||
|
||||
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();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch session logs:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LogEntry } from './columns';
|
||||
import { DataTable } from './table';
|
||||
import { columns } from './columns';
|
||||
import { Card, Title, Text, Metric, AreaChart } from '@tremor/react';
|
||||
import { RequestViewer } from './index';
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string;
|
||||
logs: LogEntry[];
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const SessionView: React.FC<SessionViewProps> = ({ sessionId, logs, onBack }) => {
|
||||
// Track which log row is expanded
|
||||
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null);
|
||||
// Calculate session metrics
|
||||
const totalCost = logs.reduce((sum, log) => sum + (log.spend || 0), 0);
|
||||
const totalTokens = logs.reduce((sum, log) => sum + (log.total_tokens || 0), 0);
|
||||
const startTime = logs.length > 0 ? new Date(logs[0].startTime) : new Date();
|
||||
const endTime = logs.length > 0 ? new Date(logs[logs.length - 1].endTime) : new Date();
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
const durationSec = (durationMs / 1000).toFixed(2);
|
||||
|
||||
// Prepare data for the timeline chart
|
||||
const timelineData = logs.map(log => ({
|
||||
time: new Date(log.startTime).toISOString(),
|
||||
tokens: log.total_tokens || 0,
|
||||
cost: log.spend || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back button */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to All Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Session Details</h1>
|
||||
<p className="text-sm text-gray-500 font-mono mt-1">{sessionId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<Text>Total Requests</Text>
|
||||
<Metric>{logs.length}</Metric>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Cost</Text>
|
||||
<Metric>${totalCost.toFixed(4)}</Metric>
|
||||
</Card>
|
||||
<Card>
|
||||
<Text>Total Tokens</Text>
|
||||
<Metric>{totalTokens}</Metric>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Request Timeline */}
|
||||
<Title>Session Logs</Title>
|
||||
<div className="mt-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
renderSubComponent={RequestViewer}
|
||||
getRowCanExpand={() => true}
|
||||
expandedRequestId={expandedRequestId}
|
||||
onRowExpand={setExpandedRequestId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -45,7 +45,10 @@ export type LogEntry = {
|
||||
requester_ip_address?: string;
|
||||
messages: string | any[] | Record<string, any>;
|
||||
response: string | any[] | Record<string, any>;
|
||||
proxy_server_request?: string | any[] | Record<string, any>;
|
||||
session_id?: string;
|
||||
onKeyHashClick?: (keyHash: string) => void;
|
||||
onSessionClick?: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<LogEntry>[] = [
|
||||
@@ -119,6 +122,27 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Session ID",
|
||||
accessorKey: "session_id",
|
||||
cell: (info: any) => {
|
||||
const value = String(info.getValue() || "");
|
||||
const onSessionClick = info.row.original.onSessionClick;
|
||||
return (
|
||||
<Tooltip title={String(info.getValue() || "")}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal text-xs max-w-[15ch] truncate block"
|
||||
onClick={() => onSessionClick?.(value)}
|
||||
>
|
||||
{String(info.getValue() || "")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: "Request ID",
|
||||
accessorKey: "request_id",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { uiSpendLogsCall, keyInfoV1Call } from "../networking";
|
||||
import { uiSpendLogsCall, keyInfoV1Call, sessionSpendLogsCall } from "../networking";
|
||||
import { DataTable } from "./table";
|
||||
import { columns, LogEntry } from "./columns";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
@@ -15,6 +15,8 @@ import { ConfigInfoMessage } from './ConfigInfoMessage';
|
||||
import { Tooltip } from "antd";
|
||||
import { KeyResponse, Team } from "../key_team_helpers/key_list";
|
||||
import KeyInfoView from "../key_info_view";
|
||||
import { SessionView } from './SessionView';
|
||||
|
||||
interface SpendLogsTableProps {
|
||||
accessToken: string | null;
|
||||
token: string | null;
|
||||
@@ -75,6 +77,7 @@ export default function SpendLogsTable({
|
||||
);
|
||||
|
||||
const [expandedRequestId, setExpandedRequestId] = useState<string | null>(null);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -201,6 +204,24 @@ export default function SpendLogsTable({
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Fetch logs for a session if selected
|
||||
const sessionLogs = useQuery<PaginatedResponse>({
|
||||
queryKey: ["sessionLogs", selectedSessionId],
|
||||
queryFn: async () => {
|
||||
if (!accessToken || !selectedSessionId) return { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 };
|
||||
const response = await sessionSpendLogsCall(accessToken, selectedSessionId);
|
||||
// If the API returns an array, wrap it in the same shape as PaginatedResponse
|
||||
return {
|
||||
data: response.data || response || [],
|
||||
total: (response.data || response || []).length,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
total_pages: 1,
|
||||
};
|
||||
},
|
||||
enabled: !!accessToken && !!selectedSessionId,
|
||||
});
|
||||
|
||||
// Add this effect to preserve expanded state when data refreshes
|
||||
useEffect(() => {
|
||||
if (logs.data?.data && expandedRequestId) {
|
||||
@@ -233,7 +254,18 @@ export default function SpendLogsTable({
|
||||
|
||||
}).map(log => ({
|
||||
...log,
|
||||
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash)
|
||||
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
|
||||
onSessionClick: (sessionId: string) => {
|
||||
if (sessionId) setSelectedSessionId(sessionId);
|
||||
},
|
||||
})) || [];
|
||||
|
||||
// For session logs, add onKeyHashClick/onSessionClick as well
|
||||
const sessionData =
|
||||
sessionLogs.data?.data?.map(log => ({
|
||||
...log,
|
||||
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
|
||||
onSessionClick: (sessionId: string) => {},
|
||||
})) || [];
|
||||
|
||||
// Add this function to handle manual refresh
|
||||
@@ -265,48 +297,66 @@ export default function SpendLogsTable({
|
||||
setExpandedRequestId(requestId);
|
||||
};
|
||||
|
||||
// When a session is selected, render the SessionView component
|
||||
if (selectedSessionId && sessionLogs.data) {
|
||||
return (
|
||||
<div className="w-full p-6">
|
||||
<SessionView
|
||||
sessionId={selectedSessionId}
|
||||
logs={sessionLogs.data.data}
|
||||
onBack={() => setSelectedSessionId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-semibold">Request Logs</h1>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{selectedSessionId ? (
|
||||
<>
|
||||
Session: <span className="font-mono">{selectedSessionId}</span>
|
||||
<button
|
||||
className="ml-4 px-3 py-1 text-sm border rounded hover:bg-gray-50"
|
||||
onClick={() => setSelectedSessionId(null)}
|
||||
>
|
||||
← Back to All Logs
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
"Request Logs"
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
|
||||
<KeyInfoView keyId={selectedKeyIdInfoView} keyData={selectedKeyInfo} accessToken={accessToken} userID={userID} userRole={userRole} teams={allTeams} onClose={() => setSelectedKeyIdInfoView(null)} />
|
||||
) : selectedSessionId ? (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={sessionData}
|
||||
renderSubComponent={RequestViewer}
|
||||
getRowCanExpand={() => true}
|
||||
// Optionally: add session-specific row expansion state
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Request ID"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
<>
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Request ID"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="relative" ref={filtersRef}>
|
||||
<button
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -315,135 +365,14 @@ export default function SpendLogsTable({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="absolute left-0 mt-2 w-[500px] bg-white rounded-lg shadow-lg border p-4 z-50">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Where</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColumnDropdown(!showColumnDropdown)}
|
||||
className="px-3 py-1.5 border rounded-md bg-white text-sm min-w-[160px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-left flex justify-between items-center"
|
||||
>
|
||||
{selectedFilter}
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{showColumnDropdown && (
|
||||
<div className="absolute left-0 mt-1 w-[160px] bg-white border rounded-md shadow-lg z-50">
|
||||
{["Team ID", "Key Hash"].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-2 ${
|
||||
selectedFilter === option
|
||||
? "bg-blue-50 text-blue-600"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedFilter(option);
|
||||
setShowColumnDropdown(false);
|
||||
if (option === "Team ID") {
|
||||
setTempKeyHash("");
|
||||
} else {
|
||||
setTempTeamId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedFilter === option && (
|
||||
<svg
|
||||
className="h-4 w-4 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter value..."
|
||||
className="px-3 py-1.5 border rounded-md text-sm flex-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={selectedFilter === "Team ID" ? tempTeamId : tempKeyHash}
|
||||
onChange={(e) => {
|
||||
if (selectedFilter === "Team ID") {
|
||||
setTempTeamId(e.target.value);
|
||||
} else {
|
||||
setTempKeyHash(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="p-1 hover:bg-gray-100 rounded-md"
|
||||
onClick={() => {
|
||||
setTempTeamId("");
|
||||
setTempKeyHash("");
|
||||
}}
|
||||
>
|
||||
<span className="text-gray-500">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setTempTeamId("");
|
||||
setTempKeyHash("");
|
||||
setShowFilters(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
setSelectedTeamId(tempTeamId);
|
||||
setSelectedKeyHash(tempKeyHash);
|
||||
setCurrentPage(1); // Reset to first page when applying new filters
|
||||
setShowFilters(false);
|
||||
}}
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative" ref={quickSelectRef}>
|
||||
</div>
|
||||
<div className="relative" ref={filtersRef}>
|
||||
<button
|
||||
onClick={() => setQuickSelectOpen(!quickSelectOpen)}
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
@@ -455,192 +384,324 @@ export default function SpendLogsTable({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
{getTimeRangeDisplay()}
|
||||
Filter
|
||||
</button>
|
||||
|
||||
{quickSelectOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border p-2 z-50">
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
|
||||
{ label: "Last Hour", value: 1, unit: "hours" },
|
||||
{ label: "Last 4 Hours", value: 4, unit: "hours" },
|
||||
{ label: "Last 24 Hours", value: 24, unit: "hours" },
|
||||
{ label: "Last 7 Days", value: 7, unit: "days" },
|
||||
].map((option) => (
|
||||
{showFilters && (
|
||||
<div className="absolute left-0 mt-2 w-[500px] bg-white rounded-lg shadow-lg border p-4 z-50">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Where</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColumnDropdown(!showColumnDropdown)}
|
||||
className="px-3 py-1.5 border rounded-md bg-white text-sm min-w-[160px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-left flex justify-between items-center"
|
||||
>
|
||||
{selectedFilter}
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{showColumnDropdown && (
|
||||
<div className="absolute left-0 mt-1 w-[160px] bg-white border rounded-md shadow-lg z-50">
|
||||
{["Team ID", "Key Hash"].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-2 ${
|
||||
selectedFilter === option
|
||||
? "bg-blue-50 text-blue-600"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedFilter(option);
|
||||
setShowColumnDropdown(false);
|
||||
if (option === "Team ID") {
|
||||
setTempKeyHash("");
|
||||
} else {
|
||||
setTempTeamId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedFilter === option && (
|
||||
<svg
|
||||
className="h-4 w-4 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter value..."
|
||||
className="px-3 py-1.5 border rounded-md text-sm flex-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={selectedFilter === "Team ID" ? tempTeamId : tempKeyHash}
|
||||
onChange={(e) => {
|
||||
if (selectedFilter === "Team ID") {
|
||||
setTempTeamId(e.target.value);
|
||||
} else {
|
||||
setTempKeyHash(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
key={option.label}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${
|
||||
getTimeRangeDisplay() === option.label ? 'bg-blue-50 text-blue-600' : ''
|
||||
}`}
|
||||
className="p-1 hover:bg-gray-100 rounded-md"
|
||||
onClick={() => {
|
||||
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
|
||||
setStartTime(
|
||||
moment()
|
||||
.subtract(option.value, option.unit as any)
|
||||
.format("YYYY-MM-DDTHH:mm")
|
||||
);
|
||||
setQuickSelectOpen(false);
|
||||
setIsCustomDate(false);
|
||||
setTempTeamId("");
|
||||
setTempKeyHash("");
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<span className="text-gray-500">×</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t my-2" />
|
||||
<button
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${
|
||||
isCustomDate ? 'bg-blue-50 text-blue-600' : ''
|
||||
}`}
|
||||
onClick={() => setIsCustomDate(!isCustomDate)}
|
||||
>
|
||||
Custom Range
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
setTempTeamId("");
|
||||
setTempKeyHash("");
|
||||
setShowFilters(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
setSelectedTeamId(tempTeamId);
|
||||
setSelectedKeyHash(tempKeyHash);
|
||||
setCurrentPage(1); // Reset to first page when applying new filters
|
||||
setShowFilters(false);
|
||||
}}
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
title="Refresh data"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${logs.isFetching ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative" ref={quickSelectRef}>
|
||||
<button
|
||||
onClick={() => setQuickSelectOpen(!quickSelectOpen)}
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{getTimeRangeDisplay()}
|
||||
</button>
|
||||
|
||||
{quickSelectOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border p-2 z-50">
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
|
||||
{ label: "Last Hour", value: 1, unit: "hours" },
|
||||
{ label: "Last 4 Hours", value: 4, unit: "hours" },
|
||||
{ label: "Last 24 Hours", value: 24, unit: "hours" },
|
||||
{ label: "Last 7 Days", value: 7, unit: "days" },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${
|
||||
getTimeRangeDisplay() === option.label ? 'bg-blue-50 text-blue-600' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
|
||||
setStartTime(
|
||||
moment()
|
||||
.subtract(option.value, option.unit as any)
|
||||
.format("YYYY-MM-DDTHH:mm")
|
||||
);
|
||||
setQuickSelectOpen(false);
|
||||
setIsCustomDate(false);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t my-2" />
|
||||
<button
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${
|
||||
isCustomDate ? 'bg-blue-50 text-blue-600' : ''
|
||||
}`}
|
||||
onClick={() => setIsCustomDate(!isCustomDate)}
|
||||
>
|
||||
Custom Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
title="Refresh data"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<svg
|
||||
className={`w-4 h-4 ${logs.isFetching ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustomDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
setStartTime(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-500">to</span>
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
setEndTime(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCustomDate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
setStartTime(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-500">to</span>
|
||||
<div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
setEndTime(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? (currentPage - 1) * pageSize + 1
|
||||
: 0}{" "}
|
||||
-{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? Math.min(currentPage * pageSize, logs.data.total)
|
||||
: 0}{" "}
|
||||
of{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? logs.data.total
|
||||
: 0}{" "}
|
||||
results
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {logs.isLoading ? "..." : currentPage} of{" "}
|
||||
Showing{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? logs.data.total_pages
|
||||
: 1}
|
||||
? (currentPage - 1) * pageSize + 1
|
||||
: 0}{" "}
|
||||
-{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? Math.min(currentPage * pageSize, logs.data.total)
|
||||
: 0}{" "}
|
||||
of{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? logs.data.total
|
||||
: 0}{" "}
|
||||
results
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={logs.isLoading || currentPage === 1}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(
|
||||
logs.data?.total_pages || 1,
|
||||
p + 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
logs.isLoading ||
|
||||
currentPage === (logs.data?.total_pages || 1)
|
||||
}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {logs.isLoading ? "..." : currentPage} of{" "}
|
||||
{logs.isLoading
|
||||
? "..."
|
||||
: logs.data
|
||||
? logs.data.total_pages
|
||||
: 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={logs.isLoading || currentPage === 1}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(
|
||||
logs.data?.total_pages || 1,
|
||||
p + 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
logs.isLoading ||
|
||||
currentPage === (logs.data?.total_pages || 1)
|
||||
}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
renderSubComponent={RequestViewer}
|
||||
getRowCanExpand={() => true}
|
||||
onRowExpand={handleRowExpand}
|
||||
expandedRequestId={expandedRequestId}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
renderSubComponent={RequestViewer}
|
||||
getRowCanExpand={() => true}
|
||||
onRowExpand={handleRowExpand}
|
||||
expandedRequestId={expandedRequestId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
export function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
// Helper function to clean metadata by removing specific fields
|
||||
const getCleanedMetadata = (metadata: any) => {
|
||||
const cleanedMetadata = {...metadata};
|
||||
if ('proxy_server_request' in cleanedMetadata) {
|
||||
delete cleanedMetadata.proxy_server_request;
|
||||
}
|
||||
return cleanedMetadata;
|
||||
};
|
||||
|
||||
const formatData = (input: any) => {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
@@ -655,8 +716,8 @@ function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
// New helper function to get raw request
|
||||
const getRawRequest = () => {
|
||||
// First check if proxy_server_request exists in metadata
|
||||
if (row.original.metadata?.proxy_server_request) {
|
||||
return formatData(row.original.metadata.proxy_server_request);
|
||||
if (row.original?.proxy_server_request) {
|
||||
return formatData(row.original.proxy_server_request);
|
||||
}
|
||||
// Fall back to messages if proxy_server_request is empty
|
||||
return formatData(row.original.messages);
|
||||
@@ -849,8 +910,7 @@ function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
<h3 className="text-lg font-medium">Metadata</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cleanedMetadata = getCleanedMetadata(row.original.metadata);
|
||||
navigator.clipboard.writeText(JSON.stringify(cleanedMetadata, null, 2));
|
||||
navigator.clipboard.writeText(JSON.stringify(row.original.metadata, null, 2));
|
||||
}}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
title="Copy metadata"
|
||||
@@ -863,7 +923,7 @@ function RequestViewer({ row }: { row: Row<LogEntry> }) {
|
||||
</div>
|
||||
<div className="p-4 overflow-auto max-h-64">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(getCleanedMetadata(row.original.metadata), null, 2)}
|
||||
{JSON.stringify(row.original.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user