diff --git a/tests/e2e/ui/helpers/mcp.ts b/tests/e2e/ui/helpers/mcp.ts index b41aec59ded..554177e11bc 100644 --- a/tests/e2e/ui/helpers/mcp.ts +++ b/tests/e2e/ui/helpers/mcp.ts @@ -12,23 +12,21 @@ export async function createMcpServer(page: PwPage, url: string): Promise { await expect(discovery).toBeVisible({ timeout: 5_000 }); await discovery.getByRole("button", { name: /Custom Server/i }).click(); - const formModal = page.locator(".ant-modal:visible").filter({ hasText: "MCP Server Name" }); + const formModal = page.getByRole("dialog").filter({ hasText: "MCP Server Name" }); await expect(formModal).toBeVisible({ timeout: 5_000 }); // Name — no spaces or hyphens per validateMCPServerName const uniqueName = `e2e_mcp_${Date.now()}`; createdServerName = uniqueName; - await formModal.locator('input[id="server_name"]').fill(uniqueName); + await formModal.getByLabel("MCP Server Name").fill(uniqueName); - // Transport: Streamable HTTP — the only value the proxy actually accepts is "http" - const transportField = formModal.locator(".ant-form-item", { hasText: "Transport Type" }); - await transportField.locator(".ant-select").click(); - await page.locator(".ant-select-dropdown:visible").getByText("Streamable HTTP").click(); + // Transport: Streamable HTTP — the only value the proxy actually accepts is "http". + // Select popups are portaled to the body, so the option lookup is page-scoped. + await formModal.getByRole("combobox", { name: "Transport Type" }).click(); + await page.getByRole("option", { name: "Streamable HTTP" }).click(); // URL — use a fake URL; the form just persists it, it doesn't have to be reachable - await formModal.locator('input[id="url"]').fill("https://e2e-fake-mcp.test.local/mcp"); + await formModal.getByLabel("MCP Server URL").fill("https://e2e-fake-mcp.test.local/mcp"); - // Authentication: None - // The auth_type Form.Item has no label prop (CreateMCPServer.tsx), so - // it can't be anchored by label text. Scope via the enclosing Collapse - // panel ("Authentication") instead — that anchor is stable even if the - // placeholder copy changes. - const authSection = formModal.locator(".ant-collapse-item", { hasText: /^Authentication/ }); - const authField = authSection.locator(".ant-form-item").first(); - await authField.locator(".ant-select").click(); - await page.locator(".ant-select-dropdown:visible").getByText("None", { exact: true }).click(); + // Authentication: None. "Authentication" is exact so it can't also match the + // "Authentication Value" field that some auth types reveal below it. + await formModal.getByRole("combobox", { name: "Authentication", exact: true }).click(); + await page.getByRole("option", { name: "None", exact: true }).click(); // Submit await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click(); diff --git a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts index bd5373e1569..dad716b4c83 100644 --- a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts @@ -21,15 +21,22 @@ async function findDeploymentByName(page: PlaywrightPage, modelName: string): Pr return body.data.find((row) => row.model_name === modelName); } +/** Anchors a substring match to the whole string, escaping regex metacharacters. */ +const exactly = (text: string): RegExp => new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`); + /** - * Helper to select a provider from the Add Model form dropdown. + * Helper to select a provider from the Add Model form dropdown. The field is a + * searchable combobox: it only opens on click, typing filters the list, and the + * option has to be picked explicitly because nothing is highlighted by default. + * Options are matched on their visible text, not their accessible name, which + * also carries the provider logo's alt text ("Anthropic logo Anthropic"). */ -async function selectProvider(page: any, providerName: string) { - const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); +async function selectProvider(page: PlaywrightPage, providerName: string) { + const providerDropdown = page.getByRole("combobox", { name: "Provider", exact: true }); + await providerDropdown.click(); await providerDropdown.fill(providerName); - await page.waitForTimeout(1000); - await providerDropdown.press("Enter"); - await page.waitForTimeout(2000); + await page.getByRole("option").filter({ hasText: exactly(providerName) }).click(); + await expect(providerDropdown).toHaveValue(providerName); } test.describe("Add Model", () => { @@ -64,11 +71,10 @@ test.describe("Add Model", () => { await selectProvider(page, "Anthropic"); // The model field should be a multi-select dropdown; click to open it - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); + await page.getByRole("combobox", { name: "Select models" }).click(); // Verify provider-specific models are listed - await expect(page.getByTitle("claude-haiku-4-5", { exact: true })).toBeVisible(); + await expect(page.getByRole("option", { name: "claude-haiku-4-5", exact: true })).toBeVisible(); }); test("Edit team model TPM and RPM limits", async ({ page }) => { @@ -156,14 +162,14 @@ test.describe("Add Model", () => { await page.getByRole("tab", { name: "Add Model" }).click(); // Labels come from /public/providers/fields, not the frontend Providers enum, and the two differ. - await selectProvider(page, "OpenAI-Compatible Endpoints"); + await selectProvider(page, "OpenAI-Compatible Endpoints (Together AI, etc.)"); const publicName = `e2e-ui-added-${Date.now()}`; uiAddedModelName = publicName; // The model picker's "custom" entry reveals the free-text name field. - await page.locator(".ant-select-selection-overflow").first().click(); - await page.locator(".ant-select-dropdown:visible").getByText("Custom Model Name (Enter below)").click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "Custom Model Name (Enter below)" }).click(); await page.keyboard.press("Escape"); await page.getByPlaceholder("Enter custom model name").fill(publicName); @@ -177,8 +183,8 @@ test.describe("Add Model", () => { await expect(page.getByTestId("connection-success-msg")).toBeVisible({ timeout: 30_000 }); // The modal swallows the Add click. Scope to the footer: the dismiss X is also named "Close". - const resultsModal = page.locator(".ant-modal:visible").filter({ hasText: "Connection Test Results" }); - await resultsModal.locator(".ant-modal-footer").getByRole("button", { name: "Close" }).click(); + const resultsModal = page.getByRole("dialog", { name: "Connection Test Results" }); + await resultsModal.locator('[data-slot="dialog-footer"]').getByRole("button", { name: "Close" }).click(); await expect(resultsModal).toBeHidden({ timeout: 5_000 }); const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => { @@ -213,9 +219,8 @@ test.describe("Add Model", () => { await selectProvider(page, "Anthropic"); // Select model: claude-haiku-4-5 - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - await page.getByTitle("claude-haiku-4-5", { exact: true }).click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "claude-haiku-4-5", exact: true }).click(); await page.keyboard.press("Escape"); // Enter bad API key @@ -239,9 +244,8 @@ test.describe("Add Model", () => { await selectProvider(page, "Anthropic"); // Select model: claude-haiku-4-5 - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - await page.getByTitle("claude-haiku-4-5", { exact: true }).click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "claude-haiku-4-5", exact: true }).click(); await page.keyboard.press("Escape"); // Enter any API key @@ -315,18 +319,15 @@ test.describe("Add Model", () => { await selectProvider(page, "Cohere"); - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - const wildcardOption = page.getByTitle(/All .* Models \(Wildcard\)/); - await wildcardOption.click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: /All .* Models \(Wildcard\)/ }).click(); await page.keyboard.press("Escape"); const apiKeyInput = page.locator('input[type="password"]').first(); await apiKeyInput.fill("sk-any-key-for-team-byok-test"); - // Flip the Team-BYOK switch on (Form.Item label "Team-BYOK Model") - const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" }); - await teamByokRow.getByRole("switch").click(); + // Flip the Team-BYOK switch on; the Switch carries its own aria-label. + await page.getByRole("switch", { name: "Team-BYOK Model" }).click(); // TeamDropdown options show the alias above the team id, so match on the id line by text. const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox"); @@ -376,10 +377,8 @@ test.describe("Add Model", () => { await selectProvider(page, "Cohere"); // Select All Cohere Models (Wildcard) - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - const wildcardOption = page.getByTitle(/All .* Models \(Wildcard\)/); - await wildcardOption.click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: /All .* Models \(Wildcard\)/ }).click(); await page.keyboard.press("Escape"); // Enter any API key diff --git a/tests/e2e/ui/tests/modelsPage/credentials.spec.ts b/tests/e2e/ui/tests/modelsPage/credentials.spec.ts index 7c836068567..ceedc959ccc 100644 --- a/tests/e2e/ui/tests/modelsPage/credentials.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/credentials.spec.ts @@ -41,7 +41,7 @@ test.describe("Edit LLM credential", () => { await row.getByTestId(`credential-actions-${credentialName}`).click(); await page.getByTestId("credential-action-edit").click(); - const modal = page.locator(".ant-modal-content").filter({ hasText: "Edit Credential" }); + const modal = page.getByRole("dialog", { name: "Edit Credential" }); await expect(modal).toBeVisible({ timeout: 10_000 }); const apiKeyField = modal.locator("#api_key"); diff --git a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts index 004fedb3263..0c38641dcc7 100644 --- a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts @@ -45,9 +45,9 @@ test.describe("Proxy Admin - Keys", () => { await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); - // Select models - await page.locator(".ant-select-selection-overflow").click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click(); + // Select models — the popup is portaled to the body, so scope options to the page. + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "All Team Models", exact: true }).click(); await page.keyboard.press("Escape"); // Submit @@ -86,7 +86,7 @@ test.describe("Proxy Admin - Keys", () => { // Scope to the modal — the Regenerate button has an icon whose aria-label // ("sync") is concatenated into the button's accessible name, and the // "Regenerate Key" button is still in the DOM behind the modal. - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Regenerate Virtual Key" }); await modal.getByRole("button", { name: /Regenerate/ }).click(); // Success view shows a Copy button in the footer (text varies between modal versions) @@ -198,8 +198,8 @@ test.describe("Proxy Admin - Keys", () => { // Select models — open the multi-select and pick the all-models meta-option. // With no team selected the modal offers "All Proxy Models"; the team-scoped // "All Team Models" option only appears once a team is picked. - await page.locator(".ant-select-selection-overflow").click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "All Proxy Models", exact: true }).click(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "Create Key", exact: true }).click(); @@ -221,17 +221,10 @@ test.describe("Proxy Admin - Keys", () => { const keyName = `e2e-admin-specific-${Date.now()}`; await page.getByLabel(/Key Name/).fill(keyName); - // Open the model multi-select and pick a single specific model. Use - // getByRole("option", ...) to avoid the strict-mode collision between - // the option container and its inner text node. + // Open the model multi-select and pick a single specific model. const modelName = "fake-openai-gpt-4"; - await page.locator(".ant-select-selection-overflow").click(); - const option = page.locator(".ant-select-dropdown:visible").getByRole("option", { name: modelName, exact: true }); - await option.waitFor({ state: "attached" }); - // Dispatch the click via the DOM — antd's dropdown can render the option - // off-viewport during the open animation, which trips Playwright's - // visibility/stability checks. The click handler fires regardless. - await option.evaluate((el: HTMLElement) => el.click()); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: modelName, exact: true }).click(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "Create Key", exact: true }).click(); @@ -242,7 +235,7 @@ test.describe("Proxy Admin - Keys", () => { // verify it can call /chat/completions for the model it was scoped to. // The mock LLM server (fixtures/mock_llm_server/server.py) replies with // a fixed "This is a mock response." body. - const apiKey = (await page.locator(".ant-modal:visible pre").innerText()).trim(); + const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").innerText()).trim(); expect(apiKey).toMatch(/^sk-/); const response = await page.request.post("/chat/completions", { diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 172fde173df..7383b452162 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -41,11 +41,12 @@ test.describe("Proxy Admin - Teams", () => { .click(); // Wait for the Create Team modal - const dialog = page.locator(".ant-modal:visible"); + const dialog = page.getByRole("dialog", { name: "Create Team" }); await expect(dialog).toBeVisible({ timeout: 5_000 }); - // Fill Team Name — the input has id="team_alias" - await dialog.locator("#team_alias").fill(uniqueAlias); + // Fill Team Name — FormField derives the control id from React.useId(), so + // the input is only addressable by its label or its test id. + await dialog.getByTestId("team-name-input").fill(uniqueAlias); // Select models — the models multi-select is inside the modal. Its popup is // portaled to the body, so scope the option lookup to the page, not the dialog. @@ -75,7 +76,7 @@ test.describe("Proxy Admin - Teams", () => { await page.getByRole("button", { name: /Add Member/i }).click(); // Wait for Add Team Member modal - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Add Team Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); // The email field is a Select — type to search, then select from dropdown @@ -112,7 +113,7 @@ test.describe("Proxy Admin - Teams", () => { await page.getByTestId("edit-member").first().click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Edit Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.getByRole("button", { name: /Save Changes/i }).click(); @@ -155,7 +156,7 @@ test.describe("Proxy Admin - Teams", () => { await page.getByTestId("edit-member").first().click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Edit Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.getByRole("button", { name: /Save Changes/i }).click(); diff --git a/tests/e2e/ui/tests/settings/routerSettings.spec.ts b/tests/e2e/ui/tests/settings/routerSettings.spec.ts index 9784abff040..1188e8f201e 100644 --- a/tests/e2e/ui/tests/settings/routerSettings.spec.ts +++ b/tests/e2e/ui/tests/settings/routerSettings.spec.ts @@ -67,29 +67,25 @@ test.describe("Router Settings - Fallbacks", () => { await page.getByRole("button", { name: /Add Fallbacks/i }).click(); await modelsLoaded; - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Configure Model Fallbacks" }); await expect(modal).toBeVisible({ timeout: 5_000 }); - // FallbackGroupConfig.tsx renders both selects with `showSearch`. The - // most stable interaction is: click to open + focus, type the model name to - // narrow the listbox to a single highlighted option, then press Enter. - // Verify each selection landed by watching the dialog's own state transition - // (the tab title updates to the picked primary; the fallback chain list - // populates) rather than by asserting on the dropdown popup, which sits in - // a custom getPopupContainer and is awkward to scope reliably. - const primarySelect = modal.locator(".ant-select").filter({ hasText: "Select primary model" }); - await primarySelect.click(); + // FallbackGroupConfig.tsx renders both fields as searchable comboboxes: they + // open on click, typing filters the listbox, and the option has to be picked + // explicitly. Verify each selection landed by watching the dialog's own state + // transition (the tab title updates to the picked primary; the fallback chain + // list populates) rather than by asserting on the popup, which is portaled + // out of the dialog. + await modal.getByRole("combobox", { name: /Primary Model/ }).click(); await page.keyboard.type(PRIMARY); - await page.keyboard.press("Enter"); + await page.getByRole("option", { name: PRIMARY, exact: true }).click(); await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({ timeout: 10_000, }); - const fallbackSelect = modal.locator(".ant-select").filter({ hasText: "Select fallback models" }); - await fallbackSelect.click(); + await modal.getByRole("combobox", { name: /Select fallback models/ }).click(); await page.keyboard.type(FALLBACK); - await page.keyboard.press("Enter"); - await page.keyboard.press("Escape"); + await page.getByRole("option", { name: FALLBACK, exact: true }).click(); // The Fallback Chain helper text reads "(N/10 used)"; once it ticks to 1 the // selection has been recorded. await expect(modal.getByText("(1/10 used)")).toBeVisible({ diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index 0ef74e71529..f93cca75347 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -61,7 +61,7 @@ test.describe("Team Admin", () => { await page.getByRole("tab", { name: "Members" }).click(); await page.getByRole("button", { name: /Add Member/i }).click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Add Team Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); // Use a dedicated invitee user so this doesn't race with the proxy-admin @@ -144,9 +144,10 @@ test.describe("Team Admin", () => { await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); - // Models — pick "All Team Models" - await page.locator(".ant-select-selection-overflow").click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click(); + // Models — pick "All Team Models". The popup is portaled to the body, so + // scope the option lookup to the page. + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "All Team Models", exact: true }).click(); await page.keyboard.press("Escape"); const generate = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/generate" }, async () => { diff --git a/tests/e2e/ui/tests/usage/usagePage.spec.ts b/tests/e2e/ui/tests/usage/usagePage.spec.ts index 6031aa54055..8fa59beb905 100644 --- a/tests/e2e/ui/tests/usage/usagePage.spec.ts +++ b/tests/e2e/ui/tests/usage/usagePage.spec.ts @@ -22,7 +22,8 @@ async function openUsage(page: PlaywrightPage): Promise { const card = topKeysCard(page); await expect(card).toBeVisible({ timeout: 30_000 }); // Widen past the default top-5 so other keys in the database cannot crowd this one out. - await card.locator(".ant-segmented-item").filter({ hasText: /^50$/ }).click(); + // The radio itself is sr-only and its label covers it, so click the label. + await card.getByRole("radiogroup", { name: "Number of top keys to show" }).getByText("50", { exact: true }).click(); return card; } diff --git a/tests/e2e/ui/tests/users/searchUsers.spec.ts b/tests/e2e/ui/tests/users/searchUsers.spec.ts index a9b0e329a2b..e87218b5a5e 100644 --- a/tests/e2e/ui/tests/users/searchUsers.spec.ts +++ b/tests/e2e/ui/tests/users/searchUsers.spec.ts @@ -11,7 +11,7 @@ test.skip("Internal Users Search", () => { await tab.click(); await expect(page.locator("tbody tr").first()).toBeVisible(); - await expect(page.locator(".ant-skeleton")).toHaveCount(0); + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0); } test("can search users by email", async ({ page }) => { diff --git a/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts index ea61c238c02..614191372d0 100644 --- a/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts +++ b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts @@ -13,7 +13,7 @@ test.skip("Internal Users Page", () => { const firstRow = page.locator("tbody tr").first(); await expect(firstRow).toBeVisible(); - await expect(page.locator(".ant-skeleton")).toHaveCount(0); + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0); } test("renders internal users table correctly", async ({ page }) => { diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 2d68ea2aa68..a9a163f3c78 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -5,9 +5,6 @@ } }, "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -22,9 +19,6 @@ "no-nested-ternary": { "count": 3 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -535,9 +529,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -871,9 +862,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 2 }, @@ -997,9 +985,6 @@ "src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/prompts/_components/index.tsx": { @@ -1187,9 +1172,6 @@ } }, "src/app/(dashboard)/users/_components/BulkEditUsers.tsx": { - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 1 } @@ -1323,9 +1305,6 @@ } }, "src/components/CreateUserButton.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1373,11 +1352,6 @@ "count": 1 } }, - "src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx": { "max-nested-callbacks": { "count": 1 @@ -1428,9 +1402,6 @@ "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 2 }, @@ -1462,14 +1433,8 @@ } }, "src/components/add_model/AddModelForm.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/add_model/RouterConfigBuilder.tsx": { @@ -1480,9 +1445,6 @@ "src/components/add_model/add_auto_router_tab.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/add_model/add_model_modes.tsx": { @@ -1639,9 +1601,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 }, @@ -1815,11 +1774,6 @@ "count": 1 } }, - "src/components/mcp_tools/ByokCredentialModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/mcp_tools/MCPToolArgumentsForm.tsx": { "no-nested-ternary": { "count": 1 @@ -1835,11 +1789,6 @@ "count": 1 } }, - "src/components/model_add/CredentialModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_add/reuse_credentials.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1907,9 +1856,6 @@ "src/components/onboarding_link.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/organisms/create_key_button.tsx": { @@ -1919,9 +1865,6 @@ "max-lines": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 2 }, @@ -2010,9 +1953,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/preserve-manual-memoization": { "count": 1 } @@ -2377,9 +2317,6 @@ "src/components/update_model_credentials_modal.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/user_agent_activity.tsx": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 903676d238a..976fb94acea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -7,7 +7,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Space, Tabs, Typography } from "antd"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Info, TriangleAlert } from "lucide-react"; import React, { useEffect, useState } from "react"; import NewBadge from "@/components/common_components/NewBadge"; @@ -35,8 +35,6 @@ import { Input } from "@/components/ui/input"; import { useZodForm } from "@/lib/forms/useZodForm"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -const { Title, Paragraph, Text } = Typography; - const allowedIPSchema = z.object({ ip: z.string().min(1, "Please enter an IP address"), }); @@ -223,7 +221,7 @@ const AdminPanel: React.FC = ({ proxySettings }) => { children: ( <> - ✨ Security Settings +

✨ Security Settings

SSO Configuration Deprecated @@ -329,7 +327,9 @@ const AdminPanel: React.FC = ({ proxySettings }) => { Confirm Delete - Are you sure you want to delete the IP address: {ipToDelete}? + + Are you sure you want to delete the IP address: {ipToDelete}? + - - {fileList.length > 0 && ( -
Selected: {fileList[0].name}
+ + + {selectedFile && ( +
+ Selected: {selectedFile.name} + +
)} Upload a .prompt file that follows the Dotprompt specification @@ -196,16 +213,13 @@ const AddPromptForm: React.FC = ({ visible, onClose, accessT - {" "} - , - , ] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx index 5e540baaaf9..d6d64a4e49f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx @@ -1,12 +1,15 @@ -import React, { useState } from "react"; -import { Typography, Divider, Table, Select, InputNumber, Card, Space, Checkbox } from "antd"; +import React, { useId, useState } from "react"; import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/components/networking"; import { UserEditView } from "./user_edit_view"; import { toast } from "@/lib/toast"; import { MoneyCell } from "@/components/shared/table_cells"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import NumericalInput from "@/components/shared/numerical_input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; - -const { Text, Title } = Typography; +import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; interface BulkEditUserModalProps { open: boolean; @@ -38,6 +41,10 @@ const BulkEditUserModal: React.FC = ({ const [teamBudget, setTeamBudget] = useState(null); const [addToTeams, setAddToTeams] = useState(false); const [updateAllUsers, setUpdateAllUsers] = useState(false); + const updateAllUsersId = useId(); + const addToTeamsId = useId(); + const selectedTeamsId = useId(); + const teamBudgetId = useId(); const handleCancel = () => { // Reset team management state @@ -210,14 +217,22 @@ const BulkEditUserModal: React.FC = ({ {allowAllUsers && (
- setUpdateAllUsers(e.target.checked)}> - Update ALL users in the system - +
+ setUpdateAllUsers(checked === true)} + aria-label="Update ALL users in the system" + /> + +
{updateAllUsers && ( -
- +
+ ⚠️ This will apply changes to ALL users in the system, not just the selected ones. - +
)}
@@ -225,118 +240,115 @@ const BulkEditUserModal: React.FC = ({ {!updateAllUsers && (
- Selected Users ({selectedUsers.length}): - ( - - {text.length > 20 ? `${text.slice(0, 20)}...` : text} - - ), - }, - { - title: "Email", - dataIndex: "user_email", - key: "user_email", - width: "25%", - render: (text: string) => ( - - {text || "No email"} - - ), - }, - { - title: "Current Role", - dataIndex: "user_role", - key: "user_role", - width: "25%", - render: (role: string) => ( - {possibleUIRoles?.[role]?.ui_label || role} - ), - }, - { - title: "Budget", - dataIndex: "max_budget", - key: "max_budget", - width: "20%", - render: (budget: number | null) => ( - - ), - }, - ]} - /> +
Selected Users ({selectedUsers.length}):
+
+
+ + + User ID + Email + Current Role + Budget + + + + {selectedUsers.map((user) => ( + + + {user.user_id.length > 20 ? `${user.user_id.slice(0, 20)}...` : user.user_id} + + {user.user_email || "No email"} + + {possibleUIRoles?.[user.user_role]?.ui_label || user.user_role} + + + + + + ))} + +
+
)} - +
- +

Instructions: Fill in the fields below with the values you want to apply to all selected users. You can bulk edit: role, budget, models, and metadata. You can also add users to teams. - +

{/* Team Management Section */} - - - setAddToTeams(e.target.checked)}> - Add selected users to teams - + + + Team Management + + +
+
+ setAddToTeams(checked === true)} + aria-label="Add selected users to teams" + /> + +
- {addToTeams && ( - <> -
- Select Teams: - onChange(selected.length === 0 ? undefined : selected)} - > - - - {(selected: string[]) => - selected.length === 0 - ? "Select Organization" - : organizationOptions - .filter((option) => selected.includes(option.value)) - .map((option) => option.label) - .join(", ") - } - - - - {organizationOptions.map((option) => ( - - {option.label} - - ))} - - + !open && handleCancel()}> + + + Invite User + +
+

Create a User who can own keys

+ +
+ +
+ + {userEmailField} + {roleField( + labelWithHint( + "Global Proxy Role", + "This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings", + ), )} - + {teamField} - {metadataField} - {sendInviteEmailField} + + {({ id, value, onChange }) => ( + + )} + - - - - Personal Key Creation - - - - {({ value, onChange }) => ( - ({ label: getModelDisplayName(model), value: model })), - ]} - value={value ?? []} - onValueChange={onChange} - placeholder="Select models" - /> - )} - - - - + {metadataField} + {sendInviteEmailField} -
- -
-
-
- + + + + Personal Key Creation + + + + {({ value, onChange }) => ( + ({ label: getModelDisplayName(model), value: model })), + ]} + value={value ?? []} + onValueChange={onChange} + placeholder="Select models" + /> + )} + + + + + +
+ +
+ + +
+
{apiuser && ( {v}, - }, - { title: "Display Name", dataIndex: "display_name", key: "display_name" }, - { - title: "URL", - dataIndex: "url", - key: "url", - render: (v: string) => ( - - {v} - - ), - }, - { - title: "Plugin Key", - dataIndex: "plugin_key", - key: "plugin_key", - render: (v?: string) => (v ? {"•".repeat(8)} : —), - }, - { - title: "Actions", - key: "actions", - render: (_: unknown, plugin: Plugin, idx: number) => ( - - - - - ), - }, - ]; + const renderRows = () => { + if (loading) { + return ( + + + + + + ); + } + + if (plugins.length === 0) { + return ( + + + No data + + + ); + } + + return plugins.map((plugin, idx) => ( + + + {plugin.name} + + {plugin.display_name} + + + {plugin.url} + + + + {plugin.plugin_key ? ( + {"•".repeat(8)} + ) : ( + — + )} + + +
+ + +
+
+
+ )); + }; return ( - Plugins - - Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in the - top-left of the sidebar. - - - Each plugin must expose GET /api/plugin-manifest returning nav items and capabilities. - + +

Plugins

+

+ Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in + the top-left of the sidebar. +

+

+ Each plugin must expose GET /api/plugin-manifest returning nav + items and capabilities. +

+
+ + - - - +
+ + + Name + Display Name + URL + Plugin Key + Actions + + + {renderRows()} +
+
!open && setModalOpen(false)}> diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 8d9fdd7598f..ccd5500622b 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -15,7 +15,7 @@ import { SearchSelect } from "@/components/shared/SearchSelect"; import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; import { useZodForm } from "@/lib/forms/useZodForm"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; -import { Layout, Tabs } from "antd"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ChevronDown, Plus, Users } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { z } from "zod/v4"; @@ -542,8 +542,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser return false; }; - const { Content } = Layout; - const tabItems = [ { key: "your-teams", @@ -611,7 +609,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser ]; return ( - +
{selectedTeamId ? ( = ({ accessToken, userID, userRole, premiumUser premiumUser={premiumUser} /> ) : ( - } - title="Teams" - subtitle="Manage teams, members, and their access to models and budgets" - primaryAction={ - canCreateOrManageTeams(userRole, userID, organizations) ? ( - setIsTeamModalVisible(true)} data-testid="create-team-button"> - - Create Team - - ) : undefined - } - tabs={({ leadingControls }) => ( - - )} - /> + + } + title="Teams" + subtitle="Manage teams, members, and their access to models and budgets" + primaryAction={ + canCreateOrManageTeams(userRole, userID, organizations) ? ( + setIsTeamModalVisible(true)} data-testid="create-team-button"> + + Create Team + + ) : undefined + } + tabs={({ leadingControls }) => ( + + {leadingControls} + {tabItems.map((item) => ( + + {item.label} + + ))} + + )} + /> + {tabItems.map((item) => ( + + {item.children} + + ))} + )} {canCreateOrManageTeams(userRole, userID, organizations) && ( @@ -1203,7 +1218,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser
)} - + ); }; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index b6dddf43588..83c9e538732 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -5,7 +5,10 @@ import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { modelCreationScope } from "@/utils/modelPermissions"; import { Switch } from "@/components/ui/switch"; import { Field, FieldLabel } from "@/components/shared/form/field"; -import { Select as AntdSelect, Card, Col, Row, Tooltip, Typography } from "antd"; +import { Card, CardContent } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; +import { SimpleTooltip } from "@/components/ui/tooltip"; import { Info } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { Button } from "@/components/ui/button"; @@ -24,6 +27,7 @@ import type { Team } from "../key_team_helpers/key_list"; import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking"; import { Providers } from "../provider_info_helpers"; import { ProviderLogo } from "../molecules/models/ProviderLogo"; +import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox"; import AdvancedSettings from "./advanced_settings"; import ConditionalPublicModelName from "./conditional_public_model_name"; import LiteLLMModelNameField from "./litellm_model_name"; @@ -57,8 +61,6 @@ const connectionTestModelName = (values: MountedFormValues): string | undefined return typeof named === "string" ? named : undefined; }; -const { Title, Link } = Typography; - const AddModelForm: React.FC = ({ form, registry, @@ -117,6 +119,34 @@ const AddModelForm: React.FC = ({ return [...providerMetadata].sort((a, b) => a.provider_display_name.localeCompare(b.provider_display_name)); }, [providerMetadata]); + const providerOptions: SearchSelectOption[] = useMemo( + () => + sortedProviderMetadata.map((providerInfo) => ({ + label: providerInfo.provider_display_name, + value: providerInfo.provider, + icon: , + })), + [sortedProviderMetadata], + ); + + const credentialOptions: SearchSelectOption[] = useMemo( + () => [ + { label: "None", value: "" }, + ...credentials.map((credential) => ({ + label: credential.credential_name, + value: credential.credential_name, + })), + ], + [credentials], + ); + + const applyProviderSelection = (provider: Providers) => { + setSelectedProvider(provider); + setProviderModelsFn(provider); + form.setValue("model", []); + form.setValue("model_name", undefined); + }; + const providerMetadataErrorText = providerMetadataError ? providerMetadataError instanceof Error ? providerMetadataError.message @@ -132,311 +162,287 @@ const AddModelForm: React.FC = ({ return ( <> - Add Model +

Add Model

- - -
{ - event.preventDefault(); - void handleOk().then((submitted) => { - if (submitted) { - setTeamAdminSelectedTeam(null); - } - }); - }} - > - <> - {requiresTeamScope && ( - <> - - {(control) => ( - { - control.onChange(value); - setTeamAdminSelectedTeam(value); - }} - /> - )} - - {!teamAdminSelectedTeam && ( - - - Team Selection Required - - As a team admin, you need to select your team first before adding models. - - - )} - - )} - {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( - <> - - {(control) => ( - { - control.onChange(value); - setSelectedProvider(value as Providers); - setProviderModelsFn(value as Providers); - form.setValue("model", []); - form.setValue("model_name", undefined); - }} - > - {providerMetadataErrorText && sortedProviderMetadata.length === 0 && ( - - {providerMetadataErrorText} - - )} - {sortedProviderMetadata.map((providerInfo) => { - const displayName = providerInfo.provider_display_name; - const providerKey = providerInfo.provider; - - return ( - -
- - {displayName} -
-
- ); - })} -
- )} -
- - - {/* Conditionally Render "Public Model Name" */} - - - {/* Select Mode */} - - {(control) => ( - { - control.onChange(value); - setTestMode(value); - }} - options={TEST_MODES} - /> - )} - - - - -

- Optional - LiteLLM endpoint to use when health checking this model{" "} - - Learn more - -

- -
- - {/* Credentials */} -
- - Either select existing credentials OR enter new provider credentials below - -
- - - {(control) => ( - - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - value={control.value as string | null | undefined} - onChange={control.onChange} - onBlur={control.onBlur} - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - )} - - - {/* Only show provider specific fields if no credentials selected */} - {!selectedCredentialName && ( - <> -
-
- OR -
-
- - - )} -
-
- Additional Model Info Settings -
-
- {/* Team-only Model Switch - Only show for proxy admins, not team admins */} - {(isAdmin || !isTeamAdmin) && ( - - - {labelWithHint( - "Team-BYOK Model", - "Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.", - )} - - - - { - setIsTeamOnly(checked); - if (!checked) { - form.setValue("team_id", undefined); - } - }} - disabled={!premiumUser} - aria-label="Team-BYOK Model" - /> - - - - )} - - {/* Conditional Team Selection */} - {isTeamOnly && !requiresTeamScope && ( + + + + { + event.preventDefault(); + void handleOk().then((submitted) => { + if (submitted) { + setTeamAdminSelectedTeam(null); + } + }); + }} + > + <> + {requiresTeamScope && ( + <> {(control) => ( { + control.onChange(value); + setTeamAdminSelectedTeam(value); + }} /> )} - )} - {isAdmin && ( - <> + {!teamAdminSelectedTeam && ( + + + Team Selection Required + + As a team admin, you need to select your team first before adding models. + + + )} + + )} + {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( + <> + + {(control) => ( + { + control.onChange(value); + applyProviderSelection(value as Providers); + }} + /> + )} + + + + {/* Conditionally Render "Public Model Name" */} + + + {/* Select Mode */} + + {(control) => ( + + )} + +
+
+
+

+ Optional - LiteLLM endpoint to use when health checking this model{" "} + + Learn more + +

+
+
+ + {/* Credentials */} +
+ + Either select existing credentials OR enter new provider credentials below + +
+ + + {(control) => ( + control.onChange(value === "" ? null : value)} + /> + )} + + + {/* Only show provider specific fields if no credentials selected */} + {!selectedCredentialName && ( + <> +
+
+ OR +
+
+ + + )} +
+
+ Additional Model Info Settings +
+
+ {/* Team-only Model Switch - Only show for proxy admins, not team admins */} + {(isAdmin || !isTeamAdmin) && ( + + + {labelWithHint( + "Team-BYOK Model", + "Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.", + )} + + + + { + setIsTeamOnly(checked); + if (!checked) { + form.setValue("team_id", undefined); + } + }} + disabled={!premiumUser} + aria-label="Team-BYOK Model" + /> + + + + )} + + {/* Conditional Team Selection */} + {isTeamOnly && !requiresTeamScope && ( {(control) => ( - ({ - value: group, - label: group, - }))} - maxTagCount="responsive" - allowClear + disabled={!premiumUser} /> )} - - )} - - - )} -
- - Need Help? - -
- - + )} + {isAdmin && ( + <> + + {(control) => ( + + )} + + + )} + + + )} +
+ + + Need Help? + + +
+ + +
-
- - - - + + + + + {/* Test Connection Results Modal */} 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 64d1519f915..66564c3e007 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,12 +1,12 @@ import React, { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useWatch } from "react-hook-form"; -import { Card } from "antd"; import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react"; import { z } from "zod/v4"; import { FieldGroup } from "@/components/shared/form/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -483,205 +483,207 @@ const AddAutoRouterTab: React.FC = ({ return ( -
handleAutoRouterSubmit())} noValidate> - - - {({ ref, ...field }) => } - - -
- - - {modelsUnverifiable && ( -
- Could not load available models.{" "} - -
- )} -
- - {requiresTeamScope && ( + + handleAutoRouterSubmit())} noValidate> + - {({ id, value, onChange }) => } + {({ ref, ...field }) => } - )} -
- +
+ )} +
+ + {requiresTeamScope && ( + - {!detailsExpanded && ( - - {tierConfigSummary(complexityRouterConfig.tiers)} - - )} - - {detailsExpanded && ( -
- -
+ > + {({ id, value, onChange }) => } +
)} -
- {isAdmin && ( - + + {detailsExpanded && ( +
+ +
)} - > - {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( - + + {isAdmin && ( + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + )} + + )} + +
+ + + Need Help? + + } /> - )} - - )} - -
- - Get help on our github + +
+ + + - - - - - + + + +
-
- - + + +
!open && setIsRoutingTestVisible(false)}> diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx index 7f52342ceb3..fbcd9c2fdcc 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx @@ -139,7 +139,7 @@ describe("CloudZeroExportModal", () => { await screen.findByLabelText("CloudZero API Key"); await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Export to CSV")); + await user.click(await screen.findByRole("option", { name: "Export to CSV" })); expect(screen.queryByLabelText("CloudZero API Key")).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Export CSV" })); diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx index c8ef2218093..81d62fc398e 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from "react"; -import { Spin, Select } from "antd"; import { CircleCheck, FileDown } from "lucide-react"; import { z } from "zod/v4"; import { getGlobalLitellmHeaderName } from "@/components/networking"; @@ -10,6 +9,7 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useZodForm } from "@/lib/forms/useZodForm"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -251,13 +251,18 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC {/* Export Type Selection */}

Export Destination

- value && setExportType(value)}> + + + + + {exportOptions.map((option) => ( + + {option.label} + + ))} + +
{/* CloudZero Configuration */} @@ -265,7 +270,7 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC
{settingsLoading ? (
- +
) : ( <> diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx index f21af04d5d5..61fb5a44c66 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx @@ -1,13 +1,14 @@ "use client"; -import React, { useState } from "react"; -import { Input, Switch } from "antd"; +import React, { useId, useState } from "react"; import { toast } from "@/lib/toast"; import { fetchClient } from "@/lib/http/api"; import { ApiError } from "@/lib/http/client"; import { ArrowLeft, ArrowRight, Check, Key, Link2, Lock, X } from "lucide-react"; import { MCPServer } from "./types"; import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { Switch } from "@/components/ui/switch"; const byokSaveErrorMessage = (e: unknown): string => { if (e instanceof ApiError) { @@ -29,6 +30,7 @@ export const ByokCredentialModal: React.FC = ({ server const [apiKey, setApiKey] = useState(""); const [saveKey, setSaveKey] = useState(true); const [loading, setLoading] = useState(false); + const apiKeyInputId = useId(); const serverDisplayName = server.alias || server.server_name || "Service"; const firstLetter = serverDisplayName.charAt(0).toUpperCase(); @@ -169,13 +171,15 @@ export const ByokCredentialModal: React.FC = ({ server

Enter your {serverDisplayName} API key to authorize this connection.

- - + {serverDisplayName} API Key + + setApiKey(e.target.value)} - size="large" - className="rounded-lg" + groupClassName="rounded-lg" /> {server.byok_api_key_help_url && ( = ({ server Save key for future use
- +
{/* Security note */} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index 0fba7c1cd9f..4f25b2a201a 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -1,5 +1,6 @@ import { Input } from "@/components/ui/input"; -import { Select as AntdSelect, Tooltip, Typography } from "antd"; +import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; +import { SimpleTooltip } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; @@ -19,7 +20,11 @@ import { Logo } from "@/components/molecules/logo/Logo"; import { resetCredentialFormOnProviderChange } from "./credential_form_helpers"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -const { Link } = Typography; +const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({ + label: providerDisplayName, + value: providerEnum, + icon: , +})); interface CredentialModalProps { open: boolean; @@ -122,34 +127,27 @@ export default function CredentialModal({ className="mb-4" > {(control) => ( - { + { control.onChange(value); resetCredentialFormOnProviderChange(formAdapter, value as Providers, setSelectedProvider); }} - > - {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ( - -
- - {providerDisplayName} -
-
- ))} -
+ /> )}
- - Need Help? - + + + Need Help? + +
- - - Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"} - - - +
+ + +
+ + + + + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + setSearchQuery("")}> + + + + )} + +
+ + + + Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"} + +
+
- setDeletingGroup(g)} - proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""} - /> + setDeletingGroup(g)} + proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""} + /> +
{ Delete routing group? - - Models in {deletingGroup?.group_name} will fall back to the proxy's top-level - routing strategy. This cannot be undone. - +

+ Models in {deletingGroup?.group_name} will fall back to the + proxy's top-level routing strategy. This cannot be undone. +

- +
); }; diff --git a/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx b/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx index 4db8c83ba9d..1198e7510ed 100644 --- a/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx +++ b/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx @@ -1,4 +1,3 @@ -import { Typography } from "antd"; import { TriangleAlert } from "lucide-react"; import { useState } from "react"; import { z } from "zod/v4"; @@ -13,8 +12,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useZodForm } from "@/lib/forms/useZodForm"; -const { Text } = Typography; - const updateCredentialsSchema = z.object({ api_key: z.string().min(1, "Enter a new API key"), }); @@ -77,10 +74,10 @@ export default function UpdateModelCredentialsModal({ Update API Key - + Update this model's API key. Only the new key is sent; the rest of the deployment configuration is left untouched. - +