From d3c2f4331a0fde99493f18f3bcadad68bf2da273 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 17 Jan 2025 18:53:45 -0800 Subject: [PATCH] (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 --- litellm/proxy/_types.py | 4 + litellm/proxy/proxy_config.yaml | 5 +- litellm/proxy/schema.prisma | 2 + .../spend_management_endpoints.py | 75 ++++ .../spend_tracking/spend_tracking_utils.py | 35 +- schema.prisma | 2 + .../logging_callback_tests/test_spend_logs.py | 62 +++ ui/litellm-dashboard/package-lock.json | 49 ++- ui/litellm-dashboard/package.json | 5 +- ui/litellm-dashboard/src/app/page.tsx | 8 + .../src/components/chat_ui.tsx | 10 +- .../src/components/leftnav.tsx | 128 ++++-- .../src/components/navbar.tsx | 88 ++--- .../src/components/networking.tsx | 48 +++ .../src/components/view_logs.tsx | 363 ++++++++++++++++++ 15 files changed, 795 insertions(+), 89 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs.tsx diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 575b408cdb..e7ccfe6f22 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1603,6 +1603,8 @@ class LiteLLM_SpendLogs(LiteLLMPydanticObjectBase): cache_key: Optional[str] = None request_tags: Optional[Json] = None requester_ip_address: Optional[str] = None + messages: Optional[Union[str, list, dict]] + response: Optional[Union[str, list, dict]] class LiteLLM_ErrorLogs(LiteLLMPydanticObjectBase): @@ -1853,6 +1855,8 @@ class SpendLogsPayload(TypedDict): end_user: Optional[str] requester_ip_address: Optional[str] custom_llm_provider: Optional[str] + messages: Optional[Union[str, list, dict]] + response: Optional[Union[str, list, dict]] class SpanAttributes(str, enum.Enum): diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 04c4e3f07e..65b04a6c84 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -12,5 +12,6 @@ model_list: model_info: health_check_model: anthropic/claude-3-5-sonnet-20240620 -litellm_settings: - callbacks: ["datadog_llm_observability"] +general_settings: + store_prompts_in_spend_logs: true + diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 69d70394f8..2332e9c6aa 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -204,6 +204,8 @@ model LiteLLM_SpendLogs { team_id String? end_user String? requester_ip_address String? + messages Json? @default("{}") + response Json? @default("{}") @@index([startTime]) @@index([end_user]) } diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 81968f9e0a..1dae6bffed 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -13,6 +13,7 @@ from litellm.proxy._types import * from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.spend_tracking.spend_tracking_utils import ( + _should_store_prompts_and_responses_in_spend_logs, get_spend_by_team_and_customer, ) 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( "/spend/logs", tags=["Budget & Spend Tracking"], diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 3240bfb8a3..c48ff105c0 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -9,6 +9,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload 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: @@ -57,6 +58,9 @@ def get_logging_payload( usage = dict(usage) id = cast(dict, response_obj).get("id") or kwargs.get("litellm_call_id") 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.startswith("sk-"): # hash the api_key @@ -151,10 +155,13 @@ def get_logging_payload( model_id=_model_id, requester_ip_address=clean_metadata.get("requester_ip_address", None), 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( - "SpendTable: created payload - payload: %s\n\n", payload + "SpendTable: created payload - payload: %s\n\n", + json.dumps(payload, indent=4, default=str), ) return payload @@ -239,3 +246,29 @@ async def get_spend_by_team_and_customer( return [] 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 diff --git a/schema.prisma b/schema.prisma index 69d70394f8..2332e9c6aa 100644 --- a/schema.prisma +++ b/schema.prisma @@ -204,6 +204,8 @@ model LiteLLM_SpendLogs { team_id String? end_user String? requester_ip_address String? + messages Json? @default("{}") + response Json? @default("{}") @@index([startTime]) @@index([end_user]) } diff --git a/tests/logging_callback_tests/test_spend_logs.py b/tests/logging_callback_tests/test_spend_logs.py index 9e4ceafd77..9ecfe47046 100644 --- a/tests/logging_callback_tests/test_spend_logs.py +++ b/tests/logging_callback_tests/test_spend_logs.py @@ -300,3 +300,65 @@ def test_spend_logs_payload_whisper(): assert payload["call_type"] == "atranscription" 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"] == "{}" diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 0280fe3aef..7ef07cd1bf 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -12,18 +12,21 @@ "@headlessui/tailwindcss": "^0.2.0", "@heroicons/react": "^1.0.6", "@remixicon/react": "^4.1.1", + "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.13.3", "antd": "^5.13.2", "fs": "^0.0.1-security", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", + "moment": "^2.30.1", "next": "^14.2.15", "openai": "^4.28.0", "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", "react-markdown": "^9.0.1", - "react-syntax-highlighter": "^15.6.1" + "react-syntax-highlighter": "^15.6.1", + "tanstack": "^1.0.0" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", @@ -753,6 +756,25 @@ "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": { "version": "3.0.2", "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" } }, + "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz", @@ -4879,6 +4913,14 @@ "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7174,6 +7216,11 @@ "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": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 6b013dd8fb..8659f09e98 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -13,18 +13,21 @@ "@headlessui/tailwindcss": "^0.2.0", "@heroicons/react": "^1.0.6", "@remixicon/react": "^4.1.1", + "@tanstack/react-table": "^8.20.6", "@tremor/react": "^3.13.3", "antd": "^5.13.2", "fs": "^0.0.1-security", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", + "moment": "^2.30.1", "next": "^14.2.15", "openai": "^4.28.0", "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", "react-markdown": "^9.0.1", - "react-syntax-highlighter": "^15.6.1" + "react-syntax-highlighter": "^15.6.1", + "tanstack": "^1.0.0" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index a667cbcd9d..b7c6c56b8a 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -12,6 +12,7 @@ import Settings from "@/components/settings"; import GeneralSettings from "@/components/general_settings"; import PassThroughSettings from "@/components/pass_through_settings"; import BudgetPanel from "@/components/budgets/budget_panel"; +import SpendLogsTable from "@/components/view_logs"; import ModelHub from "@/components/model_hub"; import APIRef from "@/components/api_ref"; import ChatUI from "@/components/chat_ui"; @@ -304,6 +305,13 @@ const CreateKeyPage = () => { accessToken={accessToken} modelData={modelData} /> + ) : page == "logs" ? ( + ) : ( = ({ useEffect(() => { // Scroll to the bottom of the chat whenever chatHistory updates 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]); @@ -359,7 +365,7 @@ const ChatUI: React.FC = ({ ))} -
+
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 27b5edfe1e..ee16ac89df 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -2,6 +2,22 @@ import { Layout, Menu } from "antd"; import Link from "next/link"; import { List } from "postcss/lib/list"; 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; @@ -18,6 +34,8 @@ interface MenuItem { page: string; label: string; roles?: string[]; + children?: MenuItem[]; // Add children property for submenus + icon?: React.ReactNode; } 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. const menuItems: MenuItem[] = [ - { key: "1", page: "api-keys", label: "Virtual Keys" }, // all roles - { key: "3", page: "llm-playground", label: "Test Key" }, // all roles - { key: "2", page: "models", label: "Models", roles: all_admin_roles }, - { key: "4", page: "usage", label: "Usage"}, // all roles - { key: "6", page: "teams", label: "Teams" }, - { key: "17", page: "organizations", label: "Organizations", roles: all_admin_roles }, - { key: "5", page: "users", label: "Internal Users", roles: all_admin_roles }, - { key: "8", page: "settings", label: "Logging & Alerts", roles: all_admin_roles }, - { key: "9", page: "caching", label: "Caching", roles: all_admin_roles }, - { key: "10", page: "budgets", label: "Budgets", roles: all_admin_roles }, - { key: "11", page: "general-settings", label: "Router Settings", roles: all_admin_roles }, - { key: "12", page: "pass-through-settings", label: "Pass-Through", roles: all_admin_roles }, - { key: "13", page: "admin-panel", label: "Admin Settings", roles: all_admin_roles }, - { key: "14", page: "api_ref", label: "API Reference" }, // all roles - { key: "16", page: "model-hub", label: "Model Hub" }, // all roles + { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { key: "3", page: "llm-playground", label: "Test Key", icon: }, + { key: "2", page: "models", label: "Models", icon: , roles: all_admin_roles }, + { key: "4", page: "usage", label: "Usage", icon: }, + { key: "6", page: "teams", label: "Teams", icon: }, + { key: "17", page: "organizations", label: "Organizations", icon: , roles: all_admin_roles }, + { key: "5", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, + { key: "14", page: "api_ref", label: "API Reference", icon: }, + { key: "16", page: "model-hub", label: "Model Hub", icon: }, + { + key: "extras", + page: "extras", + label: "Extras", + icon: , + roles: all_admin_roles, + children: [ + { key: "15", page: "logs", label: "Logs", icon: , roles: all_admin_roles }, + { key: "9", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, + { key: "10", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, + ] + }, + { + key: "settings", + page: "settings", + label: "Settings", + icon: , + roles: all_admin_roles, + children: [ + { key: "11", page: "general-settings", label: "Router Settings", icon: , roles: all_admin_roles }, + { key: "12", page: "pass-through-settings", label: "Pass-Through", icon: , roles: all_admin_roles }, + { key: "8", page: "settings", label: "Logging & Alerts", icon: , roles: all_admin_roles }, + { key: "13", page: "admin-panel", label: "Admin Settings", icon: , roles: all_admin_roles }, + ] + } ]; -// The Sidebar component can now be simplified to: const Sidebar: React.FC = ({ setPage, userRole, defaultSelectedKey, }) => { - // Find the menu item that matches the default page to get its key - const selectedMenuItem = menuItems.find(item => item.page === defaultSelectedKey); - const selectedMenuKey = selectedMenuItem?.key || "1"; + // Find the menu item that matches the default page, including in submenus + const findMenuItemKey = (page: string): string => { + // 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 => !item.roles || item.roles.includes(userRole) ); return ( - - + + - {filteredMenuItems.map(item => ( - { + style={{ + borderRight: 0, + backgroundColor: 'transparent', + fontSize: '14px', + }} + 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); newSearchParams.set('page', item.page); window.history.pushState(null, '', `?${newSearchParams.toString()}`); setPage(item.page); - }} - > - {item.label} - - ))} - + } : undefined + }))} + /> ); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 9e6a9f0ebe..97a5feb5d8 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -17,6 +17,7 @@ import { Card, } from "@tremor/react"; + // Define the props type interface NavbarProps { userID: string | null; @@ -82,57 +83,46 @@ const Navbar: React.FC = ({ ]; return ( - + ); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 744442ec7c..30a84f4760 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1513,6 +1513,54 @@ export const userSpendLogsCall = async ( 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) => { try { diff --git a/ui/litellm-dashboard/src/components/view_logs.tsx b/ui/litellm-dashboard/src/components/view_logs.tsx new file mode 100644 index 0000000000..097ef9a172 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs.tsx @@ -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; + cache_hit: string; + cache_key?: string; + request_tags?: Record; + requester_ip_address?: string; + messages: string | any[] | Record; + response: string | any[] | Record; +} + +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 ( +
+ {/* Request Card */} +
+
+

Request

+
+ + +
+
+
+          {JSON.stringify(formatData(data.request), null, 2)}
+        
+
+ + + {/* Response Card */} +
+
+

Response

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

Metadata

+
+ +
+
+
+            {JSON.stringify(data.metadata, null, 2)}
+          
+
+ )} +
+ ); +}; + +export const SpendLogsTable: React.FC = ({ accessToken, token, userRole, userID }) => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [sorting, setSorting] = useState([]); + const [expandedRow, setExpandedRow] = useState(null); + + const columnHelper = createColumnHelper(); + + const columns = [ + { + header: 'Time', + accessorKey: 'startTime', + cell: (info: any) => ( + {moment(info.getValue()).format('MMM DD HH:mm:ss')} + ), + }, + { + header: 'Request ID', + accessorKey: 'request_id', + cell: (info: any) => ( + {String(info.getValue() || '')} + ), + }, + { + header: 'Type', + accessorKey: 'call_type', + cell: (info: any) => ( + {String(info.getValue() || '')} + ), + }, + { + 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 {displayText}; + } catch (e) { + return {formatMessage(messages)}; + } + }, + }, + { + header: 'Model', + accessorKey: 'model', + cell: (info: any) => {String(info.getValue() || '')}, + }, + { + header: 'Tokens', + accessorKey: 'total_tokens', + cell: (info: any) => { + const row = info.row.original; + return ( + + {String(row.total_tokens || '0')} + + ({String(row.prompt_tokens || '0')}+{String(row.completion_tokens || '0')}) + + + ); + }, + }, + { + header: 'User', + accessorKey: 'user', + cell: (info: any) => {String(info.getValue() || '-')}, + }, + { + header: 'Cost', + accessorKey: 'spend', + cell: (info: any) => ${(Number(info.getValue() || 0)).toFixed(6)}, + }, + { + header: 'Tags', + accessorKey: 'request_tags', + cell: (info: any) => { + const tags = info.getValue(); + if (!tags || Object.keys(tags).length === 0) return '-'; + return ( +
+ {Object.entries(tags).map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+ ); + }, + }, + ]; + + 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 ( +
+
+
+
+ + + +
+
+ +
+
+ +
+ + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {logs.map((log) => ( + + setExpandedRow(expandedRow === log.request_id ? null : log.request_id)} + > + {columns.map((column) => ( + + {column.cell ? + column.cell({ + getValue: () => log[column.accessorKey as keyof LogEntry], + row: { original: log } + }) : + {String(log[column.accessorKey as keyof LogEntry] ?? '')} + } + + ))} + + {expandedRow === log.request_id && ( + + + + + + )} + + ))} + +
+
+
+
+ ); +}; + +export default SpendLogsTable; \ No newline at end of file