mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/datatable-column-spacing-e19760
This commit is contained in:
commit
1c1c0eee66
74 changed files with 1189 additions and 341 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
ui/litellm-dashboard/package-lock.json
generated
14
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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",
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MCPServer } from "./types";
|
||||
import { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
export interface RequiredFieldDef {
|
||||
key: string;
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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(),
|
||||
|
|
@ -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";
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") }));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue