From 2b2e8cf2bf3cca3431733c444203c8e8b7b8795e Mon Sep 17 00:00:00 2001 From: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:44:50 -0700 Subject: [PATCH 1/8] feat(ui): working Test Connection for the complexity auto router The consolidated auto-router tab dropped the Test Connection button because the shared prepareModelAddRequest helper returns an empty array for an auto router (it has no model_mappings), so the caller crashed destructuring result[0].litellmParamsObj. That is the crash in #31590 and the open PR #31794. #31794 only silenced the crash by pointing the test at auto_router/complexity_router, which is not a provider model, so the /health/test_connection health check (a real litellm.ahealth_check completion) would still error. Bring the button back and make it meaningful: an auto router dispatches to saved model groups, so Test Connection now probes those directly. It builds a deduped target list from the configured tiers (tiers sharing a model group collapse to one probe) plus the embedding model when semantic keyword matching is on, then runs a live /health/test_connection against each and shows per-target pass/fail. This never touches prepareModelAddRequest, so the original destructure crash cannot recur. Scope is the recommended complexity router only; the to-be-deprecated semantic router is untouched. No backend changes. Supersedes #31794. Resolves #31590. --- .../add_model/add_auto_router_tab.tsx | 70 +++++++++- .../auto_router_connection_test.test.tsx | 80 +++++++++++ .../add_model/auto_router_connection_test.tsx | 132 ++++++++++++++++++ .../build_auto_router_test_targets.test.ts | 68 +++++++++ .../build_auto_router_test_targets.ts | 42 ++++++ 5 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 79c07040210..55c62224a48 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -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 = ({ form, handleOk, acc // Semantic router config (existing) const [routerConfig, setRouterConfig] = useState(null); + const [isTestModalVisible, setIsTestModalVisible] = useState(false); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionTestId, setConnectionTestId] = useState(0); + const [testTargets, setTestTargets] = useState([]); + useEffect(() => { const fetchModelAccessGroups = async () => { const response = await modelAvailableCall(accessToken, "", "", false, null, true, true); @@ -194,6 +201,24 @@ const AddAutoRouterTab: React.FC = ({ 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 ( <> Add Auto Router @@ -355,10 +380,15 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc Need Help?
- {/* 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" && ( + + )}
+ + { + setIsTestModalVisible(false); + setIsTestingConnection(false); + }} + footer={[ + , + ]} + width={700} + > + {isTestModalVisible && ( + setIsTestingConnection(false)} + /> + )} + ); }; diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx new file mode 100644 index 00000000000..9b872d5edee --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx @@ -0,0 +1,80 @@ +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, + testConnectionRequest: vi.fn(), + }; +}); + +const getMock = async () => vi.mocked((await import("../networking")).testConnectionRequest); + +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(); + + await waitFor(() => expect(mock).toHaveBeenCalledTimes(3)); + + expect(mock).toHaveBeenCalledWith("sk-test", { model: "gpt-4o-mini" }, {}, "chat"); + expect(mock).toHaveBeenCalledWith("sk-test", { model: "claude-sonnet-4" }, {}, "chat"); + expect(mock).toHaveBeenCalledWith("sk-test", { model: "voyage-3-5" }, {}, "embedding"); + }); + + it("shows a success indicator per target when the health check passes", async () => { + const mock = await getMock(); + mock.mockResolvedValue({ status: "success" }); + + renderWithProviders(); + + 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 for a failing target while others pass", async () => { + const mock = await getMock(); + mock.mockImplementation((_token, litellmParams) => + litellmParams.model === "claude-sonnet-4" + ? Promise.resolve({ status: "error", result: { error: "litellm.AuthenticationError: invalid api key" } }) + : Promise.resolve({ status: "success" }), + ); + + renderWithProviders(); + + 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("surfaces a thrown network error as a failing row", async () => { + const mock = await getMock(); + mock.mockRejectedValue(new Error("Network request failed")); + + renderWithProviders( + , + ); + + await waitFor(() => expect(screen.getByTestId("test-error-message")).toHaveTextContent("Network request failed")); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx new file mode 100644 index 00000000000..77588006e35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { Typography } from "antd"; +import { CheckCircleTwoTone, CloseCircleTwoTone, LoadingOutlined } from "@ant-design/icons"; +import { testConnectionRequest } 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" } | { status: "success" } | { status: "error"; error: string }; + +interface NormalizedResponse { + ok: boolean; + error?: string; +} + +const normalizeTestConnectionResponse = (response: unknown): NormalizedResponse => { + if (typeof response !== "object" || response === null) { + return { ok: false, error: "Unexpected response from connection test" }; + } + const record = response as Record; + if (record.status === "success") { + return { ok: true }; + } + const result = + typeof record.result === "object" && record.result !== null ? (record.result as Record) : {}; + const resultError = typeof result.error === "string" ? result.error : undefined; + const recordMessage = typeof record.message === "string" ? record.message : undefined; + return { ok: false, error: resultError ?? recordMessage ?? "Unknown error" }; +}; + +const cleanErrorMessage = (error: string): string => { + const mainError = error.split("stack trace:")[0].trim(); + return mainError.replace(/^litellm\.(.*?)Error: /, ""); +}; + +const runTarget = async (accessToken: string, target: AutoRouterTestTarget): Promise => { + try { + const response = await testConnectionRequest(accessToken, { model: target.modelGroup }, {}, target.mode); + const normalized = normalizeTestConnectionResponse(response); + return normalized.ok + ? { status: "success" } + : { status: "error", error: cleanErrorMessage(normalized.error ?? "Unknown error") }; + } catch (error) { + return { status: "error", error: cleanErrorMessage(error instanceof Error ? error.message : String(error)) }; + } +}; + +const AutoRouterConnectionTest: React.FC = ({ + accessToken, + targets, + onTestComplete, +}) => { + const [results, setResults] = React.useState(() => targets.map(() => ({ status: "pending" }))); + + React.useEffect(() => { + let cancelled = false; + const run = async () => { + const settled = await Promise.all(targets.map((target) => runTarget(accessToken, target))); + if (cancelled) return; + setResults(settled); + if (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 health checks + }, []); + + if (targets.length === 0) { + return No complexity tiers are configured yet, so there is nothing to test.; + } + + return ( +
+ + Each configured tier routes to a saved model group. Test Connection runs a live health check against each one. + + {targets.map((target, index) => { + const result = results[index] ?? { status: "pending" }; + return ( +
+
+ {result.status === "pending" && } + {result.status === "success" && ( + + )} + {result.status === "error" && ( + + )} +
+
+ {target.labels.join(", ")}{" "} + + {"->"} {target.modelGroup} + {target.mode === "embedding" ? " (embedding)" : ""} + + {result.status === "error" && ( + + {result.error} + + )} +
+
+ ); + })} +
+ ); +}; + +export default AutoRouterConnectionTest; diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts new file mode 100644 index 00000000000..01b6470f17f --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts @@ -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" }]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts new file mode 100644 index 00000000000..0104b6bc9c5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts @@ -0,0 +1,42 @@ +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; +} + +const TIER_ORDER: (keyof ComplexityTiers)[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; + +export const buildAutoRouterTestTargets = ({ + tiers, + semanticMatchingEnabled, + embeddingModel, +}: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => { + const groupedByModel = TIER_ORDER.reduce>((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]; +}; From ddc13b331a83d49eb7c101eebde4d830ae86ea71 Mon Sep 17 00:00:00 2001 From: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:04:04 -0700 Subject: [PATCH 2/8] fix(ui): probe auto-router tiers via real proxy routing, not /health/test_connection Live testing showed the first cut was broken: /health/test_connection merges {...configParams, ...requestParams}, so passing the public model_group name as the request model overrode the resolved provider model and every tier failed with "LLM Provider NOT provided". The frontend only has the public group name, not the underlying litellm_params, so it cannot build the request that endpoint needs. Switch to testing each model group the way production actually routes it: send a minimal request to /v1/chat/completions (or /v1/embeddings for the embedding model) by public group name through the shared apiClient. The router resolves the group, credentials, and provider itself, so a green row means the tier is genuinely reachable. Verified live: voyage embedding returns 200, a tier with a bad key returns the real provider auth error. Also address Greptile feedback: rows now update progressively as each probe settles instead of all at once, and TIER_ORDER is derived through a `satisfies Record` guard so adding a tier without listing it is a compile error. --- .../auto_router_connection_test.test.tsx | 32 ++++++----- .../add_model/auto_router_connection_test.tsx | 55 +++++-------------- .../build_auto_router_test_targets.ts | 9 ++- .../src/components/networking.tsx | 28 ++++++++++ 4 files changed, 69 insertions(+), 55 deletions(-) diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx index 9b872d5edee..b07270b5ced 100644 --- a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.test.tsx @@ -7,11 +7,11 @@ vi.mock("../networking", async () => { const actual = await vi.importActual("../networking"); return { ...actual, - testConnectionRequest: vi.fn(), + testModelGroupConnection: vi.fn(), }; }); -const getMock = async () => vi.mocked((await import("../networking")).testConnectionRequest); +const getMock = async () => vi.mocked((await import("../networking")).testModelGroupConnection); const targets: AutoRouterTestTarget[] = [ { labels: ["SIMPLE"], modelGroup: "gpt-4o-mini", mode: "chat" }, @@ -32,12 +32,12 @@ describe("AutoRouterConnectionTest", () => { await waitFor(() => expect(mock).toHaveBeenCalledTimes(3)); - expect(mock).toHaveBeenCalledWith("sk-test", { model: "gpt-4o-mini" }, {}, "chat"); - expect(mock).toHaveBeenCalledWith("sk-test", { model: "claude-sonnet-4" }, {}, "chat"); - expect(mock).toHaveBeenCalledWith("sk-test", { model: "voyage-3-5" }, {}, "embedding"); + 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 health check passes", async () => { + it("shows a success indicator per target when the routing probe passes", async () => { const mock = await getMock(); mock.mockResolvedValue({ status: "success" }); @@ -48,12 +48,14 @@ describe("AutoRouterConnectionTest", () => { expect(screen.getByText("MEDIUM, COMPLEX")).toBeInTheDocument(); }); - it("renders the provider error message for a failing target while others pass", async () => { + it("renders the provider error message (litellm prefix stripped) for a failing target while others pass", async () => { const mock = await getMock(); - mock.mockImplementation((_token, litellmParams) => - litellmParams.model === "claude-sonnet-4" - ? Promise.resolve({ status: "error", result: { error: "litellm.AuthenticationError: invalid api key" } }) - : Promise.resolve({ status: "success" }), + mock.mockImplementation((_token, modelGroup) => + Promise.resolve( + modelGroup === "claude-sonnet-4" + ? { status: "error", error: "litellm.AuthenticationError: invalid api key" } + : { status: "success" }, + ), ); renderWithProviders(); @@ -64,9 +66,9 @@ describe("AutoRouterConnectionTest", () => { expect(screen.getAllByTestId("test-status-success")).toHaveLength(2); }); - it("surfaces a thrown network error as a failing row", async () => { + it("renders a non-litellm error string verbatim", async () => { const mock = await getMock(); - mock.mockRejectedValue(new Error("Network request failed")); + mock.mockResolvedValue({ status: "error", error: "Connection test failed: 404 Not Found" }); renderWithProviders( { />, ); - await waitFor(() => expect(screen.getByTestId("test-error-message")).toHaveTextContent("Network request failed")); + await waitFor(() => + expect(screen.getByTestId("test-error-message")).toHaveTextContent("Connection test failed: 404 Not Found"), + ); }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx index 77588006e35..5badd155da8 100644 --- a/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/auto_router_connection_test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Typography } from "antd"; import { CheckCircleTwoTone, CloseCircleTwoTone, LoadingOutlined } from "@ant-design/icons"; -import { testConnectionRequest } from "../networking"; +import { testModelGroupConnection, ModelGroupConnectionResult } from "../networking"; import { AutoRouterTestTarget } from "./build_auto_router_test_targets"; const { Text } = Typography; @@ -12,45 +12,13 @@ interface AutoRouterConnectionTestProps { onTestComplete?: () => void; } -type TargetResult = { status: "pending" } | { status: "success" } | { status: "error"; error: string }; - -interface NormalizedResponse { - ok: boolean; - error?: string; -} - -const normalizeTestConnectionResponse = (response: unknown): NormalizedResponse => { - if (typeof response !== "object" || response === null) { - return { ok: false, error: "Unexpected response from connection test" }; - } - const record = response as Record; - if (record.status === "success") { - return { ok: true }; - } - const result = - typeof record.result === "object" && record.result !== null ? (record.result as Record) : {}; - const resultError = typeof result.error === "string" ? result.error : undefined; - const recordMessage = typeof record.message === "string" ? record.message : undefined; - return { ok: false, error: resultError ?? recordMessage ?? "Unknown error" }; -}; +type TargetResult = { status: "pending" } | ModelGroupConnectionResult; const cleanErrorMessage = (error: string): string => { const mainError = error.split("stack trace:")[0].trim(); return mainError.replace(/^litellm\.(.*?)Error: /, ""); }; -const runTarget = async (accessToken: string, target: AutoRouterTestTarget): Promise => { - try { - const response = await testConnectionRequest(accessToken, { model: target.modelGroup }, {}, target.mode); - const normalized = normalizeTestConnectionResponse(response); - return normalized.ok - ? { status: "success" } - : { status: "error", error: cleanErrorMessage(normalized.error ?? "Unknown error") }; - } catch (error) { - return { status: "error", error: cleanErrorMessage(error instanceof Error ? error.message : String(error)) }; - } -}; - const AutoRouterConnectionTest: React.FC = ({ accessToken, targets, @@ -61,16 +29,22 @@ const AutoRouterConnectionTest: React.FC = ({ React.useEffect(() => { let cancelled = false; const run = async () => { - const settled = await Promise.all(targets.map((target) => runTarget(accessToken, target))); - if (cancelled) return; - setResults(settled); - if (onTestComplete) onTestComplete(); + 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 health checks + // 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) { @@ -80,7 +54,8 @@ const AutoRouterConnectionTest: React.FC = ({ return (
- Each configured tier routes to a saved model group. Test Connection runs a live health check against each one. + 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. {targets.map((target, index) => { const result = results[index] ?? { status: "pending" }; diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts index 0104b6bc9c5..b2a3cc10012 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts @@ -14,7 +14,14 @@ export interface BuildAutoRouterTestTargetsParams { embeddingModel: string | undefined; } -const TIER_ORDER: (keyof ComplexityTiers)[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; +// Keys drive iteration order; `satisfies Record` 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) as (keyof ComplexityTiers)[]; export const buildAutoRouterTestTargets = ({ tiers, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index da6bb079876..ae263727569 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2315,6 +2315,34 @@ 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. + */ +export const testModelGroupConnection = async ( + accessToken: string, + modelGroup: string, + mode: "chat" | "embedding", +): Promise => { + const path = mode === "embedding" ? "/v1/embeddings" : "/v1/chat/completions"; + const body = + mode === "embedding" + ? { model: modelGroup, input: "test from litellm" } + : { model: modelGroup, messages: [{ role: "user", content: "test from litellm" }], max_tokens: 1 }; + + 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 { From a80f85692b8feec7f25c1459f0da0ab51632bd10 Mon Sep 17 00:00:00 2001 From: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:43:36 -0700 Subject: [PATCH 3/8] fix(ui): drop max_tokens from the auto-router connection probe max_tokens=1 makes reasoning models (o1/o3/...) return a 400 "max_tokens reached" because reasoning tokens count against the cap, so a reachable reasoning tier showed a false failure in Test Connection. Live-verified: o3 400s with the cap and succeeds without it. Extract the request shape into a pure buildModelGroupTestRequest and cover it with a test asserting the chat body carries no max_tokens (or max_completion_tokens), so this regression is caught in unit tests instead of only against a live reasoning model. --- .../src/components/networking.test.ts | 16 +++++++++++++ .../src/components/networking.tsx | 24 ++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 43f85cc8674..b9d04a00f61 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -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" }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ae263727569..10d7f12604d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2324,17 +2324,29 @@ export type ModelGroupConnectionResult = { status: "success" } | { status: "erro * 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 } => + 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 => { - const path = mode === "embedding" ? "/v1/embeddings" : "/v1/chat/completions"; - const body = - mode === "embedding" - ? { model: modelGroup, input: "test from litellm" } - : { model: modelGroup, messages: [{ role: "user", content: "test from litellm" }], max_tokens: 1 }; - + const { path, body } = buildModelGroupTestRequest(modelGroup, mode); try { await apiClient.post(path, { accessToken, body }); return { status: "success" }; From d37ba79ebd9d5ec86a5dd395ae096236e04a31bc Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 11 Jul 2026 17:22:42 -0700 Subject: [PATCH 4/8] refactor(ui): convert projects page chart to shadcn/recharts (#32722) --- .../_components/ProjectDetailsPage.test.tsx | 67 +++++++++++++++++++ .../_components/ProjectDetailsPage.tsx | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx index 44acf90801e..6f8676360c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + expect(container.querySelector('[data-slot="chart"]')?.style.height).toBe("160px"); + + mockUseProjectDetails.mockReturnValue({ data: mockProject, isLoading: false }); + const { container: singleModelContainer } = renderWithProviders( + , + ); + expect(singleModelContainer.querySelector('[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(); + + 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: { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index 433629c0290..2bc8bc31129 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -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"; From f947ef14a2a7db3e2d81914977358636463bea71 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 11 Jul 2026 17:50:13 -0700 Subject: [PATCH 5/8] fix(xecguard): use StandardLoggingGuardrailInformation in logging hook (#32911) XecGuard's async_logging_hook wrote a bare dict to standard_logging_object["guardrail_information"] while the typed contract is Optional[List[StandardLoggingGuardrailInformation]]. Readers that iterated the field walked dict keys, raised on info.get, or silently dropped the entry from guardrail usage tracking and spend-log writes Construct the typed entry and append it to the existing list or create a new one, matching the shared helper pattern. Record the configured guardrail name instead of a hardcoded "xecguard" and pass the GuardrailEventHooks enum for guardrail_mode --- .../guardrail_hooks/xecguard/xecguard.py | 31 ++++++++++------- .../guardrail_hooks/test_xecguard.py | 33 +++++++++++++++++-- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py b/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py index 1b663c16d5b..f4a6f0aeb3b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py +++ b/litellm/proxy/guardrails/guardrail_hooks/xecguard/xecguard.py @@ -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( diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py index 60c595c64e5..f64967abbb0 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_xecguard.py @@ -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 From 8c5473f198b71213d9b703c68e6ab2115036acd8 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 11 Jul 2026 18:01:33 -0700 Subject: [PATCH 6/8] feat(ui): adopt openapi-react-query ($api) and convert useCustomers (#32949) * feat(ui): adopt openapi-react-query and convert useCustomers to $api Add openapi-react-query and expose $api = createQueryClient(fetchClient) alongside fetchClient. Rewrite useCustomers as $api.useQuery("get", "/customer/list", {}, { enabled, select }), which derives the query key from method + path (dropping the hand-written createQueryKeys entry and the manual key) and forwards the request signal for cancellation. The response type still flows from schema.d.ts as CustomerResponse[]. Tests assert the path, the admin/token enabled gate, and the empty-body select fallback. * test(ui): read the last render's options in useCustomers helper The lastCallOptions helper was named for the last call but read mock.calls[0]. Harmless while each test renders once, but it would silently assert against first-render options if a test ever re-renders. Read the final call instead. --- ui/litellm-dashboard/package-lock.json | 14 +++ ui/litellm-dashboard/package.json | 1 + .../hooks/customers/useCustomers.test.ts | 108 ++++++------------ .../hooks/customers/useCustomers.ts | 20 ++-- ui/litellm-dashboard/src/lib/http/api.ts | 9 ++ 5 files changed, 69 insertions(+), 83 deletions(-) diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 648503abb27..b8f6265441b 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 40c495cebf1..a14349c7239 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts index 1e614b709e2..b09ab4498f7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.test.ts @@ -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); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts index 25e2e3f5e90..ebea4618b7f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useCustomers.ts @@ -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 ?? [], + }, + ); }; diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index 2e8e1d36c5b..aa6c2d6c0fd 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -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({ 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); From b6dbda48f9e88ed0c3e1918488e0545946e9d627 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 11 Jul 2026 18:01:41 -0700 Subject: [PATCH 7/8] refactor(ui): colocate the mcp-servers view, keeping the shared mcp_tools surface (#32968) * refactor(ui): colocate the usage view, keeping the shared usage components Split for the usage (UsagePage) segment. Most of the folder is the usage page's own view, but four pieces are reused elsewhere and stay in @/components/UsagePage: TopKeyView (old-usage), KeyModelUsageView and value_formatters (activity_metrics), and the shared types (activity_metrics, chartUtils). The other 21 files move into usage/_components, preserving the folder structure. The external consumers import only the retained files, so they are untouched. The moved files' imports of the retained files become @/components/UsagePage paths, other escaping relative imports are absolutized, and lint suppressions are re-keyed for moved files only. No behavior change. * refactor(ui): colocate the mcp-servers view, keeping the shared mcp_tools surface --- ui/litellm-dashboard/eslint-suppressions.json | 40 +++++++++---------- .../_components}/DcrBridgeToggle.tsx | 2 +- .../_components}/EnvVarsSection.tsx | 0 .../_components}/MCPLogoSelector.test.tsx | 0 .../_components}/MCPLogoSelector.tsx | 0 .../_components}/MCPNetworkSettings.tsx | 4 +- .../MCPPermissionManagement.test.tsx | 0 .../_components}/MCPPermissionManagement.tsx | 2 +- .../_components}/MCPServerCard.test.tsx | 2 +- .../_components}/MCPServerCard.tsx | 2 +- .../MCPStandardsSettings.test.tsx | 2 +- .../_components}/MCPStandardsSettings.tsx | 2 +- .../_components}/MCPSubmissionsTab.tsx | 2 +- .../_components}/MCPToolsetsTab.tsx | 12 ++++-- .../_components}/OAuthFormFields.test.tsx | 0 .../_components}/OAuthFormFields.tsx | 2 +- .../_components}/OpenAPIFormSection.tsx | 2 +- .../_components}/OpenAPIQuickPicker.tsx | 2 +- .../PassthroughAuthorizeSection.test.tsx | 0 .../PassthroughAuthorizeSection.tsx | 2 +- .../_components}/StdioConfiguration.tsx | 0 .../TokenEndpointAuthMethodField.tsx | 0 .../_components}/TokenExchangeFormFields.tsx | 0 .../_components}/ToolTestPanel.test.tsx | 4 +- .../_components}/ToolTestPanel.tsx | 4 +- .../_components}/TruePassthroughWarning.tsx | 2 +- .../_components}/UserEnvVarsModal.tsx | 6 +-- .../_components}/create_mcp_server.test.tsx | 4 +- .../_components}/create_mcp_server.tsx | 6 +-- .../mcp-servers/_components}/index.tsx | 0 .../mcp-servers/_components}/mcp_connect.tsx | 4 +- .../mcp_connection_status.test.tsx | 0 .../_components}/mcp_connection_status.tsx | 0 .../_components}/mcp_discovery.tsx | 4 +- .../_components}/mcp_server_cost_config.tsx | 2 +- .../_components}/mcp_server_cost_display.tsx | 2 +- .../_components}/mcp_server_edit.test.tsx | 8 ++-- .../_components}/mcp_server_edit.tsx | 11 +++-- .../_components}/mcp_server_view.tsx | 2 +- .../_components}/mcp_servers.test.tsx | 6 +-- .../mcp-servers/_components}/mcp_servers.tsx | 24 ++++++----- .../mcp_tool_configuration.test.tsx | 0 .../_components}/mcp_tool_configuration.tsx | 2 +- .../_components}/mcp_tools.test.tsx | 4 +- .../mcp-servers/_components}/mcp_tools.tsx | 4 +- .../mcp-servers/_components}/testUtils.ts | 0 .../mcp-servers/_components}/utils.test.tsx | 0 .../mcp-servers/_components}/utils.tsx | 2 +- .../src/app/(dashboard)/mcp-servers/page.tsx | 2 +- .../tests/CreateKeyPage.expiredToken.test.tsx | 2 +- 50 files changed, 100 insertions(+), 83 deletions(-) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/DcrBridgeToggle.tsx (95%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/EnvVarsSection.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPLogoSelector.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPLogoSelector.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPNetworkSettings.tsx (97%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPPermissionManagement.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPPermissionManagement.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPServerCard.test.tsx (96%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPServerCard.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPStandardsSettings.test.tsx (97%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPStandardsSettings.tsx (96%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPSubmissionsTab.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/MCPToolsetsTab.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/OAuthFormFields.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/OAuthFormFields.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/OpenAPIFormSection.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/OpenAPIQuickPicker.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/PassthroughAuthorizeSection.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/PassthroughAuthorizeSection.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/StdioConfiguration.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/TokenEndpointAuthMethodField.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/TokenExchangeFormFields.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/ToolTestPanel.test.tsx (97%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/ToolTestPanel.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/TruePassthroughWarning.tsx (94%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/UserEnvVarsModal.tsx (95%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/create_mcp_server.test.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/create_mcp_server.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/index.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_connect.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_connection_status.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_connection_status.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_discovery.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_server_cost_config.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_server_cost_display.tsx (97%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_server_edit.test.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_server_edit.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_server_view.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_servers.test.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_servers.tsx (97%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_tool_configuration.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_tool_configuration.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_tools.test.tsx (98%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/mcp_tools.tsx (99%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/testUtils.ts (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/utils.test.tsx (100%) rename ui/litellm-dashboard/src/{components/mcp_tools => app/(dashboard)/mcp-servers/_components}/utils.tsx (97%) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 48b400536bd..eb8bdee0ea2 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1878,17 +1878,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 +1898,7 @@ "count": 5 } }, - "src/components/mcp_tools/MCPToolsetsTab.tsx": { + "src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx": { "no-nested-ternary": { "count": 1 }, @@ -1920,7 +1920,7 @@ "count": 1 } }, - "src/components/mcp_tools/OAuthFormFields.tsx": { + "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { "no-nested-ternary": { "count": 1 }, @@ -1928,12 +1928,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 +1944,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 +1960,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 +1968,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 +1976,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 +2005,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 +2021,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 +2558,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx similarity index 95% rename from ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx index f1b642293c0..49c182aa6be 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/DcrBridgeToggle.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx @@ -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 / diff --git a/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPNetworkSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPNetworkSettings.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx index 00323465731..7ab240389f3 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPNetworkSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx index 27cbdf2ea34..aae13d4b467 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx index 8463a23cc16..a0998b587fb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.test.tsx @@ -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", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx index c16ad87980e..4282cdba278 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPStandardsSettings.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPStandardsSettings.test.tsx index fd8d9f92c48..08f65a72537 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPStandardsSettings.test.tsx @@ -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 => ({ server_id: "s1", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPStandardsSettings.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPStandardsSettings.tsx index fb38e392631..13a9b7c171e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPStandardsSettings.tsx @@ -1,6 +1,6 @@ "use client"; -import { MCPServer } from "./types"; +import { MCPServer } from "@/components/mcp_tools/types"; export interface RequiredFieldDef { key: string; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx index 50c88a63582..de030420bc3 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index 5e7e99ee8ec..fa44694887b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx index 93afefe4358..f359bdee065 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx index d606b8ba1fc..073780b359f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx index a8208a09286..0aec81fdf4b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx index 0ed4ee555d1..dc10f0f1392 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/StdioConfiguration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/StdioConfiguration.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/TokenEndpointAuthMethodField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/TokenEndpointAuthMethodField.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/TokenExchangeFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/TokenExchangeFormFields.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx index 4bd351216b8..0613e84feed 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx index c15150eee58..8f042445c01 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ToolTestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx @@ -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 => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx similarity index 94% rename from ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx index b52d3f4c672..9c57cbd7d14 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx @@ -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 diff --git a/ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx similarity index 95% rename from ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx index 08a285cd56b..f76aef365f7 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx index 32fe439a316..6ce7f5c75ed 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx index b9a6f5d229d..70838c592c8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/index.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/index.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/index.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index 42c557f142f..7bdfd9c6b8f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx index 189b52dd6b2..6fcff011ba6 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_cost_config.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_server_cost_config.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx index 3f3986d5a2e..89c41693a4c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_cost_config.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_cost_display.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_server_cost_display.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx index e41a06b879e..f26f7ba2320 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_cost_display.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx index 5b8d0aac8c0..278bf6f7e13 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 6709eb02c65..3b184cc6f1e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx index 7ed00f74225..620f76a739a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index 446fdd8c22d..d61bc23c757 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx index f0d3e60b32e..0afb4bd9314 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx index 1ebc07eac86..60c4c264c3c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx index 2e8fa901f6d..8b0e6d62f66 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx @@ -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(), diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx index 928a2e3c6bb..428c10da284 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx @@ -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"; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/testUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/testUtils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/testUtils.ts diff --git a/ui/litellm-dashboard/src/components/mcp_tools/utils.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/mcp_tools/utils.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.test.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/utils.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx similarity index 97% rename from ui/litellm-dashboard/src/components/mcp_tools/utils.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx index 7d6e24fc480..4738e1e8fba 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/utils.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/utils.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/page.tsx index dfc7ca15896..462c48360cd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/page.tsx @@ -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() { diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index e871bbe8272..62346eff057 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -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") })); From 5fc1a3c671bf9f25e1410efd4ebb9c2617a9219a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 11 Jul 2026 18:16:18 -0700 Subject: [PATCH 8/8] refactor(ui): convert endpoint usage charts to shadcn/recharts (#32723) * refactor(ui): convert endpoint usage charts to shadcn/recharts Adds a LineChart wrapper to the shared charts kit, mirroring the BarChart/AreaChart composition with connectNulls and curveType props, and converts EndpointUsageBarChart and EndpointUsageLineChart from tremor to the shared wrappers. Both endpoint chart tests now assert on real recharts SVG output instead of tremor mocks. * refactor(ui): drop unused endpointData prop from EndpointUsageLineChart * fix(ui): point endpoint chart test type imports at the UsagePage types alias after colocation move --- ui/litellm-dashboard/eslint-suppressions.json | 103 +++++++-------- .../EndpointUsage/EndpointUsage.tsx | 2 +- .../components/EndpointUsageBarChart.test.tsx | 89 ++++++++----- .../components/EndpointUsageBarChart.tsx | 45 +++---- .../EndpointUsageLineChart.test.tsx | 119 ++++++++++++++---- .../components/EndpointUsageLineChart.tsx | 53 +++++--- .../src/components/shared/charts/index.ts | 1 + .../shared/charts/line_chart.test.tsx | 117 +++++++++++++++++ .../components/shared/charts/line_chart.tsx | 99 +++++++++++++++ 9 files changed, 473 insertions(+), 155 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/charts/line_chart.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/charts/line_chart.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index eb8bdee0ea2..3f6f4da25d0 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/EndpointUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/EndpointUsage.tsx index 64fdb13a0b9..51e451ca770 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/EndpointUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/EndpointUsage.tsx @@ -59,7 +59,7 @@ const EndpointUsage: React.FC = ({ userSpendData }) => {
- +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx index f200b8a6a18..a9e65b21f4b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.test.tsx @@ -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) =>
{categories.join(", ")}
, - CustomTooltip: () =>
Tooltip
, -})); +const endpointData = { + "/chat/completions": metric(120, 5), + "/embeddings": metric(40, 2), +}; describe("EndpointUsageBarChart", () => { - it("should render", () => { - render(); + it("renders the title and the header legend labels", () => { + renderWithProviders(); - 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument(); + expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(0); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.tsx index 2badbe30868..bf9868d77cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageBarChart.tsx @@ -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 = ({ 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 = ({ endpointD failed_requests: data.metrics.failed_requests, }, })); - }, [dataToUse]); + }, [endpointData]); const valueFormatter = (value: number) => value.toLocaleString(); return ( -
- Success vs Failed Requests by Endpoint - +
+ Success vs Failed Requests by Endpoint + +
+ + + -
- +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx index ec99825289c..33914e627dc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.test.tsx @@ -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): 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(); + it("renders the title", () => { + renderWithProviders(); - 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.getAllByText(/^\d,\d{3}$/).length).toBeGreaterThan(0); + }); + + it("draws smooth natural curves", () => { + const { container } = renderWithProviders(); + + 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(); + + expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument(); + expect(container.querySelectorAll("path.recharts-line-curve")).toHaveLength(0); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx index 9f838d6156a..483a30b1639 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageLineChart.tsx @@ -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; } // Transform daily data into chart format @@ -42,7 +42,7 @@ function transformDailyDataToChart(dailyData: DailyData[]): Array { 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 ( -
- Endpoint Usage Trends -
- value.toLocaleString()} - showLegend={true} - showGridLines={true} - yAxisWidth={60} - connectNulls={true} - curveType="natural" - /> + + Endpoint Usage Trends + + + value.toLocaleString()} + showLegend={true} + showGridLines={true} + yAxisWidth={60} + connectNulls={true} + curveType="natural" + /> +
); } diff --git a/ui/litellm-dashboard/src/components/shared/charts/index.ts b/ui/litellm-dashboard/src/components/shared/charts/index.ts index ba0a7544ddb..8383c767064 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/index.ts +++ b/ui/litellm-dashboard/src/components/shared/charts/index.ts @@ -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"; diff --git a/ui/litellm-dashboard/src/components/shared/charts/line_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/line_chart.test.tsx new file mode 100644 index 00000000000..9385dc49494 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/line_chart.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + `${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( + , + ); + expect(screen.getByText("/chat/completions")).toBeInTheDocument(); + expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull(); + + rerender( + , + ); + expect(screen.queryByText("/chat/completions")).not.toBeInTheDocument(); + }); + + it("draws straight segments by default and curved segments for curveType natural", () => { + const { container: linear } = render( + , + ); + const { container: natural } = render( + , + ); + + 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( + , + ); + const { container: bridged } = render( + , + ); + + 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(); + + 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( + , + ); + expect(container.querySelector("style")).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/line_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/line_chart.tsx new file mode 100644 index 00000000000..2dc8747118d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/line_chart.tsx @@ -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> = { + 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>({ + 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) { + const fills = categoryFills(categories.length, colors); + const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }])); + const TooltipContent = customTooltip ?? ValueTooltip; + + return ( + + + {showGridLines && } + + + {showTooltip && ( + ( + + )} + /> + )} + {showLegend && ( + } + /> + )} + {categories.map((category, i) => ( + + ))} + + + ); +}