diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 9a666e504f0..39a65a6a693 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -164,9 +164,6 @@ "src/app/(dashboard)/caching/_components/cache_health.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": { @@ -174,11 +171,6 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": { "no-restricted-imports": { "count": 1 @@ -200,11 +192,6 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": { "no-restricted-imports": { "count": 1 @@ -1446,9 +1433,6 @@ "no-nested-ternary": { "count": 10 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 } @@ -1460,9 +1444,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1486,9 +1467,6 @@ "src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/policies/_components/index.test.tsx": { @@ -1503,9 +1481,6 @@ "local/no-complex-jsx-arrow": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1520,9 +1495,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1531,9 +1503,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1541,9 +1510,6 @@ "src/app/(dashboard)/policies/_components/policy_templates.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { @@ -1561,9 +1527,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 }, @@ -2176,26 +2139,11 @@ "count": 1 } }, - "src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": { "no-restricted-imports": { "count": 1 @@ -3185,9 +3133,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/immutability": { "count": 1 } @@ -3196,9 +3141,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 1 } @@ -4061,6 +4003,11 @@ "count": 1 } }, + "src/components/ui/radio-group.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/scroll-area.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx index 13472a3d1df..e1d02e9352d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx @@ -40,14 +40,23 @@ const renderDashboard = () => , ); +const REQUESTS_CHART_TITLE = "Cache Hits vs API Requests"; +const TOKENS_CHART_TITLE = "Cached Completion Tokens vs Generated Completion Tokens"; + +// Anchored on each chart's own title rather than on a global card count, so +// adding cards elsewhere on the page cannot silently repoint these assertions. +const cardTitled = (title: string): HTMLElement => { + const card = screen.getByText(title).closest('[data-slot="card"]'); + expect(card).not.toBeNull(); + return card as HTMLElement; +}; + const findChartCards = async () => { - await screen.findByText("Cache Hits vs API Requests"); + await screen.findByText(REQUESTS_CHART_TITLE); await waitFor(() => { expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); }); - const cards = Array.from(document.querySelectorAll('[data-slot="card"]')); - expect(cards).toHaveLength(2); - return { requestsCard: cards[0] as HTMLElement, tokensCard: cards[1] as HTMLElement }; + return { requestsCard: cardTitled(REQUESTS_CHART_TITLE), tokensCard: cardTitled(TOKENS_CHART_TITLE) }; }; const barFills = (card: HTMLElement) => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 51c0b85cedb..95b73d1aacb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -1,25 +1,24 @@ -import { - Card, - Col, - DateRangePickerValue, - Grid, - Icon, - MultiSelect, - MultiSelectItem, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, -} from "@tremor/react"; +import { DateRangePickerValue } from "@tremor/react"; import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import UsageDatePicker from "@/components/shared/usage_date_picker"; import { BarChart } from "@/components/shared/charts"; -import { Card as ChartCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { RefreshIcon } from "@heroicons/react/outline"; +import { RefreshCw } from "lucide-react"; import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking"; // Import the new component @@ -258,31 +257,42 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole } }; + const statCards = [ + { label: "Cache Hit Ratio", value: `${cacheHitRatio}%` }, + { label: "Cache Hits", value: cachedResponses }, + { label: "Cached Completion Tokens", value: cachedTokens }, + ]; + return ( - - -
- Cache Analytics - Cache Health - Cache Settings - Coordination Redis -
+ +
+ + + Cache Analytics + + + Cache Health + + + Cache Settings + + + Coordination Redis + +
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - + {lastRefreshed &&

Last Refreshed: {lastRefreshed}

} +
- - - - - +
+ + + + +

Analytics for LiteLLM's{" "} = ({ accessToken, token, userRole {" "} (cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching Metrics" on the Usage page or individual requests in the Logs page. - - - - - {uniqueApiKeys.map((key) => ( - - {key} - - ))} - - - - - {uniqueModels.map((model) => ( - - {model} - - ))} - - - - { - setDateValue(value); - updateCachingData(value.from, value.to); - }} - /> - - +

-
- -

- Cache Hit Ratio -

-
-

- {cacheHitRatio}% -

-
-
- -

- Cache Hits -

-
-

- {cachedResponses} -

-
-
+
+ setSelectedApiKeys(keys)} + > + + + {(keys: string[]) => + keys.map((key) => ( + + {key} + + )) + } + + + + + No virtual keys found + + {(key: string) => ( + + {key} + + )} + + + - -

- Cached Completion Tokens -

-
-

- {cachedTokens} -

-
-
+ setSelectedModels(models)} + > + + + {(models: string[]) => + models.map((model) => ( + + {model} + + )) + } + + + + + No models found + + {(model: string) => ( + + {model} + + )} + + + + + { + setDateValue(value); + updateCachingData(value.from, value.to); + }} + />
- +
+ {statCards.map((stat) => ( + + +

{stat.label}

+
+

{stat.value}

+
+
+
+ ))} +
+ + Cache Hits vs API Requests @@ -387,9 +413,9 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole yAxisWidth={48} /> -
+ - + Cached Completion Tokens vs Generated Completion Tokens @@ -406,24 +432,27 @@ const CacheDashboard: React.FC = ({ accessToken, token, userRole yAxisWidth={48} /> - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx new file mode 100644 index 00000000000..de06ef07ed1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import { CacheHealthTab } from "./cache_health"; + +const healthyResponse = { + status: "healthy", + ping_response: true, + set_cache_response: "success", + litellm_cache_params: JSON.stringify({ type: "redis", supported_call_types: ["acompletion"] }), + health_check_cache_params: JSON.stringify({ + redis_version: "7.2.1", + namespace: "litellm-ns", + connection_kwargs: { host: "redis.internal", port: 6379 }, + }), +}; + +const errorPayload = { + message: "Connection refused", + traceback: "Traceback (most recent call last): ...", + litellm_cache_params: { type: "redis" }, + health_check_cache_params: {}, +}; + +const errorResponse = { error: { message: JSON.stringify(errorPayload) } }; + +const renderTab = (overrides: Partial> = {}) => + renderWithProviders( + , + ); + +describe("CacheHealthTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("offers a health check button and no results before one is run", () => { + renderTab(); + + expect(screen.getByRole("button", { name: "Run Health Check" })).toBeInTheDocument(); + expect(screen.queryByText(/Cache Status:/)).not.toBeInTheDocument(); + }); + + it("runs the health check when the button is clicked", async () => { + const runCachingHealthCheck = vi.fn(); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + expect(runCachingHealthCheck).toHaveBeenCalledTimes(1); + }); + + it("shows an in-flight label and disables the button while the check runs", async () => { + const runCachingHealthCheck = vi.fn(() => new Promise(() => {})); + const user = userEvent.setup(); + renderTab({ runCachingHealthCheck }); + + await user.click(screen.getByRole("button", { name: "Run Health Check" })); + + const button = await screen.findByRole("button", { name: "Running Health Check..." }); + expect(button).toBeDisabled(); + }); + + it("reports a healthy cache with its ping and set-cache results", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.getByText("Cache Details")).toBeInTheDocument(); + expect(screen.getByText("Ping Response")).toBeInTheDocument(); + expect(screen.getByText("Set Cache Response")).toBeInTheDocument(); + expect(screen.getByText("success")).toBeInTheDocument(); + }); + + it("shows the Redis detail rows when the cache type is redis", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Redis Details")).toBeInTheDocument(); + expect(screen.getByText("Redis Host")).toBeInTheDocument(); + expect(screen.getByText("redis.internal")).toBeInTheDocument(); + expect(screen.getByText("Redis Port")).toBeInTheDocument(); + expect(screen.getByText("Redis Version")).toBeInTheDocument(); + expect(screen.getByText("7.2.1")).toBeInTheDocument(); + expect(screen.getByText("Namespace")).toBeInTheDocument(); + expect(screen.getByText("litellm-ns")).toBeInTheDocument(); + }); + + it("omits the Redis detail rows for a non-redis cache type", async () => { + renderTab({ + healthCheckResponse: { + status: "healthy", + ping_response: true, + litellm_cache_params: JSON.stringify({ type: "local" }), + health_check_cache_params: JSON.stringify({}), + }, + }); + + expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument(); + expect(screen.queryByText("Redis Details")).not.toBeInTheDocument(); + }); + + it("surfaces the error message and traceback when the check fails", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Error Details")).toBeInTheDocument(); + expect(screen.getByText("Error Message")).toBeInTheDocument(); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + expect(screen.getByText("Traceback")).toBeInTheDocument(); + expect(screen.getByText("Cache Status: unhealthy")).toBeInTheDocument(); + }); + + it("still shows the cache details section when the check failed", async () => { + renderTab({ healthCheckResponse: errorResponse }); + + expect(await screen.findByText("Cache Details")).toBeInTheDocument(); + }); + + it("truncates a long value and expands it to the full value on click", async () => { + const longMessage = "M".repeat(120); + const user = userEvent.setup(); + renderTab({ + healthCheckResponse: { + error: { message: JSON.stringify({ message: longMessage, traceback: "short" }) }, + }, + }); + + await screen.findByText("Error Message"); + expect(screen.getByText(`${"M".repeat(50)}...`)).toBeInTheDocument(); + expect(screen.queryByText(longMessage)).not.toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "▶" })[0]); + + await waitFor(() => { + expect(screen.getByText(longMessage)).toBeInTheDocument(); + }); + }); + + it("offers both the summary and raw response views", async () => { + renderTab({ healthCheckResponse: healthyResponse }); + + expect(await screen.findByText("Summary")).toBeInTheDocument(); + expect(screen.getByText("Raw Response")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx index 887c12a4f3d..85649c1cf26 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Text, Button, TabGroup, TabList, Tab, TabPanel, TabPanels } from "@tremor/react"; -import { CheckCircleIcon, XCircleIcon, ClipboardCopyIcon } from "@heroicons/react/outline"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CheckCircle2, XCircle, ClipboardCopy } from "lucide-react"; import { ResponseTimeIndicator } from "./response_time_indicator"; // Helper function to deep-parse a JSON string if possible @@ -30,22 +31,26 @@ const TableClickableErrorField: React.FC<{ label: string; value: string | null | }; return ( - + -
-
-
-
{label}
-
-                {isExpanded ? safeValue : truncated}
-              
+
{label}
+
{isExpanded ? safeValue : truncated}
-
@@ -151,112 +156,114 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => { }; return ( -
- - - Summary - Raw Response - +
+ + + + Summary + + + Raw Response + + - - -
-
- {response?.status === "healthy" ? ( - - ) : ( - + +
+
+ {response?.status === "healthy" ? ( + + ) : ( + + )} +

+ Cache Status: {response?.status || "unhealthy"} +

+
+ + + + {/* Show error message if present */} + {errorDetails && ( + <> + + + + + + )} - - Cache Status: {response?.status || "unhealthy"} - - -
+ Error Details +
- - {/* Show error message if present */} - {errorDetails && ( - <> - - - - - - - )} + {/* Always show cache details, regardless of error state */} + + + + + + + - {/* Always show cache details, regardless of error state */} - - - - - - - + {/* Redis Details Section */} + {parsedLitellmParams?.type === "redis" && ( + <> + + + + + + + + + + )} + +
- Error Details -
+ Cache Details +
- Cache Details -
+ Redis Details +
+
+
- {/* Redis Details Section */} - {parsedLitellmParams?.type === "redis" && ( - <> - - - Redis Details - - - - - - - - - )} - - -
- - - -
-
-                {(() => {
-                  try {
-                    const data = {
-                      ...response,
-                      litellm_cache_params: parsedLitellmParams,
-                      health_check_cache_params: parsedRedisParams,
-                    };
-                    // First parse any string JSON values
-                    const prettyData = JSON.parse(
-                      JSON.stringify(data, (key, value) => {
-                        if (typeof value === "string") {
-                          try {
-                            return JSON.parse(value);
-                          } catch {
-                            return value;
-                          }
+        
+          
+
+              {(() => {
+                try {
+                  const data = {
+                    ...response,
+                    litellm_cache_params: parsedLitellmParams,
+                    health_check_cache_params: parsedRedisParams,
+                  };
+                  // First parse any string JSON values
+                  const prettyData = JSON.parse(
+                    JSON.stringify(data, (key, value) => {
+                      if (typeof value === "string") {
+                        try {
+                          return JSON.parse(value);
+                        } catch {
+                          return value;
                         }
-                        return value;
-                      }),
-                    );
-                    // Then stringify with proper formatting
-                    return JSON.stringify(prettyData, null, 2);
-                  } catch (e) {
-                    return "Error formatting JSON: " + (e as Error).message;
-                  }
-                })()}
-              
-
- - - + } + return value; + }), + ); + // Then stringify with proper formatting + return JSON.stringify(prettyData, null, 2); + } catch (e) { + return "Error formatting JSON: " + (e as Error).message; + } + })()} +
+
+ +
); }; @@ -282,11 +289,7 @@ export const CacheHealthTab: React.FC<{ return (
- diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx index fbca7ab5a97..40f553e6fde 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Select, SelectItem } from "@tremor/react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; interface RedisTypeSelectorProps { redisType: string; @@ -7,17 +7,30 @@ interface RedisTypeSelectorProps { onTypeChange: (type: string) => void; } +const REDIS_TYPE_LABELS: Readonly> = { + node: "Node (Single Instance)", + cluster: "Cluster", + sentinel: "Sentinel", + semantic: "Semantic", +}; + const RedisTypeSelector: React.FC = ({ redisType, redisTypeDescriptions, onTypeChange }) => { return (
- - value !== null && onTypeChange(value)}> + + {REDIS_TYPE_LABELS[redisType] ?? redisType} + + + {Object.entries(REDIS_TYPE_LABELS).map(([value, label]) => ( + + {label} + + ))} + -

+

{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx new file mode 100644 index 00000000000..76287e6e724 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector"; +import { COORDINATION_REDIS_TYPE_DESCRIPTIONS } from "./coordinationRedisFields"; + +describe("CoordinationRedisTypeSelector", () => { + it("labels the control and shows the current selection", () => { + renderWithProviders(); + + expect(screen.getByText("Redis Type")).toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Node (Single Instance)")).toBeInTheDocument(); + }); + + it("shows the description for the selected type", () => { + renderWithProviders(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.cluster)).toBeInTheDocument(); + }); + + it("switches the description when the selected type changes", () => { + const { rerender } = renderWithProviders(); + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.sentinel)).toBeInTheDocument(); + expect(screen.queryByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).not.toBeInTheDocument(); + }); + + it("reports the newly picked type to the caller", async () => { + const onTypeChange = vi.fn(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Cluster")); + + expect(onTypeChange).toHaveBeenCalledTimes(1); + expect(onTypeChange.mock.calls[0][0]).toBe("cluster"); + }); + + it("offers every supported coordination redis type", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByText("Cluster")).toBeInTheDocument(); + expect(screen.getByText("Sentinel")).toBeInTheDocument(); + expect(screen.getAllByText("Node (Single Instance)").length).toBeGreaterThan(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx index daab8505890..e6385e83649 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Select } from "antd"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { COORDINATION_REDIS_TYPES, COORDINATION_REDIS_TYPE_DESCRIPTIONS, @@ -12,21 +12,24 @@ interface CoordinationRedisTypeSelectorProps { onTypeChange: (type: CoordinationRedisType) => void; } -const OPTIONS = COORDINATION_REDIS_TYPES.map((type) => ({ value: type, label: COORDINATION_REDIS_TYPE_LABELS[type] })); - const CoordinationRedisTypeSelector: React.FC = ({ redisType, onTypeChange }) => (
-
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx new file mode 100644 index 00000000000..4db75fb1ced --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.test.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/../tests/test-utils"; +import AiSuggestionModal from "./ai_suggestion_modal"; + +const { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplateStream } = vi.hoisted(() => ({ + suggestPolicyTemplates: vi.fn(), + modelHubCall: vi.fn(), + testPolicyTemplate: vi.fn(), + enrichPolicyTemplateStream: vi.fn(), +})); + +vi.mock("@/components/networking", () => ({ + suggestPolicyTemplates, + modelHubCall, + testPolicyTemplate, + enrichPolicyTemplateStream, +})); + +const allTemplates = [ + { id: "tpl-pii", title: "PII Protection", description: "Masks PII", guardrails: ["pii-masker"], complexity: "Low" }, + { + id: "tpl-inj", + title: "Injection Defense", + description: "Blocks prompt injection", + guardrails: ["prompt-injection"], + complexity: "Medium", + }, +]; + +const suggestResponse = { + selected_templates: [ + { template_id: "tpl-pii", reason: "Your examples contain SSNs" }, + { template_id: "tpl-inj", reason: "Your examples contain instruction overrides" }, + ], + explanation: "These two cover both risks you described", +}; + +const defaultProps = { + visible: true, + onSelectTemplates: vi.fn(), + onCancel: vi.fn(), + accessToken: "sk-test", + allTemplates, +}; + +const renderModal = (props: Partial = {}) => + renderWithProviders(); + +const pickModel = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); + const options = await screen.findAllByText("gpt-5.1"); + await user.click(options[options.length - 1]); +}; + +describe("AiSuggestionModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }, { model_group: "claude-opus-4-8" }] }); + suggestPolicyTemplates.mockResolvedValue(suggestResponse); + }); + + it("renders nothing while closed", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("AI Policy Suggestion")).not.toBeInTheDocument(); + }); + + it("renders the header and prompt copy when opened", async () => { + renderModal(); + + expect(await screen.findByText("AI Policy Suggestion")).toBeInTheDocument(); + expect( + screen.getByText("Describe what you want to block and we'll suggest the best policy templates"), + ).toBeInTheDocument(); + }); + + it("loads the model list when opened", async () => { + renderModal(); + + await waitFor(() => { + expect(modelHubCall).toHaveBeenCalledWith("sk-test"); + }); + }); + + it("keeps Suggest disabled until there is both input and a model", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled(); + + await pickModel(user); + expect(screen.getByRole("button", { name: "Suggest Policies" })).not.toBeDisabled(); + }); + + it("sends the examples, description and model to the suggest API", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Ignore all previous instructions/), "my ssn is 123"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + await waitFor(() => { + expect(suggestPolicyTemplates).toHaveBeenCalledWith("sk-test", ["my ssn is 123"], "block PII", "gpt-5.1"); + }); + }); + + it("adds attack example fields up to the maximum of four", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + const countExamples = () => screen.getAllByRole("textbox").length; + const initial = countExamples(); + + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + expect(countExamples()).toBe(initial + 1); + + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + await user.click(screen.getByRole("button", { name: "+ Add another example" })); + expect(countExamples()).toBe(initial + 3); + expect(screen.queryByRole("button", { name: "+ Add another example" })).not.toBeInTheDocument(); + }); + + it("shows each suggested template with the reason it was picked", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByText("PII Protection")).toBeInTheDocument(); + expect(screen.getByText("Injection Defense")).toBeInTheDocument(); + expect(screen.getByText("Your examples contain SSNs")).toBeInTheDocument(); + expect(screen.getByText("These two cover both risks you described")).toBeInTheDocument(); + expect(screen.getByText("2 templates matched your requirements")).toBeInTheDocument(); + }); + + it("preselects every suggestion and reflects the count on the confirm button", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByRole("button", { name: "Use 2 Selected Templates" })).toBeInTheDocument(); + }); + + it("deselecting a suggestion lowers the confirm count", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + await user.click(await screen.findByText("PII Protection")); + + expect(await screen.findByRole("button", { name: "Use 1 Selected Template" })).toBeInTheDocument(); + }); + + it("hands the selected templates back to the caller", async () => { + const onSelectTemplates = vi.fn(); + const user = userEvent.setup(); + renderModal({ onSelectTemplates }); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + await user.click(await screen.findByRole("button", { name: "Use 2 Selected Templates" })); + + expect(onSelectTemplates).toHaveBeenCalledTimes(1); + expect(onSelectTemplates.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(["tpl-pii", "tpl-inj"]); + }); + + it("returns to the input phase from the results phase", async () => { + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + await user.click(await screen.findByRole("button", { name: "Back" })); + + expect( + await screen.findByText("Describe what you want to block and we'll suggest the best policy templates"), + ).toBeInTheDocument(); + }); + + it("reports an empty result set instead of failing silently", async () => { + suggestPolicyTemplates.mockRejectedValue(new Error("boom")); + const user = userEvent.setup(); + renderModal(); + + await screen.findByText("AI Policy Suggestion"); + await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII"); + await pickModel(user); + await user.click(screen.getByRole("button", { name: "Suggest Policies" })); + + expect(await screen.findByText("No matching templates found")).toBeInTheDocument(); + expect(screen.getByText("Try adjusting your examples or description.")).toBeInTheDocument(); + }); + + it("cancels back to the caller", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderModal({ onCancel }); + + await screen.findByText("AI Policy Suggestion"); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx index 34204624702..cbf24b425f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd"; -import { Button, Card } from "@tremor/react"; -import { - CheckCircleOutlined, - CloseCircleOutlined, - InfoCircleOutlined, - DownOutlined, - RightOutlined, -} from "@ant-design/icons"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { CheckCircle2, ChevronDown, ChevronRight, Info, XCircle } from "lucide-react"; import { suggestPolicyTemplates, modelHubCall, @@ -15,9 +16,6 @@ import { enrichPolicyTemplateStream, } from "@/components/networking"; -const { TextArea } = Input; -const { Text } = Typography; - interface SuggestedTemplate { template_id: string; reason: string; @@ -427,7 +425,7 @@ const AiSuggestionModal: React.FC = ({
toggleTemplate(suggestion.template_id)} + onCheckedChange={() => toggleTemplate(suggestion.template_id)} className="mt-0.5" />
@@ -447,20 +445,25 @@ const AiSuggestionModal: React.FC = ({ )} {template.estimated_latency_ms != null && ( - - + + } > +{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency - + + Estimated latency overhead added to each request )}
-

{template.description}

+

{template.description}

{template.guardrails && template.guardrails.slice(0, 4).map((g: string) => ( @@ -476,7 +479,7 @@ const AiSuggestionModal: React.FC = ({ )}
- +

{suggestion.reason}

@@ -490,7 +493,7 @@ const AiSuggestionModal: React.FC = ({ {explanation && (
- + Why these templates @@ -555,7 +558,7 @@ const AiSuggestionModal: React.FC = ({ >
{hasEnrichedGuardrails ? ( - + ) : ( = ({
setEnrichBrandName(e.target.value)} - onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()} + onKeyDown={(e) => { + if (e.key === "Enter" && enrichBrandName.trim() && !isEnriching) handleEnrichCompetitors(); + }} className="flex-1" /> -
{isEnriching && enrichStatusMessage && ( -
- +
+ {enrichStatusMessage}
)} {hasEnrichedGuardrails && (
- + Competitor names loaded for {enrichBrandName}
)} @@ -631,33 +630,29 @@ const AiSuggestionModal: React.FC = ({
- - + + } /> + Press Enter to submit. Use Shift+Enter for new line.
- Characters: {testInputText.length} + Characters: {testInputText.length}
-