mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat: Add MCP configuration review and invocation logs for troubleshooting
Backend:
- Add call_type and mcp_tool_name filters to /spend/logs/ui endpoint
- Add GET /v1/mcp/server/{server_id}/logs endpoint for per-server invocation logs
- Add GET /v1/mcp/server/{server_id}/diagnose endpoint for connection diagnostics
Frontend:
- Add 'Logs' tab to MCP Server detail view showing invocation history
- Add 'Diagnostics' tab showing pass/fail checks for config, auth, connectivity, network
- Add 'Call Type' filter to main Logs page (MCP Tool Call, Completion, etc.)
- Add fetchMCPServerLogs and fetchMCPServerDiagnostics networking functions
Tests:
- Add 5 Python tests for new backend endpoints (logs + diagnostics)
- Add 9 Vitest tests for new UI components (logs + diagnostics)
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
parent
b19cf80c55
commit
0b4a6b2679
11 changed files with 1235 additions and 1 deletions
|
|
@ -1362,3 +1362,257 @@ if MCP_AVAILABLE:
|
|||
"servers": servers,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
@router.get(
|
||||
"/server/{server_id}/logs",
|
||||
description="Returns invocation logs for a specific MCP server",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def get_mcp_server_logs(
|
||||
server_id: str,
|
||||
start_date: Optional[str] = Query(
|
||||
None, description="Start date in YYYY-MM-DD HH:MM:SS format"
|
||||
),
|
||||
end_date: Optional[str] = Query(
|
||||
None, description="End date in YYYY-MM-DD HH:MM:SS format"
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="Page number"),
|
||||
page_size: int = Query(
|
||||
default=25, ge=1, le=100, description="Items per page"
|
||||
),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Returns paginated invocation logs for a specific MCP server.
|
||||
|
||||
Queries spend logs where mcp_namespaced_tool_name starts with the
|
||||
server's prefix (server_name or alias).
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Database not connected"
|
||||
)
|
||||
|
||||
mcp_server = global_mcp_server_manager.get_mcp_server_by_id(
|
||||
server_id
|
||||
)
|
||||
if mcp_server is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"MCP server {server_id} not found",
|
||||
)
|
||||
|
||||
server_prefix = get_server_prefix(mcp_server)
|
||||
|
||||
now = datetime.utcnow()
|
||||
if start_date is None:
|
||||
start_dt = now - timedelta(days=7)
|
||||
else:
|
||||
try:
|
||||
start_dt = datetime.strptime(
|
||||
start_date.strip(), "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
except ValueError:
|
||||
start_dt = datetime.strptime(
|
||||
start_date.strip(), "%Y-%m-%d"
|
||||
)
|
||||
|
||||
if end_date is None:
|
||||
end_dt = now
|
||||
else:
|
||||
try:
|
||||
end_dt = datetime.strptime(
|
||||
end_date.strip(), "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
except ValueError:
|
||||
end_dt = datetime.strptime(
|
||||
end_date.strip(), "%Y-%m-%d"
|
||||
)
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
like_pattern = f"{server_prefix}/%"
|
||||
|
||||
total = await prisma_client.db.query_raw(
|
||||
"""
|
||||
SELECT COUNT(*)::int AS cnt
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE mcp_namespaced_tool_name LIKE $1
|
||||
AND "startTime" >= $2::timestamptz
|
||||
AND "startTime" <= $3::timestamptz
|
||||
""",
|
||||
like_pattern,
|
||||
start_dt,
|
||||
end_dt,
|
||||
)
|
||||
total_count = total[0]["cnt"] if total else 0
|
||||
|
||||
rows = await prisma_client.db.query_raw(
|
||||
"""
|
||||
SELECT
|
||||
request_id, call_type, api_key, spend, total_tokens,
|
||||
prompt_tokens, completion_tokens, "startTime", "endTime",
|
||||
model, model_id, "user", metadata, team_id,
|
||||
end_user, status, mcp_namespaced_tool_name,
|
||||
COALESCE(request_duration_ms,
|
||||
(EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER
|
||||
) AS request_duration_ms
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE mcp_namespaced_tool_name LIKE $1
|
||||
AND "startTime" >= $2::timestamptz
|
||||
AND "startTime" <= $3::timestamptz
|
||||
ORDER BY "startTime" DESC
|
||||
LIMIT $4 OFFSET $5
|
||||
""",
|
||||
like_pattern,
|
||||
start_dt,
|
||||
end_dt,
|
||||
page_size,
|
||||
skip,
|
||||
)
|
||||
|
||||
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0
|
||||
|
||||
return {
|
||||
"data": rows,
|
||||
"total": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"server_id": server_id,
|
||||
"server_name": mcp_server.server_name,
|
||||
}
|
||||
|
||||
@router.get(
|
||||
"/server/{server_id}/diagnose",
|
||||
description="Run connection diagnostics for an MCP server",
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def diagnose_mcp_server(
|
||||
server_id: str,
|
||||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Run connection diagnostics for an MCP server and return structured results.
|
||||
|
||||
Returns auth resolution, connectivity status, and actionable suggestions.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug
|
||||
|
||||
mcp_server = global_mcp_server_manager.get_mcp_server_by_id(
|
||||
server_id
|
||||
)
|
||||
if mcp_server is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"MCP server {server_id} not found",
|
||||
)
|
||||
|
||||
checks: List[Dict[str, Any]] = []
|
||||
|
||||
# Check 1: Server configuration
|
||||
config_ok = bool(mcp_server.url or mcp_server.command)
|
||||
checks.append({
|
||||
"name": "configuration",
|
||||
"status": "pass" if config_ok else "fail",
|
||||
"message": "Server URL or command is configured"
|
||||
if config_ok
|
||||
else "No URL or command configured",
|
||||
})
|
||||
|
||||
# Check 2: Auth configuration
|
||||
auth_type = mcp_server.auth_type or "none"
|
||||
auth_resolution = MCPDebug.resolve_auth_resolution(
|
||||
server=mcp_server,
|
||||
mcp_auth_header=None,
|
||||
mcp_server_auth_headers=None,
|
||||
oauth2_headers=None,
|
||||
)
|
||||
checks.append({
|
||||
"name": "auth_type",
|
||||
"status": "pass",
|
||||
"message": f"Auth type: {auth_type}, resolution: {auth_resolution}",
|
||||
"details": {
|
||||
"auth_type": auth_type,
|
||||
"auth_resolution": auth_resolution,
|
||||
"has_credentials": bool(mcp_server.authentication_token),
|
||||
"has_client_credentials": mcp_server.has_client_credentials,
|
||||
},
|
||||
})
|
||||
|
||||
# Check 3: Connectivity (health check)
|
||||
health_status = "unknown"
|
||||
health_error = None
|
||||
try:
|
||||
tool_result = await global_mcp_server_manager._get_tools_from_server(
|
||||
mcp_server
|
||||
)
|
||||
if tool_result is not None:
|
||||
health_status = "healthy"
|
||||
tool_count = len(tool_result)
|
||||
else:
|
||||
health_status = "unhealthy"
|
||||
health_error = "No tools returned"
|
||||
tool_count = 0
|
||||
except Exception as e:
|
||||
health_status = "unhealthy"
|
||||
health_error = str(e)
|
||||
tool_count = 0
|
||||
|
||||
checks.append({
|
||||
"name": "connectivity",
|
||||
"status": "pass" if health_status == "healthy" else "fail",
|
||||
"message": f"Server is {health_status}"
|
||||
+ (f": {health_error}" if health_error else f" ({tool_count} tools available)"),
|
||||
"details": {
|
||||
"health_status": health_status,
|
||||
"tool_count": tool_count,
|
||||
"error": health_error,
|
||||
},
|
||||
})
|
||||
|
||||
# Check 4: Network accessibility
|
||||
checks.append({
|
||||
"name": "network",
|
||||
"status": "pass",
|
||||
"message": "Available on public internet"
|
||||
if mcp_server.available_on_public_internet
|
||||
else "Restricted to internal network",
|
||||
"details": {
|
||||
"available_on_public_internet": mcp_server.available_on_public_internet,
|
||||
},
|
||||
})
|
||||
|
||||
# Build suggestions
|
||||
suggestions: List[str] = []
|
||||
if health_status == "unhealthy":
|
||||
if auth_resolution == "no-auth" and auth_type != "none":
|
||||
suggestions.append(
|
||||
"Auth type is set to '{}' but no credentials are configured. "
|
||||
"Add credentials in the server settings.".format(auth_type)
|
||||
)
|
||||
if health_error and "timeout" in health_error.lower():
|
||||
suggestions.append(
|
||||
"Connection timed out. Check that the MCP server URL is reachable "
|
||||
"from the LiteLLM proxy network."
|
||||
)
|
||||
if health_error and ("401" in health_error or "unauthorized" in health_error.lower()):
|
||||
suggestions.append(
|
||||
"Authentication failed. Verify credentials and auth type configuration."
|
||||
)
|
||||
if not suggestions:
|
||||
suggestions.append(
|
||||
"Check the MCP server logs and verify the URL is correct."
|
||||
)
|
||||
|
||||
all_passing = all(c["status"] == "pass" for c in checks)
|
||||
|
||||
return {
|
||||
"server_id": server_id,
|
||||
"server_name": mcp_server.server_name,
|
||||
"overall_status": "healthy" if all_passing else "unhealthy",
|
||||
"checks": checks,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1694,6 +1694,14 @@ async def ui_view_spend_logs( # noqa: PLR0915
|
|||
default="desc",
|
||||
description="Sort order: asc or desc",
|
||||
),
|
||||
call_type: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Filter logs by call type (e.g., 'call_mcp_tool', 'list_mcp_tools', 'completion')",
|
||||
),
|
||||
mcp_tool_name: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Filter logs by MCP namespaced tool name (e.g., 'github-mcp/list_repos'). Supports partial match.",
|
||||
),
|
||||
):
|
||||
"""
|
||||
View spend logs with pagination support.
|
||||
|
|
@ -1823,6 +1831,14 @@ async def ui_view_spend_logs( # noqa: PLR0915
|
|||
if end_user is not None:
|
||||
where_conditions["end_user"] = end_user
|
||||
|
||||
if call_type is not None:
|
||||
where_conditions["call_type"] = call_type
|
||||
|
||||
if mcp_tool_name is not None:
|
||||
where_conditions["mcp_namespaced_tool_name"] = {
|
||||
"contains": mcp_tool_name
|
||||
}
|
||||
|
||||
if min_spend is not None or max_spend is not None:
|
||||
where_conditions["spend"] = {}
|
||||
if min_spend is not None:
|
||||
|
|
@ -1913,6 +1929,18 @@ async def ui_view_spend_logs( # noqa: PLR0915
|
|||
sql_params.append(max_spend)
|
||||
p += 1
|
||||
|
||||
# Call type filter
|
||||
if call_type is not None:
|
||||
sql_conditions.append(f"call_type = ${p}")
|
||||
sql_params.append(call_type)
|
||||
p += 1
|
||||
|
||||
# MCP tool name filter (partial match)
|
||||
if mcp_tool_name is not None:
|
||||
sql_conditions.append(f"mcp_namespaced_tool_name LIKE ${p}")
|
||||
sql_params.append(f"%{mcp_tool_name}%")
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -1512,3 +1512,193 @@ class TestManagementPayloadValidation:
|
|||
assert len(result) == 1
|
||||
assert result[0]["server_id"] == "server-1"
|
||||
assert result[0]["status"] == "healthy"
|
||||
|
||||
|
||||
class TestMCPServerLogs:
|
||||
"""Tests for the GET /v1/mcp/server/{server_id}/logs endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mcp_server_logs_server_not_found(self):
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
get_mcp_server_logs,
|
||||
)
|
||||
|
||||
mock_user_auth = generate_mock_user_api_key_auth()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_mcp_server_by_id.return_value = None
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
), patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
MagicMock(),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_mcp_server_logs(
|
||||
server_id="nonexistent",
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
page=1,
|
||||
page_size=25,
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mcp_server_logs_returns_paginated_results(self):
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
get_mcp_server_logs,
|
||||
)
|
||||
|
||||
mock_server = generate_mock_mcp_server_config_record(
|
||||
server_id="test-server-1", name="github-mcp"
|
||||
)
|
||||
mock_user_auth = generate_mock_user_api_key_auth()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_mcp_server_by_id.return_value = mock_server
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.query_raw = AsyncMock(
|
||||
side_effect=[
|
||||
[{"cnt": 2}],
|
||||
[
|
||||
{
|
||||
"request_id": "req-1",
|
||||
"call_type": "call_mcp_tool",
|
||||
"mcp_namespaced_tool_name": "github-mcp/list_repos",
|
||||
"status": "success",
|
||||
"spend": 0.001,
|
||||
},
|
||||
{
|
||||
"request_id": "req-2",
|
||||
"call_type": "call_mcp_tool",
|
||||
"mcp_namespaced_tool_name": "github-mcp/get_file",
|
||||
"status": "success",
|
||||
"spend": 0.002,
|
||||
},
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
), patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
mock_prisma,
|
||||
):
|
||||
result = await get_mcp_server_logs(
|
||||
server_id="test-server-1",
|
||||
start_date="2025-01-01 00:00:00",
|
||||
end_date="2025-12-31 23:59:59",
|
||||
page=1,
|
||||
page_size=25,
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert result["total"] == 2
|
||||
assert result["page"] == 1
|
||||
assert result["server_id"] == "test-server-1"
|
||||
assert len(result["data"]) == 2
|
||||
assert result["data"][0]["request_id"] == "req-1"
|
||||
|
||||
|
||||
class TestMCPServerDiagnose:
|
||||
"""Tests for the GET /v1/mcp/server/{server_id}/diagnose endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnose_server_not_found(self):
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
diagnose_mcp_server,
|
||||
)
|
||||
|
||||
mock_user_auth = generate_mock_user_api_key_auth()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_mcp_server_by_id.return_value = None
|
||||
|
||||
mock_request = MagicMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await diagnose_mcp_server(
|
||||
server_id="nonexistent",
|
||||
request=mock_request,
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnose_healthy_server(self):
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
diagnose_mcp_server,
|
||||
)
|
||||
|
||||
mock_server = generate_mock_mcp_server_config_record(
|
||||
server_id="test-srv", name="test-mcp"
|
||||
)
|
||||
mock_user_auth = generate_mock_user_api_key_auth()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_mcp_server_by_id.return_value = mock_server
|
||||
|
||||
mock_tools = [MagicMock(), MagicMock()]
|
||||
mock_manager._get_tools_from_server = AsyncMock(
|
||||
return_value=mock_tools
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
result = await diagnose_mcp_server(
|
||||
server_id="test-srv",
|
||||
request=mock_request,
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert result["overall_status"] == "healthy"
|
||||
assert result["server_id"] == "test-srv"
|
||||
assert len(result["checks"]) == 4
|
||||
assert all(c["status"] == "pass" for c in result["checks"])
|
||||
assert len(result["suggestions"]) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnose_unhealthy_server(self):
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
diagnose_mcp_server,
|
||||
)
|
||||
|
||||
mock_server = generate_mock_mcp_server_config_record(
|
||||
server_id="bad-srv", name="bad-mcp"
|
||||
)
|
||||
mock_user_auth = generate_mock_user_api_key_auth()
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_mcp_server_by_id.return_value = mock_server
|
||||
|
||||
mock_manager._get_tools_from_server = AsyncMock(
|
||||
side_effect=Exception("Connection timed out")
|
||||
)
|
||||
|
||||
mock_request = MagicMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
result = await diagnose_mcp_server(
|
||||
server_id="bad-srv",
|
||||
request=mock_request,
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert result["overall_status"] == "unhealthy"
|
||||
connectivity_check = next(
|
||||
c for c in result["checks"] if c["name"] == "connectivity"
|
||||
)
|
||||
assert connectivity_check["status"] == "fail"
|
||||
assert len(result["suggestions"]) > 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import MCPServerDiagnostics from "./mcp_server_diagnostics";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
fetchMCPServerDiagnostics: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchMCPServerDiagnostics } from "../networking";
|
||||
|
||||
describe("MCPServerDiagnostics", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the component title", async () => {
|
||||
(fetchMCPServerDiagnostics as any).mockResolvedValue({
|
||||
server_id: "test-server",
|
||||
server_name: "test-mcp",
|
||||
overall_status: "healthy",
|
||||
checks: [],
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerDiagnostics
|
||||
serverId="test-server"
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connection Diagnostics")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show healthy status when all checks pass", async () => {
|
||||
(fetchMCPServerDiagnostics as any).mockResolvedValue({
|
||||
server_id: "test-server",
|
||||
server_name: "test-mcp",
|
||||
overall_status: "healthy",
|
||||
checks: [
|
||||
{ name: "configuration", status: "pass", message: "OK" },
|
||||
{ name: "connectivity", status: "pass", message: "OK" },
|
||||
],
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerDiagnostics
|
||||
serverId="test-server"
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All Checks Passing")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show unhealthy status and suggestions when checks fail", async () => {
|
||||
(fetchMCPServerDiagnostics as any).mockResolvedValue({
|
||||
server_id: "bad-server",
|
||||
server_name: "bad-mcp",
|
||||
overall_status: "unhealthy",
|
||||
checks: [
|
||||
{ name: "configuration", status: "pass", message: "OK" },
|
||||
{
|
||||
name: "connectivity",
|
||||
status: "fail",
|
||||
message: "Connection timed out",
|
||||
},
|
||||
],
|
||||
suggestions: [
|
||||
"Check that the MCP server URL is reachable from the LiteLLM proxy network.",
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerDiagnostics
|
||||
serverId="bad-server"
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Issues Detected")).toBeInTheDocument();
|
||||
expect(screen.getByText("Suggestions")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Check that the MCP server URL is reachable/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show error alert when diagnostics fail", async () => {
|
||||
(fetchMCPServerDiagnostics as any).mockRejectedValue(
|
||||
new Error("Network error")
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerDiagnostics
|
||||
serverId="test-server"
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Diagnostics Failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have a re-run button", async () => {
|
||||
(fetchMCPServerDiagnostics as any).mockResolvedValue({
|
||||
server_id: "test-server",
|
||||
server_name: "test-mcp",
|
||||
overall_status: "healthy",
|
||||
checks: [],
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerDiagnostics
|
||||
serverId="test-server"
|
||||
accessToken="test-token"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Re-run")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { Alert, Button, Spin, Tag, List } from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { fetchMCPServerDiagnostics } from "../networking";
|
||||
|
||||
interface MCPServerDiagnosticsProps {
|
||||
serverId: string;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface DiagnosticCheck {
|
||||
name: string;
|
||||
status: "pass" | "fail";
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface DiagnosticsResult {
|
||||
server_id: string;
|
||||
server_name: string;
|
||||
overall_status: "healthy" | "unhealthy";
|
||||
checks: DiagnosticCheck[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const CHECK_LABELS: Record<string, string> = {
|
||||
configuration: "Configuration",
|
||||
auth_type: "Authentication",
|
||||
connectivity: "Connectivity",
|
||||
network: "Network Access",
|
||||
};
|
||||
|
||||
const MCPServerDiagnostics: React.FC<MCPServerDiagnosticsProps> = ({
|
||||
serverId,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [result, setResult] = useState<DiagnosticsResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchMCPServerDiagnostics(accessToken, serverId);
|
||||
setResult(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to run diagnostics");
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
runDiagnostics();
|
||||
}, [serverId, accessToken]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Connection Diagnostics</Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={runDiagnostics}
|
||||
loading={loading}
|
||||
>
|
||||
Re-run
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && !result && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin tip="Running diagnostics..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Diagnostics Failed"
|
||||
description={error}
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Tag
|
||||
color={result.overall_status === "healthy" ? "green" : "red"}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{result.overall_status === "healthy"
|
||||
? "All Checks Passing"
|
||||
: "Issues Detected"}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<List
|
||||
dataSource={result.checks}
|
||||
renderItem={(check: DiagnosticCheck) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
check.status === "pass" ? (
|
||||
<CheckCircleOutlined
|
||||
style={{ fontSize: 20, color: "#52c41a" }}
|
||||
/>
|
||||
) : (
|
||||
<CloseCircleOutlined
|
||||
style={{ fontSize: 20, color: "#ff4d4f" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
title={
|
||||
<span className="font-medium">
|
||||
{CHECK_LABELS[check.name] || check.name}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<Text>{check.message}</Text>
|
||||
{check.details && (
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{Object.entries(check.details).map(
|
||||
([key, value]) =>
|
||||
value != null && (
|
||||
<Tag key={key} className="text-xs">
|
||||
{key}: {String(value)}
|
||||
</Tag>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
{result.suggestions.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message="Suggestions"
|
||||
description={
|
||||
<ul className="list-disc pl-4 mt-1">
|
||||
{result.suggestions.map((s, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
showIcon
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPServerDiagnostics;
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import MCPServerLogs from "./mcp_server_logs";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
fetchMCPServerLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchMCPServerLogs } from "../networking";
|
||||
|
||||
describe("MCPServerLogs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the component title", async () => {
|
||||
(fetchMCPServerLogs as any).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 0,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerLogs serverId="test-server" accessToken="test-token" />
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Invocation Logs")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show empty state when no logs", async () => {
|
||||
(fetchMCPServerLogs as any).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 0,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerLogs serverId="test-server" accessToken="test-token" />
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/No MCP invocation logs found/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display log entries when data is returned", async () => {
|
||||
(fetchMCPServerLogs as any).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
request_id: "req-1",
|
||||
call_type: "call_mcp_tool",
|
||||
mcp_namespaced_tool_name: "github-mcp/list_repos",
|
||||
status: "success",
|
||||
spend: 0.001,
|
||||
total_tokens: null,
|
||||
request_duration_ms: 250,
|
||||
startTime: "2025-06-01T10:00:00Z",
|
||||
endTime: "2025-06-01T10:00:01Z",
|
||||
api_key: "sk-test123",
|
||||
team_id: null,
|
||||
end_user: null,
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 1,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerLogs serverId="test-server" accessToken="test-token" />
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("list_repos")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tool Call")).toBeInTheDocument();
|
||||
expect(screen.getByText("250ms")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call fetchMCPServerLogs with correct params", async () => {
|
||||
(fetchMCPServerLogs as any).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
total_pages: 0,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<MCPServerLogs serverId="my-server-id" accessToken="my-token" />
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMCPServerLogs).toHaveBeenCalledWith(
|
||||
"my-token",
|
||||
"my-server-id",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
1,
|
||||
25,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { Table, Tag, Tooltip, Pagination, Empty, Spin, Select } from "antd";
|
||||
import moment from "moment";
|
||||
import { fetchMCPServerLogs } from "../networking";
|
||||
|
||||
interface MCPServerLogsProps {
|
||||
serverId: string;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface MCPLogEntry {
|
||||
request_id: string;
|
||||
call_type: string;
|
||||
mcp_namespaced_tool_name: string | null;
|
||||
status: string | null;
|
||||
spend: number;
|
||||
total_tokens: number | null;
|
||||
request_duration_ms: number | null;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
api_key: string | null;
|
||||
team_id: string | null;
|
||||
end_user: string | null;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
const MCPServerLogs: React.FC<MCPServerLogsProps> = ({
|
||||
serverId,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [logs, setLogs] = useState<MCPLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [timeRange, setTimeRange] = useState("7d");
|
||||
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const end = moment().utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
let start: string;
|
||||
switch (timeRange) {
|
||||
case "1h":
|
||||
start = moment().utc().subtract(1, "hours").format("YYYY-MM-DD HH:mm:ss");
|
||||
break;
|
||||
case "24h":
|
||||
start = moment().utc().subtract(24, "hours").format("YYYY-MM-DD HH:mm:ss");
|
||||
break;
|
||||
case "7d":
|
||||
start = moment().utc().subtract(7, "days").format("YYYY-MM-DD HH:mm:ss");
|
||||
break;
|
||||
case "30d":
|
||||
start = moment().utc().subtract(30, "days").format("YYYY-MM-DD HH:mm:ss");
|
||||
break;
|
||||
default:
|
||||
start = moment().utc().subtract(7, "days").format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
return { startDate: start, endDate: end };
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchMCPServerLogs(
|
||||
accessToken,
|
||||
serverId,
|
||||
startDate,
|
||||
endDate,
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
setLogs(result.data || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch MCP server logs:", err);
|
||||
setLogs([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchLogs();
|
||||
}, [accessToken, serverId, startDate, endDate, page, pageSize]);
|
||||
|
||||
const extractToolName = (namespacedName: string | null) => {
|
||||
if (!namespacedName) return "—";
|
||||
const parts = namespacedName.split("/");
|
||||
return parts.length > 1 ? parts.slice(1).join("/") : namespacedName;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Time",
|
||||
dataIndex: "startTime",
|
||||
key: "startTime",
|
||||
width: 180,
|
||||
render: (val: string) => (
|
||||
<Tooltip title={val}>
|
||||
<span className="text-xs">{moment(val).format("MMM DD HH:mm:ss")}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Tool",
|
||||
dataIndex: "mcp_namespaced_tool_name",
|
||||
key: "tool",
|
||||
render: (val: string | null) => (
|
||||
<span className="font-mono text-xs">{extractToolName(val)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "call_type",
|
||||
key: "call_type",
|
||||
width: 130,
|
||||
render: (val: string) => (
|
||||
<Tag color={val === "call_mcp_tool" ? "blue" : "default"}>
|
||||
{val === "call_mcp_tool" ? "Tool Call" : val === "list_mcp_tools" ? "List Tools" : val}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Status",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 90,
|
||||
render: (val: string | null) => {
|
||||
const isSuccess = !val || val === "success";
|
||||
return (
|
||||
<Tag color={isSuccess ? "green" : "red"}>
|
||||
{isSuccess ? "OK" : val}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Latency",
|
||||
dataIndex: "request_duration_ms",
|
||||
key: "latency",
|
||||
width: 100,
|
||||
render: (val: number | null) => {
|
||||
if (val == null) return "—";
|
||||
if (val < 1000) return `${val}ms`;
|
||||
return `${(val / 1000).toFixed(1)}s`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Cost",
|
||||
dataIndex: "spend",
|
||||
key: "spend",
|
||||
width: 80,
|
||||
render: (val: number) => (val > 0 ? `$${val.toFixed(4)}` : "—"),
|
||||
},
|
||||
{
|
||||
title: "Caller",
|
||||
key: "caller",
|
||||
width: 150,
|
||||
render: (_: any, record: MCPLogEntry) => {
|
||||
const alias = record.metadata?.user_api_key_alias;
|
||||
return (
|
||||
<span className="text-xs truncate max-w-[140px] block">
|
||||
{alias || record.api_key?.slice(0, 12) || "—"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Invocation Logs</Title>
|
||||
<Select
|
||||
value={timeRange}
|
||||
onChange={setTimeRange}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: "Last 1 hour", value: "1h" },
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<Empty
|
||||
description={
|
||||
<Text>No MCP invocation logs found for this server in the selected time range.</Text>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
dataSource={logs}
|
||||
columns={columns}
|
||||
rowKey="request_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<div className="flex justify-end mt-4">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(p) => setPage(p)}
|
||||
showSizeChanger={false}
|
||||
showTotal={(total) => `${total} logs`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPServerLogs;
|
||||
|
|
@ -7,6 +7,8 @@ import { MCPServer, handleTransport, handleAuth } from "./types";
|
|||
import { MCPToolsViewer } from ".";
|
||||
import MCPServerEdit from "./mcp_server_edit";
|
||||
import MCPServerCostDisplay from "./mcp_server_cost_display";
|
||||
import MCPServerLogs from "./mcp_server_logs";
|
||||
import MCPServerDiagnostics from "./mcp_server_diagnostics";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
|
|
@ -120,6 +122,8 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
{[
|
||||
<Tab key="overview">Overview</Tab>,
|
||||
<Tab key="tools">MCP Tools</Tab>,
|
||||
<Tab key="logs">Logs</Tab>,
|
||||
<Tab key="diagnostics">Diagnostics</Tab>,
|
||||
...(isProxyAdmin ? [<Tab key="settings">Settings</Tab>] : []),
|
||||
]}
|
||||
</TabList>
|
||||
|
|
@ -175,6 +179,22 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Logs Panel */}
|
||||
<TabPanel>
|
||||
<MCPServerLogs
|
||||
serverId={mcpServer.server_id}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Diagnostics Panel */}
|
||||
<TabPanel>
|
||||
<MCPServerDiagnostics
|
||||
serverId={mcpServer.server_id}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<TabPanel>
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -2524,6 +2524,10 @@ interface UiSpendLogsParams {
|
|||
sort_order?: "asc" | "desc";
|
||||
min_spend?: number;
|
||||
max_spend?: number;
|
||||
/** Filter by call type (e.g. "call_mcp_tool", "completion") */
|
||||
call_type?: string;
|
||||
/** Filter by MCP namespaced tool name (partial match) */
|
||||
mcp_tool_name?: string;
|
||||
}
|
||||
|
||||
interface UiSpendLogsCallOptions {
|
||||
|
|
@ -9408,3 +9412,54 @@ export const deleteToolPolicyOverride = async (
|
|||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchMCPServerLogs = async (
|
||||
accessToken: string,
|
||||
serverId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 25,
|
||||
) => {
|
||||
const q = new URLSearchParams();
|
||||
q.set("start_date", startDate);
|
||||
q.set("end_date", endDate);
|
||||
q.set("page", page.toString());
|
||||
q.set("page_size", pageSize.toString());
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/v1/mcp/server/${serverId}/logs?${q.toString()}`
|
||||
: `/v1/mcp/server/${serverId}/logs?${q.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(errorData);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const fetchMCPServerDiagnostics = async (
|
||||
accessToken: string,
|
||||
serverId: string,
|
||||
) => {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/v1/mcp/server/${serverId}/diagnose`
|
||||
: `/v1/mcp/server/${serverId}/diagnose`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(errorData);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -466,6 +466,22 @@ export default function SpendLogsTable({
|
|||
label: "Error Message",
|
||||
isSearchable: false,
|
||||
},
|
||||
{
|
||||
name: "Call Type",
|
||||
label: "Call Type",
|
||||
isSearchable: false,
|
||||
options: [
|
||||
{ label: "All", value: "" },
|
||||
{ label: "MCP Tool Call", value: "call_mcp_tool" },
|
||||
{ label: "MCP List Tools", value: "list_mcp_tools" },
|
||||
{ label: "Completion", value: "completion" },
|
||||
{ label: "Embedding", value: "embedding" },
|
||||
{ label: "Image Generation", value: "image_generation" },
|
||||
{ label: "Audio Transcription", value: "audio_transcription" },
|
||||
{ label: "Text to Speech", value: "speech" },
|
||||
{ label: "Rerank", value: "rerank" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const formatTimeUnit = (value: number, unit: string) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const FILTER_KEYS = {
|
|||
KEY_ALIAS: "Key Alias",
|
||||
ERROR_CODE: "Error Code",
|
||||
ERROR_MESSAGE: "Error Message",
|
||||
CALL_TYPE: "Call Type",
|
||||
} as const;
|
||||
|
||||
export type FilterKey = keyof typeof FILTER_KEYS;
|
||||
|
|
@ -64,6 +65,7 @@ export function useLogFilterLogic({
|
|||
[FILTER_KEYS.KEY_ALIAS]: "",
|
||||
[FILTER_KEYS.ERROR_CODE]: "",
|
||||
[FILTER_KEYS.ERROR_MESSAGE]: "",
|
||||
[FILTER_KEYS.CALL_TYPE]: "",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
|
@ -108,6 +110,7 @@ export function useLogFilterLogic({
|
|||
key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined,
|
||||
error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined,
|
||||
error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
|
||||
call_type: filters[FILTER_KEYS.CALL_TYPE] || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
},
|
||||
|
|
@ -143,7 +146,8 @@ export function useLogFilterLogic({
|
|||
filters[FILTER_KEYS.END_USER] ||
|
||||
filters[FILTER_KEYS.ERROR_CODE] ||
|
||||
filters[FILTER_KEYS.ERROR_MESSAGE] ||
|
||||
filters[FILTER_KEYS.MODEL]
|
||||
filters[FILTER_KEYS.MODEL] ||
|
||||
filters[FILTER_KEYS.CALL_TYPE]
|
||||
),
|
||||
[filters],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue