Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/datatable-column-spacing-e19760

This commit is contained in:
Yuneng Jiang 2026-07-11 18:17:32 -07:00
commit 1c1c0eee66
No known key found for this signature in database
74 changed files with 1189 additions and 341 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

@ -858,9 +858,6 @@
"src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx": {
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": {
@ -1079,6 +1076,51 @@
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": {
"no-nested-ternary": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx": {
"no-nested-ternary": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/users/_components/DefaultUserSettings.tsx": {
"no-restricted-imports": {
"count": 1
@ -1502,61 +1544,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
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx": {
"no-nested-ternary": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/VirtualKeysPage/VirtualKeysTable.tsx": {
"no-nested-ternary": {
"count": 2
@ -1878,17 +1865,17 @@
"count": 1
}
},
"src/components/mcp_tools/MCPLogoSelector.test.tsx": {
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"src/components/mcp_tools/MCPNetworkSettings.tsx": {
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/components/mcp_tools/MCPSubmissionsTab.tsx": {
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -1898,7 +1885,7 @@
"count": 5
}
},
"src/components/mcp_tools/MCPToolsetsTab.tsx": {
"src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -1920,7 +1907,7 @@
"count": 1
}
},
"src/components/mcp_tools/OAuthFormFields.tsx": {
"src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -1928,12 +1915,12 @@
"count": 1
}
},
"src/components/mcp_tools/OpenAPIQuickPicker.tsx": {
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/mcp_tools/ToolTestPanel.tsx": {
"src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -1944,12 +1931,12 @@
"count": 1
}
},
"src/components/mcp_tools/UserEnvVarsModal.tsx": {
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
}
},
"src/components/mcp_tools/create_mcp_server.tsx": {
"src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -1960,7 +1947,7 @@
"count": 4
}
},
"src/components/mcp_tools/mcp_connect.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": {
"no-restricted-imports": {
"count": 1
},
@ -1968,7 +1955,7 @@
"count": 4
}
},
"src/components/mcp_tools/mcp_connection_status.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": {
"no-nested-ternary": {
"count": 3
},
@ -1976,22 +1963,22 @@
"count": 1
}
},
"src/components/mcp_tools/mcp_discovery.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/mcp_tools/mcp_server_cost_config.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_server_cost_display.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_server_edit.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2005,12 +1992,12 @@
"count": 5
}
},
"src/components/mcp_tools/mcp_server_view.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_servers.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2021,12 +2008,12 @@
"count": 2
}
},
"src/components/mcp_tools/mcp_tool_configuration.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/mcp_tools/mcp_tools.tsx": {
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
"no-nested-ternary": {
"count": 1
},
@ -2558,4 +2545,4 @@
"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

@ -43,6 +43,14 @@ const mockProject: ProjectResponse = {
litellm_budget_table: null,
};
const rectangleFills = (container: HTMLElement) =>
new Set(Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")));
const yAxisTickLabels = (container: HTMLElement) =>
Array.from(container.querySelectorAll(".recharts-yAxis-tick-labels .recharts-cartesian-axis-tick-value")).map(
(tick) => tick.textContent,
);
describe("ProjectDetail", () => {
const onBack = vi.fn();
@ -159,6 +167,65 @@ describe("ProjectDetail", () => {
expect(screen.getByText("No team assigned")).toBeInTheDocument();
});
describe("Spend by Model chart", () => {
const multiModelProject: ProjectResponse = {
...mockProject,
model_spend: {
"claude-sonnet-5": 0.5,
"gpt-5.2": 10,
"claude-opus-4-8": 2.75,
"gpt-5.2-codex": 5.5,
},
};
it("should render one cyan bar per model without a legend", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(container.querySelectorAll(".recharts-bar")).toHaveLength(1);
expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
expect(rectangleFills(container)).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"]));
expect(container.querySelector(".recharts-legend-wrapper")).toBeNull();
});
it("should list models on the category axis sorted by spend descending", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(yAxisTickLabels(container)).toEqual(["gpt-5.2", "gpt-5.2-codex", "claude-opus-4-8", "claude-sonnet-5"]);
});
it("should format value axis ticks as dollars with four decimals", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(container.querySelector(".recharts-xAxis-tick-labels")?.textContent).toMatch(/\$\d+\.\d{4}/);
});
it("should scale the chart height at 40px per model with a 120px floor", () => {
mockUseProjectDetails.mockReturnValue({ data: multiModelProject, isLoading: false });
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(container.querySelector<HTMLElement>('[data-slot="chart"]')?.style.height).toBe("160px");
mockUseProjectDetails.mockReturnValue({ data: mockProject, isLoading: false });
const { container: singleModelContainer } = renderWithProviders(
<ProjectDetail projectId="proj-1" onBack={onBack} />,
);
expect(singleModelContainer.querySelector<HTMLElement>('[data-slot="chart"]')?.style.height).toBe("120px");
});
it("should show the empty state when no model spend is recorded", () => {
mockUseProjectDetails.mockReturnValue({
data: { ...mockProject, model_spend: {} },
isLoading: false,
});
const { container } = renderWithProviders(<ProjectDetail projectId="proj-1" onBack={onBack} />);
expect(screen.getByText("No model spend recorded yet")).toBeInTheDocument();
expect(container.querySelector('[data-slot="chart"]')).toBeNull();
});
});
it("should show team information when team data is available", () => {
mockUseTeam.mockReturnValue({
data: {

View file

@ -16,7 +16,7 @@ import {
Typography,
} from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@tremor/react";
import { BarChart } from "@/components/shared/charts";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";

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

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Radio, Badge, Space } from "antd";
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Radio, Badge, Space, Modal } from "antd";
import type { FormInstance } from "antd";
import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons";
import { Text, TextInput } from "@tremor/react";
@ -12,6 +12,8 @@ import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./Complexit
import { KeywordTierRule } from "./KeywordTierRules";
import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching";
import { buildComplexityRouterConfig, getSemanticConfigError } from "./build_complexity_router_config";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import NotificationManager from "../molecules/notifications_manager";
interface AddAutoRouterTabProps {
@ -45,6 +47,11 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
// Semantic router config (existing)
const [routerConfig, setRouterConfig] = useState<any>(null);
const [isTestModalVisible, setIsTestModalVisible] = useState<boolean>(false);
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
const [connectionTestId, setConnectionTestId] = useState<number>(0);
const [testTargets, setTestTargets] = useState<AutoRouterTestTarget[]>([]);
useEffect(() => {
const fetchModelAccessGroups = async () => {
const response = await modelAvailableCall(accessToken, "", "", false, null, true, true);
@ -194,6 +201,24 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
}
};
const handleTestConnection = () => {
const targets = buildAutoRouterTestTargets({
tiers: complexityRouterConfig.tiers,
semanticMatchingEnabled,
embeddingModel,
});
if (targets.length === 0) {
NotificationManager.fromBackend("Please select at least one model for a complexity tier");
return;
}
setTestTargets(targets);
setConnectionTestId((id) => id + 1);
setIsTestingConnection(true);
setIsTestModalVisible(true);
};
return (
<>
<Title level={2}>Add Auto Router</Title>
@ -355,10 +380,15 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
</Tooltip>
<div className="space-x-2">
{/* TODO: add back a Test Connection or JSON preview action here. Test Connection was removed
because prepareModelAddRequest can't build a valid pre-save payload for an auto router
(tiers are model-group references, not litellm_params); a JSON preview of the
complexity_router_config would be a good alternative. */}
{routerType === "recommended" && (
<Button
data-testid="auto-router-test-connect-btn"
onClick={handleTestConnection}
loading={isTestingConnection}
>
Test Connection
</Button>
)}
<Button
type="primary"
onClick={() => {
@ -371,6 +401,36 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
</div>
</Form>
</Card>
<Modal
title="Connection Test Results"
open={isTestModalVisible}
onCancel={() => {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}}
footer={[
<Button
key="close"
onClick={() => {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}}
>
Close
</Button>,
]}
width={700}
>
{isTestModalVisible && (
<AutoRouterConnectionTest
key={connectionTestId}
accessToken={accessToken}
targets={testTargets}
onTestComplete={() => setIsTestingConnection(false)}
/>
)}
</Modal>
</>
);
};

View file

@ -0,0 +1,84 @@
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { vi } from "vitest";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import { AutoRouterTestTarget } from "./build_auto_router_test_targets";
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
...actual,
testModelGroupConnection: vi.fn(),
};
});
const getMock = async () => vi.mocked((await import("../networking")).testModelGroupConnection);
const targets: AutoRouterTestTarget[] = [
{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" },
{ labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" },
{ labels: ["Embedding"], modelGroup: "voyage-3-5", mode: "embedding" },
];
describe("AutoRouterConnectionTest", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("probes each target once with the right model and mode (chat for tiers, embedding for the embedding model)", async () => {
const mock = await getMock();
mock.mockResolvedValue({ status: "success" });
renderWithProviders(<AutoRouterConnectionTest accessToken="sk-test" targets={targets} />);
await waitFor(() => expect(mock).toHaveBeenCalledTimes(3));
expect(mock).toHaveBeenCalledWith("sk-test", "gpt-4o-mini", "chat");
expect(mock).toHaveBeenCalledWith("sk-test", "claude-sonnet-4", "chat");
expect(mock).toHaveBeenCalledWith("sk-test", "voyage-3-5", "embedding");
});
it("shows a success indicator per target when the routing probe passes", async () => {
const mock = await getMock();
mock.mockResolvedValue({ status: "success" });
renderWithProviders(<AutoRouterConnectionTest accessToken="sk-test" targets={targets} />);
await waitFor(() => expect(screen.getAllByTestId("test-status-success")).toHaveLength(3));
expect(screen.queryByTestId("test-status-error")).toBeNull();
expect(screen.getByText("MEDIUM, COMPLEX")).toBeInTheDocument();
});
it("renders the provider error message (litellm prefix stripped) for a failing target while others pass", async () => {
const mock = await getMock();
mock.mockImplementation((_token, modelGroup) =>
Promise.resolve(
modelGroup === "claude-sonnet-4"
? { status: "error", error: "litellm.AuthenticationError: invalid api key" }
: { status: "success" },
),
);
renderWithProviders(<AutoRouterConnectionTest accessToken="sk-test" targets={targets} />);
await waitFor(() => expect(screen.getByTestId("test-error-message")).toBeInTheDocument());
expect(screen.getByTestId("test-error-message")).toHaveTextContent("invalid api key");
expect(screen.getByTestId("test-error-message")).not.toHaveTextContent("litellm.AuthenticationError");
expect(screen.getAllByTestId("test-status-success")).toHaveLength(2);
});
it("renders a non-litellm error string verbatim", async () => {
const mock = await getMock();
mock.mockResolvedValue({ status: "error", error: "Connection test failed: 404 Not Found" });
renderWithProviders(
<AutoRouterConnectionTest
accessToken="sk-test"
targets={[{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]}
/>,
);
await waitFor(() =>
expect(screen.getByTestId("test-error-message")).toHaveTextContent("Connection test failed: 404 Not Found"),
);
});
});

View file

@ -0,0 +1,107 @@
import React from "react";
import { Typography } from "antd";
import { CheckCircleTwoTone, CloseCircleTwoTone, LoadingOutlined } from "@ant-design/icons";
import { testModelGroupConnection, ModelGroupConnectionResult } from "../networking";
import { AutoRouterTestTarget } from "./build_auto_router_test_targets";
const { Text } = Typography;
interface AutoRouterConnectionTestProps {
accessToken: string;
targets: AutoRouterTestTarget[];
onTestComplete?: () => void;
}
type TargetResult = { status: "pending" } | ModelGroupConnectionResult;
const cleanErrorMessage = (error: string): string => {
const mainError = error.split("stack trace:")[0].trim();
return mainError.replace(/^litellm\.(.*?)Error: /, "");
};
const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
accessToken,
targets,
onTestComplete,
}) => {
const [results, setResults] = React.useState<TargetResult[]>(() => targets.map(() => ({ status: "pending" })));
React.useEffect(() => {
let cancelled = false;
const run = async () => {
await Promise.all(
targets.map(async (target, index) => {
const result = await testModelGroupConnection(accessToken, target.modelGroup, target.mode);
if (cancelled) return;
const cleaned: TargetResult =
result.status === "error" ? { status: "error", error: cleanErrorMessage(result.error) } : result;
setResults((prev) => prev.map((r, i) => (i === index ? cleaned : r)));
}),
);
if (!cancelled && onTestComplete) onTestComplete();
};
run();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- probes run once per mount; the parent remounts via `key` to start a fresh test, and re-running on prop identity changes would refire paid requests
}, []);
if (targets.length === 0) {
return <Text type="secondary">No complexity tiers are configured yet, so there is nothing to test.</Text>;
}
return (
<div className="space-y-3">
<Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
Each configured tier routes to a saved model group. Test Connection sends a minimal request through the proxy to
each one, exactly as the auto router would.
</Text>
{targets.map((target, index) => {
const result = results[index] ?? { status: "pending" };
return (
<div
key={`${target.modelGroup}-${target.mode}`}
data-testid="auto-router-test-row"
style={{
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: "12px 16px",
display: "flex",
alignItems: "flex-start",
gap: 12,
}}
>
<div style={{ fontSize: 18, lineHeight: "24px" }}>
{result.status === "pending" && <LoadingOutlined data-testid="test-status-pending" />}
{result.status === "success" && (
<CheckCircleTwoTone twoToneColor="#52c41a" data-testid="test-status-success" />
)}
{result.status === "error" && (
<CloseCircleTwoTone twoToneColor="#ff4d4f" data-testid="test-status-error" />
)}
</div>
<div style={{ flex: 1 }}>
<Text strong>{target.labels.join(", ")}</Text>{" "}
<Text type="secondary">
{"->"} {target.modelGroup}
{target.mode === "embedding" ? " (embedding)" : ""}
</Text>
{result.status === "error" && (
<Text
type="danger"
data-testid="test-error-message"
style={{ display: "block", marginTop: 4, fontSize: 13 }}
>
{result.error}
</Text>
)}
</div>
</div>
);
})}
</div>
);
};
export default AutoRouterConnectionTest;

View file

@ -0,0 +1,68 @@
import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets";
const tiers = {
SIMPLE: "gpt-4o-mini",
MEDIUM: "claude-sonnet-4",
COMPLEX: "claude-sonnet-4",
REASONING: "o3",
};
describe("buildAutoRouterTestTargets", () => {
it("dedups tiers that share a model group into one chat target carrying both labels", () => {
const targets = buildAutoRouterTestTargets({ tiers, semanticMatchingEnabled: false, embeddingModel: undefined });
expect(targets).toEqual([
{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" },
{ labels: ["MEDIUM", "COMPLEX"], modelGroup: "claude-sonnet-4", mode: "chat" },
{ labels: ["REASONING"], modelGroup: "o3", mode: "chat" },
]);
});
it("drops empty/whitespace tiers", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: " ", REASONING: "" },
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
});
it("returns [] when no tier is configured", () => {
expect(
buildAutoRouterTestTargets({
tiers: { SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" },
semanticMatchingEnabled: false,
embeddingModel: undefined,
}),
).toEqual([]);
});
it("appends an embedding target only when semantic matching is on and a model is set", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: "", REASONING: "" },
semanticMatchingEnabled: true,
embeddingModel: "voyage-3-5",
});
expect(targets).toEqual([
{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" },
{ labels: ["Embedding"], modelGroup: "voyage-3-5", mode: "embedding" },
]);
});
it("omits the embedding target when semantic matching is on but no model is chosen", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: "", REASONING: "" },
semanticMatchingEnabled: true,
embeddingModel: undefined,
});
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
});
it("omits the embedding target when a model is set but semantic matching is off", () => {
const targets = buildAutoRouterTestTargets({
tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: "", COMPLEX: "", REASONING: "" },
semanticMatchingEnabled: false,
embeddingModel: "voyage-3-5",
});
expect(targets).toEqual([{ labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }]);
});
});

View file

@ -0,0 +1,49 @@
import { ComplexityTiers } from "./ComplexityRouterConfig";
export type AutoRouterTestMode = "chat" | "embedding";
export interface AutoRouterTestTarget {
labels: string[];
modelGroup: string;
mode: AutoRouterTestMode;
}
export interface BuildAutoRouterTestTargetsParams {
tiers: ComplexityTiers;
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
}
// Keys drive iteration order; `satisfies Record<keyof ComplexityTiers, null>` makes it a
// compile error to add a tier to ComplexityTiers without listing it here (and vice versa).
const TIER_ORDER = Object.keys({
SIMPLE: null,
MEDIUM: null,
COMPLEX: null,
REASONING: null,
} satisfies Record<keyof ComplexityTiers, null>) as (keyof ComplexityTiers)[];
export const buildAutoRouterTestTargets = ({
tiers,
semanticMatchingEnabled,
embeddingModel,
}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => {
const groupedByModel = TIER_ORDER.reduce<Record<string, string[]>>((acc, tier) => {
const modelGroup = tiers[tier]?.trim();
if (!modelGroup) return acc;
return { ...acc, [modelGroup]: [...(acc[modelGroup] ?? []), tier] };
}, {});
const tierTargets: AutoRouterTestTarget[] = Object.entries(groupedByModel).map(([modelGroup, labels]) => ({
labels,
modelGroup,
mode: "chat" as const,
}));
const embeddingTarget: AutoRouterTestTarget[] =
semanticMatchingEnabled && embeddingModel?.trim()
? [{ labels: ["Embedding"], modelGroup: embeddingModel.trim(), mode: "embedding" as const }]
: [];
return [...tierTargets, ...embeddingTarget];
};

View file

@ -514,3 +514,19 @@ describe("sessionSpendLogsCall", () => {
expect(parsed.searchParams.get("page_size")).toBe("100");
});
});
describe("buildModelGroupTestRequest", () => {
it("builds a chat completion request with NO max_tokens (reasoning models 400 on a tiny cap)", () => {
const { path, body } = Networking.buildModelGroupTestRequest("o3", "chat");
expect(path).toBe("/v1/chat/completions");
expect(body).toEqual({ model: "o3", messages: [{ role: "user", content: "test from litellm" }] });
expect(body).not.toHaveProperty("max_tokens");
expect(body).not.toHaveProperty("max_completion_tokens");
});
it("builds an embeddings request for embedding mode", () => {
const { path, body } = Networking.buildModelGroupTestRequest("text-embedding-3-small", "embedding");
expect(path).toBe("/v1/embeddings");
expect(body).toEqual({ model: "text-embedding-3-small", input: "test from litellm" });
});
});

View file

@ -2315,6 +2315,46 @@ export const testConnectionRequest = async (
}
};
export type ModelGroupConnectionResult = { status: "success" } | { status: "error"; error: string };
/**
* Test an existing model group by routing a minimal request through the proxy
* exactly as production would (by public model_group name). Unlike
* /health/test_connection, this needs no litellm_params resolution: the router
* resolves the group, credentials, and provider. Used by the auto-router Test
* Connection to probe each tier's model group and the embedding model.
*/
/**
* Build the minimal request that probes a model group by public name. No
* max_tokens: reasoning models (o1/o3/...) reject a tiny cap with "max_tokens
* reached" because reasoning tokens count against it, which would show a false
* failure for a reachable tier.
*/
export const buildModelGroupTestRequest = (
modelGroup: string,
mode: "chat" | "embedding",
): { path: string; body: Record<string, unknown> } =>
mode === "embedding"
? { path: "/v1/embeddings", body: { model: modelGroup, input: "test from litellm" } }
: {
path: "/v1/chat/completions",
body: { model: modelGroup, messages: [{ role: "user", content: "test from litellm" }] },
};
export const testModelGroupConnection = async (
accessToken: string,
modelGroup: string,
mode: "chat" | "embedding",
): Promise<ModelGroupConnectionResult> => {
const { path, body } = buildModelGroupTestRequest(modelGroup, mode);
try {
await apiClient.post(path, { accessToken, body });
return { status: "success" };
} catch (error) {
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
};
// ... existing code ...
export const keyInfoV1Call = async (accessToken: string, key: string) => {
try {

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") }));