diff --git a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts index c3bd8489027d..bb53fb7a23bd 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/modelsPage/addModel.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { ADMIN_STORAGE_PATH, E2E_TEAM_CRUD_ID } from "../../constants"; +import { ADMIN_STORAGE_PATH, E2E_TEAM_CRUD_ALIAS, E2E_TEAM_CRUD_ID } from "../../constants"; import { Role, users } from "../../fixtures/users"; import { navigateToPage } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; @@ -150,6 +150,111 @@ test.describe("Add Model", () => { await expect(tableBody.getByText("claude-haiku-4-5").first()).toBeVisible({ timeout: 15_000 }); }); + test("Add team-only model via Team-BYOK toggle and verify it appears with the team", async ({ page, request }) => { + // The Team-BYOK switch is gated on `premiumUser` — without a license set + // for the proxy under test, the toggle is disabled and this manual-QA + // step cannot be exercised. + test.skip( + !process.env.LITELLM_LICENSE, + "LITELLM_LICENSE not set in test env — Team-BYOK switch is disabled", + ); + + // Make the test idempotent across retries and local reruns: delete any + // Cohere model already scoped to the e2e team before we start, and again + // after we finish. The sibling "Add wildcard route" test creates a + // team-less Cohere wildcard, so we only target rows that have BOTH the + // cohere/* model_name AND team_id == e2e-team-crud. + const masterKey = users[Role.ProxyAdmin].password; + const auth = { Authorization: `Bearer ${masterKey}` }; + const deleteTeamScopedCohereModels = async () => { + const res = await request.get("/v2/model/info", { headers: auth }); + if (!res.ok()) return; + const body = await res.json(); + const matches: Array<{ id: string }> = (body?.data ?? []).filter((m: any) => + typeof m?.model_name === "string" && + m.model_name.startsWith("cohere") && + m?.model_info?.team_id === E2E_TEAM_CRUD_ID, + ); + for (const m of matches) { + await request.post("/model/delete", { headers: auth, data: { id: m.id } }); + } + }; + await deleteTeamScopedCohereModels(); + + try { + await navigateToPage(page, Page.Models); + await page.getByRole("tab", { name: "Add Model" }).click(); + + 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.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(); + + // The Team dropdown appears underneath once the switch is on. TeamDropdown + // renders its Select.Option children with custom / markup, so + // the popup items don't carry role="option" — match by text content, + // scoped to the visible dropdown so a stale tag elsewhere in the form + // can't satisfy it. + const teamDropdown = page.getByTestId("team-dropdown"); + await expect(teamDropdown).toBeVisible({ timeout: 5_000 }); + await teamDropdown.click(); + const teamOption = page.locator(".ant-select-dropdown:visible") + .getByText(E2E_TEAM_CRUD_ID) + .first(); + await expect(teamOption).toBeVisible({ timeout: 5_000 }); + await teamOption.click(); + + await page.getByRole("button", { name: "Add Model" }).last().click(); + + // Scope the success toast to antd's notification container so a stale + // success message from an earlier test in the same context can't satisfy + // the assertion. + await expect(page.locator(".ant-notification").getByText("created successfully").last()) + .toBeVisible({ timeout: 15_000 }); + + // Verify the model is now in All Models with the team_id attached. The + // Models table renders team-scoped models with the team id in the row. + await page.getByRole("tab", { name: "All Models" }).click(); + await page.waitForLoadState("networkidle"); + // Match the sibling tests in this file — networkidle fires before the + // table finishes re-rendering, so give it the same 2s settle before + // searching. + await page.waitForTimeout(2000); + + await page.locator('input[placeholder="Search model names..."]').fill("cohere"); + await page.waitForTimeout(1000); + + // Confirm the search returned at least one result — gives a clear + // failure message when the table is empty instead of timing out on a + // row assertion. + await expect(page.getByTestId("models-results-count")).toHaveText( + /Showing \d+ - \d+ of \d+ results/, + { timeout: 15_000 }, + ); + + // Stronger than "alias appears somewhere in tbody" — pin the assertion + // to a single row that has BOTH the cohere model_name AND the seeded + // team alias, so a stale cohere row from "Add wildcard route" (no team) + // can't satisfy the check. + const teamCohereRow = page.locator("table tbody tr") + .filter({ hasText: "cohere/" }) + .filter({ hasText: E2E_TEAM_CRUD_ALIAS }); + await expect(teamCohereRow).toHaveCount(1, { timeout: 15_000 }); + } finally { + await deleteTeamScopedCohereModels(); + } + }); + test("Add wildcard route and verify it appears in All Models", async ({ page }) => { await navigateToPage(page, Page.Models); await page.getByRole("tab", { name: "Add Model" }).click();