Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_skill_marketplaces

# Conflicts:
#	ui/litellm-dashboard/eslint-suppressions.json
This commit is contained in:
Krrish Dholakia 2026-07-11 19:01:03 -07:00
commit 3d24e41121
65 changed files with 773 additions and 413 deletions

View file

@ -49,7 +49,11 @@ from litellm.llms.custom_httpx.http_handler import (
httpxSpecialProvider,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus
from litellm.types.utils import (
GenericGuardrailAPIInputs,
GuardrailStatus,
StandardLoggingGuardrailInformation,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import (
@ -246,16 +250,21 @@ class XecGuardGuardrail(CustomGuardrail):
"guardrail_intervened" if scan_result.get("decision") == "UNSAFE" else "success"
)
end_time = datetime.now()
kwargs["standard_logging_object"]["guardrail_information"] = {
"duration": (end_time - start_time).total_seconds(),
"end_time": end_time.timestamp(),
"guardrail_mode": "logging_only",
"guardrail_name": "xecguard",
"guardrail_response": scan_result,
"guardrail_status": guardrail_status,
"masked_entity_count": None,
"start_time": start_time.timestamp(),
}
slg = StandardLoggingGuardrailInformation(
guardrail_name=self.guardrail_name or "xecguard",
guardrail_mode=GuardrailEventHooks.logging_only,
guardrail_response=scan_result,
guardrail_status=guardrail_status,
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=(end_time - start_time).total_seconds(),
masked_entity_count=None,
)
existing = kwargs["standard_logging_object"].get("guardrail_information")
if isinstance(existing, list):
existing.append(slg)
else:
kwargs["standard_logging_object"]["guardrail_information"] = [slg]
except Exception as exc:
verbose_proxy_logger.debug(

View file

@ -1644,12 +1644,37 @@ class TestXecGuardLoggingHook:
)
assert out_kwargs is kwargs
assert out_result is result
info = kwargs["standard_logging_object"]["guardrail_information"]
info_list = kwargs["standard_logging_object"]["guardrail_information"]
assert isinstance(info_list, list), "guardrail_information must be a list"
assert len(info_list) == 1
info = info_list[0]
assert info["guardrail_mode"] == "logging_only"
assert info["guardrail_name"] == "xecguard"
assert info["guardrail_name"] == "test-xecguard"
assert info["guardrail_status"] == "success"
assert info["guardrail_response"]["trace_id"] == "lg-1"
@pytest.mark.asyncio
async def test_async_logging_hook_appends_to_existing_guardrail_info(
self, xecguard_guardrail, mock_request_data
):
resp = _make_response({"decision": "SAFE", "trace_id": "lg-4"})
prior_entry = {"guardrail_name": "other-guardrail"}
with patch.object(xecguard_guardrail.async_handler, "post", return_value=resp):
kwargs = {
**mock_request_data,
"standard_logging_object": {"guardrail_information": [prior_entry]},
}
await xecguard_guardrail.async_logging_hook(
kwargs=kwargs,
result=_build_model_response("some answer"),
call_type="acompletion",
)
info_list = kwargs["standard_logging_object"]["guardrail_information"]
assert len(info_list) == 2
assert info_list[0] is prior_entry
assert info_list[1]["guardrail_name"] == "test-xecguard"
assert info_list[1]["guardrail_response"]["trace_id"] == "lg-4"
@pytest.mark.asyncio
async def test_async_logging_hook_without_response_records_info(
self, xecguard_guardrail, mock_request_data
@ -1680,7 +1705,9 @@ class TestXecGuardLoggingHook:
result=_build_model_response("x"),
call_type="acompletion",
)
info = kwargs["standard_logging_object"]["guardrail_information"]
info_list = kwargs["standard_logging_object"]["guardrail_information"]
assert isinstance(info_list, list), "guardrail_information must be a list"
info = info_list[0]
assert info["guardrail_status"] == "guardrail_intervened"
@pytest.mark.asyncio

View file

@ -515,6 +515,152 @@
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/static-components": {
"count": 4
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -1073,16 +1219,6 @@
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": {
"no-nested-ternary": {
"count": 1
@ -1872,40 +2008,11 @@
"count": 1
}
},
"src/components/mcp_tools/MCPLogoSelector.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/components/mcp_tools/MCPNetworkSettings.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/components/mcp_tools/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/mcp_tools/MCPToolArgumentsForm.tsx": {
"no-nested-ternary": {
"count": 5
}
},
"src/components/mcp_tools/MCPToolsetsTab.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/components/mcp_tools/McpCrudPermissionPanel.tsx": {
"no-nested-ternary": {
"count": 3
@ -1914,123 +2021,6 @@
"count": 1
}
},
"src/components/mcp_tools/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/OpenAPIQuickPicker.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/mcp_tools/ToolTestPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/mcp_tools/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
}
},
"src/components/mcp_tools/create_mcp_server.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/components/mcp_tools/mcp_connect.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/static-components": {
"count": 4
}
},
"src/components/mcp_tools/mcp_connection_status.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_discovery.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/mcp_tools/mcp_server_cost_config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_server_cost_display.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_server_edit.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 5
}
},
"src/components/mcp_tools/mcp_server_view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_servers.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/mcp_tools/mcp_tool_configuration.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_tools.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/model_add/AddCredentialModal.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -29,6 +29,7 @@
"next": "16.2.6",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",
@ -10544,6 +10545,19 @@
"openapi-typescript-helpers": "^0.1.0"
}
},
"node_modules/openapi-react-query": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/openapi-react-query/-/openapi-react-query-0.5.4.tgz",
"integrity": "sha512-V9lRiozjHot19/BYSgXYoyznDxDJQhEBSdi26+SJ0UqjMANLQhkni4XG+Z7e3Ag7X46ZLMrL9VxYkghU3QvbWg==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.1.0"
},
"peerDependencies": {
"@tanstack/react-query": "^5.80.0",
"openapi-fetch": "^0.17.0"
}
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",

View file

@ -44,6 +44,7 @@
"next": "16.2.6",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"react": "18.3.1",
"react-copy-to-clipboard": "5.1.1",

View file

@ -1,12 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode } from "react";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCustomers, type EndUser } from "./useCustomers";
const mockGet = vi.fn();
const useQueryMock = vi.fn();
vi.mock("@/lib/http/api", () => ({
fetchClient: { GET: (...args: unknown[]) => mockGet(...args) },
$api: { useQuery: (...args: unknown[]) => useQueryMock(...args) },
}));
const mockUseAuthorized = vi.fn();
@ -14,91 +12,55 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockCustomers: EndUser[] = [
{ user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false },
{ user_id: "customer-2", alias: null, spend: 0, blocked: true },
];
const authorized = { accessToken: "test-access-token", userRole: "Admin" };
const authorized = {
accessToken: "test-access-token",
userRole: "Admin",
userId: "test-user-id",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
type QueryOptions = { enabled: boolean; select: (data: EndUser[] | undefined) => EndUser[] };
const lastCallOptions = (): QueryOptions => {
const calls = useQueryMock.mock.calls;
return calls[calls.length - 1][3] as QueryOptions;
};
describe("useCustomers", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
useQueryMock.mockReturnValue({ data: [] });
mockUseAuthorized.mockReturnValue(authorized);
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("fetches /customer/list and returns the typed list on success", async () => {
mockGet.mockResolvedValue({ data: mockCustomers });
const { result } = renderHook(() => useCustomers(), { wrapper });
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockCustomers);
expect(mockGet).toHaveBeenCalledWith("/customer/list");
expect(mockGet).toHaveBeenCalledTimes(1);
it("queries GET /customer/list with a derived key (no hand-written queryKey)", () => {
renderHook(() => useCustomers());
expect(useQueryMock).toHaveBeenCalledWith("get", "/customer/list", {}, expect.any(Object));
});
it("surfaces an error when the request rejects", async () => {
const testError = new Error("Failed to fetch customers");
mockGet.mockRejectedValue(testError);
const { result } = renderHook(() => useCustomers(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
it("enables the query only for an admin holding an access token", () => {
renderHook(() => useCustomers());
expect(lastCallOptions().enabled).toBe(true);
});
it("falls back to an empty list when the response has no body", async () => {
mockGet.mockResolvedValue({ data: undefined });
const { result } = renderHook(() => useCustomers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
it("disables the query when the access token is missing", () => {
mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null });
renderHook(() => useCustomers());
expect(lastCallOptions().enabled).toBe(false);
});
it("does not fetch when the access token is missing", () => {
mockUseAuthorized.mockReturnValue({ ...authorized, accessToken: null, token: null });
const { result } = renderHook(() => useCustomers(), { wrapper });
expect(result.current.isFetched).toBe(false);
expect(mockGet).not.toHaveBeenCalled();
});
it("does not fetch when the user is not an admin", () => {
it("disables the query for a non-admin role", () => {
mockUseAuthorized.mockReturnValue({ ...authorized, userRole: "member" });
renderHook(() => useCustomers());
expect(lastCallOptions().enabled).toBe(false);
});
const { result } = renderHook(() => useCustomers(), { wrapper });
it("selects an empty list when the response body is missing", () => {
renderHook(() => useCustomers());
expect(lastCallOptions().select(undefined)).toEqual([]);
});
expect(result.current.isFetched).toBe(false);
expect(mockGet).not.toHaveBeenCalled();
it("selects the customer list through unchanged", () => {
const customers: EndUser[] = [
{ user_id: "customer-1", alias: "Test Customer 1", spend: 150.5, blocked: false },
{ user_id: "customer-2", alias: null, spend: 0, blocked: true },
];
renderHook(() => useCustomers());
expect(lastCallOptions().select(customers)).toEqual(customers);
});
});

View file

@ -1,19 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { fetchClient } from "@/lib/http/api";
import { $api } from "@/lib/http/api";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import type { components } from "@/lib/http/schema";
export type EndUser = components["schemas"]["CustomerResponse"];
const customersKeys = createQueryKeys("customers");
export const useCustomers = () => {
const { accessToken, userRole } = useAuthorized();
return useQuery({
queryKey: customersKeys.list({}),
queryFn: async () => (await fetchClient.GET("/customer/list")).data ?? [],
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
});
return $api.useQuery(
"get",
"/customer/list",
{},
{
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
select: (data) => data ?? [],
},
);
};

View file

@ -1,7 +1,7 @@
import React from "react";
import { Form, Switch, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { isClientForwardedTokenMode } from "./types";
import { isClientForwardedTokenMode } from "@/components/mcp_tools/types";
/**
* DCR-bridge toggle for the client-forwarded token modes (true_passthrough /

View file

@ -1,13 +1,13 @@
import React, { useState, useEffect } from "react";
import { Select, Button, Card, Typography, Spin, Tag } from "antd";
import { SaveOutlined, PlusOutlined } from "@ant-design/icons";
import { DeprecationBanner } from "../DeprecationBanner";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import {
getGeneralSettingsCall,
updateConfigFieldSetting,
deleteConfigFieldSetting,
fetchMCPClientIp,
} from "../networking";
} from "@/components/networking";
const { Text } = Typography;

View file

@ -1,7 +1,7 @@
import React, { useEffect } from "react";
import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { MCPServer, AUTH_TYPE } from "./types";
import { MCPServer, AUTH_TYPE } from "@/components/mcp_tools/types";
const { Panel } = Collapse;
interface MCPPermissionManagementProps {

View file

@ -2,7 +2,7 @@ import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import MCPServerCard from "./MCPServerCard";
import type { MCPServer } from "./types";
import type { MCPServer } from "@/components/mcp_tools/types";
const baseServer: MCPServer = {
server_id: "srv-1",

View file

@ -8,7 +8,7 @@ import {
MoreOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { AUTH_TYPE, type MCPServer } from "./types";
import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types";
import { getMaskedAndFullUrl } from "./utils";
const { Text } = Typography;

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS, SETTINGS_KEY } from "./MCPStandardsSettings";
import { MCPServer } from "./types";
import { MCPServer } from "@/components/mcp_tools/types";
const makeServer = (overrides: Partial<MCPServer> = {}): MCPServer => ({
server_id: "s1",

View file

@ -1,6 +1,6 @@
"use client";
import { MCPServer } from "./types";
import { MCPServer } from "@/components/mcp_tools/types";
export interface RequiredFieldDef {
key: string;

View file

@ -18,7 +18,7 @@ import {
getGeneralSettingsCall,
updateConfigFieldSetting,
} from "@/components/networking";
import { MCPServer, MCPSubmissionsSummary } from "./types";
import { MCPServer, MCPSubmissionsSummary } from "@/components/mcp_tools/types";
import { FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS, SETTINGS_KEY } from "./MCPStandardsSettings";
import NotificationsManager from "@/components/molecules/notifications_manager";

View file

@ -7,9 +7,15 @@ import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolset
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useQueryClient } from "@tanstack/react-query";
import { DateCell, IdCell } from "@/components/shared/table_cells";
import { DataTable } from "../view_logs/table";
import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools, getProxyBaseUrl } from "../networking";
import { MCPToolset, MCPToolsetTool } from "./types";
import { DataTable } from "@/components/view_logs/table";
import {
createMCPToolset,
updateMCPToolset,
deleteMCPToolset,
listMCPTools,
getProxyBaseUrl,
} from "@/components/networking";
import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
const { Text: AntdText } = Typography;

View file

@ -2,7 +2,7 @@ import React from "react";
import { Form, Input, InputNumber, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { OAUTH_FLOW } from "./types";
import { OAUTH_FLOW } from "@/components/mcp_tools/types";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
interface OAuthFlowStatus {

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Form, Input, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { FormInstance } from "antd/es/form";
import { AUTH_TYPE, OAUTH_FLOW } from "./types";
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker";
interface OpenAPIFormSectionProps {

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Spin } from "antd";
import { fetchOpenAPIRegistry } from "../networking";
import { fetchOpenAPIRegistry } from "@/components/networking";
export interface OpenAPIKeyTool {
name: string;

View file

@ -1,7 +1,7 @@
import React from "react";
import { Button, Checkbox, Form, Input } from "antd";
import DcrBridgeToggle from "./DcrBridgeToggle";
import { credentialAuthClass, isClientForwardedTokenMode } from "./types";
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
interface PassthroughOAuthFlow {
startOAuthFlow: () => void | Promise<void>;

View file

@ -3,9 +3,9 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ToolTestPanel } from "./ToolTestPanel";
import { InputSchema, MCPTool } from "./types";
import { InputSchema, MCPTool } from "@/components/mcp_tools/types";
vi.mock("../molecules/notifications_manager", () => ({
vi.mock("@/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),

View file

@ -1,10 +1,10 @@
import React from "react";
import { Button, TextInput } from "@tremor/react";
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
import { MCPTool, InputSchema, InputSchemaProperty } from "@/components/mcp_tools/types";
import { resolveLogoSrc } from "@/lib/assetPaths";
import { Form, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import NotificationsManager from "../molecules/notifications_manager";
import NotificationsManager from "@/components/molecules/notifications_manager";
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === "object" && value !== null && !Array.isArray(value);

View file

@ -1,6 +1,6 @@
import React from "react";
import { Alert } from "antd";
import { AUTH_TYPE } from "./types";
import { AUTH_TYPE } from "@/components/mcp_tools/types";
/**
* Warning shown in the create/edit MCP server forms when auth_type

View file

@ -1,9 +1,9 @@
import React from "react";
import { Modal, Form, Input, Button, Alert, Spin, Tag, Typography } from "antd";
import { useMutation, useQuery } from "@tanstack/react-query";
import { MCPServer, MCPUserEnvVarsStatus } from "./types";
import { getMCPUserEnvVars, storeMCPUserEnvVars } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { MCPServer, MCPUserEnvVarsStatus } from "@/components/mcp_tools/types";
import { getMCPUserEnvVars, storeMCPUserEnvVars } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
const { Text, Title } = Typography;

View file

@ -1,12 +1,12 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "../networking";
import * as networking from "@/components/networking";
import { setToken } from "@/utils/mcpTokenStore";
import CreateMCPServer from "./create_mcp_server";
import { selectAntOption } from "./testUtils";
vi.mock("../networking", () => ({
vi.mock("@/components/networking", () => ({
createMCPServer: vi.fn(),
fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }),
registerMCPServer: vi.fn(),

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Modal, Tooltip, Form, Select, Input, InputNumber, Switch, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "../networking";
import { createMCPServer, registerMCPServer, storeMCPOAuthUserCredential } from "@/components/networking";
import { setToken } from "@/utils/mcpTokenStore";
import {
AUTH_TYPE,
@ -20,7 +20,7 @@ import {
isHeldOAuthTokenStale,
preservedDeclaredAppCredentials,
withoutMintedTokenCredentials,
} from "./types";
} from "@/components/mcp_tools/types";
import OAuthFormFields from "./OAuthFormFields";
import TruePassthroughWarning from "./TruePassthroughWarning";
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
@ -35,7 +35,7 @@ import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName, normalizeEnvVars, TOOL_DISPLAY_NAME_PATTERN } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";

View file

@ -4,8 +4,8 @@ import React, { useState } from "react";
import { Card, Typography, Space, Alert, Button, Switch, Form, Collapse } from "antd";
import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text as TremorText } from "@tremor/react";
import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react";
import { getProxyBaseUrl } from "../networking";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import { getProxyBaseUrl } from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
const { Title, Text } = Typography;
const { Panel } = Collapse;

View file

@ -1,7 +1,7 @@
import React, { useState, useMemo, useEffect } from "react";
import { Modal, Input, Typography } from "antd";
import { fetchDiscoverableMCPServers } from "../networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "./types";
import { fetchDiscoverableMCPServers } from "@/components/networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types";
import { mcpLogoImg } from "./create_mcp_server";
import { resolveLogoSrc } from "@/lib/assetPaths";

View file

@ -2,7 +2,7 @@ import React from "react";
import { Tooltip, InputNumber, Collapse, Badge } from "antd";
import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons";
import { Card, Title, Text } from "@tremor/react";
import { MCPServerCostInfo } from "./types";
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
interface MCPServerCostConfigProps {
value?: MCPServerCostInfo;

View file

@ -1,6 +1,6 @@
import React from "react";
import { Text } from "@tremor/react";
import { MCPServerCostInfo } from "./types";
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
interface MCPServerCostDisplayProps {
costConfig?: MCPServerCostInfo | null;

View file

@ -4,18 +4,18 @@ import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"
import userEvent from "@testing-library/user-event";
import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit";
import { setSecureItem } from "@/utils/secureStorage";
import * as networking from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import * as networking from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { selectAntOption } from "./testUtils";
vi.mock("../networking", () => ({
vi.mock("@/components/networking", () => ({
updateMCPServer: vi.fn(),
listMCPTools: vi.fn().mockResolvedValue({ tools: [], error: null }),
storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}),
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
}));
vi.mock("../molecules/notifications_manager", () => ({
vi.mock("@/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),

View file

@ -18,8 +18,13 @@ import {
TRANSPORT,
getMcpOAuthMode,
oauth2FlowToFormValue,
} from "./types";
import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential, testMCPToolsListRequest } from "../networking";
} from "@/components/mcp_tools/types";
import {
updateMCPServer,
listMCPTools,
storeMCPOAuthUserCredential,
testMCPToolsListRequest,
} from "@/components/networking";
import { getToken, isTokenValid, removeToken, setToken } from "@/utils/mcpTokenStore";
import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
import MCPServerCostConfig from "./mcp_server_cost_config";
@ -39,7 +44,7 @@ import {
normalizeToolOverrideMap,
TOOL_DISPLAY_NAME_PATTERN,
} from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline";
import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react";
import { MCPServer, handleTransport, handleAuth } from "./types";
import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
// TODO: Move Tools viewer from index file
import { MCPToolsViewer } from ".";
import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit";

View file

@ -3,10 +3,10 @@ import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MCPServers from "./mcp_servers";
import * as networking from "../networking";
import * as networking from "@/components/networking";
// Mock the networking module
vi.mock("../networking", () => ({
vi.mock("@/components/networking", () => ({
fetchMCPServers: vi.fn(),
fetchMCPServerHealth: vi.fn(),
deleteMCPServer: vi.fn(),
@ -19,7 +19,7 @@ vi.mock("../networking", () => ({
}));
// Mock NotificationsManager
vi.mock("../molecules/notifications_manager", () => ({
vi.mock("@/components/molecules/notifications_manager", () => ({
default: {
success: vi.fn(),
fromBackend: vi.fn(),

View file

@ -1,29 +1,35 @@
import { isAdminRole } from "@/utils/roles";
import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons";
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import NewBadge from "../common_components/NewBadge";
import NewBadge from "@/components/common_components/NewBadge";
import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
import NotificationsManager from "../molecules/notifications_manager";
import { deleteMCPServer } from "../networking";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPServerHealth } from "@/app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { deleteMCPServer } from "@/components/networking";
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
import { MCPToolsetsTab } from "./MCPToolsetsTab";
import CreateMCPServer from "./create_mcp_server";
import MCPConnect from "./mcp_connect";
import MCPServerCard from "./MCPServerCard";
import { MCPServerView } from "./mcp_server_view";
import type { DiscoverableMCPServer, MCPServer, MCPServerProps, MCPUserEnvVarsStatus, Team } from "./types";
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
import type {
DiscoverableMCPServer,
MCPServer,
MCPServerProps,
MCPUserEnvVarsStatus,
Team,
} from "@/components/mcp_tools/types";
import MCPSemanticFilterSettings from "@/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
import MCPNetworkSettings from "./MCPNetworkSettings";
import MCPDiscovery from "./mcp_discovery";
import { ByokCredentialModal } from "./ByokCredentialModal";
import { ByokCredentialModal } from "@/components/mcp_tools/ByokCredentialModal";
import { getSecureItem } from "@/utils/secureStorage";
import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils";
import UserEnvVarsModal from "./UserEnvVarsModal";
import { listMCPUserEnvVarStatus } from "../networking";
import { listMCPUserEnvVarStatus } from "@/components/networking";
type SortKey = "created_desc" | "updated_desc" | "name_asc" | "health";

View file

@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Card, Title, Text } from "@tremor/react";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
import McpCrudPermissionPanel from "./McpCrudPermissionPanel";
import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel";
import { TOOL_DISPLAY_NAME_PATTERN } from "./utils";
interface KeyTool {

View file

@ -2,10 +2,10 @@ import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import MCPToolsViewer from "./mcp_tools";
import { listMCPTools, getMCPOAuthUserCredentialStatus } from "../networking";
import { listMCPTools, getMCPOAuthUserCredentialStatus } from "@/components/networking";
import { isTokenValid, getToken } from "@/utils/mcpTokenStore";
vi.mock("../networking", () => ({
vi.mock("@/components/networking", () => ({
listMCPTools: vi.fn(),
callMCPTool: vi.fn(),
getMCPOAuthUserCredentialStatus: vi.fn(),

View file

@ -9,8 +9,8 @@ import {
MCPContent,
CallMCPToolResponse,
getMcpOAuthMode,
} from "./types";
import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "../networking";
} from "@/components/mcp_tools/types";
import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "@/components/networking";
import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore";
import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
import { useToolsOAuthFlow } from "@/hooks/useToolsOAuthFlow";

View file

@ -1,4 +1,4 @@
import { MCPEnvVar, MCPEnvVarScope } from "./types";
import { MCPEnvVar, MCPEnvVarScope } from "@/components/mcp_tools/types";
export const extractMCPToken = (url: string): { token: string | null; baseUrl: string } => {
try {

View file

@ -1,6 +1,6 @@
"use client";
import { MCPServers } from "@/components/mcp_tools";
import { MCPServers } from "./_components";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function McpServers() {

View file

@ -59,7 +59,7 @@ const EndpointUsage: React.FC<EndpointUsageProps> = ({ userSpendData }) => {
<div className="space-y-4">
<EndpointUsageTable endpointData={endpointData} />
<EndpointUsageBarChart endpointData={endpointData} />
<EndpointUsageLineChart dailyData={userSpendData} endpointData={endpointData} />
<EndpointUsageLineChart dailyData={userSpendData} />
</div>
);
};

View file

@ -1,39 +1,70 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { MetricWithMetadata } from "@/components/UsagePage/types";
import EndpointUsageBarChart from "./EndpointUsageBarChart";
vi.mock("@tremor/react", async () => {
const React = await import("react");
function Card({ children }: any) {
return React.createElement("div", { "data-testid": "tremor-card" }, children);
}
(Card as any).displayName = "Card";
function Title({ children }: any) {
return React.createElement("h2", { "data-testid": "tremor-title" }, children);
}
(Title as any).displayName = "Title";
function BarChart(_props: any) {
return React.createElement("div", { "data-testid": "tremor-bar-chart" }, "Bar Chart");
}
(BarChart as any).displayName = "BarChart";
return { Card, Title, BarChart };
const metric = (successful: number, failed: number): MetricWithMetadata => ({
metrics: {
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: successful + failed,
successful_requests: successful,
failed_requests: failed,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
});
vi.mock("@/components/common_components/chartUtils", () => ({
CustomLegend: ({ categories }: any) => <div data-testid="custom-legend">{categories.join(", ")}</div>,
CustomTooltip: () => <div data-testid="custom-tooltip">Tooltip</div>,
}));
const endpointData = {
"/chat/completions": metric(120, 5),
"/embeddings": metric(40, 2),
};
describe("EndpointUsageBarChart", () => {
it("should render", () => {
render(<EndpointUsageBarChart />);
it("renders the title and the header legend labels", () => {
renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
expect(screen.getByTestId("tremor-card")).toBeInTheDocument();
expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument();
expect(screen.getByTestId("tremor-bar-chart")).toBeInTheDocument();
expect(screen.getByText("Successful Requests")).toBeInTheDocument();
expect(screen.getByText("Failed Requests")).toBeInTheDocument();
});
it("renders stacked green and red bars per endpoint", () => {
const { container } = renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
expect(container.querySelectorAll(".recharts-bar")).toHaveLength(2);
const rectangles = Array.from(container.querySelectorAll("path.recharts-rectangle"));
expect(rectangles).toHaveLength(4);
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
const xPositions = rectangles.map((rect) => rect.getAttribute("d")?.split(",")[0]);
expect(new Set(xPositions).size).toBe(2);
});
it("labels the x axis with endpoint names", () => {
renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
expect(screen.getAllByText("/chat/completions").length).toBeGreaterThan(0);
expect(screen.getAllByText("/embeddings").length).toBeGreaterThan(0);
});
it("keeps the chart's own legend off; only the header legend is shown", () => {
const { container } = renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
expect(container.querySelector(".recharts-legend-wrapper")).toBeNull();
expect(screen.queryByText("metrics.successful_requests")).not.toBeInTheDocument();
});
it("renders an empty chart without bars when endpointData is absent", () => {
const { container } = renderWithProviders(<EndpointUsageBarChart />);
expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument();
expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(0);
});
});

View file

@ -1,6 +1,6 @@
import React from "react";
import { BarChart, Card, Title } from "@tremor/react";
import { CustomLegend, CustomTooltip } from "@/components/common_components/chartUtils";
import { BarChart, CustomLegend, CustomTooltip } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MetricWithMetadata } from "@/components/UsagePage/types";
interface EndpointUsageBarChartProps {
@ -8,11 +8,9 @@ interface EndpointUsageBarChartProps {
}
const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointData }) => {
const dataToUse = endpointData || {};
// Transform endpoint data into chart format
const chartData = React.useMemo(() => {
return Object.entries(dataToUse).map(([endpoint, data]) => ({
return Object.entries(endpointData || {}).map(([endpoint, data]) => ({
endpoint,
"metrics.successful_requests": data.metrics.successful_requests,
"metrics.failed_requests": data.metrics.failed_requests,
@ -21,31 +19,34 @@ const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointD
failed_requests: data.metrics.failed_requests,
},
}));
}, [dataToUse]);
}, [endpointData]);
const valueFormatter = (value: number) => value.toLocaleString();
return (
<Card>
<div className="flex justify-between items-center">
<Title>Success vs Failed Requests by Endpoint</Title>
<CustomLegend
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="text-base font-semibold">Success vs Failed Requests by Endpoint</CardTitle>
<CustomLegend
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
/>
</div>
</CardHeader>
<CardContent>
<BarChart
data={chartData}
index="endpoint"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
stack={true}
yAxisWidth={60}
/>
</div>
<BarChart
className="mt-4"
data={chartData}
index="endpoint"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
stack={true}
yAxisWidth={60}
/>
</CardContent>
</Card>
);
};

View file

@ -1,34 +1,103 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import EndpointUsageLineChart from "./EndpointUsageLineChart";
vi.mock("@tremor/react", async () => {
const React = await import("react");
function Card({ children }: any) {
return React.createElement("div", { "data-testid": "tremor-card" }, children);
}
(Card as any).displayName = "Card";
function Title({ children }: any) {
return React.createElement("h2", { "data-testid": "tremor-title" }, children);
}
(Title as any).displayName = "Title";
function LineChart(_props: any) {
return React.createElement("div", { "data-testid": "tremor-line-chart" }, "Line Chart");
}
(LineChart as any).displayName = "LineChart";
return { Card, Title, LineChart };
const spendMetrics = (apiRequests: number): SpendMetrics => ({
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: apiRequests,
successful_requests: apiRequests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const endpointMetric = (apiRequests: number): MetricWithMetadata => ({
metrics: spendMetrics(apiRequests),
metadata: {},
api_key_breakdown: {},
});
const day = (date: string, endpoints: Record<string, number>): DailyData => ({
date,
metrics: spendMetrics(0),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
endpoints: Object.fromEntries(
Object.entries(endpoints).map(([name, requests]) => [name, endpointMetric(requests)]),
),
},
});
const dailyData = {
results: [
day("2026-06-03T12:00:00", { "/chat/completions": 4000, "/embeddings": 900 }),
day("2026-06-02T12:00:00", { "/chat/completions": 2500, "/embeddings": 700 }),
day("2026-06-01T12:00:00", { "/chat/completions": 1200 }),
],
};
describe("EndpointUsageLineChart", () => {
it("should render", () => {
render(<EndpointUsageLineChart />);
it("renders the title", () => {
renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
expect(screen.getByTestId("tremor-card")).toBeInTheDocument();
expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument();
expect(screen.getByTestId("tremor-line-chart")).toBeInTheDocument();
});
it("renders one line per endpoint with the tremor palette strokes", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
const curves = Array.from(container.querySelectorAll("path.recharts-line-curve"));
expect(curves).toHaveLength(2);
expect(new Set(curves.map((curve) => curve.getAttribute("stroke")))).toEqual(
new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]),
);
});
it("shows a legend with the endpoint names", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
const legend = container.querySelector(".recharts-legend-wrapper");
expect(legend).not.toBeNull();
expect(legend!.textContent).toContain("/chat/completions");
expect(legend!.textContent).toContain("/embeddings");
});
it("orders formatted dates oldest to newest on the x axis", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
const tickLabels = Array.from(container.querySelectorAll(".recharts-xAxis-tick-labels text")).map(
(tick) => tick.textContent,
);
expect(tickLabels).toEqual(["Jun 1", "Jun 2", "Jun 3"]);
});
it("formats y axis ticks with toLocaleString", () => {
renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
expect(screen.getAllByText(/^\d,\d{3}$/).length).toBeGreaterThan(0);
});
it("draws smooth natural curves", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
const path = container.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
expect(path).toContain("C");
});
it("renders an empty chart without lines when dailyData is absent", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart />);
expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument();
expect(container.querySelectorAll("path.recharts-line-curve")).toHaveLength(0);
});
});

View file

@ -1,10 +1,10 @@
import { Card, LineChart, Title } from "@tremor/react";
import { useMemo } from "react";
import { LineChart, type ChartColor } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DailyData } from "@/components/UsagePage/types";
interface EndpointUsageLineChartProps {
dailyData?: { results: DailyData[] };
endpointData?: Record<string, any>;
}
// Transform daily data into chart format
@ -42,7 +42,7 @@ function transformDailyDataToChart(dailyData: DailyData[]): Array<Record<string,
return chartData.reverse();
}
export function EndpointUsageLineChart({ dailyData, endpointData }: EndpointUsageLineChartProps) {
export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProps) {
const chartData = useMemo(() => {
if (!dailyData?.results || dailyData.results.length === 0) {
return [];
@ -59,26 +59,39 @@ export function EndpointUsageLineChart({ dailyData, endpointData }: EndpointUsag
}, [chartData]);
// Tremor color palette for multiple lines
const colors = ["blue", "cyan", "indigo", "violet", "purple", "fuchsia", "pink", "rose", "red", "orange"];
const colors: readonly ChartColor[] = [
"blue",
"cyan",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"red",
"orange",
];
return (
<Card className="mb-6">
<div className="flex items-center justify-between mb-4">
<Title>Endpoint Usage Trends</Title>
</div>
<LineChart
className="h-80"
data={chartData}
index="date"
categories={categories}
colors={colors.slice(0, categories.length)}
valueFormatter={(value) => value.toLocaleString()}
showLegend={true}
showGridLines={true}
yAxisWidth={60}
connectNulls={true}
curveType="natural"
/>
<CardHeader>
<CardTitle className="text-base font-semibold">Endpoint Usage Trends</CardTitle>
</CardHeader>
<CardContent>
<LineChart
className="h-80"
data={chartData}
index="date"
categories={categories}
colors={colors.slice(0, categories.length)}
valueFormatter={(value) => value.toLocaleString()}
showLegend={true}
showGridLines={true}
yAxisWidth={60}
connectNulls={true}
curveType="natural"
/>
</CardContent>
</Card>
);
}

View file

@ -10,3 +10,4 @@ export {
} from "./chart_tooltip";
export { CHART_COLOR_HEX, DEFAULT_COLOR_CYCLE, categoryFills, chartColorValue, type ChartColor } from "./colors";
export { DonutChart, type DonutChartProps } from "./donut_chart";
export { LineChart, type LineChartCurveType, type LineChartProps } from "./line_chart";

View file

@ -0,0 +1,117 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it } from "vitest";
import { LineChart } from "./line_chart";
const data = [
{ date: "Jun 1", "/chat/completions": 10, "/embeddings": 4 },
{ date: "Jun 2", "/chat/completions": 15, "/embeddings": 6 },
{ date: "Jun 3", "/chat/completions": 12, "/embeddings": 9 },
];
describe("LineChart", () => {
it("renders one line per category with the mapped tremor stroke colors", () => {
const { container } = render(
<LineChart
data={data}
index="date"
categories={["/chat/completions", "/embeddings"]}
colors={["blue", "cyan"]}
/>,
);
const curves = Array.from(container.querySelectorAll("path.recharts-line-curve"));
expect(curves).toHaveLength(2);
expect(curves.map((curve) => curve.getAttribute("stroke"))).toEqual([
"var(--color-blue-500, #3b82f6)",
"var(--color-cyan-500, #06b6d4)",
]);
});
it("falls back to the tremor default color cycle when no colors are passed", () => {
const { container } = render(
<LineChart data={data} index="date" categories={["/chat/completions", "/embeddings"]} />,
);
const strokes = Array.from(container.querySelectorAll("path.recharts-line-curve")).map((curve) =>
curve.getAttribute("stroke"),
);
expect(strokes).toEqual(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]);
});
it("applies valueFormatter to the value axis ticks", () => {
render(
<LineChart
data={data}
index="date"
categories={["/chat/completions"]}
colors={["blue"]}
valueFormatter={(v) => `${v} req`}
/>,
);
expect(screen.getAllByText(/ req$/).length).toBeGreaterThan(0);
});
it("renders a legend by default, matching tremor, and hides it when showLegend is false", () => {
const { container, rerender } = render(
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
);
expect(screen.getByText("/chat/completions")).toBeInTheDocument();
expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull();
rerender(
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} showLegend={false} />,
);
expect(screen.queryByText("/chat/completions")).not.toBeInTheDocument();
});
it("draws straight segments by default and curved segments for curveType natural", () => {
const { container: linear } = render(
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
);
const { container: natural } = render(
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} curveType="natural" />,
);
const linearPath = linear.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
const naturalPath = natural.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
expect(linearPath).not.toContain("C");
expect(naturalPath).toContain("C");
});
it("bridges gaps over null values only when connectNulls is set", () => {
const gappedData = [
{ date: "Jun 1", "/chat/completions": 10 },
{ date: "Jun 2", "/chat/completions": null },
{ date: "Jun 3", "/chat/completions": 12 },
{ date: "Jun 4", "/chat/completions": 15 },
];
const { container: broken } = render(
<LineChart data={gappedData} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
);
const { container: bridged } = render(
<LineChart data={gappedData} index="date" categories={["/chat/completions"]} colors={["blue"]} connectNulls />,
);
const brokenPath = broken.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
const bridgedPath = bridged.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";
expect((brokenPath.match(/M/g) ?? []).length).toBeGreaterThan(1);
expect((bridgedPath.match(/M/g) ?? []).length).toBe(1);
});
it("renders an empty chart without lines when there are no categories", () => {
const { container } = render(<LineChart data={[]} index="date" categories={[]} />);
expect(container.querySelector("[data-slot='chart']")).not.toBeNull();
expect(container.querySelectorAll("path.recharts-line-curve")).toHaveLength(0);
});
it("emits no per-chart style tag; colors flow through strokes, not CSS vars", () => {
const { container } = render(
<LineChart data={data} index="date" categories={["/chat/completions"]} colors={["blue"]} />,
);
expect(container.querySelector("style")).toBeNull();
});
});

View file

@ -0,0 +1,99 @@
"use client";
import * as React from "react";
import { CartesianGrid, Line, LineChart as RechartsLineChart, XAxis, YAxis } from "recharts";
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart";
import { cn } from "@/lib/cva.config";
import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip";
import { categoryFills, type ChartColor } from "./colors";
export type LineChartCurveType = "linear" | "natural" | "monotone" | "step";
export type LineChartProps<TDatum extends Record<string, unknown>> = {
data: readonly TDatum[];
index: string;
categories: readonly string[];
colors?: readonly ChartColor[];
valueFormatter?: (value: number) => string;
yAxisWidth?: number;
tickGap?: number;
showLegend?: boolean;
showXAxis?: boolean;
showGridLines?: boolean;
showTooltip?: boolean;
customTooltip?: ChartTooltipComponent;
connectNulls?: boolean;
curveType?: LineChartCurveType;
className?: string;
style?: React.CSSProperties;
};
export function LineChart<TDatum extends Record<string, unknown>>({
data,
index,
categories,
colors,
valueFormatter,
yAxisWidth = 56,
tickGap = 5,
showLegend = true,
showXAxis = true,
showGridLines = true,
showTooltip = true,
customTooltip,
connectNulls = false,
curveType = "linear",
className,
style,
}: LineChartProps<TDatum>) {
const fills = categoryFills(categories.length, colors);
const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }]));
const TooltipContent = customTooltip ?? ValueTooltip;
return (
<ChartContainer config={config} className={cn("aspect-auto h-80 w-full", className)} style={style}>
<RechartsLineChart data={[...data]}>
{showGridLines && <CartesianGrid vertical={false} />}
<XAxis
dataKey={index}
hide={!showXAxis}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
interval="equidistantPreserveStart"
/>
<YAxis width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={valueFormatter} />
{showTooltip && (
<ChartTooltip
content={({ active, payload, label }) => (
<TooltipContent
active={active}
payload={payload}
label={label}
{...(customTooltip ? {} : { valueFormatter })}
/>
)}
/>
)}
{showLegend && (
<ChartLegend
verticalAlign="top"
content={<ChartLegendContent className="justify-end text-muted-foreground" />}
/>
)}
{categories.map((category, i) => (
<Line
key={category}
type={curveType}
dataKey={category}
stroke={fills[i]}
strokeWidth={2}
dot={false}
isAnimationActive={false}
connectNulls={connectNulls}
/>
))}
</RechartsLineChart>
</ChartContainer>
);
}

View file

@ -1,4 +1,5 @@
import createFetchClient, { type Middleware } from "openapi-fetch";
import createQueryClient from "openapi-react-query";
import type { paths } from "./schema";
import { ApiError, deriveErrorMessage } from "./client";
import { getAuthHeaderName, getAuthToken, getRequestBaseUrl, reportError } from "./runtime";
@ -46,3 +47,11 @@ const middleware: Middleware = {
*/
export const fetchClient = createFetchClient<paths>({ baseUrl: globalThis.location?.origin ?? "" });
fetchClient.use(middleware);
/**
* TanStack Query bound to the typed client. Callers write
* `$api.useQuery("get", "/path", init, options)`; the query key is derived from
* method + path + init (no hand-maintained key), the request signal is
* forwarded for cancellation, and the response type comes from schema.d.ts.
*/
export const $api = createQueryClient(fetchClient);

View file

@ -130,7 +130,7 @@ vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard"
vi.mock("@/app/(dashboard)/guardrails/_components", () => ({ default: stub("guardrails") }));
vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
vi.mock("@/components/transform_request", () => ({ default: stub("transform-request") }));
vi.mock("@/components/mcp_tools", () => ({ MCPServers: stub("mcp-servers") }));
vi.mock("@/app/(dashboard)/mcp-servers/_components", () => ({ MCPServers: stub("mcp-servers") }));
vi.mock("@/app/(dashboard)/tag-management/_components", () => ({ default: stub("tag-management") }));
vi.mock("@/app/(dashboard)/vector-stores/_components", () => ({ default: stub("vector-stores") }));
vi.mock("@/components/ui_theme_settings", () => ({ default: stub("ui-theme-settings") }));