(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:
Ishaan Jaff 2025-01-17 18:53:45 -08:00 committed by GitHub
parent a99deb6d0a
commit d3c2f4331a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 795 additions and 89 deletions

View file

@ -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):

View file

@ -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

View file

@ -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])
}

View file

@ -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"],

View file

@ -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

View file

@ -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])
}

View file

@ -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"] == "{}"

View file

@ -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",

View file

@ -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",

View file

@ -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" ? (
<SpendLogsTable
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
/>
) : (
<Usage
userID={userID}

View file

@ -145,7 +145,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
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<ChatUIProps> = ({
))}
<TableRow>
<TableCell>
<div ref={chatEndRef} />
<div ref={chatEndRef} style={{ height: "1px" }} />
</TableCell>
</TableRow>
</TableBody>

View file

@ -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: <KeyOutlined /> },
{ key: "3", page: "llm-playground", label: "Test Key", icon: <PlayCircleOutlined /> },
{ key: "2", page: "models", label: "Models", icon: <BlockOutlined />, roles: all_admin_roles },
{ key: "4", page: "usage", label: "Usage", icon: <BarChartOutlined /> },
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined /> },
{ key: "17", page: "organizations", label: "Organizations", icon: <BankOutlined />, roles: all_admin_roles },
{ key: "5", page: "users", label: "Internal Users", icon: <UserOutlined />, roles: all_admin_roles },
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined /> },
{ key: "16", page: "model-hub", label: "Model Hub", icon: <AppstoreOutlined /> },
{
key: "extras",
page: "extras",
label: "Extras",
icon: <DatabaseOutlined />,
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> = ({
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 (
<Layout style={{ minHeight: "100vh", maxWidth: userRole === "Admin Viewer" ? "120px" : "145px" }}>
<Sider width={userRole === "Admin Viewer" ? 120 : 145}>
<Layout style={{ minHeight: "100vh" }}>
<Sider theme="light" width={220}>
<Menu
mode="inline"
selectedKeys={[selectedMenuKey]}
style={{ height: "100%", borderRight: 0 }}
>
{filteredMenuItems.map(item => (
<Menu.Item
key={item.key}
onClick={() => {
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);
}}
>
<Text>{item.label}</Text>
</Menu.Item>
))}
</Menu>
} : undefined
}))}
/>
</Sider>
</Layout>
);

View file

@ -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<NavbarProps> = ({
];
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">
<div className="flex flex-col items-center">
<Link href="/">
<button className="text-gray-800 rounded text-center">
<img
src={imageUrl}
width={160}
height={160}
alt="LiteLLM Brand"
className="mr-2"
/>
</button>
<>
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="w-full px-4">
<div className="flex justify-between items-center h-14">
{/* Left side - Just Logo */}
<div className="flex items-center">
<Link href="/" className="flex items-center">
<span className="text-xl">🚅</span>
<span className="ml-2 text-base font-medium text-gray-700">LiteLLM</span>
</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
style={{
border: "1px solid #391085",
padding: "6px",
borderRadius: "8px", // Added border-radius property
}}
{/* Right side - Links and Admin */}
<div className="flex items-center space-x-6">
<a
href="https://docs.litellm.ai/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-600 hover:text-gray-800"
>
Docs
</a>
<Dropdown menu={{ items }}>
<Space>{userEmail ? userEmail : userRole}</Space>
<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>
</nav>
</>
);
};

View file

@ -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 {

View 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;