mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
* fix(ui): scope key models dropdown options to the key's team A teamless key no longer offers the all-team-models option in the create and edit forms; the backend expands that sentinel to the full proxy model list when no team is attached, which is rarely what the user intended. A team key no longer surfaces the all-proxy-models sentinel that leaks in verbatim when the team's own model list carries it; the dropdown keeps All Team Models plus the team's individual models. Adds browser coverage to the management e2e suite: playwright (an optional dependency behind importorskip) drives the proxy-served dashboard at /ui, asserts the dropdown options a real user sees for teamless and team keys on both create and edit, and walks the create modal end to end, reading the persisted key back through /key/info. * fix(ui): offer all-proxy-models on teamless keys in the models dropdown A teamless key has no team allowlist to inherit, so the dropdown now offers All Proxy Models in place of All Team Models on both the create and edit forms, with the same exclusive-selection handling. Component and browser e2e tests updated to pin the swapped option pair; the teamless create case now also walks the modal end to end and reads the persisted key back through /key/info. * test(ui): update no-team key creation spec to pick All Proxy Models The create modal no longer offers All Team Models without a team; the teamless path now offers All Proxy Models, which is what this spec exercises * fix(ui): gate All Team Models on the team object being loaded When a key has a team_id but the teams prop does not yet include the matching team, availableModels stays empty and the models dropdown rendered All Team Models on its own with nothing to compare against. Gate the option on the team object being present so it only appears once team models are known, and add a regression test for the loading state Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): filter all-proxy-models from teamless model fetch in key edit form The teamless fetch path stored modelAvailableCall results without excludeProxyWideSentinel, so an all-proxy-models entry in the response rendered a second option colliding with the hardcoded All Proxy Models sentinel. Apply the same filter used on the team path and add a regression test asserting the sentinel option is not duplicated Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Mubashir Osmani <mubashir@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
204 lines
8.6 KiB
TypeScript
204 lines
8.6 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import {
|
|
ADMIN_STORAGE_PATH,
|
|
E2E_DELETE_KEY_ALIAS,
|
|
E2E_REGENERATE_KEY_ALIAS,
|
|
E2E_UPDATE_LIMITS_KEY_ALIAS,
|
|
E2E_INTERNAL_USER_KEY_ALIAS,
|
|
E2E_TEAM_CRUD_ALIAS,
|
|
} from "../../constants";
|
|
import { Page } from "../../fixtures/pages";
|
|
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
|
|
|
test.describe("Proxy Admin - Keys", () => {
|
|
test.use({ storageState: ADMIN_STORAGE_PATH });
|
|
|
|
test("Create a key in a team", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
// Click "+ Create New Key" button
|
|
await page.getByRole("button", { name: /Create New Key/i }).click();
|
|
|
|
// Wait for the key creation modal
|
|
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Fill key name (has data-testid="base-input" in the built UI)
|
|
const keyName = `e2e-admin-key-${Date.now()}`;
|
|
await page.getByTestId("base-input").fill(keyName);
|
|
|
|
// Select team — the team dropdown has placeholder "Search or select a team"
|
|
const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" });
|
|
await teamSelect.click();
|
|
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
|
await page.locator(".ant-select-dropdown: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();
|
|
await page.keyboard.press("Escape");
|
|
|
|
// Submit
|
|
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
|
|
|
// Success shows "Save your Key" in a second dialog
|
|
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
|
await page.keyboard.press("Escape");
|
|
|
|
// Verify the new key appears in the table
|
|
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Regenerate key", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
// Key IDs are rendered as buttons in the table
|
|
const keyRow = page.locator("tr", { hasText: E2E_REGENERATE_KEY_ALIAS });
|
|
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
|
await keyRow.locator("button").first().click();
|
|
|
|
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("button", { name: "Regenerate Key" }).click();
|
|
|
|
// 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");
|
|
await modal.getByRole("button", { name: /Regenerate/ }).click();
|
|
|
|
// Success view shows a Copy button in the footer (text varies between modal versions)
|
|
await expect(modal.getByRole("button", { name: /Copy.*Key/ })).toBeVisible({ timeout: 20_000 });
|
|
});
|
|
|
|
test("Update key TPM and RPM limits", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
const keyRow = page.locator("tr", { hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
|
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
|
await keyRow.locator("button").first().click();
|
|
|
|
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("tab", { name: "Settings" }).click();
|
|
await page.getByRole("button", { name: "Edit Settings" }).click();
|
|
|
|
await page.getByRole("spinbutton", { name: "TPM Limit" }).fill("123");
|
|
await page.getByRole("spinbutton", { name: "RPM Limit" }).fill("456");
|
|
await page.getByRole("button", { name: "Save Changes" }).click();
|
|
|
|
await expect(page.getByRole("paragraph").filter({ hasText: "TPM: 123" })).toBeVisible({ timeout: 10_000 });
|
|
await expect(page.getByRole("paragraph").filter({ hasText: "RPM: 456" })).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Delete key", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
const keyRow = page.locator("tr", { hasText: E2E_DELETE_KEY_ALIAS });
|
|
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
|
await keyRow.locator("button").first().click();
|
|
|
|
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
|
|
|
await page.getByRole("button", { name: "Delete Key" }).click();
|
|
|
|
const modal = page.locator(".ant-modal:visible");
|
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
|
await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS);
|
|
|
|
const deleteButton = modal.getByRole("button", { name: "Delete", exact: true });
|
|
await expect(deleteButton).toBeEnabled();
|
|
await deleteButton.click();
|
|
|
|
await expect(page.getByText(/Key deleted/i).first()).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("See internal user keys in team", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
await expect(page.getByText(E2E_INTERNAL_USER_KEY_ALIAS)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Create a key with All Proxy Models (no team)", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
await page.getByRole("button", { name: /Create New Key/i }).click();
|
|
|
|
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const keyName = `e2e-admin-allproxy-${Date.now()}`;
|
|
await page.getByTestId("base-input").fill(keyName);
|
|
|
|
// No team selection — leave team dropdown empty so the key is owned by the admin user
|
|
|
|
// 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.keyboard.press("Escape");
|
|
|
|
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
|
|
|
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("Create a key with a specific proxy model (no team)", async ({ page }) => {
|
|
await navigateToPage(page, Page.ApiKeys);
|
|
await dismissFeedbackPopup(page);
|
|
|
|
await page.getByRole("button", { name: /Create New Key/i }).click();
|
|
|
|
await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const keyName = `e2e-admin-specific-${Date.now()}`;
|
|
await page.getByTestId("base-input").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.
|
|
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.keyboard.press("Escape");
|
|
|
|
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
|
|
|
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Grab the new key from the success modal (rendered inside a <pre>) and
|
|
// 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();
|
|
expect(apiKey).toMatch(/^sk-/);
|
|
|
|
const response = await page.request.post("/chat/completions", {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
data: {
|
|
model: modelName,
|
|
messages: [{ role: "user", content: "ping" }],
|
|
},
|
|
});
|
|
expect(response.status()).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.choices?.[0]?.message?.content).toBe("This is a mock response.");
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
});
|