diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 64615c50cc1..0555884026c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -191,11 +191,6 @@ "count": 1 } }, - "src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": { "local/filename-pascal-case": { "count": 1 @@ -204,9 +199,6 @@ "src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.integration.test.tsx new file mode 100644 index 00000000000..785976b8df4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.integration.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import PromptCompressionTab from "./PromptCompressionTab"; + +const createGuardrailCall = vi.fn(); +const getGuardrailsList = vi.fn(); + +vi.mock("@/components/networking", () => ({ + createGuardrailCall: (...args: unknown[]) => createGuardrailCall(...args), + getGuardrailsList: (...args: unknown[]) => getGuardrailsList(...args), +})); + +const submittedPayload = (): Record => { + expect(createGuardrailCall).toHaveBeenCalledTimes(1); + return createGuardrailCall.mock.calls[0][1] as Record; +}; + +describe("PromptCompressionTab submit payload", () => { + beforeEach(() => { + createGuardrailCall.mockClear().mockResolvedValue({}); + getGuardrailsList.mockClear().mockResolvedValue({ guardrails: [] }); + }); + + it("sends the trimmed name and api base with default_on true", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Name"), " headroom-compression "); + await user.type(screen.getByLabelText("Headroom API base"), " https://headroom.example.com "); + await user.click(screen.getByRole("button", { name: "Add guardrail" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + guardrail_name: "headroom-compression", + litellm_params: { + guardrail: "headroom", + mode: "pre_call", + api_base: "https://headroom.example.com", + default_on: true, + }, + }), + ); + expect(createGuardrailCall.mock.calls[0][0]).toBe("test-token"); + }); + + it("sends default_on false once the apply-to-all switch is turned off", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Name"), "headroom-optin"); + await user.type(screen.getByLabelText("Headroom API base"), "https://headroom.example.com"); + await user.click(screen.getByLabelText("Apply to all requests")); + await user.click(screen.getByRole("button", { name: "Add guardrail" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + guardrail_name: "headroom-optin", + litellm_params: { + guardrail: "headroom", + mode: "pre_call", + api_base: "https://headroom.example.com", + default_on: false, + }, + }), + ); + }); + + it("blocks submission and shows both required messages when the form is empty", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add guardrail" })); + + expect(await screen.findByText("Name is required")).toBeInTheDocument(); + expect(screen.getByText("API base is required")).toBeInTheDocument(); + expect(createGuardrailCall).not.toHaveBeenCalled(); + }); + + it("submits when Enter is pressed inside a text field", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Name"), "headroom-compression"); + await user.type(screen.getByLabelText("Headroom API base"), "https://headroom.example.com{Enter}"); + + await vi.waitFor(() => expect(createGuardrailCall).toHaveBeenCalledTimes(1)); + }); + + it("clears the name and restores the default switch state after a successful create", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Name"), "headroom-compression"); + await user.type(screen.getByLabelText("Headroom API base"), "https://headroom.example.com"); + await user.click(screen.getByLabelText("Apply to all requests")); + await user.click(screen.getByRole("button", { name: "Add guardrail" })); + + await vi.waitFor(() => expect(screen.getByLabelText("Name")).toHaveValue("")); + expect(screen.getByLabelText("Headroom API base")).toHaveValue(""); + expect(screen.getByLabelText("Apply to all requests")).toBeChecked(); + expect(getGuardrailsList).toHaveBeenCalledTimes(2); + }); + + it("keeps the always-on and opt-in badges for the guardrails it lists", async () => { + getGuardrailsList.mockResolvedValue({ + guardrails: [ + { + guardrail_id: "g-1", + guardrail_name: "always-on-one", + litellm_params: { guardrail: "headroom", api_base: "https://a.example.com", default_on: true }, + }, + { + guardrail_id: "g-2", + guardrail_name: "opt-in-one", + litellm_params: { guardrail: "headroom", api_base: "https://b.example.com", default_on: false }, + }, + ], + }); + render(); + + expect(await screen.findByText("Always on")).toBeInTheDocument(); + expect(screen.getByText("Opt-in")).toBeInTheDocument(); + expect(screen.getByText("https://a.example.com")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx index 3aab8c59259..c9f662ca985 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx @@ -1,10 +1,19 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; -import { Button, Form, Input, Switch } from "antd"; +import { CircleHelp } from "lucide-react"; +import { z } from "zod/v4"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { createGuardrailCall, getGuardrailsList } from "@/components/networking"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { useZodForm } from "@/lib/forms/useZodForm"; import { toast } from "@/lib/toast"; import { buildCompressionGuardrailPayload, @@ -17,14 +26,32 @@ interface PromptCompressionTabProps { accessToken: string | null; } -interface CompressionFormValues { - name: string; - apiBase: string; - defaultOn: boolean; -} +const compressionSchema = z.object({ + name: z.string().min(1, "Name is required"), + apiBase: z.string().min(1, "API base is required"), + defaultOn: z.boolean(), +}); + +type CompressionFormValues = z.infer; + +const EMPTY_VALUES: CompressionFormValues = { + name: "", + apiBase: "", + defaultOn: true, +}; + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); const PromptCompressionTab: React.FC = ({ accessToken }) => { - const [form] = Form.useForm(); + const form = useZodForm(compressionSchema, { defaultValues: EMPTY_VALUES }); const [guardrails, setGuardrails] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -61,7 +88,7 @@ const PromptCompressionTab: React.FC = ({ accessToken }), ); toast.success("Compression guardrail created"); - form.resetFields(); + form.reset(EMPTY_VALUES); await loadGuardrails(); } catch (error) { console.error("Failed to create compression guardrail:", error); @@ -85,7 +112,7 @@ const PromptCompressionTab: React.FC = ({ accessToken href="https://docs.litellm.ai/docs/proxy/headroom" target="_blank" rel="noopener noreferrer" - className="text-blue-600 underline" + className="text-blue-600 underline dark:text-blue-400" > Headroom setup docs @@ -97,7 +124,7 @@ const PromptCompressionTab: React.FC = ({ accessToken

)} {!isLoading && guardrails.length > 0 && ( -
    +
      {guardrails.map((guardrail) => (
    • @@ -107,8 +134,8 @@ const PromptCompressionTab: React.FC = ({ accessToken {guardrail.litellm_params?.default_on ? "Always on" : "Opt-in"} @@ -125,48 +152,57 @@ const PromptCompressionTab: React.FC = ({ accessToken Add Headroom compression guardrail -
      - - - - - - - - - -
      -

      - Applying compression to all requests is available to all users. Enabling it selectively per key or team - is a LiteLLM Enterprise feature. Get a trial key{" "} - + + + + {({ ref, ...field }) => } + + - here - -

      -
      -
      - -
      -
      + {({ ref, ...field }) => } + + + {({ value, onChange, ref: _ref, ...field }) => ( + } + checked={value} + onCheckedChange={onChange} + /> + )} + + +
      +

      + Applying compression to all requests is available to all users. Enabling it selectively per key or + team is a LiteLLM Enterprise feature. Get a trial key{" "} + + here + +

      +
      +
      + +
      + +
      diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.integration.test.tsx new file mode 100644 index 00000000000..83875cfc676 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.integration.test.tsx @@ -0,0 +1,68 @@ +// eslint-disable-next-line no-restricted-imports -- the parent cost_tracking_settings still owns this antd Form, and the point of this test is that AddProviderForm registers nothing in it +import { Form } from "antd"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders } from "../../../../../tests/test-utils"; +import AddProviderForm from "./add_provider_form"; +import { DiscountConfig } from "./types"; + +const onAddProvider = vi.fn(); +const onParentFinish = vi.fn(); +const readParentStore = vi.fn(); + +const ParentOwnedForm = () => { + const [form] = Form.useForm(); + return ( +
      + { + readParentStore(form.getFieldsValue()); + onAddProvider(); + }} + /> + + ); +}; + +describe("AddProviderForm inside the antd form its parent owns", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("registers no field in the parent FormInstance, so the parent's resetFields is a no-op", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /add provider discount/i })); + + expect(readParentStore).toHaveBeenCalledTimes(1); + expect(readParentStore.mock.calls[0][0]).toEqual({}); + }); + + it("drives both the onAddProvider prop and the parent form submit from one click", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /add provider discount/i })); + + expect(onAddProvider).toHaveBeenCalledTimes(1); + expect(onParentFinish).toHaveBeenCalledTimes(1); + }); + + it("treats Enter in the discount field exactly like a click on the add button", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByPlaceholderText("5"), "{Enter}"); + + await vi.waitFor(() => expect(onParentFinish).toHaveBeenCalledTimes(1)); + expect(onAddProvider).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx index 0fdaed8814b..4f4edf6ff30 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx @@ -1,11 +1,28 @@ import React from "react"; -import { TextInput, Button } from "@tremor/react"; -import { Select as AntdSelect, Form, Tooltip } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { Providers, provider_map } from "@/components/provider_info_helpers"; +import { CircleHelp } from "lucide-react"; + import { Logo } from "@/components/molecules/logo/Logo"; +import { Providers, provider_map } from "@/components/provider_info_helpers"; +import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { InputGroupAddon } from "@/components/ui/input-group"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { DiscountConfig } from "./types"; +interface ProviderOption { + value: string; + label: string; +} + interface AddProviderFormProps { discountConfig: DiscountConfig; selectedProvider: string | undefined; @@ -15,6 +32,35 @@ interface AddProviderFormProps { onAddProvider: () => void; } +const PROVIDER_FIELD_ID = "add-provider-discount-provider"; +const DISCOUNT_FIELD_ID = "add-provider-discount-percentage"; + +const providerOptionsWithoutDiscount = (discountConfig: DiscountConfig): ProviderOption[] => + Object.entries(Providers) + .filter(([providerEnum]) => { + const providerValue = provider_map[providerEnum as keyof typeof provider_map]; + return !(providerValue && discountConfig[providerValue]); + }) + .map(([value, label]) => ({ value, label })); + +const selectedProviderOption = (selectedProvider: string | undefined): ProviderOption | null => { + if (!selectedProvider) { + return null; + } + const label = Providers[selectedProvider as keyof typeof Providers]; + return label ? { value: selectedProvider, label } : null; +}; + +const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + const AddProviderForm: React.FC = ({ discountConfig, selectedProvider, @@ -23,79 +69,71 @@ const AddProviderForm: React.FC = ({ onDiscountChange, onAddProvider, }) => { + const options = providerOptionsWithoutDiscount(discountConfig); + const selectedOption = selectedProviderOption(selectedProvider); + return ( -
      - - Provider - - - - - } - rules={[{ required: true, message: "Please select a provider" }]} - > - - String(option?.label ?? "") - .toLowerCase() - .includes(input.toLowerCase()) - } - > - {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => { - const providerValue = provider_map[providerEnum as keyof typeof provider_map]; - // Only show providers that don't already have a discount configured - if (providerValue && discountConfig[providerValue]) { - return null; - } - return ( - -
      - - {providerDisplayName} -
      -
      - ); - })} -
      -
      + +
      + + + + {labelWithHint("Provider", "Select the LLM provider you want to configure a discount for")} + + onProviderChange(option?.value)} + itemToStringLabel={(option: ProviderOption) => option.label} + isItemEqualToValue={(option: ProviderOption, value: ProviderOption) => option.value === value.value} + > + + {selectedOption && ( + + + + )} + + + No providers found + + {(option: ProviderOption) => ( + + + + {option.label} + + + )} + + + + - - Discount Percentage - - - - - } - rules={[{ required: true, message: "Please enter a discount percentage" }]} - > -
      - - % + + + {labelWithHint("Discount Percentage", "Enter a percentage value (e.g., 5 for 5% discount)")} + +
      + onDiscountChange(event.target.value)} + className="flex-1 rounded-lg" + /> + % +
      +
      + + +
      +
      - - -
      -
      -
      + ); }; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.integration.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.integration.test.tsx new file mode 100644 index 00000000000..4df208bece2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.integration.test.tsx @@ -0,0 +1,95 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CloudZeroCreateModal from "./CloudZeroCreateModal"; + +const mutate = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: () => ({ accessToken: "test-token" }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate", () => ({ + useCloudZeroCreate: () => ({ mutate, isPending: false }), +})); + +const renderModal = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +}; + +const submittedPayload = (): Record => { + expect(mutate).toHaveBeenCalledTimes(1); + return mutate.mock.calls[0][0] as Record; +}; + +describe("CloudZeroCreateModal submit payload", () => { + beforeEach(() => { + mutate.mockClear(); + }); + + it("sends every filled field verbatim", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("CloudZero API Key"), "cz-secret-key"); + await user.type(screen.getByLabelText("Connection ID"), "conn-42"); + await user.type(screen.getByLabelText("Timezone"), "America/New_York"); + await user.click(screen.getByRole("button", { name: "Create" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + connection_id: "conn-42", + timezone: "America/New_York", + api_key: "cz-secret-key", + }), + ); + }); + + it("defaults an untouched timezone to UTC", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("CloudZero API Key"), "cz-secret-key"); + await user.type(screen.getByLabelText("Connection ID"), "conn-42"); + await user.click(screen.getByRole("button", { name: "Create" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + connection_id: "conn-42", + timezone: "UTC", + api_key: "cz-secret-key", + }), + ); + }); + + it("blocks submission and shows both required messages when the form is empty", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(await screen.findByText("Please enter your CloudZero API key")).toBeInTheDocument(); + expect(screen.getByText("Please enter your CloudZero connection ID")).toBeInTheDocument(); + expect(mutate).not.toHaveBeenCalled(); + }); + + it("does not submit when Enter is pressed inside a text field", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("CloudZero API Key"), "cz-secret-key"); + await user.type(screen.getByLabelText("Connection ID"), "conn-42{Enter}"); + + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx index bd08bf028ad..4b1bffb5f52 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx @@ -1,8 +1,18 @@ -import { Form, Modal, Input } from "antd"; -import { toast } from "@/lib/toast"; +import { Modal } from "antd"; import { useEffect } from "react"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { z } from "zod/v4"; + import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Input } from "@/components/ui/input"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { toast } from "@/lib/toast"; + +import { CloudZeroApiKeyInput, labelWithHint } from "./CloudZeroFormControls"; +import { buildCloudZeroPayload, EMPTY_CLOUDZERO_FORM_VALUES, type CloudZeroFormValues } from "./cloudZeroPayload"; interface CloudZeroCreationModalProps { open: boolean; @@ -10,50 +20,38 @@ interface CloudZeroCreationModalProps { onCancel: () => void; } +const createSchema = z.object({ + api_key: z.string().min(1, "Please enter your CloudZero API key"), + connection_id: z.string().min(1, "Please enter your CloudZero connection ID"), + timezone: z.string(), +}); + export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZeroCreationModalProps) { const { accessToken } = useAuthorized(); - const [form] = Form.useForm(); + const form = useZodForm(createSchema, { defaultValues: EMPTY_CLOUDZERO_FORM_VALUES }); const createMutation = useCloudZeroCreate(accessToken || ""); useEffect(() => { if (open) { - form.resetFields(); + form.reset(EMPTY_CLOUDZERO_FORM_VALUES); } }, [open, form]); - const handleSubmit = async () => { - try { - const values = await form.validateFields(); - createMutation.mutate( - { - connection_id: values.connection_id, - timezone: values.timezone || "UTC", - ...(values.api_key && { api_key: values.api_key }), - }, - { - onSuccess: () => { - toast.success("CloudZero integration created successfully"); - form.resetFields(); - onOk(); - }, - onError: (error: any) => { - if (error?.errorFields) { - return; - } - toast.error(error?.message || "Failed to create CloudZero integration"); - }, - }, - ); - } catch (error: any) { - if (error?.errorFields) { - return; - } - toast.error(error?.message || "Failed to create CloudZero integration"); - } + const handleSubmit = (values: CloudZeroFormValues) => { + createMutation.mutate(buildCloudZeroPayload(values), { + onSuccess: () => { + toast.success("CloudZero integration created successfully"); + form.reset(EMPTY_CLOUDZERO_FORM_VALUES); + onOk(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create CloudZero integration"); + }, + }); }; const handleCancel = () => { - form.resetFields(); + form.reset(EMPTY_CLOUDZERO_FORM_VALUES); onCancel(); }; @@ -61,7 +59,7 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe void form.handleSubmit(handleSubmit)()} onCancel={handleCancel} confirmLoading={createMutation.isPending} okText={createMutation.isPending ? "Creating..." : "Create"} @@ -73,29 +71,27 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe disabled: createMutation.isPending, }} > -
      - - - - - - - - - -
      + +
      event.preventDefault()} noValidate> + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => } + + + {({ ref, ...field }) => } + + +
      +
      ); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroFormControls.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroFormControls.tsx new file mode 100644 index 00000000000..7f11f76cfe0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroFormControls.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { CircleHelp, Eye, EyeOff } from "lucide-react"; +import * as React from "react"; + +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +export const labelWithHint = (label: string, hint: string): React.ReactNode => ( + <> + {label} + + } /> + {hint} + + +); + +export const CloudZeroApiKeyInput = React.forwardRef< + HTMLInputElement, + Omit, "type"> +>(({ className, ...props }, ref) => { + const [revealed, setRevealed] = React.useState(false); + + return ( + + + + setRevealed((current) => !current)} + > + {revealed ? : } + + + + ); +}); +CloudZeroApiKeyInput.displayName = "CloudZeroApiKeyInput"; diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.integration.test.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.integration.test.tsx new file mode 100644 index 00000000000..a0582657281 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.integration.test.tsx @@ -0,0 +1,139 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CloudZeroUpdateModal from "./CloudZeroUpdateModal"; +import { CloudZeroSettings } from "./types"; + +const mutate = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + __esModule: true, + default: () => ({ accessToken: "test-token" }), +})); + +vi.mock("@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings", () => ({ + useCloudZeroUpdateSettings: () => ({ mutate, isPending: false }), +})); + +const STORED_SETTINGS: CloudZeroSettings = { + connection_id: "stored-connection-id", + api_key_masked: "sk-cz-****last4", + timezone: "Europe/Berlin", + status: "Active", +}; + +const renderModal = (settings: CloudZeroSettings = STORED_SETTINGS) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +}; + +const submittedPayload = (): Record => { + expect(mutate).toHaveBeenCalledTimes(1); + return mutate.mock.calls[0][0] as Record; +}; + +describe("CloudZeroUpdateModal submit payload", () => { + beforeEach(() => { + mutate.mockClear(); + }); + + it("seeds the stored connection id and timezone but never the stored key", () => { + renderModal(); + + expect(screen.getByLabelText("Connection ID")).toHaveValue("stored-connection-id"); + expect(screen.getByLabelText("Timezone")).toHaveValue("Europe/Berlin"); + expect(screen.getByLabelText("CloudZero API Key")).toHaveValue(""); + }); + + it("omits api_key entirely when the key field is left untouched, preserving the stored secret", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(screen.getByRole("button", { name: "Update" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + connection_id: "stored-connection-id", + timezone: "Europe/Berlin", + }), + ); + expect("api_key" in submittedPayload()).toBe(false); + }); + + it("sends api_key only once the user types a replacement key", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("CloudZero API Key"), "cz-rotated-key"); + await user.click(screen.getByRole("button", { name: "Update" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + connection_id: "stored-connection-id", + timezone: "Europe/Berlin", + api_key: "cz-rotated-key", + }), + ); + }); + + it("falls back to UTC when the stored timezone is cleared", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.clear(screen.getByLabelText("Timezone")); + await user.click(screen.getByRole("button", { name: "Update" })); + + await vi.waitFor(() => + expect(submittedPayload()).toEqual({ + connection_id: "stored-connection-id", + timezone: "UTC", + }), + ); + }); + + it("falls back to UTC when the stored settings carry no timezone", () => { + renderModal({ ...STORED_SETTINGS, timezone: null }); + + expect(screen.getByLabelText("Timezone")).toHaveValue("UTC"); + }); + + it("reports a null connection id from the server as the required field, not as a type error", async () => { + const user = userEvent.setup(); + renderModal({ ...STORED_SETTINGS, connection_id: null, timezone: null }); + + await user.click(screen.getByRole("button", { name: "Update" })); + + expect(await screen.findByText("Please enter your CloudZero connection ID")).toBeInTheDocument(); + expect(screen.queryByText(/expected string, received null/i)).not.toBeInTheDocument(); + expect(mutate).not.toHaveBeenCalled(); + }); + + it("blocks submission when the connection id is cleared, and leaves the key optional", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.clear(screen.getByLabelText("Connection ID")); + await user.click(screen.getByRole("button", { name: "Update" })); + + expect(await screen.findByText("Please enter your CloudZero connection ID")).toBeInTheDocument(); + expect(screen.queryByText("Please enter your CloudZero API key")).not.toBeInTheDocument(); + expect(mutate).not.toHaveBeenCalled(); + }); + + it("does not submit when Enter is pressed inside a text field", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(screen.getByLabelText("CloudZero API Key"), "cz-rotated-key{Enter}"); + + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx index 44049714c6c..74c16624887 100644 --- a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx @@ -1,8 +1,18 @@ +import { Modal } from "antd"; +import { useEffect } from "react"; +import { z } from "zod/v4"; + import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { Form, Input, Modal } from "antd"; +import { FieldGroup } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { Input } from "@/components/ui/input"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useZodForm } from "@/lib/forms/useZodForm"; import { toast } from "@/lib/toast"; -import { useEffect } from "react"; + +import { CloudZeroApiKeyInput, labelWithHint } from "./CloudZeroFormControls"; +import { buildCloudZeroPayload, EMPTY_CLOUDZERO_FORM_VALUES, type CloudZeroFormValues } from "./cloudZeroPayload"; import { CloudZeroSettings } from "./types"; interface CloudZeroUpdateModalProps { @@ -12,56 +22,44 @@ interface CloudZeroUpdateModalProps { settings: CloudZeroSettings; } +const updateSchema = z.object({ + api_key: z.string(), + connection_id: z.string().min(1, "Please enter your CloudZero connection ID"), + timezone: z.string(), +}); + export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: CloudZeroUpdateModalProps) { const { accessToken } = useAuthorized(); - const [form] = Form.useForm(); + const form = useZodForm(updateSchema, { defaultValues: EMPTY_CLOUDZERO_FORM_VALUES }); const updateMutation = useCloudZeroUpdateSettings(accessToken || ""); useEffect(() => { if (open && settings) { - form.setFieldsValue({ - connection_id: settings.connection_id, + form.reset({ + connection_id: settings.connection_id ?? "", timezone: settings.timezone || "UTC", api_key: "", }); } else if (open) { - form.resetFields(); + form.reset(EMPTY_CLOUDZERO_FORM_VALUES); } }, [open, settings, form]); - const handleSubmit = async () => { - try { - const values = await form.validateFields(); - updateMutation.mutate( - { - connection_id: values.connection_id, - timezone: values.timezone || "UTC", - ...(values.api_key && { api_key: values.api_key }), - }, - { - onSuccess: () => { - toast.success("CloudZero integration updated successfully"); - form.resetFields(); - onOk(); - }, - onError: (error: any) => { - if (error?.errorFields) { - return; - } - toast.error(error?.message || "Failed to update CloudZero integration"); - }, - }, - ); - } catch (error: any) { - if (error?.errorFields) { - return; - } - toast.error(error?.message || "Failed to update CloudZero integration"); - } + const handleSubmit = (values: CloudZeroFormValues) => { + updateMutation.mutate(buildCloudZeroPayload(values), { + onSuccess: () => { + toast.success("CloudZero integration updated successfully"); + form.reset(EMPTY_CLOUDZERO_FORM_VALUES); + onOk(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update CloudZero integration"); + }, + }); }; const handleCancel = () => { - form.resetFields(); + form.reset(EMPTY_CLOUDZERO_FORM_VALUES); onCancel(); }; @@ -69,7 +67,7 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: void form.handleSubmit(handleSubmit)()} onCancel={handleCancel} confirmLoading={updateMutation.isPending} okText={updateMutation.isPending ? "Updating..." : "Update"} @@ -81,30 +79,31 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: disabled: updateMutation.isPending, }} > -
      - - - - - - - - - -
      + +
      event.preventDefault()} noValidate> + + + {({ ref, ...field }) => ( + + )} + + + {({ ref, ...field }) => } + + + {({ ref, ...field }) => } + + +
      +
      ); } diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/cloudZeroPayload.test.ts b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/cloudZeroPayload.test.ts new file mode 100644 index 00000000000..8517295b4fe --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/cloudZeroPayload.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { buildCloudZeroPayload } from "./cloudZeroPayload"; + +describe("buildCloudZeroPayload", () => { + it("passes a filled form through verbatim", () => { + expect( + buildCloudZeroPayload({ api_key: "cz-key", connection_id: "conn-1", timezone: "America/New_York" }), + ).toStrictEqual({ + connection_id: "conn-1", + timezone: "America/New_York", + api_key: "cz-key", + }); + }); + + it("omits the api_key key entirely when the field is blank, so a stored secret survives an untouched save", () => { + const payload = buildCloudZeroPayload({ api_key: "", connection_id: "conn-1", timezone: "UTC" }); + + expect("api_key" in payload).toBe(false); + expect(payload).toStrictEqual({ connection_id: "conn-1", timezone: "UTC" }); + }); + + it.each([ + ["blank", ""], + ["absent", undefined], + ])("falls back to UTC when the timezone is %s", (_label, timezone) => { + const payload = buildCloudZeroPayload({ + api_key: "cz-key", + connection_id: "conn-1", + timezone: timezone as string, + }); + + expect(payload.timezone).toBe("UTC"); + }); + + it("never invents a timezone default over a real value", () => { + expect(buildCloudZeroPayload({ api_key: "", connection_id: "conn-1", timezone: "UTC+2" }).timezone).toBe("UTC+2"); + }); + + it("keeps a whitespace-only api_key, matching the truthiness check the antd modals used", () => { + expect(buildCloudZeroPayload({ api_key: " ", connection_id: "conn-1", timezone: "UTC" }).api_key).toBe(" "); + }); +}); diff --git a/ui/litellm-dashboard/src/components/CloudZeroCostTracking/cloudZeroPayload.ts b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/cloudZeroPayload.ts new file mode 100644 index 00000000000..3873df97058 --- /dev/null +++ b/ui/litellm-dashboard/src/components/CloudZeroCostTracking/cloudZeroPayload.ts @@ -0,0 +1,23 @@ +export interface CloudZeroFormValues { + api_key: string; + connection_id: string; + timezone: string; +} + +export interface CloudZeroPayload { + connection_id: string; + timezone: string; + api_key?: string; +} + +export const EMPTY_CLOUDZERO_FORM_VALUES: CloudZeroFormValues = { + api_key: "", + connection_id: "", + timezone: "", +}; + +export const buildCloudZeroPayload = (values: CloudZeroFormValues): CloudZeroPayload => ({ + connection_id: values.connection_id, + timezone: values.timezone || "UTC", + ...(values.api_key && { api_key: values.api_key }), +});