mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
(UI - View SpendLogs Table) (#7842)
* litellm log messages / responses * add messages/response to schema.prisma * add support for logging messages / responses in DB * test_spend_logs_payload_with_prompts_enabled * _get_messages_for_spend_logs_payload * ui_view_spend_logs endpoint * add tanstack and moment * add uiSpendLogsCall * ui view logs table * ui view spendLogs table * ui_view_spend_logs * fix code quality * test_spend_logs_payload_with_prompts_enabled * _get_messages_for_spend_logs_payload * test_spend_logs_payload_with_prompts_enabled * test_spend_logs_payload_with_prompts_enabled * ui view spend logs * minor ui fix * ui - update leftnav * ui - clean up ui * fix leftnav * ui fix navbar * ui fix moving chat ui tab
This commit is contained in:
parent
a99deb6d0a
commit
d3c2f4331a
15 changed files with 795 additions and 89 deletions
|
@ -1603,6 +1603,8 @@ class LiteLLM_SpendLogs(LiteLLMPydanticObjectBase):
|
||||||
cache_key: Optional[str] = None
|
cache_key: Optional[str] = None
|
||||||
request_tags: Optional[Json] = None
|
request_tags: Optional[Json] = None
|
||||||
requester_ip_address: Optional[str] = None
|
requester_ip_address: Optional[str] = None
|
||||||
|
messages: Optional[Union[str, list, dict]]
|
||||||
|
response: Optional[Union[str, list, dict]]
|
||||||
|
|
||||||
|
|
||||||
class LiteLLM_ErrorLogs(LiteLLMPydanticObjectBase):
|
class LiteLLM_ErrorLogs(LiteLLMPydanticObjectBase):
|
||||||
|
@ -1853,6 +1855,8 @@ class SpendLogsPayload(TypedDict):
|
||||||
end_user: Optional[str]
|
end_user: Optional[str]
|
||||||
requester_ip_address: Optional[str]
|
requester_ip_address: Optional[str]
|
||||||
custom_llm_provider: Optional[str]
|
custom_llm_provider: Optional[str]
|
||||||
|
messages: Optional[Union[str, list, dict]]
|
||||||
|
response: Optional[Union[str, list, dict]]
|
||||||
|
|
||||||
|
|
||||||
class SpanAttributes(str, enum.Enum):
|
class SpanAttributes(str, enum.Enum):
|
||||||
|
|
|
@ -12,5 +12,6 @@ model_list:
|
||||||
model_info:
|
model_info:
|
||||||
health_check_model: anthropic/claude-3-5-sonnet-20240620
|
health_check_model: anthropic/claude-3-5-sonnet-20240620
|
||||||
|
|
||||||
litellm_settings:
|
general_settings:
|
||||||
callbacks: ["datadog_llm_observability"]
|
store_prompts_in_spend_logs: true
|
||||||
|
|
||||||
|
|
|
@ -204,6 +204,8 @@ model LiteLLM_SpendLogs {
|
||||||
team_id String?
|
team_id String?
|
||||||
end_user String?
|
end_user String?
|
||||||
requester_ip_address String?
|
requester_ip_address String?
|
||||||
|
messages Json? @default("{}")
|
||||||
|
response Json? @default("{}")
|
||||||
@@index([startTime])
|
@@index([startTime])
|
||||||
@@index([end_user])
|
@@index([end_user])
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ from litellm.proxy._types import *
|
||||||
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
|
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
|
||||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||||
from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
from litellm.proxy.spend_tracking.spend_tracking_utils import (
|
||||||
|
_should_store_prompts_and_responses_in_spend_logs,
|
||||||
get_spend_by_team_and_customer,
|
get_spend_by_team_and_customer,
|
||||||
)
|
)
|
||||||
from litellm.proxy.utils import handle_exception_on_proxy
|
from litellm.proxy.utils import handle_exception_on_proxy
|
||||||
|
@ -1607,6 +1608,80 @@ async def calculate_spend(request: SpendCalculateRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/spend/logs/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_spend_logs( # noqa: PLR0915
|
||||||
|
api_key: Optional[str] = fastapi.Query(
|
||||||
|
default=None,
|
||||||
|
description="Get spend logs based on api key",
|
||||||
|
),
|
||||||
|
user_id: Optional[str] = fastapi.Query(
|
||||||
|
default=None,
|
||||||
|
description="Get spend logs based on user_id",
|
||||||
|
),
|
||||||
|
request_id: Optional[str] = fastapi.Query(
|
||||||
|
default=None,
|
||||||
|
description="request_id to get spend logs for specific request_id. If none passed then pass spend logs for all requests",
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
View spend logs for UI
|
||||||
|
"""
|
||||||
|
from litellm.proxy.proxy_server import prisma_client
|
||||||
|
|
||||||
|
if prisma_client is None:
|
||||||
|
raise ProxyException(
|
||||||
|
message="Prisma Client is not initialized",
|
||||||
|
type="internal_error",
|
||||||
|
param="None",
|
||||||
|
code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
if _should_store_prompts_and_responses_in_spend_logs() is not True:
|
||||||
|
verbose_proxy_logger.debug(
|
||||||
|
"Prompts and responses are not stored in spend logs, returning empty list"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
if start_date is None or end_date is None:
|
||||||
|
raise ProxyException(
|
||||||
|
message="Start date and end date are required",
|
||||||
|
type="bad_request",
|
||||||
|
param="None",
|
||||||
|
code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
# Convert the date strings to datetime objects
|
||||||
|
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
|
||||||
|
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# Convert to ISO format strings for Prisma
|
||||||
|
start_date_iso = start_date_obj.isoformat() + "Z" # Add Z to indicate UTC
|
||||||
|
end_date_iso = end_date_obj.isoformat() + "Z" # Add Z to indicate UTC
|
||||||
|
|
||||||
|
return await prisma_client.db.litellm_spendlogs.find_many(
|
||||||
|
where={
|
||||||
|
"startTime": {"gte": start_date_iso, "lte": end_date_iso},
|
||||||
|
},
|
||||||
|
order={
|
||||||
|
"startTime": "desc",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/spend/logs",
|
"/spend/logs",
|
||||||
tags=["Budget & Spend Tracking"],
|
tags=["Budget & Spend Tracking"],
|
||||||
|
|
|
@ -9,6 +9,7 @@ import litellm
|
||||||
from litellm._logging import verbose_proxy_logger
|
from litellm._logging import verbose_proxy_logger
|
||||||
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
|
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
|
||||||
from litellm.proxy.utils import PrismaClient, hash_token
|
from litellm.proxy.utils import PrismaClient, hash_token
|
||||||
|
from litellm.types.utils import StandardLoggingPayload
|
||||||
|
|
||||||
|
|
||||||
def _is_master_key(api_key: str, _master_key: Optional[str]) -> bool:
|
def _is_master_key(api_key: str, _master_key: Optional[str]) -> bool:
|
||||||
|
@ -57,6 +58,9 @@ def get_logging_payload(
|
||||||
usage = dict(usage)
|
usage = dict(usage)
|
||||||
id = cast(dict, response_obj).get("id") or kwargs.get("litellm_call_id")
|
id = cast(dict, response_obj).get("id") or kwargs.get("litellm_call_id")
|
||||||
api_key = metadata.get("user_api_key", "")
|
api_key = metadata.get("user_api_key", "")
|
||||||
|
standard_logging_payload: Optional[StandardLoggingPayload] = kwargs.get(
|
||||||
|
"standard_logging_object", None
|
||||||
|
)
|
||||||
if api_key is not None and isinstance(api_key, str):
|
if api_key is not None and isinstance(api_key, str):
|
||||||
if api_key.startswith("sk-"):
|
if api_key.startswith("sk-"):
|
||||||
# hash the api_key
|
# hash the api_key
|
||||||
|
@ -151,10 +155,13 @@ def get_logging_payload(
|
||||||
model_id=_model_id,
|
model_id=_model_id,
|
||||||
requester_ip_address=clean_metadata.get("requester_ip_address", None),
|
requester_ip_address=clean_metadata.get("requester_ip_address", None),
|
||||||
custom_llm_provider=kwargs.get("custom_llm_provider", ""),
|
custom_llm_provider=kwargs.get("custom_llm_provider", ""),
|
||||||
|
messages=_get_messages_for_spend_logs_payload(standard_logging_payload),
|
||||||
|
response=_get_response_for_spend_logs_payload(standard_logging_payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
verbose_proxy_logger.debug(
|
verbose_proxy_logger.debug(
|
||||||
"SpendTable: created payload - payload: %s\n\n", payload
|
"SpendTable: created payload - payload: %s\n\n",
|
||||||
|
json.dumps(payload, indent=4, default=str),
|
||||||
)
|
)
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
@ -239,3 +246,29 @@ async def get_spend_by_team_and_customer(
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return db_response
|
return db_response
|
||||||
|
|
||||||
|
|
||||||
|
def _get_messages_for_spend_logs_payload(
|
||||||
|
payload: Optional[StandardLoggingPayload],
|
||||||
|
) -> str:
|
||||||
|
if payload is None:
|
||||||
|
return "{}"
|
||||||
|
if _should_store_prompts_and_responses_in_spend_logs():
|
||||||
|
return json.dumps(payload.get("messages", {}))
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_response_for_spend_logs_payload(
|
||||||
|
payload: Optional[StandardLoggingPayload],
|
||||||
|
) -> str:
|
||||||
|
if payload is None:
|
||||||
|
return "{}"
|
||||||
|
if _should_store_prompts_and_responses_in_spend_logs():
|
||||||
|
return json.dumps(payload.get("response", {}))
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def _should_store_prompts_and_responses_in_spend_logs() -> bool:
|
||||||
|
from litellm.proxy.proxy_server import general_settings
|
||||||
|
|
||||||
|
return general_settings.get("store_prompts_in_spend_logs") is True
|
||||||
|
|
|
@ -204,6 +204,8 @@ model LiteLLM_SpendLogs {
|
||||||
team_id String?
|
team_id String?
|
||||||
end_user String?
|
end_user String?
|
||||||
requester_ip_address String?
|
requester_ip_address String?
|
||||||
|
messages Json? @default("{}")
|
||||||
|
response Json? @default("{}")
|
||||||
@@index([startTime])
|
@@index([startTime])
|
||||||
@@index([end_user])
|
@@index([end_user])
|
||||||
}
|
}
|
||||||
|
|
|
@ -300,3 +300,65 @@ def test_spend_logs_payload_whisper():
|
||||||
|
|
||||||
assert payload["call_type"] == "atranscription"
|
assert payload["call_type"] == "atranscription"
|
||||||
assert payload["spend"] == 0.00023398580000000003
|
assert payload["spend"] == 0.00023398580000000003
|
||||||
|
|
||||||
|
|
||||||
|
def test_spend_logs_payload_with_prompts_enabled(monkeypatch):
|
||||||
|
"""
|
||||||
|
Test that messages and responses are logged in spend logs when store_prompts_in_spend_logs is enabled
|
||||||
|
"""
|
||||||
|
# Mock general_settings
|
||||||
|
from litellm.proxy.proxy_server import general_settings
|
||||||
|
|
||||||
|
general_settings["store_prompts_in_spend_logs"] = True
|
||||||
|
|
||||||
|
input_args: dict = {
|
||||||
|
"kwargs": {
|
||||||
|
"model": "gpt-3.5-turbo",
|
||||||
|
"messages": [{"role": "user", "content": "Hello!"}],
|
||||||
|
"litellm_params": {
|
||||||
|
"metadata": {
|
||||||
|
"user_api_key": "fake_key",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"response_obj": litellm.ModelResponse(
|
||||||
|
id="chatcmpl-123",
|
||||||
|
choices=[
|
||||||
|
litellm.Choices(
|
||||||
|
finish_reason="stop",
|
||||||
|
index=0,
|
||||||
|
message=litellm.Message(content="Hi there!", role="assistant"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
model="gpt-3.5-turbo",
|
||||||
|
usage=litellm.Usage(completion_tokens=2, prompt_tokens=1, total_tokens=3),
|
||||||
|
),
|
||||||
|
"start_time": datetime.datetime.now(),
|
||||||
|
"end_time": datetime.datetime.now(),
|
||||||
|
"end_user_id": "user123",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a standard logging payload
|
||||||
|
standard_logging_payload = {
|
||||||
|
"messages": [{"role": "user", "content": "Hello!"}],
|
||||||
|
"response": {"role": "assistant", "content": "Hi there!"},
|
||||||
|
}
|
||||||
|
input_args["kwargs"]["standard_logging_object"] = standard_logging_payload
|
||||||
|
|
||||||
|
payload: SpendLogsPayload = get_logging_payload(**input_args)
|
||||||
|
|
||||||
|
print("json payload: ", json.dumps(payload, indent=4, default=str))
|
||||||
|
|
||||||
|
# Verify messages and response are included in payload
|
||||||
|
assert payload["messages"] == json.dumps([{"role": "user", "content": "Hello!"}])
|
||||||
|
assert payload["response"] == json.dumps(
|
||||||
|
{"role": "assistant", "content": "Hi there!"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up - reset general_settings
|
||||||
|
general_settings["store_prompts_in_spend_logs"] = False
|
||||||
|
|
||||||
|
# Verify messages and response are not included when disabled
|
||||||
|
payload_disabled: SpendLogsPayload = get_logging_payload(**input_args)
|
||||||
|
assert payload_disabled["messages"] == "{}"
|
||||||
|
assert payload_disabled["response"] == "{}"
|
||||||
|
|
49
ui/litellm-dashboard/package-lock.json
generated
49
ui/litellm-dashboard/package-lock.json
generated
|
@ -12,18 +12,21 @@
|
||||||
"@headlessui/tailwindcss": "^0.2.0",
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@remixicon/react": "^4.1.1",
|
"@remixicon/react": "^4.1.1",
|
||||||
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tremor/react": "^3.13.3",
|
"@tremor/react": "^3.13.3",
|
||||||
"antd": "^5.13.2",
|
"antd": "^5.13.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-syntax-highlighter": "^15.6.1"
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"tanstack": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
@ -753,6 +756,25 @@
|
||||||
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
|
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.20.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",
|
||||||
|
"integrity": "sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.20.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.0.2.tgz",
|
||||||
|
@ -769,6 +791,18 @@
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.20.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz",
|
||||||
|
"integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/virtual-core": {
|
"node_modules/@tanstack/virtual-core": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz",
|
||||||
|
@ -4879,6 +4913,14 @@
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/moment": {
|
||||||
|
"version": "2.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
@ -7174,6 +7216,11 @@
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tanstack": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tanstack/-/tanstack-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BUpDmwGlWHk2F183Uu1+k85biSLrpSh/zA9ephJwmZ9ze+XDEw3JOyN9vhcbFqrQFrf5yuWImt+0Kn4fUNgzTg=="
|
||||||
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
|
||||||
|
|
|
@ -13,18 +13,21 @@
|
||||||
"@headlessui/tailwindcss": "^0.2.0",
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@remixicon/react": "^4.1.1",
|
"@remixicon/react": "^4.1.1",
|
||||||
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"@tremor/react": "^3.13.3",
|
"@tremor/react": "^3.13.3",
|
||||||
"antd": "^5.13.2",
|
"antd": "^5.13.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"moment": "^2.30.1",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-syntax-highlighter": "^15.6.1"
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"tanstack": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
|
|
@ -12,6 +12,7 @@ import Settings from "@/components/settings";
|
||||||
import GeneralSettings from "@/components/general_settings";
|
import GeneralSettings from "@/components/general_settings";
|
||||||
import PassThroughSettings from "@/components/pass_through_settings";
|
import PassThroughSettings from "@/components/pass_through_settings";
|
||||||
import BudgetPanel from "@/components/budgets/budget_panel";
|
import BudgetPanel from "@/components/budgets/budget_panel";
|
||||||
|
import SpendLogsTable from "@/components/view_logs";
|
||||||
import ModelHub from "@/components/model_hub";
|
import ModelHub from "@/components/model_hub";
|
||||||
import APIRef from "@/components/api_ref";
|
import APIRef from "@/components/api_ref";
|
||||||
import ChatUI from "@/components/chat_ui";
|
import ChatUI from "@/components/chat_ui";
|
||||||
|
@ -304,6 +305,13 @@ const CreateKeyPage = () => {
|
||||||
accessToken={accessToken}
|
accessToken={accessToken}
|
||||||
modelData={modelData}
|
modelData={modelData}
|
||||||
/>
|
/>
|
||||||
|
) : page == "logs" ? (
|
||||||
|
<SpendLogsTable
|
||||||
|
userID={userID}
|
||||||
|
userRole={userRole}
|
||||||
|
token={token}
|
||||||
|
accessToken={accessToken}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Usage
|
<Usage
|
||||||
userID={userID}
|
userID={userID}
|
||||||
|
|
|
@ -145,7 +145,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Scroll to the bottom of the chat whenever chatHistory updates
|
// Scroll to the bottom of the chat whenever chatHistory updates
|
||||||
if (chatEndRef.current) {
|
if (chatEndRef.current) {
|
||||||
chatEndRef.current.scrollIntoView({ behavior: "smooth" });
|
// Add a small delay to ensure content is rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
chatEndRef.current?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "end" // Keep the scroll position at the end
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [chatHistory]);
|
}, [chatHistory]);
|
||||||
|
|
||||||
|
@ -359,7 +365,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
||||||
))}
|
))}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div ref={chatEndRef} />
|
<div ref={chatEndRef} style={{ height: "1px" }} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
|
@ -2,6 +2,22 @@ import { Layout, Menu } from "antd";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { List } from "postcss/lib/list";
|
import { List } from "postcss/lib/list";
|
||||||
import { Text } from "@tremor/react";
|
import { Text } from "@tremor/react";
|
||||||
|
import {
|
||||||
|
KeyOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
BlockOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
BankOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
LineOutlined,
|
||||||
|
LineChartOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
|
@ -18,6 +34,8 @@ interface MenuItem {
|
||||||
page: string;
|
page: string;
|
||||||
label: string;
|
label: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
|
children?: MenuItem[]; // Add children property for submenus
|
||||||
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const old_admin_roles = ["Admin", "Admin Viewer"];
|
const old_admin_roles = ["Admin", "Admin Viewer"];
|
||||||
|
@ -28,59 +46,103 @@ const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Inter
|
||||||
|
|
||||||
// Note: If a menu item does not have a role, it is visible to all roles.
|
// Note: If a menu item does not have a role, it is visible to all roles.
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{ key: "1", page: "api-keys", label: "Virtual Keys" }, // all roles
|
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined /> },
|
||||||
{ key: "3", page: "llm-playground", label: "Test Key" }, // all roles
|
{ key: "3", page: "llm-playground", label: "Test Key", icon: <PlayCircleOutlined /> },
|
||||||
{ key: "2", page: "models", label: "Models", roles: all_admin_roles },
|
{ key: "2", page: "models", label: "Models", icon: <BlockOutlined />, roles: all_admin_roles },
|
||||||
{ key: "4", page: "usage", label: "Usage"}, // all roles
|
{ key: "4", page: "usage", label: "Usage", icon: <BarChartOutlined /> },
|
||||||
{ key: "6", page: "teams", label: "Teams" },
|
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined /> },
|
||||||
{ key: "17", page: "organizations", label: "Organizations", roles: all_admin_roles },
|
{ key: "17", page: "organizations", label: "Organizations", icon: <BankOutlined />, roles: all_admin_roles },
|
||||||
{ key: "5", page: "users", label: "Internal Users", roles: all_admin_roles },
|
{ key: "5", page: "users", label: "Internal Users", icon: <UserOutlined />, roles: all_admin_roles },
|
||||||
{ key: "8", page: "settings", label: "Logging & Alerts", roles: all_admin_roles },
|
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined /> },
|
||||||
{ key: "9", page: "caching", label: "Caching", roles: all_admin_roles },
|
{ key: "16", page: "model-hub", label: "Model Hub", icon: <AppstoreOutlined /> },
|
||||||
{ key: "10", page: "budgets", label: "Budgets", roles: all_admin_roles },
|
{
|
||||||
{ key: "11", page: "general-settings", label: "Router Settings", roles: all_admin_roles },
|
key: "extras",
|
||||||
{ key: "12", page: "pass-through-settings", label: "Pass-Through", roles: all_admin_roles },
|
page: "extras",
|
||||||
{ key: "13", page: "admin-panel", label: "Admin Settings", roles: all_admin_roles },
|
label: "Extras",
|
||||||
{ key: "14", page: "api_ref", label: "API Reference" }, // all roles
|
icon: <DatabaseOutlined />,
|
||||||
{ key: "16", page: "model-hub", label: "Model Hub" }, // all roles
|
roles: all_admin_roles,
|
||||||
|
children: [
|
||||||
|
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined />, roles: all_admin_roles },
|
||||||
|
{ key: "9", page: "caching", label: "Caching", icon: <DatabaseOutlined />, roles: all_admin_roles },
|
||||||
|
{ key: "10", page: "budgets", label: "Budgets", icon: <BankOutlined />, roles: all_admin_roles },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "settings",
|
||||||
|
page: "settings",
|
||||||
|
label: "Settings",
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
roles: all_admin_roles,
|
||||||
|
children: [
|
||||||
|
{ key: "11", page: "general-settings", label: "Router Settings", icon: <SettingOutlined />, roles: all_admin_roles },
|
||||||
|
{ key: "12", page: "pass-through-settings", label: "Pass-Through", icon: <ApiOutlined />, roles: all_admin_roles },
|
||||||
|
{ key: "8", page: "settings", label: "Logging & Alerts", icon: <SettingOutlined />, roles: all_admin_roles },
|
||||||
|
{ key: "13", page: "admin-panel", label: "Admin Settings", icon: <SettingOutlined />, roles: all_admin_roles },
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// The Sidebar component can now be simplified to:
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({
|
const Sidebar: React.FC<SidebarProps> = ({
|
||||||
setPage,
|
setPage,
|
||||||
userRole,
|
userRole,
|
||||||
defaultSelectedKey,
|
defaultSelectedKey,
|
||||||
}) => {
|
}) => {
|
||||||
// Find the menu item that matches the default page to get its key
|
// Find the menu item that matches the default page, including in submenus
|
||||||
const selectedMenuItem = menuItems.find(item => item.page === defaultSelectedKey);
|
const findMenuItemKey = (page: string): string => {
|
||||||
const selectedMenuKey = selectedMenuItem?.key || "1";
|
// Check top-level items
|
||||||
|
const topLevelItem = menuItems.find(item => item.page === page);
|
||||||
|
if (topLevelItem) return topLevelItem.key;
|
||||||
|
|
||||||
|
// Check submenu items
|
||||||
|
for (const item of menuItems) {
|
||||||
|
if (item.children) {
|
||||||
|
const childItem = item.children.find(child => child.page === page);
|
||||||
|
if (childItem) return childItem.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "1"; // Default to first item if not found
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMenuKey = findMenuItemKey(defaultSelectedKey);
|
||||||
|
|
||||||
const filteredMenuItems = menuItems.filter(item =>
|
const filteredMenuItems = menuItems.filter(item =>
|
||||||
!item.roles || item.roles.includes(userRole)
|
!item.roles || item.roles.includes(userRole)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: "100vh", maxWidth: userRole === "Admin Viewer" ? "120px" : "145px" }}>
|
<Layout style={{ minHeight: "100vh" }}>
|
||||||
<Sider width={userRole === "Admin Viewer" ? 120 : 145}>
|
<Sider theme="light" width={220}>
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedMenuKey]}
|
selectedKeys={[selectedMenuKey]}
|
||||||
style={{ height: "100%", borderRight: 0 }}
|
style={{
|
||||||
>
|
borderRight: 0,
|
||||||
{filteredMenuItems.map(item => (
|
backgroundColor: 'transparent',
|
||||||
<Menu.Item
|
fontSize: '14px',
|
||||||
key={item.key}
|
}}
|
||||||
onClick={() => {
|
items={filteredMenuItems.map(item => ({
|
||||||
|
key: item.key,
|
||||||
|
icon: item.icon,
|
||||||
|
label: item.label,
|
||||||
|
children: item.children?.map(child => ({
|
||||||
|
key: child.key,
|
||||||
|
icon: child.icon,
|
||||||
|
label: child.label,
|
||||||
|
onClick: () => {
|
||||||
|
const newSearchParams = new URLSearchParams(window.location.search);
|
||||||
|
newSearchParams.set('page', child.page);
|
||||||
|
window.history.pushState(null, '', `?${newSearchParams.toString()}`);
|
||||||
|
setPage(child.page);
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
onClick: !item.children ? () => {
|
||||||
const newSearchParams = new URLSearchParams(window.location.search);
|
const newSearchParams = new URLSearchParams(window.location.search);
|
||||||
newSearchParams.set('page', item.page);
|
newSearchParams.set('page', item.page);
|
||||||
window.history.pushState(null, '', `?${newSearchParams.toString()}`);
|
window.history.pushState(null, '', `?${newSearchParams.toString()}`);
|
||||||
setPage(item.page);
|
setPage(item.page);
|
||||||
}}
|
} : undefined
|
||||||
>
|
}))}
|
||||||
<Text>{item.label}</Text>
|
/>
|
||||||
</Menu.Item>
|
|
||||||
))}
|
|
||||||
</Menu>
|
|
||||||
</Sider>
|
</Sider>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Card,
|
Card,
|
||||||
} from "@tremor/react";
|
} from "@tremor/react";
|
||||||
|
|
||||||
|
|
||||||
// Define the props type
|
// Define the props type
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
userID: string | null;
|
userID: string | null;
|
||||||
|
@ -82,57 +83,46 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="left-0 right-0 top-0 flex justify-between items-center h-12 mb-4">
|
<>
|
||||||
<div className="text-left my-2 absolute top-0 left-0">
|
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||||
<div className="flex flex-col items-center">
|
<div className="w-full px-4">
|
||||||
<Link href="/">
|
<div className="flex justify-between items-center h-14">
|
||||||
<button className="text-gray-800 rounded text-center">
|
{/* Left side - Just Logo */}
|
||||||
<img
|
<div className="flex items-center">
|
||||||
src={imageUrl}
|
<Link href="/" className="flex items-center">
|
||||||
width={160}
|
<span className="text-xl">🚅</span>
|
||||||
height={160}
|
<span className="ml-2 text-base font-medium text-gray-700">LiteLLM</span>
|
||||||
alt="LiteLLM Brand"
|
</Link>
|
||||||
className="mr-2"
|
</div>
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right mx-4 my-2 absolute top-0 right-0 flex items-center justify-end space-x-2">
|
|
||||||
{premiumUser ? null : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
// border: '1px solid #391085',
|
|
||||||
padding: "6px",
|
|
||||||
borderRadius: "8px", // Added border-radius property
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat"
|
|
||||||
target="_blank"
|
|
||||||
style={{
|
|
||||||
fontSize: "14px",
|
|
||||||
textDecoration: "underline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Get enterprise license
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
{/* Right side - Links and Admin */}
|
||||||
style={{
|
<div className="flex items-center space-x-6">
|
||||||
border: "1px solid #391085",
|
<a
|
||||||
padding: "6px",
|
href="https://docs.litellm.ai/docs/"
|
||||||
borderRadius: "8px", // Added border-radius property
|
target="_blank"
|
||||||
}}
|
rel="noopener noreferrer"
|
||||||
>
|
className="text-sm text-gray-600 hover:text-gray-800"
|
||||||
<Dropdown menu={{ items }}>
|
>
|
||||||
<Space>{userEmail ? userEmail : userRole}</Space>
|
Docs
|
||||||
</Dropdown>
|
</a>
|
||||||
|
<Dropdown menu={{ items }}>
|
||||||
|
<button className="flex items-center text-sm text-gray-600 hover:text-gray-800">
|
||||||
|
Admin
|
||||||
|
<svg
|
||||||
|
className="ml-1 w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
</nav>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1513,6 +1513,54 @@ export const userSpendLogsCall = async (
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
export const uiSpendLogsCall = async (
|
||||||
|
accessToken: String,
|
||||||
|
api_key?: string,
|
||||||
|
user_id?: string,
|
||||||
|
request_id?: string,
|
||||||
|
start_date?: string,
|
||||||
|
end_date?: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// Construct base URL
|
||||||
|
let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs/ui` : `/spend/logs/ui`;
|
||||||
|
|
||||||
|
// Add query parameters if they exist
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (api_key) queryParams.append('api_key', api_key);
|
||||||
|
if (user_id) queryParams.append('user_id', user_id);
|
||||||
|
if (request_id) queryParams.append('request_id', request_id);
|
||||||
|
if (start_date) queryParams.append('start_date', start_date);
|
||||||
|
if (end_date) queryParams.append('end_date', end_date);
|
||||||
|
// Append query parameters to URL if any exist
|
||||||
|
const queryString = queryParams.toString();
|
||||||
|
if (queryString) {
|
||||||
|
url += `?${queryString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Spend Logs UI Response:", data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch spend logs:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export const adminSpendLogsCall = async (accessToken: String) => {
|
export const adminSpendLogsCall = async (accessToken: String) => {
|
||||||
try {
|
try {
|
||||||
|
|
363
ui/litellm-dashboard/src/components/view_logs.tsx
Normal file
363
ui/litellm-dashboard/src/components/view_logs.tsx
Normal file
|
@ -0,0 +1,363 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card } from "@tremor/react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Text
|
||||||
|
} from "@tremor/react";
|
||||||
|
import { uiSpendLogsCall } from './networking';
|
||||||
|
import moment from 'moment';
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
|
interface SpendLogsTableProps {
|
||||||
|
accessToken: string | null;
|
||||||
|
token: string | null;
|
||||||
|
userRole: string | null;
|
||||||
|
userID: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
request_id: string;
|
||||||
|
api_key: string;
|
||||||
|
model: string;
|
||||||
|
api_base?: string;
|
||||||
|
call_type: string;
|
||||||
|
spend: number;
|
||||||
|
total_tokens: number;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
user?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
cache_hit: string;
|
||||||
|
cache_key?: string;
|
||||||
|
request_tags?: Record<string, any>;
|
||||||
|
requester_ip_address?: string;
|
||||||
|
messages: string | any[] | Record<string, any>;
|
||||||
|
response: string | any[] | Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMessage = (message: any): string => {
|
||||||
|
if (!message) return 'N/A';
|
||||||
|
if (typeof message === 'string') return message;
|
||||||
|
if (typeof message === 'object') {
|
||||||
|
// Handle the {text, type} object specifically
|
||||||
|
if (message.text) return message.text;
|
||||||
|
if (message.content) return message.content;
|
||||||
|
return JSON.stringify(message);
|
||||||
|
}
|
||||||
|
return String(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RequestViewer: React.FC<{ data: any }> = ({ data }) => {
|
||||||
|
const formatData = (input: any) => {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(input);
|
||||||
|
} catch {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 bg-gray-50 space-y-6">
|
||||||
|
{/* Request Card */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
|
<h3 className="text-lg font-medium">Request</h3>
|
||||||
|
<div>
|
||||||
|
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">Expand</button>
|
||||||
|
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="p-4 overflow-auto text-sm">
|
||||||
|
{JSON.stringify(formatData(data.request), null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Response Card */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
|
<h3 className="text-lg font-medium">Response</h3>
|
||||||
|
<div>
|
||||||
|
<button className="mr-2 px-3 py-1 text-sm border rounded hover:bg-gray-50">Expand</button>
|
||||||
|
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="p-4 overflow-auto text-sm">
|
||||||
|
{JSON.stringify(formatData(data.response), null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{/* Metadata Card */}
|
||||||
|
{data.metadata && Object.keys(data.metadata).length > 0 && (
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
|
<h3 className="text-lg font-medium">Metadata</h3>
|
||||||
|
<div>
|
||||||
|
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="p-4 overflow-auto text-sm">
|
||||||
|
{JSON.stringify(data.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpendLogsTable: React.FC<SpendLogsTableProps> = ({ accessToken, token, userRole, userID }) => {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<LogEntry>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
header: 'Time',
|
||||||
|
accessorKey: 'startTime',
|
||||||
|
cell: (info: any) => (
|
||||||
|
<span>{moment(info.getValue()).format('MMM DD HH:mm:ss')}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Request ID',
|
||||||
|
accessorKey: 'request_id',
|
||||||
|
cell: (info: any) => (
|
||||||
|
<span className="font-mono text-xs">{String(info.getValue() || '')}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Type',
|
||||||
|
accessorKey: 'call_type',
|
||||||
|
cell: (info: any) => (
|
||||||
|
<span>{String(info.getValue() || '')}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Request',
|
||||||
|
accessorKey: 'messages',
|
||||||
|
cell: (info: any) => {
|
||||||
|
const messages = info.getValue();
|
||||||
|
try {
|
||||||
|
const content = typeof messages === 'string' ? JSON.parse(messages) : messages;
|
||||||
|
let displayText = '';
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
displayText = formatMessage(content[0]?.content);
|
||||||
|
} else {
|
||||||
|
displayText = formatMessage(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="truncate max-w-md text-sm">{displayText}</span>;
|
||||||
|
} catch (e) {
|
||||||
|
return <span className="truncate max-w-md text-sm">{formatMessage(messages)}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Model',
|
||||||
|
accessorKey: 'model',
|
||||||
|
cell: (info: any) => <span>{String(info.getValue() || '')}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tokens',
|
||||||
|
accessorKey: 'total_tokens',
|
||||||
|
cell: (info: any) => {
|
||||||
|
const row = info.row.original;
|
||||||
|
return (
|
||||||
|
<span className="text-sm">
|
||||||
|
{String(row.total_tokens || '0')}
|
||||||
|
<span className="text-gray-400 text-xs ml-1">
|
||||||
|
({String(row.prompt_tokens || '0')}+{String(row.completion_tokens || '0')})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'User',
|
||||||
|
accessorKey: 'user',
|
||||||
|
cell: (info: any) => <span>{String(info.getValue() || '-')}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Cost',
|
||||||
|
accessorKey: 'spend',
|
||||||
|
cell: (info: any) => <span>${(Number(info.getValue() || 0)).toFixed(6)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Tags',
|
||||||
|
accessorKey: 'request_tags',
|
||||||
|
cell: (info: any) => {
|
||||||
|
const tags = info.getValue();
|
||||||
|
if (!tags || Object.keys(tags).length === 0) return '-';
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(tags).map(([key, value]) => (
|
||||||
|
<span key={key} className="px-2 py-1 bg-gray-100 rounded-full text-xs">
|
||||||
|
{key}: {String(value)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: logs,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
if (!accessToken || !token || !userRole || !userID) {
|
||||||
|
console.log("got None values for one of accessToken, token, userRole, userID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// Get logs for last 24 hours using ISO string and proper date formatting
|
||||||
|
const endTime = moment().format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
const startTime = moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
|
||||||
|
const data = await uiSpendLogsCall(
|
||||||
|
accessToken,
|
||||||
|
token,
|
||||||
|
userRole,
|
||||||
|
userID,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("response from uiSpendLogsCall:", data);
|
||||||
|
|
||||||
|
// Transform the data and add unique keys
|
||||||
|
const formattedLogs = data.map((log: LogEntry, index: number) => ({
|
||||||
|
...log,
|
||||||
|
key: index.toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLogs(formattedLogs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLogs();
|
||||||
|
}, [accessToken, token, userRole, userID]);
|
||||||
|
|
||||||
|
if (!accessToken || !token || !userRole || !userID) {
|
||||||
|
console.log("got None values for one of accessToken, token, userRole, userID");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[90%] mx-auto px-4">
|
||||||
|
<div className="bg-white rounded-lg shadow w-full">
|
||||||
|
<div className="p-4 border-b flex justify-between items-center">
|
||||||
|
<div className="flex space-x-4 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by request ID, model, or user..."
|
||||||
|
className="px-4 py-2 border rounded-lg w-80"
|
||||||
|
/>
|
||||||
|
<button className="px-4 py-2 border rounded-lg flex items-center gap-2">
|
||||||
|
<span>Filters</span>
|
||||||
|
<span className="text-xs bg-gray-100 px-2 py-1 rounded">0</span>
|
||||||
|
</button>
|
||||||
|
<select className="px-4 py-2 border rounded-lg">
|
||||||
|
<option>Last 24 hours</option>
|
||||||
|
<option>Last 7 days</option>
|
||||||
|
<option>Last 30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="px-3 py-1 text-sm border rounded hover:bg-gray-50">Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto w-full">
|
||||||
|
<Table className="w-full table-fixed">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableHeaderCell
|
||||||
|
key={column.header}
|
||||||
|
className={`${column.header === 'Request ID' ? 'w-32' : ''} whitespace-nowrap`}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</TableHeaderCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<React.Fragment key={log.request_id || log.startTime}>
|
||||||
|
<TableRow
|
||||||
|
className="cursor-pointer hover:bg-gray-50"
|
||||||
|
onClick={() => setExpandedRow(expandedRow === log.request_id ? null : log.request_id)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<TableCell
|
||||||
|
key={column.header}
|
||||||
|
className={column.header === 'Request ID' ? 'w-32 truncate' : ''}
|
||||||
|
>
|
||||||
|
{column.cell ?
|
||||||
|
column.cell({
|
||||||
|
getValue: () => log[column.accessorKey as keyof LogEntry],
|
||||||
|
row: { original: log }
|
||||||
|
}) :
|
||||||
|
<span>{String(log[column.accessorKey as keyof LogEntry] ?? '')}</span>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
{expandedRow === log.request_id && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="p-0">
|
||||||
|
<RequestViewer data={{
|
||||||
|
request: typeof log.messages === 'string' ? JSON.parse(log.messages) : log.messages,
|
||||||
|
response: typeof log.response === 'string' ? JSON.parse(log.response) : log.response,
|
||||||
|
metadata: log.metadata
|
||||||
|
}} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpendLogsTable;
|
Loading…
Add table
Add a link
Reference in a new issue