Merge pull request #20720 from OrionCodeDev/fix-spend-logs

fix-spend-logs
This commit is contained in:
yuneng-jiang 2026-02-12 21:33:41 -08:00 committed by GitHub
commit f0a61625b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1350 additions and 58 deletions

View file

@ -10,10 +10,13 @@ Endpoints here:
- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`.
- GET `/v1/mcp/tools - lists all the tools available for a key
- GET `/v1/mcp/access_groups` - lists all available MCP access groups
- GET `/v1/mcp/discover` - Returns curated list of well-known MCP servers for discovery UI
"""
import importlib
import json
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Literal, Optional
@ -1176,3 +1179,88 @@ if MCP_AVAILABLE:
except Exception as e:
verbose_proxy_logger.exception(f"Error making agent public: {e}")
raise HTTPException(status_code=500, detail=str(e))
# --- MCP Discovery ---
_MCP_REGISTRY_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"mcp_registry.json",
)
_mcp_registry_cache: Optional[Dict[str, Any]] = None
def _load_mcp_registry() -> Dict[str, Any]:
"""Load the curated MCP registry from disk. Cached after first read."""
global _mcp_registry_cache
if _mcp_registry_cache is not None:
return _mcp_registry_cache
try:
with open(_MCP_REGISTRY_PATH, "r") as f:
data: Dict[str, Any] = json.load(f)
except Exception as e:
verbose_proxy_logger.warning(
f"Failed to load MCP registry from {_MCP_REGISTRY_PATH}: {e}"
)
data = {"servers": []}
_mcp_registry_cache = data
return data
@router.get(
"/discover",
description="Returns a curated list of well-known MCP servers for discovery UI",
dependencies=[Depends(user_api_key_auth)],
)
async def discover_mcp_servers(
query: Optional[str] = Query(
None, description="Search filter for server names and descriptions"
),
category: Optional[str] = Query(
None, description="Filter by category"
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Returns a curated list of well-known MCP servers that can be added to the proxy.
Used by the UI to show a discovery grid when adding new MCP servers.
"""
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=403,
detail={
"error": "Only proxy admins can access MCP discovery. Your role={}".format(
user_api_key_dict.user_role
)
},
)
registry = _load_mcp_registry()
servers = registry.get("servers", [])
# Apply query filter
if query:
query_lower = query.lower()
servers = [
s
for s in servers
if query_lower in s.get("name", "").lower()
or query_lower in s.get("title", "").lower()
or query_lower in s.get("description", "").lower()
]
# Apply category filter
if category:
servers = [
s for s in servers if s.get("category", "") == category
]
# Extract unique categories from the full list (before filtering)
all_servers = registry.get("servers", [])
categories = sorted(
set(s.get("category", "Other") for s in all_servers)
)
return {
"servers": servers,
"categories": categories,
}

View file

@ -0,0 +1,426 @@
{
"servers": [
{
"name": "github",
"title": "GitHub",
"description": "Manage repos, issues, PRs, and workflows through natural language",
"icon_url": "https://cdn.simpleicons.org/github",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.github%2Fgithub-mcp-server",
"transport": "http",
"url": "https://api.githubcopilot.com/mcp/",
"env_vars": [
{"name": "GITHUB_PERSONAL_ACCESS_TOKEN", "description": "GitHub Personal Access Token", "secret": true}
]
},
{
"name": "gitlab",
"title": "GitLab",
"description": "Official GitLab MCP Server for project and repository management",
"icon_url": "https://cdn.simpleicons.org/gitlab",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.gitlab%2Fmcp",
"transport": "http",
"url": "https://gitlab.com/api/v4/mcp",
"env_vars": [
{"name": "GITLAB_PERSONAL_ACCESS_TOKEN", "description": "GitLab Personal Access Token", "secret": true}
]
},
{
"name": "atlassian",
"title": "Atlassian (Jira & Confluence)",
"description": "Jira issues, Confluence pages, and Atlassian product integration",
"icon_url": "https://cdn.simpleicons.org/atlassian",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.atlassian%2Fatlassian-mcp-server",
"transport": "sse",
"url": "https://mcp.atlassian.com/v1/sse",
"env_vars": []
},
{
"name": "linear",
"title": "Linear",
"description": "Issue tracking, project management, and team workflow automation",
"icon_url": "https://cdn.simpleicons.org/linear",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/app.linear%2Flinear",
"transport": "sse",
"url": "https://mcp.linear.app/sse",
"env_vars": []
},
{
"name": "sentry",
"title": "Sentry",
"description": "Error monitoring, issue tracking, and debugging for AI assistants",
"icon_url": "https://cdn.simpleicons.org/sentry",
"category": "Developer Tools",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.getsentry%2Fsentry-mcp",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@sentry/mcp-server"],
"env_vars": [
{"name": "SENTRY_ACCESS_TOKEN", "description": "Sentry Access Token", "secret": true}
]
},
{
"name": "slack",
"title": "Slack",
"description": "Channel management, messaging, and Slack workspace integration",
"icon_url": "https://cdn.simpleicons.org/slack",
"category": "Communication",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env_vars": [
{"name": "SLACK_BOT_TOKEN", "description": "Slack Bot User OAuth Token", "secret": true},
{"name": "SLACK_TEAM_ID", "description": "Slack Team/Workspace ID", "secret": false}
]
},
{
"name": "discord",
"title": "Discord",
"description": "Discord server management, messaging, and bot integration",
"icon_url": "https://cdn.simpleicons.org/discord",
"category": "Communication",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-discord"],
"env_vars": [
{"name": "DISCORD_BOT_TOKEN", "description": "Discord Bot Token", "secret": true}
]
},
{
"name": "postgresql",
"title": "PostgreSQL",
"description": "Query and manage PostgreSQL databases with read-only access",
"icon_url": "https://cdn.simpleicons.org/postgresql",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env_vars": [
{"name": "POSTGRES_CONNECTION_STRING", "description": "PostgreSQL connection string (e.g., postgresql://user:pass@host:5432/db)", "secret": true}
]
},
{
"name": "sqlite",
"title": "SQLite",
"description": "Query and manage SQLite databases",
"icon_url": "https://cdn.simpleicons.org/sqlite",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sqlite"],
"env_vars": [
{"name": "SQLITE_DB_PATH", "description": "Path to SQLite database file", "secret": false}
]
},
{
"name": "mysql",
"title": "MySQL",
"description": "Query and manage MySQL databases",
"icon_url": "https://cdn.simpleicons.org/mysql",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-mysql"],
"env_vars": [
{"name": "MYSQL_HOST", "description": "MySQL host", "secret": false},
{"name": "MYSQL_USER", "description": "MySQL username", "secret": false},
{"name": "MYSQL_PASSWORD", "description": "MySQL password", "secret": true},
{"name": "MYSQL_DATABASE", "description": "MySQL database name", "secret": false}
]
},
{
"name": "mongodb",
"title": "MongoDB",
"description": "Query and manage MongoDB databases and collections",
"icon_url": "https://cdn.simpleicons.org/mongodb",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-mongodb"],
"env_vars": [
{"name": "MONGODB_CONNECTION_STRING", "description": "MongoDB connection string", "secret": true}
]
},
{
"name": "redis",
"title": "Redis",
"description": "Interact with Redis key-value stores",
"icon_url": "https://cdn.simpleicons.org/redis",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-redis"],
"env_vars": [
{"name": "REDIS_URL", "description": "Redis connection URL (e.g., redis://localhost:6379)", "secret": true}
]
},
{
"name": "snowflake",
"title": "Snowflake",
"description": "MCP Server for Snowflake from Snowflake Labs",
"icon_url": "https://cdn.simpleicons.org/snowflake",
"category": "Databases",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.Snowflake-Labs%2Fmcp",
"transport": "stdio",
"command": "uvx",
"args": ["snowflake-labs-mcp"],
"env_vars": [
{"name": "SNOWFLAKE_ACCOUNT", "description": "Snowflake account identifier (e.g., xy12345.us-east-1)", "secret": false},
{"name": "SNOWFLAKE_USER", "description": "Snowflake username", "secret": false},
{"name": "SNOWFLAKE_PASSWORD", "description": "Snowflake password", "secret": true}
]
},
{
"name": "notion",
"title": "Notion",
"description": "Official Notion MCP server for pages and databases",
"icon_url": "https://cdn.simpleicons.org/notion",
"category": "Productivity",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.notion%2Fmcp",
"transport": "sse",
"url": "https://mcp.notion.com/sse",
"env_vars": []
},
{
"name": "google_drive",
"title": "Google Drive",
"description": "Search and access files in Google Drive",
"icon_url": "https://cdn.simpleicons.org/googledrive",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-gdrive"],
"env_vars": [
{"name": "GOOGLE_CLIENT_ID", "description": "Google OAuth Client ID", "secret": false},
{"name": "GOOGLE_CLIENT_SECRET", "description": "Google OAuth Client Secret", "secret": true}
]
},
{
"name": "google_calendar",
"title": "Google Calendar",
"description": "Manage events and calendars in Google Calendar",
"icon_url": "https://cdn.simpleicons.org/googlecalendar",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-google-calendar"],
"env_vars": [
{"name": "GOOGLE_CLIENT_ID", "description": "Google OAuth Client ID", "secret": false},
{"name": "GOOGLE_CLIENT_SECRET", "description": "Google OAuth Client Secret", "secret": true}
]
},
{
"name": "obsidian",
"title": "Obsidian",
"description": "Read, search, and manage Obsidian vault notes and files",
"icon_url": "https://cdn.simpleicons.org/obsidian",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-obsidian"],
"env_vars": [
{"name": "OBSIDIAN_VAULT_PATH", "description": "Path to Obsidian vault directory", "secret": false}
]
},
{
"name": "brave_search",
"title": "Brave Search",
"description": "Web results, images, videos, and AI summaries via Brave Search API",
"icon_url": "https://cdn.simpleicons.org/brave",
"category": "Search",
"registry_url": "https://registry.modelcontextprotocol.io/servers/io.github.brave%2Fbrave-search-mcp-server",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@brave/brave-search-mcp-server"],
"env_vars": [
{"name": "BRAVE_API_KEY", "description": "Brave Search API Key", "secret": true}
]
},
{
"name": "exa",
"title": "Exa",
"description": "Fast, intelligent web search and web crawling",
"icon_url": "https://cdn.simpleicons.org/exa",
"category": "Search",
"registry_url": "https://registry.modelcontextprotocol.io/servers/ai.exa%2Fexa",
"transport": "http",
"url": "https://mcp.exa.ai/mcp",
"env_vars": [
{"name": "EXA_API_KEY", "description": "Exa API Key", "secret": true}
]
},
{
"name": "tavily",
"title": "Tavily",
"description": "AI-optimized search engine for research and retrieval",
"icon_url": "https://cdn.simpleicons.org/tavily",
"category": "Search",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-tavily"],
"env_vars": [
{"name": "TAVILY_API_KEY", "description": "Tavily API Key", "secret": true}
]
},
{
"name": "puppeteer",
"title": "Puppeteer",
"description": "Browser automation, web scraping, and screenshot capture",
"icon_url": "https://cdn.simpleicons.org/puppeteer",
"category": "Web & Browser",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
"env_vars": []
},
{
"name": "playwright",
"title": "Playwright",
"description": "Browser automation and testing with Playwright",
"icon_url": "https://cdn.simpleicons.org/playwright",
"category": "Web & Browser",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-playwright"],
"env_vars": []
},
{
"name": "browserbase",
"title": "Browserbase",
"description": "Cloud browser automation and session management",
"icon_url": "https://cdn.simpleicons.org/browserbase",
"category": "Web & Browser",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-browserbase"],
"env_vars": [
{"name": "BROWSERBASE_API_KEY", "description": "Browserbase API Key", "secret": true},
{"name": "BROWSERBASE_PROJECT_ID", "description": "Browserbase Project ID", "secret": false}
]
},
{
"name": "aws",
"title": "AWS",
"description": "Interact with Amazon Web Services resources and APIs",
"icon_url": "https://cdn.simpleicons.org/amazonaws",
"category": "Cloud",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-aws"],
"env_vars": [
{"name": "AWS_ACCESS_KEY_ID", "description": "AWS Access Key ID", "secret": true},
{"name": "AWS_SECRET_ACCESS_KEY", "description": "AWS Secret Access Key", "secret": true},
{"name": "AWS_REGION", "description": "AWS Region (e.g., us-east-1)", "secret": false}
]
},
{
"name": "cloudflare",
"title": "Cloudflare",
"description": "Manage Cloudflare Workers, KV, R2, D1, and more",
"icon_url": "https://cdn.simpleicons.org/cloudflare",
"category": "Cloud",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.cloudflare.mcp%2Fmcp",
"transport": "sse",
"url": "https://bindings.mcp.cloudflare.com/sse",
"env_vars": []
},
{
"name": "filesystem",
"title": "Filesystem",
"description": "Read, write, and manage files and directories on disk",
"icon_url": "https://cdn.simpleicons.org/files",
"category": "System",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env_vars": []
},
{
"name": "docker",
"title": "Docker",
"description": "Manage Docker containers, images, and networks",
"icon_url": "https://cdn.simpleicons.org/docker",
"category": "System",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-docker"],
"env_vars": []
},
{
"name": "stripe",
"title": "Stripe",
"description": "Manage payments, customers, and subscriptions via Stripe",
"icon_url": "https://cdn.simpleicons.org/stripe",
"category": "Finance",
"registry_url": "https://registry.modelcontextprotocol.io/servers/com.stripe%2Fmcp",
"transport": "http",
"url": "https://mcp.stripe.com",
"env_vars": []
},
{
"name": "shopify",
"title": "Shopify",
"description": "Manage Shopify stores, products, orders, and customers",
"icon_url": "https://cdn.simpleicons.org/shopify",
"category": "E-Commerce",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-shopify"],
"env_vars": [
{"name": "SHOPIFY_ACCESS_TOKEN", "description": "Shopify Admin API Access Token", "secret": true},
{"name": "SHOPIFY_STORE_URL", "description": "Shopify Store URL (e.g., mystore.myshopify.com)", "secret": false}
]
},
{
"name": "twilio",
"title": "Twilio",
"description": "Send SMS, make calls, and manage communication via Twilio",
"icon_url": "https://cdn.simpleicons.org/twilio",
"category": "Communication",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-twilio"],
"env_vars": [
{"name": "TWILIO_ACCOUNT_SID", "description": "Twilio Account SID", "secret": false},
{"name": "TWILIO_AUTH_TOKEN", "description": "Twilio Auth Token", "secret": true}
]
},
{
"name": "supabase",
"title": "Supabase",
"description": "Manage Supabase projects, databases, and storage",
"icon_url": "https://cdn.simpleicons.org/supabase",
"category": "Databases",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-supabase"],
"env_vars": [
{"name": "SUPABASE_URL", "description": "Supabase Project URL", "secret": false},
{"name": "SUPABASE_SERVICE_ROLE_KEY", "description": "Supabase Service Role Key", "secret": true}
]
}
]
}

View file

@ -3,7 +3,6 @@ import collections
import json
import os
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional
import fastapi
@ -1865,13 +1864,87 @@ async def ui_view_spend_logs( # noqa: PLR0915
where=where_conditions,
)
# Get paginated data
data = await prisma_client.db.litellm_spendlogs.find_many(
where=where_conditions,
order=order_clause,
skip=skip,
take=page_size,
)
# Build raw SQL to fetch paginated data WITHOUT heavy columns
# (messages, response, proxy_server_request can be hundreds of KB per row).
# These are only needed in the detail endpoint /spend/logs/ui/{request_id}.
sql_conditions: List[str] = []
sql_params: List[Any] = []
p = 1 # parameter index counter
# Date range (always present)
sql_conditions.append(f'"startTime" >= ${p}::timestamptz')
sql_params.append(start_date_obj)
p += 1
sql_conditions.append(f'"startTime" <= ${p}::timestamptz')
sql_params.append(end_date_obj)
p += 1
# Equality filters - read effective values from where_conditions (post-authorization)
for sql_col, wc_key in [
("team_id", "team_id"),
('"user"', "user"),
("api_key", "api_key"),
("request_id", "request_id"),
("model", "model"),
("model_id", "model_id"),
("end_user", "end_user"),
]:
val = where_conditions.get(wc_key)
if val is not None and isinstance(val, str):
sql_conditions.append(f"{sql_col} = ${p}")
sql_params.append(val)
p += 1
# Status filter
if status_filter is not None:
if status_filter == "success":
sql_conditions.append("(status = 'success' OR status IS NULL)")
else:
sql_conditions.append(f"status = ${p}")
sql_params.append(status_filter)
p += 1
# Spend range
if min_spend is not None:
sql_conditions.append(f"spend >= ${p}")
sql_params.append(min_spend)
p += 1
if max_spend is not None:
sql_conditions.append(f"spend <= ${p}")
sql_params.append(max_spend)
p += 1
# Metadata JSON filters (PostgreSQL JSONB operators)
if key_alias is not None:
sql_conditions.append(f"metadata->>'user_api_key_alias' LIKE ${p}")
sql_params.append(f"%{key_alias}%")
p += 1
if error_code is not None:
sql_conditions.append(f"metadata->'error_information'->>'error_code' = ${p}")
sql_params.append(error_code)
p += 1
if error_message is not None:
sql_conditions.append(f"metadata->'error_information'->>'error_message' LIKE ${p}")
sql_params.append(f"%{error_message}%")
p += 1
sql_query = f"""
SELECT
request_id, call_type, api_key, spend, total_tokens,
prompt_tokens, completion_tokens, "startTime", "endTime",
"completionStartTime", model, model_id, model_group,
custom_llm_provider, api_base, "user", metadata,
cache_hit, cache_key, request_tags, team_id,
organization_id, end_user, requester_ip_address,
session_id, status, mcp_namespaced_tool_name, agent_id
FROM "LiteLLM_SpendLogs"
WHERE {" AND ".join(sql_conditions)}
ORDER BY "startTime" DESC
LIMIT ${p} OFFSET ${p + 1}
"""
sql_params.extend([page_size, skip])
data = await prisma_client.db.query_raw(sql_query, *sql_params)
# Calculate total pages
total_pages = (total_records + page_size - 1) // page_size
@ -1892,7 +1965,6 @@ async def ui_view_spend_logs( # noqa: PLR0915
raise handle_exception_on_proxy(e)
@lru_cache(maxsize=128)
@router.get(
"/spend/logs/ui/{request_id}",
tags=["Budget & Spend Tracking"],
@ -1939,6 +2011,29 @@ async def ui_view_request_response_for_request_id(
if payload is not None:
return payload
# Fallback: fetch heavy columns directly from the database.
# The list endpoint (/spend/logs/ui) intentionally excludes messages,
# response, and proxy_server_request for performance. When no custom
# logger (S3, GCS, etc.) is configured, we still need to serve these
# fields from the DB for the detail/drawer view.
from litellm.proxy.proxy_server import prisma_client
if prisma_client is not None:
sql_query = """
SELECT messages, response, proxy_server_request
FROM "LiteLLM_SpendLogs"
WHERE request_id = $1
LIMIT 1
"""
db_result = await prisma_client.db.query_raw(sql_query, request_id)
if db_result and len(db_result) > 0:
row = db_result[0]
return {
"messages": row.get("messages"),
"response": row.get("response"),
"proxy_server_request": row.get("proxy_server_request"),
}
return None
@ -3104,13 +3199,22 @@ async def ui_view_session_spend_logs(
where=where_conditions
)
# Query the database with pagination
result = await prisma_client.db.litellm_spendlogs.find_many(
where=where_conditions,
order={"startTime": "asc"},
skip=skip,
take=page_size,
)
# Query with raw SQL to exclude heavy columns (messages, response, proxy_server_request)
sql_query = """
SELECT
request_id, call_type, api_key, spend, total_tokens,
prompt_tokens, completion_tokens, "startTime", "endTime",
"completionStartTime", model, model_id, model_group,
custom_llm_provider, api_base, "user", metadata,
cache_hit, cache_key, request_tags, team_id,
organization_id, end_user, requester_ip_address,
session_id, status, mcp_namespaced_tool_name, agent_id
FROM "LiteLLM_SpendLogs"
WHERE session_id = $1
ORDER BY "startTime" ASC
LIMIT $2 OFFSET $3
"""
result = await prisma_client.db.query_raw(sql_query, session_id, page_size, skip)
total_pages = (total_records + page_size - 1) // page_size

View file

@ -0,0 +1,182 @@
import json
import os
import sys
import pytest
sys.path.insert(
0, os.path.abspath("../../../..")
) # Adds the parent directory to the system path
class TestMCPRegistryFile:
"""Tests for the curated MCP registry JSON file."""
@pytest.fixture
def registry_path(self):
return os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"..",
"..",
"..",
"..",
"litellm",
"proxy",
"mcp_registry.json",
)
def test_registry_file_exists(self, registry_path):
assert os.path.exists(registry_path), f"Registry file not found at {registry_path}"
def test_registry_file_is_valid_json(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
assert isinstance(data, dict)
assert "servers" in data
def test_registry_servers_have_required_fields(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
servers = data["servers"]
assert len(servers) > 0, "Registry should have at least one server"
required_fields = ["name", "title", "description", "category", "transport"]
for server in servers:
for field in required_fields:
assert field in server, f"Server {server.get('name', '?')} missing field '{field}'"
def test_registry_server_names_are_unique(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
names = [s["name"] for s in data["servers"]]
assert len(names) == len(set(names)), f"Duplicate server names found: {[n for n in names if names.count(n) > 1]}"
def test_registry_transport_values_are_valid(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
valid_transports = {"stdio", "http", "sse"}
for server in data["servers"]:
assert server["transport"] in valid_transports, (
f"Server {server['name']} has invalid transport '{server['transport']}'"
)
def test_stdio_servers_have_command(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
for server in data["servers"]:
if server["transport"] == "stdio":
assert "command" in server and server["command"], (
f"stdio server {server['name']} missing 'command'"
)
def test_http_servers_have_url(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
for server in data["servers"]:
if server["transport"] in ("http", "sse"):
assert "url" in server and server["url"], (
f"HTTP/SSE server {server['name']} missing 'url'"
)
def test_well_known_servers_present(self, registry_path):
"""Ensure key well-known MCPs are in the registry."""
with open(registry_path, "r") as f:
data = json.load(f)
names = {s["name"] for s in data["servers"]}
expected = {"github", "slack", "postgresql", "snowflake", "atlassian"}
missing = expected - names
assert not missing, f"Missing well-known servers: {missing}"
def test_env_vars_structure(self, registry_path):
with open(registry_path, "r") as f:
data = json.load(f)
for server in data["servers"]:
if "env_vars" in server:
assert isinstance(server["env_vars"], list)
for var in server["env_vars"]:
assert "name" in var, f"env_var in {server['name']} missing 'name'"
class TestDiscoverEndpointFiltering:
"""Tests for the discover endpoint filtering logic (unit-level)."""
@pytest.fixture
def sample_servers(self):
return [
{
"name": "github",
"title": "GitHub",
"description": "Repository management",
"category": "Developer Tools",
"transport": "http",
"url": "https://mcp.github.com/sse",
},
{
"name": "slack",
"title": "Slack",
"description": "Channel management and messaging",
"category": "Communication",
"transport": "stdio",
"command": "npx",
},
{
"name": "postgresql",
"title": "PostgreSQL",
"description": "Query and manage databases",
"category": "Databases",
"transport": "stdio",
"command": "npx",
},
]
def test_query_filter_by_name(self, sample_servers):
query = "github"
q = query.lower()
result = [
s
for s in sample_servers
if q in s.get("name", "").lower()
or q in s.get("title", "").lower()
or q in s.get("description", "").lower()
]
assert len(result) == 1
assert result[0]["name"] == "github"
def test_query_filter_by_description(self, sample_servers):
query = "messaging"
q = query.lower()
result = [
s
for s in sample_servers
if q in s.get("name", "").lower()
or q in s.get("title", "").lower()
or q in s.get("description", "").lower()
]
assert len(result) == 1
assert result[0]["name"] == "slack"
def test_category_filter(self, sample_servers):
category = "Databases"
result = [s for s in sample_servers if s.get("category") == category]
assert len(result) == 1
assert result[0]["name"] == "postgresql"
def test_no_filter_returns_all(self, sample_servers):
assert len(sample_servers) == 3
def test_query_filter_no_match(self, sample_servers):
query = "nonexistent"
q = query.lower()
result = [
s
for s in sample_servers
if q in s.get("name", "").lower()
or q in s.get("title", "").lower()
or q in s.get("description", "").lower()
]
assert len(result) == 0
def test_categories_extraction(self, sample_servers):
categories = sorted(set(s.get("category", "Other") for s in sample_servers))
assert categories == ["Communication", "Databases", "Developer Tools"]

View file

@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { uiSpendLogDetailsCall } from "@/components/networking";
/**
* Hook to lazy-load log details (messages/response) for a specific log entry.
* Fetches data on-demand when the drawer is open, instead of prefetching all logs.
*
* @param requestId - The request_id of the log entry
* @param startTime - The formatted start time for the query
* @param enabled - Whether the query should be enabled (e.g., drawer is open)
*/
export const useLogDetails = (
requestId: string | undefined,
startTime: string | undefined,
enabled: boolean,
) => {
const { accessToken } = useAuthorized();
return useQuery({
queryKey: ["logDetails", requestId, startTime, accessToken],
queryFn: async () => {
if (!accessToken || !requestId || !startTime) return null;
return await uiSpendLogDetailsCall(accessToken, requestId, startTime);
},
enabled: enabled && !!accessToken && !!requestId && !!startTime,
staleTime: 10 * 60 * 1000, // 10 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});
};

View file

@ -3,7 +3,7 @@ import { Modal, Tooltip, Form, Select, Input } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
@ -25,6 +25,8 @@ interface CreateMCPServerProps {
isModalVisible: boolean;
setModalVisible: (visible: boolean) => void;
availableAccessGroups: string[];
prefillData?: DiscoverableMCPServer | null;
onBackToDiscovery?: () => void;
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.BASIC];
@ -38,6 +40,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
isModalVisible,
setModalVisible,
availableAccessGroups,
prefillData,
onBackToDiscovery,
}) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
@ -183,6 +187,50 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setPendingRestoredValues(null);
}, [pendingRestoredValues, form, transportType]);
// Pre-fill form from discovery selection
React.useEffect(() => {
if (!isModalVisible || !prefillData) {
return;
}
// Sanitize server name: strip vendor prefix, replace hyphens with underscores
const sanitizedName = (prefillData.name || "")
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
const transport = prefillData.transport || "";
setTransportType(transport);
const prefillValues: Record<string, any> = {
server_name: sanitizedName,
alias: sanitizedName,
description: prefillData.description || "",
transport: transport,
};
if (transport === "stdio") {
const stdioObj: Record<string, any> = {};
if (prefillData.command) stdioObj.command = prefillData.command;
if (prefillData.args && prefillData.args.length > 0) stdioObj.args = prefillData.args;
if (prefillData.env_vars && prefillData.env_vars.length > 0) {
const envObj: Record<string, string> = {};
for (const v of prefillData.env_vars) {
envObj[v.name] = v.description ? `<${v.description}>` : "";
}
stdioObj.env = envObj;
}
if (Object.keys(stdioObj).length > 0) {
prefillValues.stdio_config = JSON.stringify(stdioObj, null, 2);
}
} else if (prefillData.url) {
prefillValues.url = prefillData.url;
}
form.setFieldsValue(prefillValues);
setFormValues(prefillValues);
setAliasManuallyEdited(false);
}, [isModalVisible, prefillData, form]);
const handleCreate = async (values: Record<string, any>) => {
setIsLoading(true);
try {
@ -391,7 +439,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
return (
<Modal
title={
<div className="flex items-center space-x-3 pb-4 border-b border-gray-100">
<div className="flex items-center pb-4 border-b border-gray-100" style={{ gap: 12 }}>
{onBackToDiscovery && (
<button
onClick={onBackToDiscovery}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none"
style={{ flexShrink: 0 }}
>
&#8592;
</button>
)}
<img
src={mcpLogoImg}
alt="MCP Logo"
@ -399,7 +456,6 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>

View file

@ -0,0 +1,320 @@
import React, { useState, useMemo, useEffect } from "react";
import { Modal, Input, Typography } from "antd";
import { fetchDiscoverableMCPServers } from "../networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "./types";
import { mcpLogoImg } from "./create_mcp_server";
const { Search } = Input;
const { Text } = Typography;
interface MCPDiscoveryProps {
isVisible: boolean;
onClose: () => void;
onSelectServer: (server: DiscoverableMCPServer) => void;
onCustomServer: () => void;
accessToken: string | null;
}
const INITIAL_COLORS = [
"#3B82F6",
"#10B981",
"#F59E0B",
"#EF4444",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
];
function getInitialAvatar(name: string) {
const initial = name.charAt(0).toUpperCase();
const colorIndex =
name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) %
INITIAL_COLORS.length;
return { initial, backgroundColor: INITIAL_COLORS[colorIndex] };
}
const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
isVisible,
onClose,
onSelectServer,
onCustomServer,
accessToken,
}) => {
const [servers, setServers] = useState<DiscoverableMCPServer[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("All");
useEffect(() => {
if (isVisible && accessToken) {
setLoading(true);
setError(null);
fetchDiscoverableMCPServers(accessToken)
.then((data: DiscoverMCPServersResponse) => {
setServers(data.servers || []);
setCategories(data.categories || []);
})
.catch((err: Error) => {
setError(err.message || "Failed to load MCP servers");
})
.finally(() => {
setLoading(false);
});
}
}, [isVisible, accessToken]);
useEffect(() => {
if (isVisible) {
setSearchQuery("");
setSelectedCategory("All");
}
}, [isVisible]);
const filteredServers = useMemo(() => {
let result = servers;
if (selectedCategory !== "All") {
result = result.filter((s) => s.category === selectedCategory);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.title.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q),
);
}
return result;
}, [servers, selectedCategory, searchQuery]);
const groupedServers = useMemo(() => {
const groups: Record<string, DiscoverableMCPServer[]> = {};
for (const server of filteredServers) {
const cat = server.category || "Other";
if (!groups[cat]) groups[cat] = [];
groups[cat].push(server);
}
return groups;
}, [filteredServers]);
return (
<Modal
title={
<div className="flex items-center justify-between pb-4 border-b border-gray-100">
<div className="flex items-center space-x-3">
<img
src={mcpLogoImg}
alt="MCP Logo"
className="w-8 h-8 object-contain"
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>
<h2 className="text-xl font-semibold text-gray-900">Add MCP Server</h2>
</div>
<button
onClick={onCustomServer}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium"
>
+ Custom Server
</button>
</div>
}
open={isVisible}
onCancel={onClose}
footer={null}
width={1000}
className="top-8"
styles={{
body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
{/* Filter pills */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
{["All", ...categories].map((cat) => {
const isSelected = selectedCategory === cat;
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
style={{
padding: "4px 12px",
borderRadius: 4,
border: isSelected ? "1px solid #111827" : "1px solid #e5e7eb",
background: isSelected ? "#111827" : "#fff",
color: isSelected ? "#fff" : "#4b5563",
cursor: "pointer",
fontSize: 12,
fontWeight: isSelected ? 500 : 400,
lineHeight: "20px",
}}
>
{cat}
</button>
);
})}
</div>
{/* Search */}
<Search
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ marginBottom: 16 }}
allowClear
/>
{/* Loading skeleton */}
{loading && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
style={{
height: 36,
borderRadius: 6,
background: "#f9fafb",
}}
/>
))}
</div>
)}
{error && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
<Text>Failed to load servers: {error}</Text>
</div>
)}
{!loading && !error && filteredServers.length === 0 && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
<Text>
No servers found.{" "}
<a
onClick={onCustomServer}
style={{ color: "#2563eb", cursor: "pointer" }}
>
Add a custom server
</a>
</Text>
</div>
)}
{/* Server list grouped by category — 2 columns */}
{!loading &&
!error &&
Object.entries(groupedServers).map(([category, categoryServers]) => (
<div key={category} style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 11,
fontWeight: 500,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
padding: "6px 0",
borderBottom: "1px solid #f3f4f6",
marginBottom: 4,
}}
>
{category}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0 16px",
}}
>
{categoryServers.map((server) => {
const avatar = getInitialAvatar(server.title || server.name);
return (
<div
key={server.name}
onClick={() => onSelectServer(server)}
style={{
display: "flex",
alignItems: "center",
padding: "8px 10px",
borderRadius: 6,
cursor: "pointer",
transition: "background 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f9fafb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
{server.icon_url ? (
<img
src={server.icon_url}
alt={server.title}
style={{
width: 20,
height: 20,
objectFit: "contain",
flexShrink: 0,
marginRight: 12,
}}
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
const next = target.nextElementSibling as HTMLElement;
if (next) next.style.display = "flex";
}}
/>
) : null}
<div
style={{
width: 20,
height: 20,
borderRadius: 4,
backgroundColor: avatar.backgroundColor,
color: "#fff",
display: server.icon_url ? "none" : "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 600,
fontSize: 11,
flexShrink: 0,
marginRight: 12,
}}
>
{avatar.initial}
</div>
<span
style={{
fontSize: 14,
fontWeight: 400,
color: "#111827",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{server.title || server.name}
</span>
<span style={{ color: "#d1d5db", fontSize: 14, flexShrink: 0, marginLeft: 8 }}>
&#8250;
</span>
</div>
);
})}
</div>
</div>
))}
</Modal>
);
};
export default MCPDiscovery;

View file

@ -12,9 +12,10 @@ import CreateMCPServer from "./create_mcp_server";
import MCPConnect from "./mcp_connect";
import { mcpServerColumns } from "./mcp_server_columns";
import { MCPServerView } from "./mcp_server_view";
import { MCPServer, MCPServerProps, Team } from "./types";
import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types";
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
import MCPNetworkSettings from "./MCPNetworkSettings";
import MCPDiscovery from "./mcp_discovery";
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
@ -66,6 +67,8 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const [selectedMcpAccessGroup, setSelectedMcpAccessGroup] = useState<string>("all");
const [filteredServers, setFilteredServers] = useState<MCPServer[]>([]);
const [isModalVisible, setModalVisible] = useState(false);
const [isDiscoveryVisible, setDiscoveryVisible] = useState(false);
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const isInternalUser = userRole === "Internal User";
@ -291,14 +294,35 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
isModalVisible={isModalVisible}
setModalVisible={setModalVisible}
availableAccessGroups={uniqueMcpAccessGroups}
prefillData={prefillData}
onBackToDiscovery={() => {
setModalVisible(false);
setPrefillData(null);
setDiscoveryVisible(true);
}}
/>
<Title>MCP Servers</Title>
<Text className="text-tremor-content mt-2">Configure and manage your MCP servers</Text>
{isAdminRole(userRole) && (
<Button className="mt-4 mb-4" onClick={() => setModalVisible(true)}>
<Button className="mt-4 mb-4" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
<MCPDiscovery
isVisible={isDiscoveryVisible}
onClose={() => setDiscoveryVisible(false)}
onSelectServer={(server: DiscoverableMCPServer) => {
setPrefillData(server);
setDiscoveryVisible(false);
setModalVisible(true);
}}
onCustomServer={() => {
setPrefillData(null);
setDiscoveryVisible(false);
setModalVisible(true);
}}
accessToken={accessToken}
/>
<TabGroup className="w-full h-full">
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">

View file

@ -175,3 +175,23 @@ export interface MCPServerProps {
userRole: string | null;
userID: string | null;
}
// Discoverable MCP server from the curated registry
export interface DiscoverableMCPServer {
name: string;
title: string;
description: string;
icon_url?: string | null;
category: string;
registry_url?: string | null;
transport: string;
url?: string | null;
command?: string | null;
args?: string[] | null;
env_vars?: Array<{ name: string; description?: string; secret?: boolean }> | null;
}
export interface DiscoverMCPServersResponse {
servers: DiscoverableMCPServer[];
categories: string[];
}

View file

@ -6097,6 +6097,34 @@ export const updateInternalUserSettings = async (accessToken: string, settings:
}
};
export const fetchDiscoverableMCPServers = async (accessToken: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/v1/mcp/discover`
: `/v1/mcp/discover`;
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return await response.json();
} catch (error) {
console.error("Failed to fetch discoverable MCP servers:", error);
throw error;
}
};
export const fetchMCPServers = async (accessToken: string) => {
try {
// Construct base URL

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space } from "antd";
import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space, Spin } from "antd";
import moment from "moment";
import { LogEntry } from "../columns";
import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -38,6 +38,8 @@ const { Text } = Typography;
export interface LogDetailContentProps {
logEntry: LogEntry;
onOpenSettings?: () => void;
/** When true, log details (messages/response) are still being lazy-loaded. */
isLoadingDetails?: boolean;
}
/**
@ -48,14 +50,15 @@ export interface LogDetailContentProps {
* Designed to be placed inside LogDetailsDrawer's right panel so it can
* be reused for both single-log and session-mode views.
*/
export function LogDetailContent({ logEntry, onOpenSettings }: LogDetailContentProps) {
export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = false }: LogDetailContentProps) {
const metadata = logEntry.metadata || {};
const hasError = metadata.status === "failure";
const errorInfo = hasError ? metadata.error_information : null;
const hasMessages = checkHasMessages(logEntry.messages);
const hasResponse = checkHasResponse(logEntry.response);
const missingData = !hasMessages && !hasResponse && !hasError;
// Don't show "missing data" warning while details are still loading
const missingData = !hasMessages && !hasResponse && !hasError && !isLoadingDetails;
// Guardrail data
const guardrailInfo = metadata?.guardrail_information;
@ -145,13 +148,20 @@ export function LogDetailContent({ logEntry, onOpenSettings }: LogDetailContentP
)}
{/* Request/Response JSON */}
<RequestResponseSection
hasResponse={hasResponse}
hasError={hasError}
getRawRequest={getRawRequest}
getFormattedResponse={getFormattedResponse}
logEntry={logEntry}
/>
{isLoadingDetails ? (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6 p-8 text-center">
<Spin size="default" />
<div style={{ marginTop: 8, color: "#999" }}>Loading request &amp; response data...</div>
</div>
) : (
<RequestResponseSection
hasResponse={hasResponse}
hasError={hasError}
getRawRequest={getRawRequest}
getFormattedResponse={getFormattedResponse}
logEntry={logEntry}
/>
)}
{/* Guardrail Data */}
{hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}

View file

@ -17,6 +17,7 @@ import { sessionSpendLogsCall } from "../../networking";
import { useQuery } from "@tanstack/react-query";
import { getSpendString } from "@/utils/dataUtils";
import { DRAWER_WIDTH } from "./constants";
import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails";
export interface LogDetailsDrawerProps {
open: boolean;
@ -27,6 +28,7 @@ export interface LogDetailsDrawerProps {
onOpenSettings?: () => void;
allLogs?: LogEntry[];
onSelectLog?: (log: LogEntry) => void;
startTime?: string;
}
const SIDEBAR_WIDTH_PX = 224;
@ -106,6 +108,7 @@ export function LogDetailsDrawer({
onOpenSettings,
allLogs = [],
onSelectLog,
startTime,
}: LogDetailsDrawerProps) {
const isSessionMode = Boolean(sessionId);
const [selectedSessionRequestId, setSelectedSessionRequestId] = useState<string | null>(null);
@ -180,6 +183,25 @@ export function LogDetailsDrawer({
},
});
// Lazy-load log details (messages/response) only when drawer is open.
// This fetches data for a single log on-demand instead of prefetching all 50.
const logDetails = useLogDetails(currentLog?.request_id, startTime, open && !!currentLog?.request_id);
const detailsData = logDetails.data as any;
const isLoadingDetails = logDetails.isLoading;
// Build an enriched log entry that merges lazy-loaded details.
// The list endpoint may already include messages/response when store_prompts_in_spend_logs is enabled,
// while the detail endpoint fetches from custom loggers (S3, GCS, etc.) or DB fallback.
const enrichedLog = useMemo(() => {
if (!currentLog) return null;
return {
...currentLog,
messages: detailsData?.messages || currentLog.messages,
response: detailsData?.response || currentLog.response,
proxy_server_request: detailsData?.proxy_server_request || currentLog.proxy_server_request,
};
}, [currentLog, detailsData]);
const metadata = currentLog?.metadata || {};
// Status display values
@ -212,7 +234,7 @@ export function LogDetailsDrawer({
} catch { /* clipboard unavailable in non-secure contexts */ }
};
if (!currentLog) return null;
if (!currentLog || !enrichedLog) return null;
return (
<Drawer
@ -352,7 +374,11 @@ export function LogDetailsDrawer({
environment={environment}
/>
<div className="flex-1 overflow-y-auto">
<LogDetailContent logEntry={currentLog} onOpenSettings={onOpenSettings} />
<LogDetailContent
logEntry={enrichedLog}
onOpenSettings={onOpenSettings}
isLoadingDetails={isLoadingDetails}
/>
</div>
</div>
</div>

View file

@ -28,7 +28,6 @@ import { ErrorViewer } from "./ErrorViewer";
import { useLogFilterLogic } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { getTimeRangeDisplay } from "./logs_utils";
import { prefetchLogDetails } from "./prefetch";
import { RequestResponsePanel } from "./RequestResponsePanel";
import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal";
import { DataTable } from "./table";
@ -51,11 +50,6 @@ export interface PaginatedResponse {
total_pages: number;
}
interface PrefetchedLog {
messages: any[];
response: any;
}
export default function SpendLogsTable({
accessToken,
token,
@ -193,6 +187,8 @@ export default function SpendLogsTable({
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
// Get base response from API
// NOTE: We only fetch the list of logs here (lightweight).
// Log details (messages/response) are fetched on-demand when user clicks a row.
const response = await uiSpendLogsCall(
accessToken,
selectedKeyHash || undefined,
@ -209,25 +205,6 @@ export default function SpendLogsTable({
selectedModel || undefined,
);
// Trigger prefetch for all logs
await prefetchLogDetails(response.data, formattedStartTime, accessToken, queryClient);
// Update logs with prefetched data if available
response.data = response.data.map((log: LogEntry) => {
const prefetchedData = queryClient.getQueryData<PrefetchedLog>([
"logDetails",
log.request_id,
formattedStartTime,
]);
if (prefetchedData?.messages && prefetchedData?.response) {
log.messages = prefetchedData.messages;
log.response = prefetchedData.response;
return log;
}
return log;
});
return response;
},
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
@ -751,6 +728,7 @@ export default function SpendLogsTable({
onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)}
allLogs={filteredData}
onSelectLog={handleSelectLog}
startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")}
/>
</div>
);